Compare commits

..

11 Commits

Author SHA1 Message Date
ケイラ 8e051a8e2c feat: add group attributes to all modules (#123)
Goes along with https://github.com/coder/coder/issues/8237

Most people probably gets apps from modules, and so to group them we'll
need new versions of aaaaaall of these modules.

Also some were missing `order`, which is related and intertwined pretty
closely in the implementation of `group`, so add it where necessary.
2025-05-30 16:52:55 -06:00
Charlie Voiselle 67b27550cd fix: update tf highlight example in CONTRIBUTING (#126)
Leveraging an extra backtick in the wrapper to encourage the middle
sample to render properly.
2025-05-30 15:58:29 -04:00
Hugo Dutka 3a8fa168d0 fix(claude-code): create folder if it does not exist (#124) 2025-05-30 19:40:23 +02:00
Charlie Voiselle c5a21e07a4 feat: add path-based sharing for kasm (#115)
Co-authored-by: Atif Ali <atif@coder.com>
2025-05-30 21:09:27 +05:00
Callum Styan 8b6a80b82d ci: add golangci-lint and fix existing lint failures (#118)
This PR adds `golangci-lint` based on the configuration from
`coder/coder`
([here](https://github.com/coder/coder/blob/main/.golangci.yaml)) then
migrated to v2 using `golangci-lint migrate` plus the addition of few
more linters.

---------

Signed-off-by: Callum Styan <callumstyan@gmail.com>
2025-05-29 10:24:35 -07:00
dependabot[bot] 3a54a3132f chore(deps): bump golang.org/x/crypto from 0.11.0 to 0.35.0 in the go_modules group across 1 directory (#120)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-28 09:45:12 +05:00
Michael Smith d004200d93 chore: add avatars for each contributor (#117)
## Changes made
- Added avatars for each contributor
- Updated validation steps to account for avatars, if the avatar field
exists
- Went ahead and made some slog changes, ahead of @cstyan's refactor PR
2025-05-27 23:29:20 -04:00
Birdie Kingston a8d92df7d5 feat(vault-token): add optional vault enterprise namespace variable (#108)
Added an optional envvar to vault-token module to handle communicating
with a non default vault namespace.

in vault enterprise, you can run multiple secure isolated vault
environments from the one vault server.
each namespace has it's own authentication methods and secrets engines. 
vault uses the VAULT_NAMESPACE envvar to determine the namespace to use.
no value, or either `root` or `/` will use the root (default) namespace,
any other value will use a different namespace

in vault community edition, the only supported namespace is "root", no
other namespaces can be used.

in HCP vault dedicated (the saas hosted version), you cant access vault
without a namespace set

this defaults to not setting the env var, so is backwards compatible,
and works with vault CE

---------

Co-authored-by: Birdie K <5210502+moo-im-a-cow@users.noreply.github.com>
2025-05-27 19:57:14 -05:00
Danny Kopping 5a3ade7cd4 chore: add security notice for --dangerously-skip-permissions in Claude module (#116)
Let's be upfront about how our module works so operators/template
authors can evaluate the security implications.

Signed-off-by: Danny Kopping <dannykopping@gmail.com>
2025-05-23 11:43:03 +02:00
DevCats b1a1103f7e Cat/jetbrains gateway trailing slash fix (#114)
Made Trailing Slash optional in regex validation for folder

closes https://github.com/coder/registry/issues/61
2025-05-22 21:02:38 -05:00
DevCats afa23b8d3c feat(goose): Add tmux support and Session Name variable to Goose (#113)
- Add Multiplexer, and tmux with mouse support.
- Add check to make sure tmux and screen are not set at the same time.
- Add Variable for session name so the name of the screen or tmux
session can be customized.

Tested in dev environment.

Reference: https://github.com/coder/registry/issues/31
2025-05-21 19:50:57 -05:00
59 changed files with 1278 additions and 477 deletions
+24
View File
@@ -0,0 +1,24 @@
name: golangci-lint
on:
push:
branches:
- main
- master
pull_request:
permissions:
contents: read
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.1
+213
View File
@@ -0,0 +1,213 @@
version: "2"
linters:
default: none
enable:
- asciicheck
- bidichk
- bodyclose
- dogsled
- dupl
- errcheck
- errname
- errorlint
- exhaustruct
- forcetypeassert
- gocognit
- gocritic
- godot
- gomodguard
- gosec
- govet
- importas
- ineffassign
- makezero
- misspell
- nestif
- nilnil
# - noctx
# - paralleltest
- revive
- staticcheck
# - tparallel
- unconvert
- unused
settings:
dupl:
threshold: 412
godot:
scope: all
capital: true
exhaustruct:
include:
- httpmw\.\w+
- github.com/coder/coder/v2/coderd/database\.[^G][^e][^t]\w+Params
gocognit:
min-complexity: 300
goconst:
min-len: 4
min-occurrences: 3
gocritic:
enabled-checks:
- badLock
- badRegexp
- boolExprSimplify
- builtinShadow
- builtinShadowDecl
- commentedOutImport
- deferUnlambda
- dupImport
- dynamicFmtString
- emptyDecl
- emptyFallthrough
- emptyStringTest
- evalOrder
- externalErrorReassign
- filepathJoin
- hexLiteral
- httpNoBody
- importShadow
- indexAlloc
- initClause
- methodExprCall
- nestingReduce
- nilValReturn
- preferFilepathJoin
- rangeAppendAll
- regexpPattern
- redundantSprint
- regexpSimplify
- ruleguard
- sliceClear
- sortSlice
- sprintfQuotedString
- sqlQuery
- stringConcatSimplify
- stringXbytes
- todoCommentWithoutDetail
- tooManyResultsChecker
- truncateCmp
- typeAssertChain
- typeDefFirst
- unlabelStmt
- weakCond
- whyNoLint
settings:
ruleguard:
failOn: all
rules: ${base-path}/scripts/rules.go
gosec:
excludes:
- G601
govet:
disable:
- loopclosure
importas:
no-unaliased: true
misspell:
locale: US
ignore-rules:
- trialer
nestif:
min-complexity: 20
revive:
severity: warning
rules:
- name: atomic
- name: bare-return
- name: blank-imports
- name: bool-literal-in-expr
- name: call-to-gc
- name: confusing-results
- name: constant-logical-expr
- name: context-as-argument
- name: context-keys-type
# - name: deep-exit
- name: defer
- name: dot-imports
- name: duplicated-imports
- name: early-return
- name: empty-block
- name: empty-lines
- name: error-naming
- name: error-return
- name: error-strings
- name: errorf
- name: exported
- name: flag-parameter
- name: get-return
- name: identical-branches
- name: if-return
- name: import-shadowing
- name: increment-decrement
- name: indent-error-flow
- name: modifies-value-receiver
- name: package-comments
- name: range
- name: receiver-naming
- name: redefines-builtin-id
- name: string-of-int
- name: struct-tag
- name: superfluous-else
- name: time-naming
- name: unconditional-recursion
- name: unexported-naming
- name: unexported-return
- name: unhandled-error
- name: unnecessary-stmt
- name: unreachable-code
- name: unused-parameter
- name: unused-receiver
- name: var-declaration
- name: var-naming
- name: waitgroup-by-value
staticcheck:
checks:
- all
- SA4006 # Detects redundant assignments
- SA4009 # Detects redundant variable declarations
- SA1019
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- errcheck
- exhaustruct
- forcetypeassert
path: _test\.go
- linters:
- exhaustruct
path: scripts/*
- linters:
- ALL
path: scripts/rules.go
paths:
- scripts/rules.go
- coderd/database/dbmem
- node_modules
- .git
- third_party$
- builtin$
- examples$
issues:
max-issues-per-linter: 0
max-same-issues: 0
fix: true
formatters:
enable:
- goimports
- gofmt
exclusions:
generated: lax
paths:
- scripts/rules.go
- coderd/database/dbmem
- node_modules
- .git
- third_party$
- builtin$
- examples$
+3 -3
View File
@@ -179,11 +179,11 @@ The following criteria exists for two reasons:
- When increasing the level of a header, the header's level must be incremented by one each time.
- Any `.hcl` code snippets must be labeled as `.tf` snippets instead
```txt
\`\`\`tf
````txt
```tf
Content
\`\`\`
```
````
#### Namespace (contributor profile) criteria
+30 -30
View File
@@ -3,7 +3,6 @@ package main
import (
"bufio"
"errors"
"fmt"
"log"
"net/url"
"os"
@@ -12,6 +11,7 @@ import (
"slices"
"strings"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
@@ -27,7 +27,7 @@ type coderResourceFrontmatter struct {
// coderResourceReadme represents a README describing a Terraform resource used
// to help create Coder workspaces. As of 2025-04-15, this encapsulates both
// Coder Modules and Coder Templates
// Coder Modules and Coder Templates.
type coderResourceReadme struct {
resourceType string
filePath string
@@ -37,14 +37,14 @@ type coderResourceReadme struct {
func validateCoderResourceDisplayName(displayName *string) error {
if displayName != nil && *displayName == "" {
return errors.New("if defined, display_name must not be empty string")
return xerrors.New("if defined, display_name must not be empty string")
}
return nil
}
func validateCoderResourceDescription(description string) error {
if description == "" {
return errors.New("frontmatter description cannot be empty")
return xerrors.New("frontmatter description cannot be empty")
}
return nil
}
@@ -53,29 +53,29 @@ func validateCoderResourceIconURL(iconURL string) []error {
problems := []error{}
if iconURL == "" {
problems = append(problems, errors.New("icon URL cannot be empty"))
problems = append(problems, xerrors.New("icon URL cannot be empty"))
return problems
}
isAbsoluteURL := !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/")
if isAbsoluteURL {
if _, err := url.ParseRequestURI(iconURL); err != nil {
problems = append(problems, errors.New("absolute icon URL is not correctly formatted"))
problems = append(problems, xerrors.New("absolute icon URL is not correctly formatted"))
}
if strings.Contains(iconURL, "?") {
problems = append(problems, errors.New("icon URLs cannot contain query parameters"))
problems = append(problems, xerrors.New("icon URLs cannot contain query parameters"))
}
return problems
}
// 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
// repo, and where this logic will run.
isPermittedRelativeURL := strings.HasPrefix(iconURL, "./") ||
strings.HasPrefix(iconURL, "/") ||
strings.HasPrefix(iconURL, "../../../../.icons")
if !isPermittedRelativeURL {
problems = append(problems, fmt.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))
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))
}
return problems
@@ -83,7 +83,7 @@ func validateCoderResourceIconURL(iconURL string) []error {
func validateCoderResourceTags(tags []string) error {
if tags == nil {
return errors.New("provided tags array is nil")
return xerrors.New("provided tags array is nil")
}
if len(tags) == 0 {
return nil
@@ -91,7 +91,7 @@ func validateCoderResourceTags(tags []string) error {
// 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
// URL without issue.
invalidTags := []string{}
for _, t := range tags {
if t != url.QueryEscape(t) {
@@ -100,7 +100,7 @@ func validateCoderResourceTags(tags []string) error {
}
if len(invalidTags) != 0 {
return fmt.Errorf("found invalid tags (tags that cannot be used for filter state in the Registry website): [%s]", strings.Join(invalidTags, ", "))
return xerrors.Errorf("found invalid tags (tags that cannot be used for filter state in the Registry website): [%s]", strings.Join(invalidTags, ", "))
}
return nil
}
@@ -110,7 +110,7 @@ func validateCoderResourceTags(tags []string) error {
// 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+=")
var terraformVersionRe = regexp.MustCompile(`^\s*\bversion\s+=`)
func validateCoderResourceReadmeBody(body string) []error {
trimmed := strings.TrimSpace(body)
@@ -132,7 +132,7 @@ func validateCoderResourceReadmeBody(body string) []error {
// 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
// first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
@@ -147,7 +147,7 @@ func validateCoderResourceReadmeBody(body string) []error {
terraformCodeBlockCount++
}
if strings.HasPrefix(nextLine, "```hcl") {
errs = append(errs, errors.New("all .hcl language references must be converted to .tf"))
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
}
continue
}
@@ -160,34 +160,34 @@ func validateCoderResourceReadmeBody(body string) []error {
}
// Code assumes that we can treat this case as the end of the "h1
// section" and don't need to process any further lines
// 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
// references made via [] syntax.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
}
if terraformCodeBlockCount == 0 {
errs = append(errs, errors.New("did not find Terraform code block within h1 section"))
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
} else {
if terraformCodeBlockCount > 1 {
errs = append(errs, errors.New("cannot have more than one Terraform code block in h1 section"))
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
}
if !foundTerraformVersionRef {
errs = append(errs, errors.New("did not find Terraform code block that specifies 'version' field"))
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
}
}
if !foundParagraph {
errs = append(errs, errors.New("did not find paragraph within h1 section"))
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
}
if isInsideCodeBlock {
errs = append(errs, errors.New("code blocks inside h1 section do not all terminate before end of file"))
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
}
return errs
@@ -220,12 +220,12 @@ func validateCoderResourceReadme(rm coderResourceReadme) []error {
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, error) {
fm, body, err := separateFrontmatter(rm.rawText)
if err != nil {
return coderResourceReadme{}, fmt.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
}
yml := coderResourceFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return coderResourceReadme{}, fmt.Errorf("%q: failed to parse: %v", rm.filePath, err)
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
}
return coderResourceReadme{
@@ -257,9 +257,9 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
yamlValidationErrors := []error{}
for _, readme := range resources {
errors := validateCoderResourceReadme(readme)
if len(errors) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errors...)
errs := validateCoderResourceReadme(readme)
if len(errs) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errs...)
}
}
if len(yamlValidationErrors) != 0 {
@@ -273,8 +273,8 @@ 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(resources map[string]coderResourceReadme) error {
// the body's AST.
func validateCoderResourceRelativeUrls(_ map[string]coderResourceReadme) error {
return nil
}
@@ -330,7 +330,7 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
func validateAllCoderResourceFilesOfType(resourceType string) error {
if !slices.Contains(supportedResourceTypes, resourceType) {
return fmt.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
return xerrors.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
}
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
+36 -40
View File
@@ -1,8 +1,6 @@
package main
import (
"errors"
"fmt"
"log"
"net/url"
"os"
@@ -10,21 +8,20 @@ import (
"slices"
"strings"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
var validContributorStatuses = []string{"official", "partner", "community"}
type contributorProfileFrontmatter struct {
DisplayName string `yaml:"display_name"`
Bio string `yaml:"bio"`
ContributorStatus string `yaml:"status"`
// Script assumes that if avatar URL is nil, the Registry site build step
// will backfill the value with the user's GitHub avatar URL
AvatarURL *string `yaml:"avatar"`
LinkedinURL *string `yaml:"linkedin"`
WebsiteURL *string `yaml:"website"`
SupportEmail *string `yaml:"support_email"`
DisplayName string `yaml:"display_name"`
Bio string `yaml:"bio"`
ContributorStatus string `yaml:"status"`
AvatarURL *string `yaml:"avatar"`
LinkedinURL *string `yaml:"linkedin"`
WebsiteURL *string `yaml:"website"`
SupportEmail *string `yaml:"support_email"`
}
type contributorProfileReadme struct {
@@ -35,7 +32,7 @@ type contributorProfileReadme struct {
func validateContributorDisplayName(displayName string) error {
if displayName == "" {
return fmt.Errorf("missing display_name")
return xerrors.New("missing display_name")
}
return nil
@@ -47,7 +44,7 @@ func validateContributorLinkedinURL(linkedinURL *string) error {
}
if _, err := url.ParseRequestURI(*linkedinURL); err != nil {
return fmt.Errorf("linkedIn URL %q is not valid: %v", *linkedinURL, err)
return xerrors.Errorf("linkedIn URL %q is not valid: %v", *linkedinURL, err)
}
return nil
@@ -63,31 +60,31 @@ func validateContributorSupportEmail(email *string) []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
// pipeline. Best we can do is verify the general structure.
username, server, ok := strings.Cut(*email, "@")
if !ok {
errs = append(errs, fmt.Errorf("email address %q is missing @ symbol", *email))
errs = append(errs, xerrors.Errorf("email address %q is missing @ symbol", *email))
return errs
}
if username == "" {
errs = append(errs, fmt.Errorf("email address %q is missing username", *email))
errs = append(errs, xerrors.Errorf("email address %q is missing username", *email))
}
domain, tld, ok := strings.Cut(server, ".")
if !ok {
errs = append(errs, fmt.Errorf("email address %q is missing period for server segment", *email))
errs = append(errs, xerrors.Errorf("email address %q is missing period for server segment", *email))
return errs
}
if domain == "" {
errs = append(errs, fmt.Errorf("email address %q is missing domain", *email))
errs = append(errs, xerrors.Errorf("email address %q is missing domain", *email))
}
if tld == "" {
errs = append(errs, fmt.Errorf("email address %q is missing top-level domain", *email))
errs = append(errs, xerrors.Errorf("email address %q is missing top-level domain", *email))
}
if strings.Contains(*email, "?") {
errs = append(errs, errors.New("email is not allowed to contain query parameters"))
errs = append(errs, xerrors.New("email is not allowed to contain query parameters"))
}
return errs
@@ -99,7 +96,7 @@ func validateContributorWebsite(websiteURL *string) error {
}
if _, err := url.ParseRequestURI(*websiteURL); err != nil {
return fmt.Errorf("linkedIn URL %q is not valid: %v", *websiteURL, err)
return xerrors.Errorf("linkedIn URL %q is not valid: %v", *websiteURL, err)
}
return nil
@@ -107,14 +104,14 @@ func validateContributorWebsite(websiteURL *string) error {
func validateContributorStatus(status string) error {
if !slices.Contains(validContributorStatuses, status) {
return fmt.Errorf("contributor status %q is not valid", status)
return xerrors.Errorf("contributor status %q is not valid", status)
}
return nil
}
// Can't validate the image actually leads to a valid resource in a pure
// function, but can at least catch obvious problems
// function, but can at least catch obvious problems.
func validateContributorAvatarURL(avatarURL *string) []error {
if avatarURL == nil {
return nil
@@ -122,17 +119,17 @@ func validateContributorAvatarURL(avatarURL *string) []error {
errs := []error{}
if *avatarURL == "" {
errs = append(errs, errors.New("avatar URL must be omitted or non-empty string"))
errs = append(errs, xerrors.New("avatar URL must be omitted or non-empty string"))
return errs
}
// Have to use .Parse instead of .ParseRequestURI because this is the
// one field that's allowed to be a relative URL
// one field that's allowed to be a relative URL.
if _, err := url.Parse(*avatarURL); err != nil {
errs = append(errs, fmt.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
errs = append(errs, xerrors.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
}
if strings.Contains(*avatarURL, "?") {
errs = append(errs, errors.New("avatar URL is not allowed to contain search parameters"))
errs = append(errs, xerrors.New("avatar URL is not allowed to contain search parameters"))
}
matched := false
@@ -145,7 +142,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
if !matched {
segments := strings.Split(*avatarURL, ".")
fileExtension := segments[len(segments)-1]
errs = append(errs, fmt.Errorf("avatar URL '.%s' does not end in a supported file format: [%s]", fileExtension, strings.Join(supportedAvatarFileFormats, ", ")))
errs = append(errs, xerrors.Errorf("avatar URL '.%s' does not end in a supported file format: [%s]", fileExtension, strings.Join(supportedAvatarFileFormats, ", ")))
}
return errs
@@ -180,12 +177,12 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
fm, _, err := separateFrontmatter(rm.rawText)
if err != nil {
return contributorProfileReadme{}, fmt.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
}
yml := contributorProfileFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return contributorProfileReadme{}, fmt.Errorf("%q: failed to parse: %v", rm.filePath, err)
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
}
return contributorProfileReadme{
@@ -206,7 +203,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
}
if prev, alreadyExists := profilesByNamespace[p.namespace]; alreadyExists {
yamlParsingErrors = append(yamlParsingErrors, fmt.Errorf("%q: namespace %q conflicts with namespace from %q", p.filePath, p.namespace, prev.filePath))
yamlParsingErrors = append(yamlParsingErrors, xerrors.Errorf("%q: namespace %q conflicts with namespace from %q", p.filePath, p.namespace, prev.filePath))
continue
}
profilesByNamespace[p.namespace] = p
@@ -274,12 +271,9 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
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
errs := []error{}
// 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
}
@@ -290,16 +284,18 @@ func validateContributorRelativeUrls(contributors map[string]contributorProfileR
continue
}
if strings.HasPrefix(*con.frontmatter.AvatarURL, "..") {
errs = append(errs, fmt.Errorf("%q: relative avatar URLs cannot be placed outside a user's namespaced directory", con.filePath))
isAvatarInApprovedSpot := strings.HasPrefix(*con.frontmatter.AvatarURL, "./.images/") ||
strings.HasPrefix(*con.frontmatter.AvatarURL, ".images/")
if !isAvatarInApprovedSpot {
errs = append(errs, xerrors.Errorf("%q: relative avatar URLs cannot be placed outside a user's namespaced directory", con.filePath))
continue
}
absolutePath := strings.TrimSuffix(con.filePath, "README.md") +
*con.frontmatter.AvatarURL
_, err := os.ReadFile(absolutePath)
_, err := os.Stat(absolutePath)
if err != nil {
errs = append(errs, fmt.Errorf("%q: relative avatar path %q does not point to image in file system", con.filePath, *con.frontmatter.AvatarURL))
errs = append(errs, xerrors.Errorf("%q: relative avatar path %q does not point to image in file system", con.filePath, absolutePath))
}
}
+6 -2
View File
@@ -1,6 +1,10 @@
package main
import "fmt"
import (
"fmt"
"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
@@ -24,5 +28,5 @@ func (vpe validationPhaseError) Error() string {
}
func addFilePathToError(filePath string, err error) error {
return fmt.Errorf("%q: %v", filePath, err)
return xerrors.Errorf("%q: %v", filePath, err)
}
+14 -9
View File
@@ -6,25 +6,30 @@
package main
import (
"fmt"
"context"
"log"
"os"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
)
var logger = slog.Make(sloghuman.Sink(os.Stdout))
func main() {
log.Println("Starting README validation")
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
repoErr := validateRepoStructure()
if repoErr != nil {
log.Println(repoErr)
// or accurate.
err := validateRepoStructure()
if err != nil {
log.Println(err)
os.Exit(1)
}
var errs []error
err := validateAllContributorFiles()
err = validateAllContributorFiles()
if err != nil {
errs = append(errs, err)
}
@@ -34,11 +39,11 @@ func main() {
}
if len(errs) == 0 {
log.Printf("Processed all READMEs in the %q directory\n", rootRegistryPath)
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
os.Exit(0)
}
for _, err := range errs {
fmt.Println(err)
logger.Error(context.Background(), err.Error())
}
os.Exit(1)
}
+26 -30
View File
@@ -2,10 +2,10 @@ package main
import (
"bufio"
"errors"
"fmt"
"regexp"
"strings"
"golang.org/x/xerrors"
)
const rootRegistryPath = "./registry"
@@ -23,9 +23,9 @@ type readme struct {
// 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) (string, string, error) {
func separateFrontmatter(readmeText string) (readmeFrontmatter string, readmeBody string, err error) {
if readmeText == "" {
return "", "", errors.New("README is empty")
return "", "", xerrors.New("README is empty")
}
const fence = "---"
@@ -41,7 +41,7 @@ func separateFrontmatter(readmeText string) (string, string, error) {
continue
}
// Break early if the very first line wasn't a fence, because then we
// know for certain that the README has problems
// know for certain that the README has problems.
if fenceCount == 0 {
break
}
@@ -49,7 +49,7 @@ func separateFrontmatter(readmeText string) (string, string, error) {
// 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
// gather all the lines, and then trim around it.
if inReadmeBody := fenceCount >= 2; inReadmeBody {
body += nextLine + "\n"
} else {
@@ -57,31 +57,31 @@ func separateFrontmatter(readmeText string) (string, string, error) {
}
}
if fenceCount < 2 {
return "", "", errors.New("README does not have two sets of frontmatter fences")
return "", "", xerrors.New("README does not have two sets of frontmatter fences")
}
if fm == "" {
return "", "", errors.New("readme has frontmatter fences but no frontmatter content")
return "", "", xerrors.New("readme has frontmatter fences but no frontmatter content")
}
return fm, strings.TrimSpace(body), nil
}
var readmeHeaderRe = regexp.MustCompile("^(#{1,})(\\s*)")
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
// this is by parsing this as an AST, and then checking the resulting nodes.
func validateReadmeBody(body string) []error {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return []error{errors.New("README body is empty")}
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
// how the README is actually structured.
if !strings.HasPrefix(trimmed, "# ") {
return []error{errors.New("README body must start with ATX-style h1 header (i.e., \"# \")")}
return []error{xerrors.New("README body must start with ATX-style h1 header (i.e., \"# \")")}
}
var errs []error
@@ -95,7 +95,7 @@ func validateReadmeBody(body string) []error {
// 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
// way to tell the difference between a markdown header and code comment.
if strings.HasPrefix(nextLine, "```") {
isInCodeBlock = !isInCodeBlock
continue
@@ -111,7 +111,7 @@ func validateReadmeBody(body string) []error {
spaceAfterHeader := headerGroups[2]
if spaceAfterHeader == "" {
errs = append(errs, errors.New("header does not have space between header characters and main header text"))
errs = append(errs, xerrors.New("header does not have space between header characters and main header text"))
}
nextHeaderLevel := len(headerGroups[1])
@@ -122,26 +122,26 @@ func validateReadmeBody(body string) []error {
}
// If we have obviously invalid headers, it's not really safe to keep
// proceeding with the rest of the content
// proceeding with the rest of the content.
if nextHeaderLevel == 1 {
errs = append(errs, errors.New("READMEs cannot contain more than h1 header"))
errs = append(errs, xerrors.New("READMEs cannot contain more than h1 header"))
break
}
if nextHeaderLevel > 6 {
errs = append(errs, fmt.Errorf("README/HTML files cannot have headers exceed level 6 (found level %d)", nextHeaderLevel))
errs = append(errs, xerrors.Errorf("README/HTML files cannot have headers exceed level 6 (found level %d)", nextHeaderLevel))
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
// files in the GitHub web view.
if nextHeaderLevel > latestHeaderLevel && nextHeaderLevel != (latestHeaderLevel+1) {
errs = append(errs, fmt.Errorf("headers are not allowed to increase more than 1 level at a time"))
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
// going up a header level or going down 1+ header levels.
latestHeaderLevel = nextHeaderLevel
}
@@ -154,24 +154,20 @@ func validateReadmeBody(body string) []error {
type validationPhase string
const (
// validationPhaseFileStructureValidation indicates when the entire Registry
// 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 indicates when README files are being read from
// the file system.
validationPhaseFileLoad = "Filesystem reading"
// validationPhaseReadmeParsing indicates when a README's frontmatter is
// ValidationPhaseReadmeParsing indicates when a README's frontmatter is
// being parsed as YAML. This phase does not include YAML validation.
validationPhaseReadmeParsing = "README parsing"
// validationPhaseReadmeValidation indicates when a README's frontmatter is
// being validated as proper YAML with expected keys.
validationPhaseReadmeValidation = "README validation"
// validationPhaseAssetCrossReference indicates when a README's frontmatter
// 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"
+12 -12
View File
@@ -2,14 +2,15 @@ package main
import (
"errors"
"fmt"
"os"
"path"
"slices"
"strings"
"golang.org/x/xerrors"
)
var supportedUserNameSpaceDirectories = append(supportedResourceTypes[:], ".icons", ".images")
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images")
func validateCoderResourceSubdirectory(dirPath string) []error {
errs := []error{}
@@ -17,7 +18,7 @@ func validateCoderResourceSubdirectory(dirPath string) []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
// that if it does exist, it must follow specific rules.
if !errors.Is(err, os.ErrNotExist) {
errs = append(errs, addFilePathToError(dirPath, err))
}
@@ -25,7 +26,7 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
}
if !subDir.IsDir() {
errs = append(errs, fmt.Errorf("%q: path is not a directory", dirPath))
errs = append(errs, xerrors.Errorf("%q: path is not a directory", dirPath))
return errs
}
@@ -38,7 +39,7 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
// 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
// skip over them.
if !f.IsDir() || f.Name() == ".coder" {
continue
}
@@ -47,7 +48,7 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
_, err := os.Stat(resourceReadmePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("%q: 'README.md' does not exist", resourceReadmePath))
errs = append(errs, xerrors.Errorf("%q: 'README.md' does not exist", resourceReadmePath))
} else {
errs = append(errs, addFilePathToError(resourceReadmePath, err))
}
@@ -57,12 +58,11 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
_, err = os.Stat(mainTerraformPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath))
errs = append(errs, xerrors.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath))
} else {
errs = append(errs, addFilePathToError(mainTerraformPath, err))
}
}
}
return errs
@@ -78,7 +78,7 @@ func validateRegistryDirectory() []error {
for _, d := range userDirs {
dirPath := path.Join(rootRegistryPath, d.Name())
if !d.IsDir() {
allErrs = append(allErrs, fmt.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
continue
}
@@ -96,7 +96,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
// ensure about non-directories scoped to user namespaces.
if !f.IsDir() {
continue
}
@@ -105,7 +105,7 @@ func validateRegistryDirectory() []error {
filePath := path.Join(dirPath, segment)
if !slices.Contains(supportedUserNameSpaceDirectories, segment) {
allErrs = append(allErrs, fmt.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", ")))
allErrs = append(allErrs, xerrors.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", ")))
continue
}
@@ -129,7 +129,7 @@ func validateRepoStructure() error {
_, err := os.Stat("./.icons")
if err != nil {
problems = append(problems, errors.New("missing top-level .icons directory (used for storing reusable Coder resource icons)"))
problems = append(problems, xerrors.New("missing top-level .icons directory (used for storing reusable Coder resource icons)"))
}
if len(problems) != 0 {
-167
View File
@@ -1,167 +0,0 @@
package main
import (
"encoding/json"
"fmt"
)
type terraformLifecycleCondition struct {
// Any terraform expression that evaluates to a boolean
expression string
errorMessage *string
}
type terraformLifecycle struct {
createBeforeDestroy bool
preventDestroy bool
ignoreChanges []string
replaceTriggeredBy []string
preCondition *terraformLifecycleCondition
postCondition *terraformLifecycleCondition
}
type terraformForEachKind string
const (
terraformForEachKindMap terraformForEachKind = "map"
terraformForEachKindSet terraformForEachKind = "set"
)
type terraformForEach struct {
kind terraformForEachKind
// If the kind is "map", all values should be guaranteed to be a definite
// string for each key. If it's "set", all values should be nil
values map[string]*string
}
type terraformVariableKind string
const (
terraformVariableKindString terraformVariableKind = "string"
terraformVariableKindNumber terraformVariableKind = "number"
terraformVariableKindBool terraformVariableKind = "bool"
terraformVariableKindList terraformVariableKind = "list"
terraformVariableKindMap terraformVariableKind = "map"
terraformVariableKindSet terraformVariableKind = "set"
// This value kind is incredibly rare. It corresponds to any variable with
// a null value but no defined type
terraformVariableKindUnknown terraformVariableKind = "unknown"
)
type terraformVariable struct {
kind terraformVariableKind
value any
}
// coderTerraformModule represents the values that can be parsed from a
// Terraform module that are relevant to a Coder deployment. Most of the fields
// are derived from the main Terraform spec, but some additional Coder-specific
// fields are appended, too, for convenience
type coderTerraformModule struct {
// Corresponds to the optional `lifecycle` metadata field available to all
// resource types
Lifecycle *terraformLifecycle `json:"lifecycle"`
// Corresponds to the optional `for_each` metadata field available to all
// resource types
ForEach *terraformForEach `json:"for_each"`
// Corresponds to the `source` field in a module block
ModuleSource string `json:"module_source"`
// Corresponds to the `version` field in Terraform module blocks. Note that
// while the Terraform spec marks this field as optional, Coder requires
// that one always be defined.
Version string `json:"version"`
// Corresponds to the optional `provider` field in a module block
Provider *string `json:"provider"`
// Corresponds to optional `depends_on` field for module blocks
DependsOn []string `json:"depends_on"`
// Corresponds to the `count` field for any Terraform resource type. It
// defines the number of resource instances to create when using Terraform
// Apply.
InstanceCount int `json:"instance_count"`
// Corresponds to `agent_id`` field in a module block. Terraform doesn't
// have any built-in concept of an agent_id, but it's needed to make a
// module work with a Coder deployment
AgentID string `json:"agent_id"`
// Captures all other arbitrary values defined for a Terraform module block.
// Note that while Terraform itself has you define all other fields at the
// same level as the well-known/official fields, they've been isolated into
// a map for the Go struct definition to improve type-safety
Values map[string]terraformVariable `json:"values"`
// The raw Terraform snippet used to derive the coderTerraformModule struct
SourceCode string `json:"source_code"`
}
var _ json.Marshaler = &coderTerraformModule{}
func (ctmCopy coderTerraformModule) MarshalJSON() ([]byte, error) {
if ctmCopy.Lifecycle != nil {
lCopy := &terraformLifecycle{
createBeforeDestroy: ctmCopy.Lifecycle.createBeforeDestroy,
preventDestroy: ctmCopy.Lifecycle.preventDestroy,
ignoreChanges: ctmCopy.Lifecycle.ignoreChanges,
replaceTriggeredBy: ctmCopy.Lifecycle.replaceTriggeredBy,
preCondition: ctmCopy.Lifecycle.preCondition,
postCondition: ctmCopy.Lifecycle.postCondition,
}
// Make sure that both slices always get serialized as JSON null if
// they're empty. Serializing as an empty JSON array has no extra
// semantics
if len(lCopy.ignoreChanges) == 0 {
lCopy.ignoreChanges = nil
}
if len(lCopy.replaceTriggeredBy) == 0 {
lCopy.replaceTriggeredBy = nil
}
ctmCopy.Lifecycle = lCopy
}
if ctmCopy.ForEach != nil {
feCopy := &terraformForEach{
kind: ctmCopy.ForEach.kind,
values: ctmCopy.ForEach.values,
}
// Make sure that the values map is NEVER serialized as JSON null
if feCopy.values == nil {
feCopy.values = make(map[string]*string)
}
ctmCopy.ForEach = feCopy
}
if len(ctmCopy.DependsOn) == 0 {
ctmCopy.DependsOn = nil
}
if ctmCopy.Values == nil {
ctmCopy.Values = make(map[string]terraformVariable)
}
for k, tfValue := range ctmCopy.Values {
switch tfValue.kind {
case terraformVariableKindString, terraformVariableKindBool, terraformVariableKindNumber, terraformVariableKindUnknown:
continue
case terraformVariableKindSet, terraformVariableKindList:
recast, ok := tfValue.value.([]any)
if !ok {
return nil, fmt.Errorf("unable to process terraform variable %q of kind %q as set/list", k, tfValue.kind)
}
if recast == nil {
ctmCopy.Values[k] = terraformVariable{
kind: tfValue.kind,
value: []any{},
}
}
case terraformVariableKindMap:
recast, ok := tfValue.value.(map[string]any)
if !ok {
return nil, fmt.Errorf("unable to process terraform variable %q of kind %q as map", k, tfValue.kind)
}
if recast == nil {
ctmCopy.Values[k] = terraformVariable{
kind: tfValue.kind,
value: make(map[string]any),
}
}
}
}
return json.Marshal(ctmCopy)
}
+4 -4
View File
@@ -15,7 +15,7 @@ tags: [helper]
module "MODULE_NAME" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder"
version = "1.0.2"
version = "1.0.0"
}
```
@@ -31,7 +31,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
module "MODULE_NAME" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder"
version = "1.0.2"
version = "1.0.0"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -49,7 +49,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "MODULE_NAME" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder"
version = "1.0.2"
version = "1.0.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -65,7 +65,7 @@ Run code-server in the background, don't fetch it from GitHub:
```tf
module "MODULE_NAME" {
source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder"
version = "1.0.2"
version = "1.0.0"
agent_id = coder_agent.example.id
offline = true
}
+1 -2
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
version = ">= 2.5"
}
}
}
@@ -105,4 +105,3 @@ data "coder_parameter" "MODULE_NAME" {
}
}
}
+22 -1
View File
@@ -2,4 +2,25 @@ module coder.com/coder-registry
go 1.23.2
require gopkg.in/yaml.v3 v3.0.1
require (
cdr.dev/slog v1.6.1
github.com/quasilyte/go-ruleguard/dsl v0.3.22
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v0.7.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
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
)
+76
View File
@@ -1,3 +1,79 @@
cdr.dev/slog v1.6.1 h1:IQjWZD0x6//sfv5n+qEhbu3wBkmtBQY5DILXNvMaIv4=
cdr.dev/slog v1.6.1/go.mod h1:eHEYQLaZvxnIAXC+XdTSNLb/kgA/X2RVSF72v5wsxEI=
cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/logging v1.7.0 h1:CJYxlNNNNAMkHp9em/YEXcfJg+rPDg7YfwoRpMU+t5I=
cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M=
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE=
github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s=
go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4=
go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo=
go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4=
go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiMWgE=
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/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/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=
google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:0ggbjUrZYpy1q+ANUS30SEoGZ53cdfwtbuG7Ptgy108=
google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 h1:XVeBY8d/FaK4848myy41HBqnDwvxeV3zMZhwN1TvAMU=
google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:mPBs5jNgx2GuQGvFwUvVKqtn6HsUw9nP64BedgvqEsQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 h1:2FZP5XuJY9zQyGM5N0rtovnoXjiMUEIUMvw0m9wlpLc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o=
google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+1
View File
@@ -2,6 +2,7 @@
display_name: Coder
bio: Coder provisions cloud development environments via Terraform, supporting Linux, macOS, Windows, X86, ARM, Kubernetes and more.
github: coder
avatar: ./.images/avatar.png
linkedin: https://www.linkedin.com/company/coderhq
website: https://www.coder.com
status: official
+6 -6
View File
@@ -14,7 +14,7 @@ Run [Aider](https://aider.chat) AI pair programming in your workspace. This modu
```tf
module "aider" {
source = "registry.coder.com/coder/aider/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -69,7 +69,7 @@ variable "anthropic_api_key" {
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
ai_api_key = var.anthropic_api_key
}
@@ -94,7 +94,7 @@ variable "openai_api_key" {
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
use_tmux = true
ai_provider = "openai"
@@ -115,7 +115,7 @@ variable "custom_api_key" {
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
ai_provider = "custom"
custom_env_var_name = "MY_CUSTOM_API_KEY"
@@ -132,7 +132,7 @@ You can extend Aider's capabilities by adding custom extensions:
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
ai_api_key = var.anthropic_api_key
@@ -211,7 +211,7 @@ data "coder_parameter" "ai_prompt" {
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
ai_api_key = var.anthropic_api_key
task_prompt = data.coder_parameter.ai_prompt.value
+40 -33
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
version = ">= 2.5"
}
}
}
@@ -24,6 +24,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "icon" {
type = string
description = "The icon to use for the app."
@@ -224,17 +230,17 @@ resource "coder_script" "aider" {
}
echo "Setting up Aider AI pair programming..."
if [ "${var.use_screen}" = "true" ] && [ "${var.use_tmux}" = "true" ]; then
echo "Error: Both use_screen and use_tmux cannot be enabled at the same time."
exit 1
fi
mkdir -p "${var.folder}"
if [ "$(uname)" = "Linux" ]; then
echo "Checking dependencies for Linux..."
if [ "${var.use_tmux}" = "true" ]; then
if ! command_exists tmux; then
echo "Installing tmux for persistent sessions..."
@@ -296,7 +302,7 @@ resource "coder_script" "aider" {
if [ "${var.install_aider}" = "true" ]; then
echo "Installing Aider..."
if ! command_exists python3 || ! command_exists pip3; then
echo "Installing Python dependencies required for Aider..."
if command -v apt-get >/dev/null 2>&1; then
@@ -319,37 +325,37 @@ resource "coder_script" "aider" {
else
echo "Python is already installed, skipping installation."
fi
if ! command_exists aider; then
curl -LsSf https://aider.chat/install.sh | sh
fi
if [ -f "$HOME/.bashrc" ]; then
if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.bashrc"; then
echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.bashrc"
fi
fi
if [ -f "$HOME/.zshrc" ]; then
if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.zshrc"; then
echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.zshrc"
fi
fi
fi
if [ -n "${local.encoded_post_install_script}" ]; then
echo "Running post-install script..."
echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh
chmod +x /tmp/post_install.sh
/tmp/post_install.sh
fi
if [ "${var.experiment_report_tasks}" = "true" ]; then
echo "Configuring Aider to report tasks via Coder MCP..."
mkdir -p "$HOME/.config/aider"
cat > "$HOME/.config/aider/config.yml" << EOL
${trimspace(local.combined_extensions)}
EOL
@@ -357,29 +363,29 @@ EOL
fi
echo "Starting persistent Aider session..."
touch "$HOME/.aider.log"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
export PATH="$HOME/bin:$PATH"
if [ "${var.use_tmux}" = "true" ]; then
if [ -n "${var.task_prompt}" ]; then
echo "Running Aider with message in tmux session..."
# Configure tmux for shared sessions
if [ ! -f "$HOME/.tmux.conf" ]; then
echo "Creating ~/.tmux.conf with shared session settings..."
echo "set -g mouse on" > "$HOME/.tmux.conf"
fi
if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then
echo "Adding 'set -g mouse on' to ~/.tmux.conf..."
echo "set -g mouse on" >> "$HOME/.tmux.conf"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
tmux new-session -d -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\""
echo "Aider task started in tmux session '${var.session_name}'. Check the UI for progress."
@@ -389,12 +395,12 @@ EOL
echo "Creating ~/.tmux.conf with shared session settings..."
echo "set -g mouse on" > "$HOME/.tmux.conf"
fi
if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then
echo "Adding 'set -g mouse on' to ~/.tmux.conf..."
echo "set -g mouse on" >> "$HOME/.tmux.conf"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
tmux new-session -d -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${var.system_prompt}\""
echo "Tmux session '${var.session_name}' started. Access it by clicking the Aider button."
@@ -402,12 +408,12 @@ EOL
else
if [ -n "${var.task_prompt}" ]; then
echo "Running Aider with message in screen session..."
if [ ! -f "$HOME/.screenrc" ]; then
echo "Creating ~/.screenrc and adding multiuser settings..."
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..."
echo "multiuser on" >> "$HOME/.screenrc"
@@ -417,7 +423,7 @@ EOL
echo "Adding 'acladd $(whoami)' to ~/.screenrc..."
echo "acladd $(whoami)" >> "$HOME/.screenrc"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
screen -U -dmS ${var.session_name} bash -c "
cd ${var.folder}
@@ -426,15 +432,15 @@ EOL
aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"
/bin/bash
"
echo "Aider task started in screen session '${var.session_name}'. Check the UI for progress."
else
if [ ! -f "$HOME/.screenrc" ]; then
echo "Creating ~/.screenrc and adding multiuser settings..."
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..."
echo "multiuser on" >> "$HOME/.screenrc"
@@ -444,7 +450,7 @@ EOL
echo "Adding 'acladd $(whoami)' to ~/.screenrc..."
echo "acladd $(whoami)" >> "$HOME/.screenrc"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
screen -U -dmS ${var.session_name} bash -c "
cd ${var.folder}
@@ -456,7 +462,7 @@ EOL
echo "Screen session '${var.session_name}' started. Access it by clicking the Aider button."
fi
fi
echo "Aider setup complete!"
EOT
run_on_start = true
@@ -471,12 +477,12 @@ resource "coder_app" "aider_cli" {
command = <<-EOT
#!/bin/bash
set -e
export PATH="$HOME/bin:$HOME/.local/bin:$PATH"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
if [ "${var.use_tmux}" = "true" ]; then
if tmux has-session -t ${var.session_name} 2>/dev/null; then
echo "Attaching to existing Aider tmux session..."
@@ -499,4 +505,5 @@ resource "coder_app" "aider_cli" {
fi
EOT
order = var.order
group = var.group
}
@@ -19,7 +19,7 @@ Enable DCV Server and Web Client on Windows workspaces.
module "dcv" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/amazon-dcv-windows/coder"
version = "1.0.24"
version = "1.1.0"
agent_id = resource.coder_agent.main.id
}
@@ -4,11 +4,23 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
version = ">= 2.5"
}
}
}
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
@@ -45,6 +57,8 @@ resource "coder_app" "web-dcv" {
url = "https://localhost:${var.port}${local.web_url_path}?username=${local.admin_username}&password=${var.admin_password}"
icon = "/icon/dcv.svg"
subdomain = var.subdomain
order = var.order
group = var.group
}
resource "coder_script" "install-dcv" {
+4 -4
View File
@@ -14,7 +14,7 @@ Run [Amazon Q](https://aws.amazon.com/q/) in your workspace to access Amazon's A
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
# Required: see below for how to generate
experiment_auth_tarball = var.amazon_q_auth_tarball
@@ -82,7 +82,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
experiment_auth_tarball = var.amazon_q_auth_tarball
experiment_use_tmux = true
@@ -94,7 +94,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
experiment_auth_tarball = var.amazon_q_auth_tarball
experiment_report_tasks = true
@@ -106,7 +106,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
experiment_auth_tarball = var.amazon_q_auth_tarball
experiment_pre_install_script = "echo Pre-install!"
+17 -9
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
version = ">= 2.5"
}
}
}
@@ -24,6 +24,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "icon" {
type = string
description = "The icon to use for the app."
@@ -213,7 +219,7 @@ resource "coder_script" "amazon_q" {
fi
if [ "${var.experiment_report_tasks}" = "true" ]; then
echo "Configuring Amazon Q to report tasks via Coder MCP..."
echo "Configuring Amazon Q to report tasks via Coder MCP..."
mkdir -p ~/.aws/amazonq
echo "${local.encoded_mcp_json}" | base64 -d > ~/.aws/amazonq/mcp.json
echo "Created the ~/.aws/amazonq/mcp.json configuration file"
@@ -227,19 +233,19 @@ resource "coder_script" "amazon_q" {
if [ "${var.experiment_use_tmux}" = "true" ]; then
echo "Running Amazon Q in the background with tmux..."
if ! command_exists tmux; then
echo "Error: tmux is not installed. Please install tmux manually."
exit 1
fi
touch "$HOME/.amazon-q.log"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
tmux new-session -d -s amazon-q -c "${var.folder}" "q chat --trust-all-tools | tee -a "$HOME/.amazon-q.log" && exec bash"
tmux send-keys -t amazon-q "${local.full_prompt}"
sleep 5
tmux send-keys -t amazon-q Enter
@@ -247,7 +253,7 @@ resource "coder_script" "amazon_q" {
if [ "${var.experiment_use_screen}" = "true" ]; then
echo "Running Amazon Q in the background..."
if ! command_exists screen; then
echo "Error: screen is not installed. Please install screen manually."
exit 1
@@ -259,7 +265,7 @@ resource "coder_script" "amazon_q" {
echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.amazon-q.log"
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.amazon-q.log"
echo "multiuser on" >> "$HOME/.screenrc"
@@ -271,7 +277,7 @@ resource "coder_script" "amazon_q" {
fi
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
screen -U -dmS amazon-q bash -c '
cd ${var.folder}
q chat --trust-all-tools | tee -a "$HOME/.amazon-q.log
@@ -326,4 +332,6 @@ resource "coder_app" "amazon_q" {
fi
EOT
icon = var.icon
order = var.order
group = var.group
}
+7 -2
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.2.1"
version = "1.3.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -22,6 +22,11 @@ module "claude-code" {
}
```
> **Security Notice**: This module uses the [`--dangerously-skip-permissions`](https://docs.anthropic.com/en/docs/claude-code/cli-usage#cli-flags) flag when running Claude Code. This flag
> bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While
> this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as
> the user running it. Use this module _only_ in trusted environments and be aware of the security implications.
## Prerequisites
- Node.js and npm must be installed in your workspace to install Claude Code
@@ -102,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.2.1"
version = "1.3.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
+26 -8
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
version = ">= 2.5"
}
}
}
@@ -24,6 +24,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "icon" {
type = string
description = "The icon to use for the app."
@@ -97,6 +103,16 @@ resource "coder_script" "claude_code" {
command -v "$1" >/dev/null 2>&1
}
# Check if the specified folder exists
if [ ! -d "${var.folder}" ]; then
echo "Warning: The specified folder '${var.folder}' does not exist."
echo "Creating the folder..."
# The folder must exist before tmux is started or else claude will start
# in the home directory.
mkdir -p "${var.folder}"
echo "Folder created successfully."
fi
# Run pre-install script if provided
if [ -n "${local.encoded_pre_install_script}" ]; then
echo "Running pre-install script..."
@@ -138,7 +154,7 @@ resource "coder_script" "claude_code" {
# Run with tmux if enabled
if [ "${var.experiment_use_tmux}" = "true" ]; then
echo "Running Claude Code in the background with tmux..."
# Check if tmux is installed
if ! command_exists tmux; then
echo "Error: tmux is not installed. Please install tmux manually."
@@ -146,13 +162,13 @@ resource "coder_script" "claude_code" {
fi
touch "$HOME/.claude-code.log"
export LANG=en_US.UTF-8
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"
# 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"
@@ -164,7 +180,7 @@ resource "coder_script" "claude_code" {
# Run with screen if enabled
if [ "${var.experiment_use_screen}" = "true" ]; then
echo "Running Claude Code in the background..."
# Check if screen is installed
if ! command_exists screen; then
echo "Error: screen is not installed. Please install screen manually."
@@ -178,7 +194,7 @@ resource "coder_script" "claude_code" {
echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.claude-code.log"
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log"
echo "multiuser on" >> "$HOME/.screenrc"
@@ -190,7 +206,7 @@ resource "coder_script" "claude_code" {
fi
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
screen -U -dmS claude-code bash -c '
cd ${var.folder}
claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"
@@ -246,4 +262,6 @@ resource "coder_app" "claude_code" {
fi
EOT
icon = var.icon
order = var.order
group = var.group
}
+7 -7
View File
@@ -15,7 +15,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.2.0"
version = "1.3.0"
agent_id = coder_agent.example.id
}
```
@@ -30,7 +30,7 @@ module "code-server" {
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.2.0"
version = "1.3.0"
agent_id = coder_agent.example.id
install_version = "4.8.3"
}
@@ -44,7 +44,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.2.0"
version = "1.3.0"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -62,7 +62,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.2.0"
version = "1.3.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -79,7 +79,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.2.0"
version = "1.3.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
@@ -95,7 +95,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.2.0"
version = "1.3.0"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@@ -108,7 +108,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.2.0"
version = "1.3.0"
agent_id = coder_agent.example.id
offline = true
}
+8 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.1"
version = ">= 2.5"
}
}
}
@@ -89,6 +89,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "offline" {
type = bool
description = "Just run code-server in the background, don't fetch it from GitHub"
@@ -187,6 +193,7 @@ resource "coder_app" "code-server" {
subdomain = var.subdomain
share = var.share
order = var.order
group = var.group
open_in = var.open_in
healthcheck {
+2 -2
View File
@@ -17,7 +17,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
}
```
@@ -30,7 +30,7 @@ module "cursor" {
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
+8 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.23"
version = ">= 2.5"
}
}
}
@@ -32,6 +32,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "slug" {
type = string
description = "The slug of the app."
@@ -54,6 +60,7 @@ resource "coder_app" "cursor" {
slug = var.slug
display_name = var.display_name
order = var.order
group = var.group
url = join("", [
"cursor://coder.coder-remote/open",
"?owner=",
+6 -6
View File
@@ -19,7 +19,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.0.29"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -32,7 +32,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.0.29"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -43,7 +43,7 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.0.29"
version = "1.1.0"
agent_id = coder_agent.example.id
user = "root"
}
@@ -55,14 +55,14 @@ module "dotfiles" {
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.0.29"
version = "1.1.0"
agent_id = coder_agent.example.id
}
module "dotfiles-root" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.0.29"
version = "1.1.0"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
@@ -77,7 +77,7 @@ You can set a default dotfiles repository for all users by setting the `default_
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.0.29"
version = "1.1.0"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}
+15 -1
View File
@@ -4,11 +4,23 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.12"
version = ">= 2.5"
}
}
}
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
@@ -79,6 +91,8 @@ resource "coder_app" "dotfiles" {
display_name = "Refresh Dotfiles"
slug = "dotfiles"
icon = "/icon/dotfiles.svg"
order = var.order
group = var.group
command = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user
+4 -4
View File
@@ -15,7 +15,7 @@ A file browser for your workspace.
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.0.31"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -30,7 +30,7 @@ module "filebrowser" {
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.0.31"
version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -42,7 +42,7 @@ module "filebrowser" {
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.0.31"
version = "1.1.0"
agent_id = coder_agent.example.id
database_path = ".config/filebrowser.db"
}
@@ -54,7 +54,7 @@ module "filebrowser" {
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.0.31"
version = "1.1.0"
agent_id = coder_agent.example.id
agent_name = "main"
subdomain = false
+9 -2
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
version = ">= 2.5"
}
}
}
@@ -68,6 +68,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "slug" {
type = string
description = "The slug of the coder_app resource."
@@ -108,6 +114,7 @@ resource "coder_app" "filebrowser" {
subdomain = var.subdomain
share = var.share
order = var.order
group = var.group
healthcheck {
url = local.healthcheck_url
@@ -120,4 +127,4 @@ locals {
server_base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug)
url = "http://localhost:${var.port}${local.server_base_path}"
healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/health"
}
}
+11 -7
View File
@@ -14,7 +14,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener
```tf
module "goose" {
source = "registry.coder.com/coder/goose/coder"
version = "1.1.1"
version = "1.3.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
@@ -24,14 +24,14 @@ module "goose" {
## Prerequisites
- `screen` must be installed in your workspace to run Goose in the background
- `screen` or `tmux` must be installed in your workspace to run Goose in the background
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces.
## Examples
Your workspace must have `screen` installed to use this.
Your workspace must have `screen` or `tmux` installed to use the background session functionality.
### Run in the background and report tasks (Experimental)
@@ -90,7 +90,7 @@ resource "coder_agent" "main" {
module "goose" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/goose/coder"
version = "1.1.1"
version = "1.3.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
@@ -99,8 +99,12 @@ module "goose" {
# Enable experimental features
experiment_report_tasks = true
# Run Goose in the background
# Run Goose in the background with screen (pick one: screen or tmux)
experiment_use_screen = true
# experiment_use_tmux = true # Alternative: use tmux instead of screen
# Optional: customize the session name (defaults to "goose")
# session_name = "goose-session"
# Avoid configuring Goose manually
experiment_auto_configure = true
@@ -143,12 +147,12 @@ Note: The indentation in the heredoc is preserved, so you can write the YAML nat
## Run standalone
Run Goose as a standalone app in your workspace. This will install Goose and run it directly without using screen or any task reporting to the Coder UI.
Run Goose as a standalone app in your workspace. This will install Goose and run it directly without using screen or tmux, and without any task reporting to the Coder UI.
```tf
module "goose" {
source = "registry.coder.com/coder/goose/coder"
version = "1.1.1"
version = "1.3.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
+87 -34
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
version = ">= 2.5"
}
}
}
@@ -24,6 +24,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "icon" {
type = string
description = "The icon to use for the app."
@@ -54,6 +60,18 @@ variable "experiment_use_screen" {
default = false
}
variable "experiment_use_tmux" {
type = bool
description = "Whether to use tmux instead of screen for running Goose in the background."
default = false
}
variable "session_name" {
type = string
description = "Name for the persistent session (screen or tmux)"
default = "goose"
}
variable "experiment_report_tasks" {
type = bool
description = "Whether to enable task reporting."
@@ -182,15 +200,59 @@ GOOSE_MODEL: ${var.experiment_goose_model}
${trimspace(local.combined_extensions)}
EOL
fi
# Write system prompt to config
mkdir -p "$HOME/.config/goose"
echo "$GOOSE_SYSTEM_PROMPT" > "$HOME/.config/goose/.goosehints"
# Run with screen if enabled
if [ "${var.experiment_use_screen}" = "true" ]; then
# 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."
echo "Please set only one of them to true."
exit 1
fi
# Determine goose command
if command_exists goose; then
GOOSE_CMD=goose
elif [ -f "$HOME/.local/bin/goose" ]; then
GOOSE_CMD="$HOME/.local/bin/goose"
else
echo "Error: Goose is not installed. Please enable install_goose or install it manually."
exit 1
fi
# Run with tmux if enabled
if [ "${var.experiment_use_tmux}" = "true" ]; then
echo "Running Goose in the background with tmux..."
# Check if tmux is installed
if ! command_exists tmux; then
echo "Error: tmux is not installed. Please install tmux manually."
exit 1
fi
touch "$HOME/.goose.log"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
# Configure tmux for shared sessions
if [ ! -f "$HOME/.tmux.conf" ]; then
echo "Creating ~/.tmux.conf with shared session settings..."
echo "set -g mouse on" > "$HOME/.tmux.conf"
fi
if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then
echo "Adding 'set -g mouse on' to ~/.tmux.conf..."
echo "set -g mouse on" >> "$HOME/.tmux.conf"
fi
# Create a new tmux session in detached mode
tmux new-session -d -s ${var.session_name} -c ${var.folder} "\"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\"; exec bash"
elif [ "${var.experiment_use_screen}" = "true" ]; then
echo "Running Goose in the background..."
# Check if screen is installed
if ! command_exists screen; then
echo "Error: screen is not installed. Please install screen manually."
@@ -204,7 +266,7 @@ EOL
echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.goose.log"
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.goose.log"
echo "multiuser on" >> "$HOME/.screenrc"
@@ -216,32 +278,12 @@ EOL
fi
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
# Determine goose command
if command_exists goose; then
GOOSE_CMD=goose
elif [ -f "$HOME/.local/bin/goose" ]; then
GOOSE_CMD="$HOME/.local/bin/goose"
else
echo "Error: Goose is not installed. Please enable install_goose or install it manually."
exit 1
fi
screen -U -dmS goose bash -c "
screen -U -dmS ${var.session_name} bash -c "
cd ${var.folder}
\"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\"
/bin/bash
"
else
# Check if goose is installed before running
if command_exists goose; then
GOOSE_CMD=goose
elif [ -f "$HOME/.local/bin/goose" ]; then
GOOSE_CMD="$HOME/.local/bin/goose"
else
echo "Error: Goose is not installed. Please enable install_goose or install it manually."
exit 1
fi
fi
EOT
run_on_start = true
@@ -270,20 +312,31 @@ resource "coder_app" "goose" {
exit 1
fi
if [ "${var.experiment_use_screen}" = "true" ]; then
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
if [ "${var.experiment_use_tmux}" = "true" ]; then
if tmux has-session -t ${var.session_name} 2>/dev/null; then
echo "Attaching to existing Goose tmux session." | tee -a "$HOME/.goose.log"
tmux attach-session -t ${var.session_name}
else
echo "Starting a new Goose tmux session." | tee -a "$HOME/.goose.log"
tmux new-session -s ${var.session_name} -c ${var.folder} "\"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\"; exec bash"
fi
elif [ "${var.experiment_use_screen}" = "true" ]; then
# Check if session exists first
if ! screen -list | grep -q "goose"; then
if ! screen -list | grep -q "${var.session_name}"; then
echo "Error: No existing Goose session found. Please wait for the script to start it."
exit 1
fi
# Only attach to existing session
screen -xRR goose
screen -xRR ${var.session_name}
else
cd ${var.folder}
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
"$GOOSE_CMD" run --text "Review goosehints. Your task: $GOOSE_TASK_PROMPT" --interactive
fi
EOT
icon = var.icon
order = var.order
group = var.group
}
@@ -18,7 +18,7 @@ Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prereq
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"]
@@ -36,7 +36,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
@@ -50,7 +50,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["IU", "PY"]
@@ -65,7 +65,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["IU", "PY"]
@@ -90,7 +90,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
@@ -108,7 +108,7 @@ Due to the highest priority of the `ide_download_link` parameter in the `(jetbra
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
version = ">= 2.5"
}
http = {
source = "hashicorp/http"
@@ -45,7 +45,7 @@ variable "folder" {
type = string
description = "The directory to open in the IDE. e.g. /home/coder/project"
validation {
condition = can(regex("^(?:/[^/]+)+$", var.folder))
condition = can(regex("^(?:/[^/]+)+/?$", var.folder))
error_message = "The folder must be a full path and must not start with a ~."
}
}
@@ -62,6 +62,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "coder_parameter_order" {
type = number
description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)."
@@ -324,6 +330,7 @@ resource "coder_app" "gateway" {
icon = local.icon
external = true
order = var.order
group = var.group
url = join("", [
"jetbrains-gateway://connect#type=coder&workspace=",
data.coder_workspace.me.name,
@@ -17,7 +17,7 @@ A module that adds Jupyter Notebook in your Coder template.
module "jupyter-notebook" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jupyter-notebook/coder"
version = "1.0.19"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
version = ">= 2.5"
}
}
}
@@ -42,6 +42,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
resource "coder_script" "jupyter-notebook" {
agent_id = var.agent_id
display_name = "jupyter-notebook"
@@ -62,4 +68,5 @@ resource "coder_app" "jupyter-notebook" {
subdomain = true
share = var.share
order = var.order
group = var.group
}
+1 -1
View File
@@ -17,7 +17,7 @@ A module that adds JupyterLab in your Coder template.
module "jupyterlab" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jupyterlab/coder"
version = "1.0.31"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
+8 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
version = ">= 2.5"
}
}
}
@@ -51,6 +51,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
resource "coder_script" "jupyterlab" {
agent_id = var.agent_id
display_name = "jupyterlab"
@@ -72,4 +78,5 @@ resource "coder_app" "jupyterlab" {
subdomain = var.subdomain
share = var.share
order = var.order
group = var.group
}
+2 -1
View File
@@ -15,9 +15,10 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
module "kasmvnc" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/kasmvnc/coder"
version = "1.0.23"
version = "1.2.0"
agent_id = coder_agent.example.id
desktop_environment = "xfce"
subdomain = true
}
```
+32 -8
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.12"
version = ">= 2.5"
}
}
}
@@ -29,32 +29,56 @@ variable "kasm_version" {
variable "desktop_environment" {
type = string
description = "Specifies the desktop environment of the workspace. This should be pre-installed on the workspace."
validation {
condition = contains(["xfce", "kde", "gnome", "lxde", "lxqt"], var.desktop_environment)
error_message = "Invalid desktop environment. Please specify a valid desktop environment."
}
}
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "subdomain" {
type = bool
default = true
description = "Is subdomain sharing enabled in your cluster?"
}
resource "coder_script" "kasm_vnc" {
agent_id = var.agent_id
display_name = "KasmVNC"
icon = "/icon/kasmvnc.svg"
script = templatefile("${path.module}/run.sh", {
PORT : var.port,
DESKTOP_ENVIRONMENT : var.desktop_environment,
KASM_VERSION : var.kasm_version
})
run_on_start = true
script = templatefile("${path.module}/run.sh", {
PORT = var.port,
DESKTOP_ENVIRONMENT = var.desktop_environment,
KASM_VERSION = var.kasm_version
SUBDOMAIN = tostring(var.subdomain)
PATH_VNC_HTML = var.subdomain ? "" : file("${path.module}/path_vnc.html")
})
}
resource "coder_app" "kasm_vnc" {
agent_id = var.agent_id
slug = "kasm-vnc"
display_name = "kasmVNC"
display_name = "KasmVNC"
url = "http://localhost:${var.port}"
icon = "/icon/kasmvnc.svg"
subdomain = true
subdomain = var.subdomain
share = "owner"
order = var.order
group = var.group
healthcheck {
url = "http://localhost:${var.port}/app"
interval = 5
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html>
<head>
<title>Path-Sharing Bounce Page</title>
<style type="text/css">
:root {
color-scheme: light dark;
--dark: #121212;
--header-bg: rgba(127,127,127,0.2);
--light: white;
--rule-color: light-dark(rgba(0,0,0,0.8), rgba(255,255,255,0.8));
background-color: light-dark(var(--light), var(--dark));
color: light-dark(var(--dark), var(--light));
}
body, h1, p {
box-sizing: border-box;
margin:0; padding:0;
}
body{
font-family:Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
h1{
width: 100%;
padding: 1rem;
letter-spacing: -1.5pt;
padding-bottom:10px;
border-bottom: 1px solid var(--rule-color);
background-color: var(--header-bg);
}
p {
padding: 1rem; letter-spacing: -0.5pt;}
a.indent { display:inline-block; padding-top:0.5rem; padding-left: 2rem; font-size:0.8rem }
</style>
<meta charset="UTF-8" />
</head>
<body>
<h1>Path-Sharing Bounce Page</h1>
<p>
This application is being served via path sharing.
If you are not redirected, <span id="help">check the
Javascript console in your browser's developer tools
for more information.</span>
</p>
</body>
<script language="javascript">
// This page exists to satisfy the querystring driven client API
// specified here - https://raw.githubusercontent.com/kasmtech/noVNC/bce2d6a7048025c6e6c05df9d98b206c23f6dbab/docs/EMBEDDING.md
// tl;dr:
// * `host` - The WebSocket host to connect to.
// This is just the hostname component of the original URL
// * `port` - The WebSocket port to connect to.
// It doesn't look like we need to set this unless it's different
// than the incoming http request.
// * `encrypt` - If TLS should be used for the WebSocket connection.
// we base this on whether or not the protocol is `https`, seems
// reasonable for now.
// * `path` - The WebSocket path to use.
// This apparently doesn't tolerate a leading `/` so we use a
// function to tidy that up.
function trimFirstCharIf(str, char) {
return str.charAt(0) === char ? str.slice(1) : str;
}
function trimLastCharIf(str, char) {
return str.endsWith("/") ? str.slice(0,str.length-1) : str;
}
const newloc = new URL(window.location);
const h = document.getElementById("help")
// Building the websockify path must happen before we append the filename to newloc.pathname
newloc.searchParams.append("path",
trimLastCharIf(trimFirstCharIf(newloc.pathname,"/"),"/")+"/websockify");
newloc.searchParams.append("encrypted", newloc.protocol==="https:"? true : false);
newloc.pathname += "vnc.html"
console.log(newloc);
h.innerHTML = `click <a id="link" href="${newloc.toString()}">here</a> to go to the application.
<br/><br/>The rewritten URL is:<br/><a id="link" class="indent" href="${newloc.toString()}">${newloc.toString()}</a>`
window.location = newloc.href;
</script>
</html>
+77 -8
View File
@@ -3,6 +3,8 @@
# Exit on error, undefined variables, and pipe failures
set -euo pipefail
error() { printf "💀 ERROR: %s\n" "$@"; exit 1; }
# Function to check if vncserver is already installed
check_installed() {
if command -v vncserver &> /dev/null; then
@@ -188,7 +190,7 @@ if command -v sudo &> /dev/null && sudo -n true 2> /dev/null; then
SUDO=sudo
else
kasm_config_file="$HOME/.vnc/kasmvnc.yaml"
SUDO=
SUDO=""
echo "WARNING: Sudo access not available, using user config dir!"
@@ -206,6 +208,7 @@ echo "Writing KasmVNC config to $kasm_config_file"
$SUDO tee "$kasm_config_file" > /dev/null << EOF
network:
protocol: http
interface: 127.0.0.1
websocket_port: ${PORT}
ssl:
require_ssl: false
@@ -220,16 +223,82 @@ EOF
# and does not listen publicly
echo -e "password\npassword\n" | vncpasswd -wo -u "$USER"
get_http_dir() {
# determine the served file path
# Start with the default
httpd_directory="/usr/share/kasmvnc/www"
# Check the system configuration path
if [[ -e /etc/kasmvnc/kasmvnc.yaml ]]; then
d=($(grep -E "^\s*httpd_directory:.*$" /etc/kasmvnc/kasmvnc.yaml))
# If this grep is successful, it will return:
# httpd_directory: /usr/share/kasmvnc/www
if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then
httpd_directory="$${d[1]}"
fi
fi
# Check the home directory for overriding values
if [[ -e "$HOME/.vnc/kasmvnc.yaml" ]]; then
d=($(grep -E "^\s*httpd_directory:.*$" /etc/kasmvnc/kasmvnc.yaml))
if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then
httpd_directory="$${d[1]}"
fi
fi
echo $httpd_directory
}
fix_server_index_file(){
local fname=$${FUNCNAME[0]} # gets current function name
if [[ $# -ne 1 ]]; then
error "$fname requires exactly 1 parameter:\n\tpath to KasmVNC httpd_directory"
fi
local httpdir="$1"
if [[ ! -d "$httpdir" ]]; then
error "$fname: $httpdir is not a directory"
fi
pushd "$httpdir" > /dev/null
cat <<'EOH' > /tmp/path_vnc.html
${PATH_VNC_HTML}
EOH
$SUDO mv /tmp/path_vnc.html .
# check for the switcheroo
if [[ -f "index.html" && -L "vnc.html" ]]; then
$SUDO mv $httpdir/index.html $httpdir/vnc.html
fi
$SUDO ln -s -f path_vnc.html index.html
popd > /dev/null
}
patch_kasm_http_files(){
homedir=$(get_http_dir)
fix_server_index_file "$homedir"
}
if [[ "${SUBDOMAIN}" == "false" ]]; then
echo "🩹 Patching up webserver files to support path-sharing..."
patch_kasm_http_files
fi
VNC_LOG="/tmp/kasmvncserver.log"
# Start the server
printf "🚀 Starting KasmVNC server...\n"
vncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > /tmp/kasmvncserver.log 2>&1 &
pid=$!
# Wait for server to start
sleep 5
grep -v '^[[:space:]]*$' /tmp/kasmvncserver.log | tail -n 10
if ps -p $pid | grep -q "^$pid"; then
echo "ERROR: Failed to start KasmVNC server. Check full logs at /tmp/kasmvncserver.log"
set +e
vncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > "$VNC_LOG" 2>&1
RETVAL=$?
set -e
if [[ $RETVAL -ne 0 ]]; then
echo "ERROR: Failed to start KasmVNC server. Return code: $RETVAL"
if [[ -f "$VNC_LOG" ]]; then
echo "Full logs:"
cat "$VNC_LOG"
else
echo "ERROR: Log file not found: $VNC_LOG"
fi
exit 1
fi
printf "🚀 KasmVNC server started successfully!\n"
+7 -6
View File
@@ -20,11 +20,12 @@ variable "vault_token" {
}
module "vault" {
source = "registry.coder.com/coder/vault-token/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
vault_token = var.token # optional
vault_addr = "https://vault.example.com"
source = "registry.coder.com/coder/vault-token/coder"
version = "1.2.0"
agent_id = coder_agent.example.id
vault_token = var.token # optional
vault_addr = "https://vault.example.com"
vault_namespace = "prod" # optional, vault enterprise only
}
```
@@ -74,7 +75,7 @@ variable "vault_token" {
module "vault" {
source = "registry.coder.com/coder/vault-token/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
vault_addr = "https://vault.example.com"
vault_token = var.token
@@ -26,6 +26,11 @@ variable "vault_token" {
sensitive = true
default = null
}
variable "vault_namespace" {
type = string
description = "The Vault namespace to use."
default = null
}
variable "vault_cli_version" {
type = string
@@ -62,3 +67,10 @@ resource "coder_env" "vault_token" {
name = "VAULT_TOKEN"
value = var.vault_token
}
resource "coder_env" "vault_namespace" {
count = var.vault_namespace != null ? 1 : 0
agent_id = var.agent_id
name = "VAULT_NAMESPACE"
value = var.vault_namespace
}
@@ -17,7 +17,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-desktop/coder"
version = "1.0.15"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -30,7 +30,7 @@ module "vscode" {
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-desktop/coder"
version = "1.0.15"
version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.23"
version = ">= 2.5"
}
}
}
@@ -32,6 +32,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
@@ -42,6 +48,8 @@ resource "coder_app" "vscode" {
slug = "vscode"
display_name = "VS Code Desktop"
order = var.order
group = var.group
url = join("", [
"vscode://coder.coder-remote/open",
"?owner=",
+5 -5
View File
@@ -15,7 +15,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
accept_license = true
}
@@ -31,7 +31,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder"
@@ -45,7 +45,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true
@@ -60,7 +60,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -78,7 +78,7 @@ By default, this module installs the latest. To pin a specific version, retrieve
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
accept_license = true
+8 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
version = ">= 2.5"
}
}
}
@@ -97,6 +97,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "settings" {
type = any
description = "A map of settings to apply to VS Code web."
@@ -194,6 +200,7 @@ resource "coder_app" "vscode-web" {
subdomain = var.subdomain
share = var.share
order = var.order
group = var.group
healthcheck {
url = local.healthcheck_url
+2 -2
View File
@@ -17,7 +17,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "windsurf" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windsurf/coder"
version = "1.0.0"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -30,7 +30,7 @@ module "windsurf" {
module "windsurf" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windsurf/coder"
version = "1.0.0"
version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
+8 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.23"
version = ">= 2.5"
}
}
}
@@ -32,6 +32,12 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
@@ -42,6 +48,7 @@ resource "coder_app" "windsurf" {
slug = "windsurf"
display_name = "Windsurf Editor"
order = var.order
group = var.group
url = join("", [
"windsurf://coder.coder-remote/open",
"?owner=",
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+1
View File
@@ -2,6 +2,7 @@
display_name: Nataindata
bio: Data engineer
github: nataindata
avatar: ./.images/avatar.png
website: https://www.nataindata.com
status: community
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+2 -1
View File
@@ -1,7 +1,8 @@
---
display_name: TheZoker
bio: I'm a master computer science student at the TU munich and a webdesigner.
bio: I'm a master computer science student at the TU munich and a web designer.
github: TheZoker
avatar: ./.images/avatar.jpeg
website: https://gareis.io/
status: community
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+1
View File
@@ -1,6 +1,7 @@
---
display_name: WhizUs
bio: WhizUs is your premier choice for DevOps, Kubernetes, and Cloud Native consulting. Based in Vienna we combine our expert solutions with a strong commitment to the community. Explore automation, scalability and drive success through collaboration.
avatar: ./.images/avatar.png
github: WhizUs
linkedin: https://www.linkedin.com/company/whizus
website: https://www.whizus.com/
+255
View File
@@ -0,0 +1,255 @@
// Package gorules defines custom lint rules for ruleguard.
//
// golangci-lint runs these rules via go-critic, which includes support
// for ruleguard. All Go files in this directory define lint rules
// in the Ruleguard DSL; see:
//
// - https://go-ruleguard.github.io/by-example/
// - https://pkg.go.dev/github.com/quasilyte/go-ruleguard/dsl
//
// You run one of the following commands to execute your go rules only:
//
// golangci-lint run
// golangci-lint run --disable-all --enable=gocritic
//
// Note: don't forget to run `golangci-lint cache clean`!
package gorules
import (
"github.com/quasilyte/go-ruleguard/dsl"
)
// Use xerrors everywhere! It provides additional stacktrace info!
//
//nolint:unused,deadcode,varnamelen
func xerrors(m dsl.Matcher) {
m.Import("errors")
m.Import("fmt")
m.Import("golang.org/x/xerrors")
m.Match("fmt.Errorf($arg)").
Suggest("xerrors.New($arg)").
Report("Use xerrors to provide additional stacktrace information!")
m.Match("fmt.Errorf($arg1, $*args)").
Suggest("xerrors.Errorf($arg1, $args)").
Report("Use xerrors to provide additional stacktrace information!")
m.Match("errors.$_($msg)").
Where(m["msg"].Type.Is("string")).
Suggest("xerrors.New($msg)").
Report("Use xerrors to provide additional stacktrace information!")
}
// databaseImport enforces not importing any database types into /codersdk.
//
//nolint:unused,deadcode,varnamelen
func databaseImport(m dsl.Matcher) {
m.Import("github.com/coder/coder/v2/coderd/database")
m.Match("database.$_").
Report("Do not import any database types into codersdk").
Where(m.File().PkgPath.Matches("github.com/coder/coder/v2/codersdk"))
}
// doNotCallTFailNowInsideGoroutine enforces not calling t.FailNow or
// functions that may themselves call t.FailNow in goroutines outside
// the main test goroutine. See testing.go:834 for why.
//
//nolint:unused,deadcode,varnamelen
func doNotCallTFailNowInsideGoroutine(m dsl.Matcher) {
m.Import("testing")
m.Match(`
go func($*_){
$*_
$require.$_($*_)
$*_
}($*_)`).
At(m["require"]).
Where(m["require"].Text == "require").
Report("Do not call functions that may call t.FailNow in a goroutine, as this can cause data races (see testing.go:834)")
// require.Eventually runs the function in a goroutine.
m.Match(`
require.Eventually(t, func() bool {
$*_
$require.$_($*_)
$*_
}, $*_)`).
At(m["require"]).
Where(m["require"].Text == "require").
Report("Do not call functions that may call t.FailNow in a goroutine, as this can cause data races (see testing.go:834)")
m.Match(`
go func($*_){
$*_
$t.$fail($*_)
$*_
}($*_)`).
At(m["fail"]).
Where(m["t"].Type.Implements("testing.TB") && m["fail"].Text.Matches("^(FailNow|Fatal|Fatalf)$")).
Report("Do not call functions that may call t.FailNow in a goroutine, as this can cause data races (see testing.go:834)")
}
// useStandardTimeoutsAndDelaysInTests ensures all tests use common
// constants for timeouts and delays in usual scenarios, this allows us
// to tweak them based on platform (important to avoid CI flakes).
//
//nolint:unused,deadcode,varnamelen
func useStandardTimeoutsAndDelaysInTests(m dsl.Matcher) {
m.Import("github.com/stretchr/testify/require")
m.Import("github.com/stretchr/testify/assert")
m.Import("github.com/coder/coder/v2/testutil")
m.Match(`context.WithTimeout($ctx, $duration)`).
Where(m.File().Imports("testing") && !m.File().PkgPath.Matches("testutil$") && !m["duration"].Text.Matches("^testutil\\.")).
At(m["duration"]).
Report("Do not use magic numbers in test timeouts and delays. Use the standard testutil.Wait* or testutil.Interval* constants instead.")
m.Match(`
$testify.$Eventually($t, func() bool {
$*_
}, $timeout, $interval, $*_)
`).
Where((m["testify"].Text == "require" || m["testify"].Text == "assert") &&
(m["Eventually"].Text == "Eventually" || m["Eventually"].Text == "Eventuallyf") &&
!m["timeout"].Text.Matches("^testutil\\.")).
At(m["timeout"]).
Report("Do not use magic numbers in test timeouts and delays. Use the standard testutil.Wait* or testutil.Interval* constants instead.")
m.Match(`
$testify.$Eventually($t, func() bool {
$*_
}, $timeout, $interval, $*_)
`).
Where((m["testify"].Text == "require" || m["testify"].Text == "assert") &&
(m["Eventually"].Text == "Eventually" || m["Eventually"].Text == "Eventuallyf") &&
!m["interval"].Text.Matches("^testutil\\.")).
At(m["interval"]).
Report("Do not use magic numbers in test timeouts and delays. Use the standard testutil.Wait* or testutil.Interval* constants instead.")
}
// ProperRBACReturn ensures we always write to the response writer after a
// call to Authorize. If we just do a return, the client will get a status code
// 200, which is incorrect.
func ProperRBACReturn(m dsl.Matcher) {
m.Match(`
if !$_.Authorize($*_) {
return
}
`).Report("Must write to 'ResponseWriter' before returning'")
}
// slogFieldNameSnakeCase is a lint rule that ensures naming consistency
// of logged field names.
func slogFieldNameSnakeCase(m dsl.Matcher) {
m.Import("cdr.dev/slog")
m.Match(
`slog.F($name, $value)`,
).
Where(m["name"].Const && !m["name"].Text.Matches(`^"[a-z]+(_[a-z]+)*"$`)).
Report("Field name $name must be snake_case.")
}
// slogUUIDFieldNameHasIDSuffix ensures that "uuid.UUID" field has ID prefix
// in the field name.
func slogUUIDFieldNameHasIDSuffix(m dsl.Matcher) {
m.Import("cdr.dev/slog")
m.Import("github.com/google/uuid")
m.Match(
`slog.F($name, $value)`,
).
Where(m["value"].Type.Is("uuid.UUID") && !m["name"].Text.Matches(`_id"$`)).
Report(`uuid.UUID field $name must have "_id" suffix.`)
}
// slogMessageFormat ensures that the log message starts with lowercase, and does not
// end with special character.
func slogMessageFormat(m dsl.Matcher) {
m.Import("cdr.dev/slog")
m.Match(
`logger.Error($ctx, $message, $*args)`,
`logger.Warn($ctx, $message, $*args)`,
`logger.Info($ctx, $message, $*args)`,
`logger.Debug($ctx, $message, $*args)`,
`$foo.logger.Error($ctx, $message, $*args)`,
`$foo.logger.Warn($ctx, $message, $*args)`,
`$foo.logger.Info($ctx, $message, $*args)`,
`$foo.logger.Debug($ctx, $message, $*args)`,
`Logger.Error($ctx, $message, $*args)`,
`Logger.Warn($ctx, $message, $*args)`,
`Logger.Info($ctx, $message, $*args)`,
`Logger.Debug($ctx, $message, $*args)`,
`$foo.Logger.Error($ctx, $message, $*args)`,
`$foo.Logger.Warn($ctx, $message, $*args)`,
`$foo.Logger.Info($ctx, $message, $*args)`,
`$foo.Logger.Debug($ctx, $message, $*args)`,
).
Where(
(
// It doesn't end with a special character:
m["message"].Text.Matches(`[.!?]"$`) ||
// it starts with lowercase:
m["message"].Text.Matches(`^"[A-Z]{1}`) &&
// but there are exceptions:
!m["message"].Text.Matches(`^"Prometheus`) &&
!m["message"].Text.Matches(`^"X11`) &&
!m["message"].Text.Matches(`^"CSP`) &&
!m["message"].Text.Matches(`^"OIDC`))).
Report(`Message $message must start with lowercase, and does not end with a special characters.`)
}
// slogMessageLength ensures that important log messages are meaningful, and must be at least 16 characters long.
func slogMessageLength(m dsl.Matcher) {
m.Import("cdr.dev/slog")
m.Match(
`logger.Error($ctx, $message, $*args)`,
`logger.Warn($ctx, $message, $*args)`,
`logger.Info($ctx, $message, $*args)`,
`$foo.logger.Error($ctx, $message, $*args)`,
`$foo.logger.Warn($ctx, $message, $*args)`,
`$foo.logger.Info($ctx, $message, $*args)`,
`Logger.Error($ctx, $message, $*args)`,
`Logger.Warn($ctx, $message, $*args)`,
`Logger.Info($ctx, $message, $*args)`,
`$foo.Logger.Error($ctx, $message, $*args)`,
`$foo.Logger.Warn($ctx, $message, $*args)`,
`$foo.Logger.Info($ctx, $message, $*args)`,
// no debug
).
Where(
// It has at least 16 characters (+ ""):
m["message"].Text.Matches(`^".{0,15}"$`) &&
// but there are exceptions:
!m["message"].Text.Matches(`^"command exit"$`)).
Report(`Message $message is too short, it must be at least 16 characters long.`)
}
// slogErr ensures that errors are logged with "slog.Error" instead of "slog.F"
func slogError(m dsl.Matcher) {
m.Import("cdr.dev/slog")
m.Match(
`slog.F($name, $value)`,
).
Where(m["name"].Const && m["value"].Type.Is("error") && !m["name"].Text.Matches(`^"internal_error"$`)).
Report(`Error should be logged using "slog.Error" instead.`)
}
// withTimezoneUTC ensures that we don't just sprinkle dbtestutil.WithTimezone("UTC") about
// to work around real timezone bugs in our code.
//
//nolint:unused,deadcode,varnamelen
func withTimezoneUTC(m dsl.Matcher) {
m.Match(
`dbtestutil.WithTimezone($tz)`,
).Where(
m["tz"].Text.Matches(`[uU][tT][cC]"$`),
).Report(`Setting database timezone to UTC may mask timezone-related bugs.`).
At(m["tz"])
}