Compare commits

..

2 Commits

Author SHA1 Message Date
Ben Potter 9e7ce393c5 fix(claude-code): run post-install script after configuring MCP and remove send-keys workaround (#130)
- needed to install MCP servers
- workaround sucked

---------

Co-authored-by: DevelopmentCats <christofer@coder.com>
2025-06-02 18:37:48 -05:00
Callum Styan 13a25ff4af refactor: use coder/slog + minor go style changes (#107)
Changes are broken down in to multiples commits to hopefully make
reviewing easy. 1 commit for the slog change and then a commit per Go
file for style changes.

Style changes are generally:
- try to use full sentences for all comments
- try to stick to 120 column lines (not strict) instead of 80
- try to one line as many `call function, check if err != nil` blocks as
possible (ex: only err or variables are not reused outside the if statement)
- try to use `err` or `errs` for all return type names, previously used
`problems` in some cases but `errs` in others
- some minor readability changes
- `Todo` -> `TODO`, sometimes also useful to do `TODO (name):` to make
it easier to find things a specific author meant to follow up on
- comments for types/functions should generally start with `//
FunctionName/TypeName ...`

---------

Signed-off-by: Callum Styan <callumstyan@gmail.com>
2025-06-02 12:23:55 -07:00
10 changed files with 182 additions and 225 deletions
+45 -48
View File
@@ -2,8 +2,8 @@ package main
import (
"bufio"
"context"
"errors"
"log"
"net/url"
"os"
"path"
@@ -15,7 +15,14 @@ import (
"gopkg.in/yaml.v3"
)
var supportedResourceTypes = []string{"modules", "templates"}
var (
supportedResourceTypes = []string{"modules", "templates"}
// TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but
// realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's
// structured. Just validating whether it *can* be parsed as Terraform would be a big improvement.
terraformVersionRe = regexp.MustCompile(`^\s*\bversion\s+=`)
)
type coderResourceFrontmatter struct {
Description string `yaml:"description"`
@@ -49,36 +56,36 @@ func validateCoderResourceDescription(description string) error {
return nil
}
func validateCoderResourceIconURL(iconURL string) []error {
problems := []error{}
func isPermittedRelativeURL(checkURL string) bool {
// Would normally be skittish about having relative paths like this, but it should be safe because we have
// guarantees about the structure of the repo, and where this logic will run.
return strings.HasPrefix(checkURL, "./") || strings.HasPrefix(checkURL, "/") || strings.HasPrefix(checkURL, "../../../../.icons")
}
func validateCoderResourceIconURL(iconURL string) []error {
if iconURL == "" {
problems = append(problems, xerrors.New("icon URL cannot be empty"))
return problems
return []error{xerrors.New("icon URL cannot be empty")}
}
isAbsoluteURL := !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/")
if isAbsoluteURL {
errs := []error{}
// If the URL does not have a relative path.
if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") {
if _, err := url.ParseRequestURI(iconURL); err != nil {
problems = append(problems, xerrors.New("absolute icon URL is not correctly formatted"))
errs = append(errs, xerrors.New("absolute icon URL is not correctly formatted"))
}
if strings.Contains(iconURL, "?") {
problems = append(problems, xerrors.New("icon URLs cannot contain query parameters"))
errs = append(errs, xerrors.New("icon URLs cannot contain query parameters"))
}
return problems
return errs
}
// Would normally be skittish about having relative paths like this, but it
// should be safe because we have guarantees about the structure of the
// repo, and where this logic will run.
isPermittedRelativeURL := strings.HasPrefix(iconURL, "./") ||
strings.HasPrefix(iconURL, "/") ||
strings.HasPrefix(iconURL, "../../../../.icons")
if !isPermittedRelativeURL {
problems = append(problems, xerrors.Errorf("relative icon URL %q must either be scoped to that module's directory, or the top-level /.icons directory (this can usually be done by starting the path with \"../../../.icons\")", iconURL))
// If the URL has a relative path.
if !isPermittedRelativeURL(iconURL) {
errs = append(errs, xerrors.Errorf("relative icon URL %q must either be scoped to that module's directory, or the top-level /.icons directory (this can usually be done by starting the path with \"../../../.icons\")", iconURL))
}
return problems
return errs
}
func validateCoderResourceTags(tags []string) error {
@@ -89,9 +96,8 @@ func validateCoderResourceTags(tags []string) error {
return nil
}
// All of these tags are used for the module/template filter controls in the
// Registry site. Need to make sure they can all be placed in the browser
// URL without issue.
// All of these tags are used for the module/template filter controls in the Registry site. Need to make sure they
// can all be placed in the browser URL without issue.
invalidTags := []string{}
for _, t := range tags {
if t != url.QueryEscape(t) {
@@ -105,16 +111,11 @@ func validateCoderResourceTags(tags []string) error {
return nil
}
// Todo: This is a holdover from the validation logic used by the Coder Modules
// repo. It gives us some assurance, but realistically, we probably want to
// parse any Terraform code snippets, and make some deeper guarantees about how
// it's structured. Just validating whether it *can* be parsed as Terraform
// would be a big improvement.
var terraformVersionRe = regexp.MustCompile(`^\s*\bversion\s+=`)
func validateCoderResourceReadmeBody(body string) []error {
trimmed := strings.TrimSpace(body)
var errs []error
trimmed := strings.TrimSpace(body)
// TODO: this may cause unexpected behavior since the errors slice may have a 0 length. Add a test.
errs = append(errs, validateReadmeBody(trimmed)...)
foundParagraph := false
@@ -130,9 +131,8 @@ func validateCoderResourceReadmeBody(body string) []error {
lineNum++
nextLine := lineScanner.Text()
// Code assumes that invalid headers would've already been handled by
// the base validation function, so we don't need to check deeper if the
// first line isn't an h1.
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
// need to check deeper if the first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
@@ -159,15 +159,13 @@ func validateCoderResourceReadmeBody(body string) []error {
continue
}
// Code assumes that we can treat this case as the end of the "h1
// section" and don't need to process any further lines.
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
break
}
// Code assumes that if we've reached this point, the only other options
// are: (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset
// references made via [] syntax.
// Code assumes that if we've reached this point, the only other options are:
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
@@ -250,7 +248,7 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
}
if len(yamlParsingErrs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadmeParsing,
phase: validationPhaseReadme,
errors: yamlParsingErrs,
}
}
@@ -264,7 +262,7 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
}
if len(yamlValidationErrors) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadmeParsing,
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
@@ -274,7 +272,7 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
// Todo: Need to beef up this function by grabbing each image/video URL from
// the body's AST.
func validateCoderResourceRelativeUrls(_ map[string]coderResourceReadme) error {
func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error {
return nil
}
@@ -321,7 +319,7 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
if len(errs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseFileLoad,
phase: validationPhaseFile,
errors: errs,
}
}
@@ -338,17 +336,16 @@ func validateAllCoderResourceFilesOfType(resourceType string) error {
return err
}
log.Printf("Processing %d README files\n", len(allReadmeFiles))
logger.Info(context.Background(), "rocessing README files", "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
log.Printf("Processed %d README files as valid Coder resources with type %q", len(resources), resourceType)
logger.Info(context.Background(), "rocessed README files as valid Coder resources", "num_files", len(resources), "type", resourceType)
err = validateCoderResourceRelativeUrls(resources)
if err != nil {
if err := validateCoderResourceRelativeURLs(resources); err != nil {
return err
}
log.Printf("All relative URLs for %s READMEs are valid\n", resourceType)
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "type", resourceType)
return nil
}
+29 -36
View File
@@ -1,7 +1,7 @@
package main
import (
"log"
"context"
"net/url"
"os"
"path"
@@ -50,6 +50,9 @@ func validateContributorLinkedinURL(linkedinURL *string) error {
return nil
}
// validateContributorSupportEmail does best effort validation of a contributors email address. We can't 100% validate
// that this is correct without actually sending an email, especially because some contributors are individual developers
// and we don't want to do that on every single run of the CI pipeline. The best we can do is verify the general structure.
func validateContributorSupportEmail(email *string) []error {
if email == nil {
return nil
@@ -57,10 +60,6 @@ func validateContributorSupportEmail(email *string) []error {
errs := []error{}
// Can't 100% validate that this is correct without actually sending
// an email, and especially with some contributors being individual
// developers, we don't want to do that on every single run of the CI
// pipeline. Best we can do is verify the general structure.
username, server, ok := strings.Cut(*email, "@")
if !ok {
errs = append(errs, xerrors.Errorf("email address %q is missing @ symbol", *email))
@@ -110,21 +109,18 @@ func validateContributorStatus(status string) error {
return nil
}
// Can't validate the image actually leads to a valid resource in a pure
// function, but can at least catch obvious problems.
// Can't validate the image actually leads to a valid resource in a pure function, but can at least catch obvious problems.
func validateContributorAvatarURL(avatarURL *string) []error {
if avatarURL == nil {
return nil
}
errs := []error{}
if *avatarURL == "" {
errs = append(errs, xerrors.New("avatar URL must be omitted or non-empty string"))
return errs
return []error{xerrors.New("avatar URL must be omitted or non-empty string")}
}
// Have to use .Parse instead of .ParseRequestURI because this is the
// one field that's allowed to be a relative URL.
errs := []error{}
// Have to use .Parse instead of .ParseRequestURI because this is the one field that's allowed to be a relative URL.
if _, err := url.Parse(*avatarURL); err != nil {
errs = append(errs, xerrors.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
}
@@ -132,7 +128,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
errs = append(errs, xerrors.New("avatar URL is not allowed to contain search parameters"))
}
matched := false
var matched bool
for _, ff := range supportedAvatarFileFormats {
matched = strings.HasSuffix(*avatarURL, ff)
if matched {
@@ -210,22 +206,21 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
}
if len(yamlParsingErrors) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadmeParsing,
phase: validationPhaseReadme,
errors: yamlParsingErrors,
}
}
yamlValidationErrors := []error{}
for _, p := range profilesByNamespace {
errors := validateContributorReadme(p)
if len(errors) > 0 {
if errors := validateContributorReadme(p); len(errors) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errors...)
continue
}
}
if len(yamlValidationErrors) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadmeParsing,
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
@@ -241,12 +236,13 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
allReadmeFiles := []readme{}
errs := []error{}
dirPath := ""
for _, e := range dirEntries {
dirPath := path.Join(rootRegistryPath, e.Name())
if !e.IsDir() {
continue
}
dirPath = path.Join(rootRegistryPath, e.Name())
readmePath := path.Join(dirPath, "README.md")
rmBytes, err := os.ReadFile(readmePath)
if err != nil {
@@ -261,7 +257,7 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
if len(errs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseFileLoad,
phase: validationPhaseFile,
errors: errs,
}
}
@@ -269,18 +265,18 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
return allReadmeFiles, nil
}
func validateContributorRelativeUrls(contributors map[string]contributorProfileReadme) error {
// This function only validates relative avatar URLs for now, but it can be
// beefed up to validate more in the future.
func validateContributorRelativeURLs(contributors map[string]contributorProfileReadme) error {
// This function only validates relative avatar URLs for now, but it can be beefed up to validate more in the future.
var errs []error
for _, con := range contributors {
// If the avatar URL is missing, we'll just assume that the Registry site build step will take care of filling
// in the data properly.
if con.frontmatter.AvatarURL == nil {
continue
}
isRelativeURL := strings.HasPrefix(*con.frontmatter.AvatarURL, ".") ||
strings.HasPrefix(*con.frontmatter.AvatarURL, "/")
if !isRelativeURL {
if !strings.HasPrefix(*con.frontmatter.AvatarURL, ".") || !strings.HasPrefix(*con.frontmatter.AvatarURL, "/") {
continue
}
@@ -291,10 +287,8 @@ func validateContributorRelativeUrls(contributors map[string]contributorProfileR
continue
}
absolutePath := strings.TrimSuffix(con.filePath, "README.md") +
*con.frontmatter.AvatarURL
_, err := os.Stat(absolutePath)
if err != nil {
absolutePath := strings.TrimSuffix(con.filePath, "README.md") + *con.frontmatter.AvatarURL
if _, err := os.ReadFile(absolutePath); err != nil {
errs = append(errs, xerrors.Errorf("%q: relative avatar path %q does not point to image in file system", con.filePath, absolutePath))
}
}
@@ -303,7 +297,7 @@ func validateContributorRelativeUrls(contributors map[string]contributorProfileR
return nil
}
return validationPhaseError{
phase: validationPhaseAssetCrossReference,
phase: validationPhaseCrossReference,
errors: errs,
}
}
@@ -314,19 +308,18 @@ func validateAllContributorFiles() error {
return err
}
log.Printf("Processing %d README files\n", len(allReadmeFiles))
logger.Info(context.Background(), "processing README files", "num_files", len(allReadmeFiles))
contributors, err := parseContributorFiles(allReadmeFiles)
if err != nil {
return err
}
log.Printf("Processed %d README files as valid contributor profiles", len(contributors))
logger.Info(context.Background(), "processed README files as valid contributor profiles", "num_contributors", len(contributors))
err = validateContributorRelativeUrls(contributors)
if err != nil {
if err := validateContributorRelativeURLs(contributors); err != nil {
return err
}
log.Println("All relative URLs for READMEs are valid")
logger.Info(context.Background(), "all relative URLs for READMEs are valid")
log.Printf("Processed all READMEs in the %q directory\n", rootRegistryPath)
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
return nil
}
+2 -4
View File
@@ -6,10 +6,8 @@ import (
"golang.org/x/xerrors"
)
// validationPhaseError represents an error that occurred during a specific
// phase of README validation. It should be used to collect ALL validation
// errors that happened during a specific phase, rather than the first one
// encountered.
// validationPhaseError represents an error that occurred during a specific phase of README validation. It should be
// used to collect ALL validation errors that happened during a specific phase, rather than the first one encountered.
type validationPhaseError struct {
phase validationPhase
errors []error
+3 -5
View File
@@ -7,7 +7,6 @@ package main
import (
"context"
"log"
"os"
"cdr.dev/slog"
@@ -19,12 +18,11 @@ var logger = slog.Make(sloghuman.Sink(os.Stdout))
func main() {
logger.Info(context.Background(), "starting README validation")
// If there are fundamental problems with how the repo is structured, we
// can't make any guarantees that any further validations will be relevant
// or accurate.
// If there are fundamental problems with how the repo is structured, we can't make any guarantees that any further
// validations will be relevant or accurate.
err := validateRepoStructure()
if err != nil {
log.Println(err)
logger.Error(context.Background(), "error when validating the repo structure", "error", err.Error())
os.Exit(1)
}
+59 -63
View File
@@ -2,26 +2,55 @@ package main
import (
"bufio"
"fmt"
"regexp"
"strings"
"golang.org/x/xerrors"
)
const rootRegistryPath = "./registry"
// validationPhase represents a specific phase during README validation. It is expected that each phase is discrete, and
// errors during one will prevent a future phase from starting.
type validationPhase string
var supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
const (
rootRegistryPath = "./registry"
// readme represents a single README file within the repo (usually within the
// top-level "/registry" directory).
// --- validationPhases ---
// validationPhaseStructure indicates when the entire Registry
// directory is being verified for having all files be placed in the file
// system as expected.
validationPhaseStructure validationPhase = "File structure validation"
// ValidationPhaseFile indicates when README files are being read from
// the file system.
validationPhaseFile validationPhase = "Filesystem reading"
// ValidationPhaseReadme indicates when a README's frontmatter is
// being parsed as YAML. This phase does not include YAML validation.
validationPhaseReadme validationPhase = "README parsing"
// ValidationPhaseCrossReference indicates when a README's frontmatter
// is having all its relative URLs be validated for whether they point to
// valid resources.
validationPhaseCrossReference validationPhase = "Cross-referencing relative asset URLs"
// --- end of validationPhases ---.
)
var (
supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
// Matches markdown headers, must be at the beginning of a line, such as "# " or "### ".
readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`)
)
// readme represents a single README file within the repo (usually within the top-level "/registry" directory).
type readme struct {
filePath string
rawText string
}
// separateFrontmatter attempts to separate a README file's frontmatter content
// from the main README body, returning both values in that order. It does not
// validate whether the structure of the frontmatter is valid (i.e., that it's
// separateFrontmatter attempts to separate a README file's frontmatter content from the main README body, returning
// both values in that order. It does not validate whether the structure of the frontmatter is valid (i.e., that it's
// structured as YAML).
func separateFrontmatter(readmeText string) (readmeFrontmatter string, readmeBody string, err error) {
if readmeText == "" {
@@ -29,8 +58,9 @@ func separateFrontmatter(readmeText string) (readmeFrontmatter string, readmeBod
}
const fence = "---"
fm := ""
body := ""
var fm strings.Builder
var body strings.Builder
fenceCount := 0
lineScanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(readmeText)))
@@ -40,36 +70,32 @@ func separateFrontmatter(readmeText string) (readmeFrontmatter string, readmeBod
fenceCount++
continue
}
// Break early if the very first line wasn't a fence, because then we
// know for certain that the README has problems.
// Break early if the very first line wasn't a fence, because then we know for certain that the README has problems.
if fenceCount == 0 {
break
}
// It should be safe to trim each line of the frontmatter on a per-line
// basis, because there shouldn't be any extra meaning attached to the
// indentation. The same does NOT apply to the README; best we can do is
// gather all the lines, and then trim around it.
// It should be safe to trim each line of the frontmatter on a per-line basis, because there shouldn't be any
// extra meaning attached to the indentation. The same does NOT apply to the README; best we can do is gather
// all the lines and then trim around it.
if inReadmeBody := fenceCount >= 2; inReadmeBody {
body += nextLine + "\n"
fmt.Fprintf(&body, "%s\n", nextLine)
} else {
fm += strings.TrimSpace(nextLine) + "\n"
fmt.Fprintf(&fm, "%s\n", strings.TrimSpace(nextLine))
}
}
if fenceCount < 2 {
return "", "", xerrors.New("README does not have two sets of frontmatter fences")
}
if fm == "" {
if fm.Len() == 0 {
return "", "", xerrors.New("readme has frontmatter fences but no frontmatter content")
}
return fm, strings.TrimSpace(body), nil
return fm.String(), strings.TrimSpace(body.String()), nil
}
var readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`)
// Todo: This seems to work okay for now, but the really proper way of doing
// this is by parsing this as an AST, and then checking the resulting nodes.
// TODO: This seems to work okay for now, but the really proper way of doing this is by parsing this as an AST, and then
// checking the resulting nodes.
func validateReadmeBody(body string) []error {
trimmed := strings.TrimSpace(body)
@@ -77,9 +103,8 @@ func validateReadmeBody(body string) []error {
return []error{xerrors.New("README body is empty")}
}
// If the very first line of the README, there's a risk that the rest of the
// validation logic will break, since we don't have many guarantees about
// how the README is actually structured.
// If the very first line of the README doesn't start with an ATX-style H1 header, there's a risk that the rest of the
// validation logic will break, since we don't have many guarantees about how the README is actually structured.
if !strings.HasPrefix(trimmed, "# ") {
return []error{xerrors.New("README body must start with ATX-style h1 header (i.e., \"# \")")}
}
@@ -93,9 +118,8 @@ func validateReadmeBody(body string) []error {
for lineScanner.Scan() {
nextLine := lineScanner.Text()
// Have to check this because a lot of programming languages support #
// comments (including Terraform), and without any context, there's no
// way to tell the difference between a markdown header and code comment.
// Have to check this because a lot of programming languages support # comments (including Terraform), and
// without any context, there's no way to tell the difference between a markdown header and code comment.
if strings.HasPrefix(nextLine, "```") {
isInCodeBlock = !isInCodeBlock
continue
@@ -109,8 +133,8 @@ func validateReadmeBody(body string) []error {
continue
}
spaceAfterHeader := headerGroups[2]
if spaceAfterHeader == "" {
// In the Markdown spec it is mandatory to have a space following the header # symbol(s).
if headerGroups[2] == "" {
errs = append(errs, xerrors.New("header does not have space between header characters and main header text"))
}
@@ -121,8 +145,7 @@ func validateReadmeBody(body string) []error {
continue
}
// If we have obviously invalid headers, it's not really safe to keep
// proceeding with the rest of the content.
// If we have obviously invalid headers, it's not really safe to keep proceeding with the rest of the content.
if nextHeaderLevel == 1 {
errs = append(errs, xerrors.New("READMEs cannot contain more than h1 header"))
break
@@ -132,43 +155,16 @@ func validateReadmeBody(body string) []error {
break
}
// This is something we need to enforce for accessibility, not just for
// the Registry website, but also when users are viewing the README
// files in the GitHub web view.
// This is something we need to enforce for accessibility, not just for the Registry website, but also when
// users are viewing the README files in the GitHub web view.
if nextHeaderLevel > latestHeaderLevel && nextHeaderLevel != (latestHeaderLevel+1) {
errs = append(errs, xerrors.New("headers are not allowed to increase more than 1 level at a time"))
continue
}
// As long as the above condition passes, there's no problems with
// going up a header level or going down 1+ header levels.
// As long as the above condition passes, there's no problems with going up a header level or going down 1+ header levels.
latestHeaderLevel = nextHeaderLevel
}
return errs
}
// validationPhase represents a specific phase during README validation. It is
// expected that each phase is discrete, and errors during one will prevent a
// future phase from starting.
type validationPhase string
const (
// ValidationPhaseFileStructureValidation indicates when the entire Registry
// directory is being verified for having all files be placed in the file
// system as expected.
validationPhaseFileStructureValidation validationPhase = "File structure validation"
// ValidationPhaseFileLoad indicates when README files are being read from
// the file system.
validationPhaseFileLoad = "Filesystem reading"
// ValidationPhaseReadmeParsing indicates when a README's frontmatter is
// being parsed as YAML. This phase does not include YAML validation.
validationPhaseReadmeParsing = "README parsing"
// ValidationPhaseAssetCrossReference indicates when a README's frontmatter
// is having all its relative URLs be validated for whether they point to
// valid resources.
validationPhaseAssetCrossReference = "Cross-referencing relative asset URLs"
)
+21 -34
View File
@@ -13,40 +13,33 @@ import (
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images")
func validateCoderResourceSubdirectory(dirPath string) []error {
errs := []error{}
subDir, err := os.Stat(dirPath)
if err != nil {
// It's valid for a specific resource directory not to exist. It's just
// that if it does exist, it must follow specific rules.
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow specific rules.
if !errors.Is(err, os.ErrNotExist) {
errs = append(errs, addFilePathToError(dirPath, err))
return []error{addFilePathToError(dirPath, err)}
}
return errs
}
if !subDir.IsDir() {
errs = append(errs, xerrors.Errorf("%q: path is not a directory", dirPath))
return errs
return []error{xerrors.Errorf("%q: path is not a directory", dirPath)}
}
files, err := os.ReadDir(dirPath)
if err != nil {
errs = append(errs, addFilePathToError(dirPath, err))
return errs
return []error{addFilePathToError(dirPath, err)}
}
errs := []error{}
for _, f := range files {
// The .coder subdirectories are sometimes generated as part of Bun
// tests. These subdirectories will never be committed to the repo, but
// in the off chance that they don't get cleaned up properly, we want to
// skip over them.
// The .coder subdirectories are sometimes generated as part of Bun tests. These subdirectories will never be
// committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over them.
if !f.IsDir() || f.Name() == ".coder" {
continue
}
resourceReadmePath := path.Join(dirPath, f.Name(), "README.md")
_, err := os.Stat(resourceReadmePath)
if err != nil {
if _, err := os.Stat(resourceReadmePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, xerrors.Errorf("%q: 'README.md' does not exist", resourceReadmePath))
} else {
@@ -55,8 +48,7 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
}
mainTerraformPath := path.Join(dirPath, f.Name(), "main.tf")
_, err = os.Stat(mainTerraformPath)
if err != nil {
if _, err := os.Stat(mainTerraformPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, xerrors.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath))
} else {
@@ -64,7 +56,6 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
}
}
}
return errs
}
@@ -83,8 +74,7 @@ func validateRegistryDirectory() []error {
}
contributorReadmePath := path.Join(dirPath, "README.md")
_, err := os.Stat(contributorReadmePath)
if err != nil {
if _, err := os.Stat(contributorReadmePath); err != nil {
allErrs = append(allErrs, err)
}
@@ -95,8 +85,7 @@ func validateRegistryDirectory() []error {
}
for _, f := range files {
// Todo: Decide if there's anything more formal that we want to
// ensure about non-directories scoped to user namespaces.
// TODO: Decide if there's anything more formal that we want to ensure about non-directories scoped to user namespaces.
if !f.IsDir() {
continue
}
@@ -110,8 +99,7 @@ func validateRegistryDirectory() []error {
}
if slices.Contains(supportedResourceTypes, segment) {
errs := validateCoderResourceSubdirectory(filePath)
if len(errs) != 0 {
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
allErrs = append(allErrs, errs...)
}
}
@@ -122,20 +110,19 @@ func validateRegistryDirectory() []error {
}
func validateRepoStructure() error {
var problems []error
if errs := validateRegistryDirectory(); len(errs) != 0 {
problems = append(problems, errs...)
var errs []error
if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 {
errs = append(errs, vrdErrs...)
}
_, err := os.Stat("./.icons")
if err != nil {
problems = append(problems, xerrors.New("missing top-level .icons directory (used for storing reusable Coder resource icons)"))
if _, err := os.Stat("./.icons"); err != nil {
errs = append(errs, xerrors.New("missing top-level .icons directory (used for storing reusable Coder resource icons)"))
}
if len(problems) != 0 {
if len(errs) != 0 {
return validationPhaseError{
phase: validationPhaseFileStructureValidation,
errors: problems,
phase: validationPhaseStructure,
errors: errs,
}
}
return nil
+3 -3
View File
@@ -20,7 +20,7 @@ require (
github.com/rivo/uniseg v0.4.4 // indirect
go.opentelemetry.io/otel v1.16.0 // indirect
go.opentelemetry.io/otel/trace v1.16.0 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/term v0.10.0 // indirect
)
+10 -10
View File
@@ -51,17 +51,17 @@ go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiM
go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4=
go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs=
go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e h1:xIXmWJ303kJCuogpj0bHq+dcjcZHU+XFyc1I0Yl9cRg=
+3 -3
View File
@@ -14,7 +14,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "1.3.0"
version = "1.3.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -88,7 +88,7 @@ resource "coder_agent" "main" {
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
version = "1.1.0"
version = "1.3.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -107,7 +107,7 @@ Run Claude Code as a standalone app in your workspace. This will install Claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "1.3.0"
version = "1.3.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
+7 -19
View File
@@ -131,6 +131,11 @@ resource "coder_script" "claude_code" {
npm install -g @anthropic-ai/claude-code@${var.claude_code_version}
fi
if [ "${var.experiment_report_tasks}" = "true" ]; then
echo "Configuring Claude Code to report tasks via Coder MCP..."
coder exp mcp configure claude-code ${var.folder}
fi
# Run post-install script if provided
if [ -n "${local.encoded_post_install_script}" ]; then
echo "Running post-install script..."
@@ -139,11 +144,6 @@ resource "coder_script" "claude_code" {
/tmp/post_install.sh
fi
if [ "${var.experiment_report_tasks}" = "true" ]; then
echo "Configuring Claude Code to report tasks via Coder MCP..."
coder exp mcp configure claude-code ${var.folder}
fi
# Handle terminal multiplexer selection (tmux or screen)
if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then
echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously."
@@ -167,14 +167,8 @@ resource "coder_script" "claude_code" {
export LC_ALL=en_US.UTF-8
# Create a new tmux session in detached mode
tmux new-session -d -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions"
tmux new-session -d -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions \"$CODER_MCP_CLAUDE_TASK_PROMPT\""
# Send the prompt to the tmux session if needed
if [ -n "$CODER_MCP_CLAUDE_TASK_PROMPT" ]; then
tmux send-keys -t claude-code "$CODER_MCP_CLAUDE_TASK_PROMPT"
sleep 5
tmux send-keys -t claude-code Enter
fi
fi
# Run with screen if enabled
@@ -209,15 +203,9 @@ resource "coder_script" "claude_code" {
screen -U -dmS claude-code bash -c '
cd ${var.folder}
claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"
claude --dangerously-skip-permissions "$CODER_MCP_CLAUDE_TASK_PROMPT" | tee -a "$HOME/.claude-code.log"
exec bash
'
# Extremely hacky way to send the prompt to the screen session
# This will be fixed in the future, but `claude` was not sending MCP
# tasks when an initial prompt is provided.
screen -S claude-code -X stuff "$CODER_MCP_CLAUDE_TASK_PROMPT"
sleep 5
screen -S claude-code -X stuff "^M"
else
# Check if claude is installed before running
if ! command_exists claude; then