Compare commits

..

16 Commits

Author SHA1 Message Date
blink-so[bot] 4dcaea7bf9 feat(claude-code): support binary distribution without Node.js dependency (#332)
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: kyle <kyle@coder.com>
Co-authored-by: 35C4n0r <work.jaykumar@gmail.com>
2025-08-18 17:00:34 +05:00
Atif Ali c2bc5cd314 chore(cursor): update mcp example to use GitHub (#330) 2025-08-18 16:57:30 +05:00
blink-so[bot] c73b923e40 Fix broken main.tf link in tasks-docker template README (#331)
Fixes the broken main.tf link reported in the tasks-docker template
README.

The relative link `./main.tf` was returning a 404 error on the registry
website. This PR replaces it with the correct GitHub URL pointing to the
main.tf file.

**Changes:**
- Updated the main.tf link in
`/registry/coder-labs/templates/tasks-docker/README.md`
- Changed from `[main.tf](./main.tf)` to
`[main.tf](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/tasks-docker/main.tf)`

Tested: Verified the new link works correctly.

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2025-08-15 20:31:00 -04:00
Michael Smith 08ed594bfd chore: add validation for Coder Template README files (#326)
Closes #194 alongside #325

## Description

This PR adds the missing base layer of validation for all Coder template
README files, ensuring that they all follow a consistent structure when
processed by the Registry website's build step. It also updates a few
README files to match the new standards.

## Type of Change

- [ ] New module
- [x] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [x] Other

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun run fmt`)
- [x] Changes tested locally
2025-08-13 13:38:11 -05:00
Michael Smith fd074a5643 fix: improve logic for existing README validation (#325)
Addresses part of https://github.com/coder/registry/issues/194

## Description

This PR beefs up the validation for the validation logic that we already
had in place. This PR does not include adding validation for templates
(which will be addressed in a second PR).

### Changes made
- Added logic to reject unknown frontmatter fields for modules and
contributor profile README files
- Added logic to handle frontmatter fields that were previously missed
in validation steps (GitHub username for contributors and Operating
Systems for modules)
- Updated a few comments (added some new comments, formatted existing
comments to meet 100-column width)

### Type of Change

- [ ] New module
- [x] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [x] Other

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun run fmt`)
- [x] Changes tested locally
2025-08-13 11:07:08 -05:00
Phorcys 40863c0aa7 chore: add Disclaimer for docker-based templates (#324) 2025-08-13 09:03:47 -05:00
Atif Ali d9b223ac3c feat(zed): settings input and MCP servers example (#317)
This PR adds an optional `settings` input to the Zed module and updates
the README with an example for configuring MCP servers.

Changes:
- Add `settings` variable to modules/zed/main.tf
- Add `coder_script` to write/merge `~/.config/zed/settings.json`
(respects `$XDG_CONFIG_HOME` and merges with existing settings if `jq`
is available)
- Update README with a `settings` example configuring MCP context
servers and clarify default settings path

Test plan:
- Syntax-only: `bun test --filter zed` fails in CI without Terraform;
this change only adds inputs and a startup script. No behavior change to
existing outputs.

Co-authored-by: Atif Ali <10648092+matifali@users.noreply.github.com>

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: DevCats <christofer@coder.com>
2025-08-12 20:26:28 -05:00
DevCats 1749f9ca05 feat(gemini): gemini cleanup and refactor (#300)
Closes #260

## Description

Cleans up and fixes issues with Gemini module.

## Type of Change

- [ ] New module
- [ ] Bug fix
- [X] Feature/enhancement
- [X] Documentation
- [ ] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/coder-labs/modules/gemini`  
**New version:** `v1.1.0`  
**Breaking change:** [X] Yes [ ] No

## Testing & Validation

- [X] Tests pass (`bun test`)
- [X] Code formatted (`bun run fmt`)
- [X] Changes tested locally

## Related Issues
 
#260 
<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: Atif Ali <atif@coder.com>
2025-08-12 11:37:45 -05:00
Atif Ali 61554aaa8c feat(cursor): add mcp input to configure MCP servers for Cursor (#314)
This adds a new optional input `mcp` to the cursor module.

- Accepts a JSON-encoded string with MCP server configuration
- When provided, a `coder_script` writes it to `~/.cursor/mcp.json` on
start
- Keeps existing behavior unchanged if `mcp` is empty
- Adds tests verifying the `mcp.json` is written
- Updates README with `mcp` usage example
- Fixes Prettier and `terraform fmt` formatting issues flagged by CI

CI should now pass after the latest commits.

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2025-08-12 11:18:31 -05:00
DevCats f4fcae7c0f chore(jetbrains): version bump to 1.0.3 (#323)
## Description

Patch version bump to 1.0.3 since it was missed in last PR.
<!-- Briefly describe what this PR does and why -->

## Type of Change

- [ ] New module
- [ ] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [X] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/coder/modules/jetbrains`  
**New version:** `v1.0.3`  
**Breaking change:** [ ] Yes [X] No

## Testing & Validation

- [X] Tests pass (`bun test`)
- [X] Code formatted (`bun run fmt`)
- [X] Changes tested locally

## Related Issues

None
<!-- Link related issues or write "None" if not applicable -->
2025-08-11 22:36:41 -05:00
DevCats 05b9bb1ae4 feat(tag_release): add advanced options and devops friendly features (#322)
## Description

- Add CLI argument parsing with short and long options
- Implement JSON output format for programmatic consumption
- Add dry-run mode for safe testing
- Include verbose and quiet logging modes
- Add namespace and module filtering capabilities
- Implement skip-push option for tag creation without remote push
- Add pre-flight checks for git repository validation
- Enhance error handling with structured logging
- Add exit codes for different operation states
- Include comprehensive help documentation with examples

```
Usage: ./scripts/tag_release.sh [OPTIONS]

OPTIONS:
  -y, --auto-approve       Skip confirmation prompt
  -d, --dry-run           Preview without creating tags
  -v, --verbose           Detailed output
  -q, --quiet             Minimal output
  -f, --format=FORMAT     Output format: 'plain' or 'json'
  -n, --namespace=NAME    Target specific namespace
  -m, --module=NAME       Target specific module
  -s, --skip-push         Create tags but don't push
  -h, --help              Show this help

EXAMPLES:
  ./scripts/tag_release.sh                      # Interactive mode
  ./scripts/tag_release.sh -y -q -f json        # CI/CD automation
  ./scripts/tag_release.sh -d -v                # Test with verbose output
  ./scripts/tag_release.sh -m code-server -d    # Target specific module
  ./scripts/tag_release.sh -n coder -m code-server -d  # Target module in namespace

Exit codes: 0=success, 1=error, 2=no action needed, 3=validation failed
```

## Type of Change

- [ ] New module
- [ ] Bug fix
- [X] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Testing & Validation

- [X] Tests pass (`bun test`)
- [X] Code formatted (`bun run fmt`)
- [X] Changes tested locally

## Related Issues

<!-- Link related issues or write "None" if not applicable -->## Module
Information
None
2025-08-11 21:18:39 -05:00
Atif Ali 45b72c7241 Migrate tests to Terraform for jetbrains, zed, and code-server (#307)
## Summary
- Introduces Terraform native tests (`terraform test`) alongside
existing Bun tests
- Migrates tests for modules: jetbrains, zed, and code-server
- Removes Bun test files for these migrated modules only
- Adds repo-wide test runner script for Terraform tests
- Updates docs and new-module sample to reflect Terraform tests

## Transition plan
- Mixed mode: Other modules retain Bun tests; CI should run both Bun and
Terraform tests temporarily
- Follow the linked epic to migrate remaining modules

## Test plan
- Run: `./scripts/terraform_test_all.sh` (passes locally)
- Bun tests still available for non-migrated modules

## Affected paths
- registry/coder/modules/jetbrains/jetbrains.tftest.hcl
- registry/coder/modules/zed/zed.tftest.hcl
- registry/coder/modules/code-server/code-server.tftest.hcl
- scripts/terraform_test_all.sh
- examples/modules/MODULE_NAME.tftest.hcl
- CONTRIBUTING.md


Contributes to #308
2025-08-11 21:09:09 -05:00
dependabot[bot] 2646b36cb1 chore(deps): bump google-github-actions/setup-gcloud from 2.1.5 to 2.2.0 (#319)
Bumps
[google-github-actions/setup-gcloud](https://github.com/google-github-actions/setup-gcloud)
from 2.1.5 to 2.2.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/google-github-actions/setup-gcloud/releases">google-github-actions/setup-gcloud's
releases</a>.</em></p>
<blockquote>
<h2>v2.2.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Introduce an option to skip the tool cache by <a
href="https://github.com/sethvargo"><code>@​sethvargo</code></a> in <a
href="https://redirect.github.com/google-github-actions/setup-gcloud/pull/718">google-github-actions/setup-gcloud#718</a></li>
<li>Release: v2.2.0 by <a
href="https://github.com/google-github-actions-bot"><code>@​google-github-actions-bot</code></a>
in <a
href="https://redirect.github.com/google-github-actions/setup-gcloud/pull/719">google-github-actions/setup-gcloud#719</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/google-github-actions/setup-gcloud/compare/v2.1.5...v2.2.0">https://github.com/google-github-actions/setup-gcloud/compare/v2.1.5...v2.2.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/google-github-actions/setup-gcloud/commit/cb1e50a9932213ecece00a606661ae9ca44f3397"><code>cb1e50a</code></a>
Release: v2.2.0 (<a
href="https://redirect.github.com/google-github-actions/setup-gcloud/issues/719">#719</a>)</li>
<li><a
href="https://github.com/google-github-actions/setup-gcloud/commit/ef52f8c087fe78d43262625448b746144fe6448c"><code>ef52f8c</code></a>
Introduce an option to skip the tool cache (<a
href="https://redirect.github.com/google-github-actions/setup-gcloud/issues/718">#718</a>)</li>
<li>See full diff in <a
href="https://github.com/google-github-actions/setup-gcloud/compare/6a7c903a70c8625ed6700fa299f5ddb4ca6022e9...cb1e50a9932213ecece00a606661ae9ca44f3397">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google-github-actions/setup-gcloud&package-manager=github_actions&previous-version=2.1.5&new-version=2.2.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: DevCats <christofer@coder.com>
2025-08-11 20:57:05 -05:00
dependabot[bot] 3202e4899a chore(deps): bump crate-ci/typos from 1.34.0 to 1.35.3 (#318)
Bumps [crate-ci/typos](https://github.com/crate-ci/typos) from 1.34.0 to
1.35.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/crate-ci/typos/releases">crate-ci/typos's
releases</a>.</em></p>
<blockquote>
<h2>v1.35.3</h2>
<h2>[1.35.3] - 2025-08-08</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>ratatui</code> in Rust files</li>
</ul>
<h2>v1.35.2</h2>
<h2>[1.35.2] - 2025-08-07</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>unmarshaling</code></li>
</ul>
<h2>v1.35.1</h2>
<h2>[1.35.1] - 2025-08-04</h2>
<h3>Fixes</h3>
<ul>
<li>Fix typo in correction to <code>apostroph</code></li>
<li>Fix typo in correction to <code>cordinate</code></li>
<li>Fix typo in correction to <code>reproduceability</code></li>
<li>Fix typo in correction to <code>revolutionss</code></li>
<li>Fix typo in correction to <code>transivity</code></li>
</ul>
<h2>v1.35.0</h2>
<h2>[1.35.0] - 2025-08-04</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1331">July
2025</a> changes</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/crate-ci/typos/blob/master/CHANGELOG.md">crate-ci/typos's
changelog</a>.</em></p>
<blockquote>
<h2>[1.35.3] - 2025-08-08</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>ratatui</code> in Rust files</li>
</ul>
<h2>[1.35.2] - 2025-08-07</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>unmarshaling</code></li>
</ul>
<h2>[1.35.1] - 2025-08-04</h2>
<h3>Fixes</h3>
<ul>
<li>Fix typo in correction to <code>apostroph</code></li>
<li>Fix typo in correction to <code>cordinate</code></li>
<li>Fix typo in correction to <code>reproduceability</code></li>
<li>Fix typo in correction to <code>revolutionss</code></li>
<li>Fix typo in correction to <code>transivity</code></li>
</ul>
<h2>[1.35.0] - 2025-08-04</h2>
<h3>Features</h3>
<ul>
<li>Updated the dictionary with the <a
href="https://redirect.github.com/crate-ci/typos/issues/1331">July
2025</a> changes</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/crate-ci/typos/commit/52bd719c2c91f9d676e2aa359fc8e0db8925e6d8"><code>52bd719</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/c6f77dda9e9bf82551f03a500347eb06ce8a90b1"><code>c6f77dd</code></a>
docs: Update changelog</li>
<li><a
href="https://github.com/crate-ci/typos/commit/e35d08c453d5ac2a4630b633dbb63e819b129193"><code>e35d08c</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1353">#1353</a>
from Rolv-Apneseth/ratatui</li>
<li><a
href="https://github.com/crate-ci/typos/commit/9d6691bc8cf087436d192d03414b9c2420570343"><code>9d6691b</code></a>
fix: Ignore <code>ratatui</code> in Rust files</li>
<li><a
href="https://github.com/crate-ci/typos/commit/f1231bc2bcc92b2b18da70a877cf89afce08dd42"><code>f1231bc</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/66def6387b9bb7954423333521eed23e75651f6e"><code>66def63</code></a>
docs: Update changelog</li>
<li><a
href="https://github.com/crate-ci/typos/commit/623f09b5bc658227e7e051fc494f3af24030d1cf"><code>623f09b</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/1080316783320230c1f65e1c374e44dfc13829c6"><code>1080316</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/442605b52920ac6faab2e457d3bafc0a6d05a5d1"><code>442605b</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1352">#1352</a>
from epage/marshaling</li>
<li><a
href="https://github.com/crate-ci/typos/commit/983f866bac2164c77fc4ad8a06cdb0738c38ddba"><code>983f866</code></a>
fix(dict): Don't correct marshaling</li>
<li>Additional commits viewable in <a
href="https://github.com/crate-ci/typos/compare/v1.34.0...v1.35.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=crate-ci/typos&package-manager=github_actions&previous-version=1.34.0&new-version=1.35.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-11 20:50:46 -05:00
Hugo Dutka c4a5184725 chore: bump agentapi versions in the goose and gemini modules (#321)
related to https://github.com/coder/registry/pull/320
2025-08-11 19:28:14 +02:00
Phorcys 63d56eadc9 feat: add vscode-desktop-core module (#278)
Co-authored-by: Atif Ali <atif@coder.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-11 21:21:23 +05:00
51 changed files with 1946 additions and 520 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@v1.34.0
uses: crate-ci/typos@v1.35.3
with:
config: .github/typos.toml
validate-readme-files:
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
workload_identity_provider: projects/309789351055/locations/global/workloadIdentityPools/github-actions/providers/github
service_account: registry-v2-github@coder-registry-1.iam.gserviceaccount.com
- name: Set up Google Cloud SDK
uses: google-github-actions/setup-gcloud@6a7c903a70c8625ed6700fa299f5ddb4ca6022e9
uses: google-github-actions/setup-gcloud@cb1e50a9932213ecece00a606661ae9ca44f3397
- name: Deploy to dev.registry.coder.com
run: gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch main
- name: Deploy to registry.coder.com
+16 -11
View File
@@ -24,7 +24,7 @@ The Coder Registry is a collection of Terraform modules and templates for Coder
### Install Dependencies
Install Bun:
Install Bun (for formatting and scripts):
```bash
curl -fsSL https://bun.sh/install | bash
@@ -124,19 +124,23 @@ This script generates:
- Accurate description and usage examples
- Correct icon path (usually `../../../../.icons/your-icon.svg`)
- Proper tags that describe your module
3. **Create `main.test.ts`** to test your module
3. **Create at least one `.tftest.hcl`** to test your module with `terraform test`
4. **Add any scripts** or additional files your module needs
### 4. Test and Submit
```bash
# Test your module
bun test -t 'module-name'
# Test your module (from the module directory)
terraform init -upgrade
terraform test -verbose
# Or run all tests in the repo
./scripts/terraform_test_all.sh
# Format code
bun fmt
bun run fmt
# Commit and create PR
# Commit and create PR (do not push to main directly)
git add .
git commit -m "Add [module-name] module"
git push origin your-branch
@@ -335,11 +339,12 @@ coder templates push test-[template-name] -d .
### 2. Test Your Changes
```bash
# Test a specific module
bun test -t 'module-name'
# Test a specific module (from the module directory)
terraform init -upgrade
terraform test -verbose
# Test all modules
bun test
./scripts/terraform_test_all.sh
```
### 3. Maintain Backward Compatibility
@@ -388,7 +393,7 @@ Example: `https://github.com/coder/registry/compare/main...your-branch?template=
### Every Module Must Have
- `main.tf` - Terraform code
- `main.test.ts` - Working tests
- One or more `.tftest.hcl` files - Working tests with `terraform test`
- `README.md` - Documentation with frontmatter
### Every Template Must Have
@@ -488,6 +493,6 @@ When reporting bugs, include:
2. **No tests** or broken tests
3. **Hardcoded values** instead of variables
4. **Breaking changes** without defaults
5. **Not running** `bun fmt` before submitting
5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`) before submitting
Happy contributing! 🚀
+2 -2
View File
@@ -18,9 +18,9 @@ sudo apt install golang-go
Check that PRs have:
- [ ] All required files (`main.tf`, `main.test.ts`, `README.md`)
- [ ] All required files (`main.tf`, `README.md`, at least one `.tftest.hcl`)
- [ ] Proper frontmatter in README
- [ ] Working tests (`bun test`)
- [ ] Working tests (`terraform test`)
- [ ] Formatted code (`bun run fmt`)
- [ ] Avatar image for new namespaces (`avatar.png` or `avatar.svg` in `.images/`)
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"bufio"
"context"
"strings"
"golang.org/x/xerrors"
)
func validateCoderModuleReadmeBody(body string) []error {
var errs []error
trimmed := strings.TrimSpace(body)
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
errs = append(errs, baseErrs...)
}
foundParagraph := false
terraformCodeBlockCount := 0
foundTerraformVersionRef := false
lineNum := 0
isInsideCodeBlock := false
isInsideTerraform := false
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
for lineScanner.Scan() {
lineNum++
nextLine := lineScanner.Text()
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
// need to check deeper if the first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
}
continue
}
if strings.HasPrefix(nextLine, "```") {
isInsideCodeBlock = !isInsideCodeBlock
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
if isInsideTerraform {
terraformCodeBlockCount++
}
if strings.HasPrefix(nextLine, "```hcl") {
errs = append(errs, xerrors.New("all hcl code blocks must be converted to tf"))
}
continue
}
if isInsideCodeBlock {
if isInsideTerraform {
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
}
continue
}
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
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.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
}
if terraformCodeBlockCount == 0 {
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
} else {
if terraformCodeBlockCount > 1 {
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
}
if !foundTerraformVersionRef {
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
}
}
if !foundParagraph {
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
}
if isInsideCodeBlock {
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
}
return errs
}
func validateCoderModuleReadme(rm coderResourceReadme) []error {
var errs []error
for _, err := range validateCoderModuleReadmeBody(rm.body) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
errs = append(errs, fmErrs...)
}
return errs
}
func validateAllCoderModuleReadmes(resources []coderResourceReadme) error {
var yamlValidationErrors []error
for _, readme := range resources {
errs := validateCoderModuleReadme(readme)
if len(errs) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errs...)
}
}
if len(yamlValidationErrors) != 0 {
return validationPhaseError{
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
return nil
}
func validateAllCoderModules() error {
const resourceType = "modules"
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
if err != nil {
return err
}
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
err = validateAllCoderModuleReadmes(resources)
if err != nil {
return err
}
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
if err := validateCoderResourceRelativeURLs(resources); err != nil {
return err
}
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
return nil
}
@@ -14,7 +14,7 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) {
t.Run("Parses a valid README body with zero issues", func(t *testing.T) {
t.Parallel()
errs := validateCoderResourceReadmeBody(testBody)
errs := validateCoderModuleReadmeBody(testBody)
for _, e := range errs {
t.Error(e)
}
+78 -150
View File
@@ -1,8 +1,6 @@
package main
import (
"bufio"
"context"
"errors"
"net/url"
"os"
@@ -17,6 +15,7 @@ import (
var (
supportedResourceTypes = []string{"modules", "templates"}
operatingSystems = []string{"windows", "macos", "linux"}
// TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but
// realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's
@@ -25,11 +24,21 @@ var (
)
type coderResourceFrontmatter struct {
Description string `yaml:"description"`
IconURL string `yaml:"icon"`
DisplayName *string `yaml:"display_name"`
Verified *bool `yaml:"verified"`
Tags []string `yaml:"tags"`
Description string `yaml:"description"`
IconURL string `yaml:"icon"`
DisplayName *string `yaml:"display_name"`
Verified *bool `yaml:"verified"`
Tags []string `yaml:"tags"`
OperatingSystems []string `yaml:"supported_os"`
}
// A slice version of the struct tags from coderResourceFrontmatter. Might be worth using reflection to generate this
// list at runtime in the future, but this should be okay for now
var supportedCoderResourceStructKeys = []string{
"description", "icon", "display_name", "verified", "tags", "supported_os",
// TODO: This is an old, officially deprecated key from the archived coder/modules repo. We can remove this once we
// make sure that the Registry Server is no longer checking this field.
"maintainer_github",
}
// coderResourceReadme represents a README describing a Terraform resource used
@@ -42,6 +51,17 @@ type coderResourceReadme struct {
frontmatter coderResourceFrontmatter
}
func validateSupportedOperatingSystems(systems []string) []error {
var errs []error
for _, s := range systems {
if slices.Contains(operatingSystems, s) {
continue
}
errs = append(errs, xerrors.Errorf("detected unknown operating system %q", s))
}
return errs
}
func validateCoderResourceDisplayName(displayName *string) error {
if displayName != nil && *displayName == "" {
return xerrors.New("if defined, display_name must not be empty string")
@@ -67,7 +87,7 @@ func validateCoderResourceIconURL(iconURL string) []error {
return []error{xerrors.New("icon URL cannot be empty")}
}
errs := []error{}
var errs []error
// If the URL does not have a relative path.
if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") {
@@ -98,7 +118,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.
invalidTags := []string{}
var invalidTags []string
for _, t := range tags {
if t != url.QueryEscape(t) {
invalidTags = append(invalidTags, t)
@@ -111,119 +131,50 @@ func validateCoderResourceTags(tags []string) error {
return nil
}
func validateCoderResourceReadmeBody(body string) []error {
func validateCoderResourceFrontmatter(resourceType string, filePath string, fm coderResourceFrontmatter) []error {
if !slices.Contains(supportedResourceTypes, resourceType) {
return []error{xerrors.Errorf("cannot process unknown resource type %q", resourceType)}
}
var errs []error
trimmed := strings.TrimSpace(body)
// TODO: this may cause unexpected behavior since the errors slice may have a 0 length. Add a test.
errs = append(errs, validateReadmeBody(trimmed)...)
foundParagraph := false
terraformCodeBlockCount := 0
foundTerraformVersionRef := false
lineNum := 0
isInsideCodeBlock := false
isInsideTerraform := false
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
for lineScanner.Scan() {
lineNum++
nextLine := lineScanner.Text()
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
// need to check deeper if the first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
}
continue
}
if strings.HasPrefix(nextLine, "```") {
isInsideCodeBlock = !isInsideCodeBlock
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
if isInsideTerraform {
terraformCodeBlockCount++
}
if strings.HasPrefix(nextLine, "```hcl") {
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
}
continue
}
if isInsideCodeBlock {
if isInsideTerraform {
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
}
continue
}
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
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.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
if err := validateCoderResourceDisplayName(fm.DisplayName); err != nil {
errs = append(errs, addFilePathToError(filePath, err))
}
if err := validateCoderResourceDescription(fm.Description); err != nil {
errs = append(errs, addFilePathToError(filePath, err))
}
if err := validateCoderResourceTags(fm.Tags); err != nil {
errs = append(errs, addFilePathToError(filePath, err))
}
if terraformCodeBlockCount == 0 {
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
} else {
if terraformCodeBlockCount > 1 {
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
}
if !foundTerraformVersionRef {
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
}
for _, err := range validateCoderResourceIconURL(fm.IconURL) {
errs = append(errs, addFilePathToError(filePath, err))
}
if !foundParagraph {
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
}
if isInsideCodeBlock {
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) {
errs = append(errs, addFilePathToError(filePath, err))
}
return errs
}
func validateCoderResourceReadme(rm coderResourceReadme) []error {
var errs []error
for _, err := range validateCoderResourceReadmeBody(rm.body) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if err := validateCoderResourceDisplayName(rm.frontmatter.DisplayName); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if err := validateCoderResourceDescription(rm.frontmatter.Description); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if err := validateCoderResourceTags(rm.frontmatter.Tags); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
return errs
}
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, error) {
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, []error) {
fm, body, err := separateFrontmatter(rm.rawText)
if err != nil {
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
}
keyErrs := validateFrontmatterYamlKeys(fm, supportedCoderResourceStructKeys)
if len(keyErrs) != 0 {
var remapped []error
for _, e := range keyErrs {
remapped = append(remapped, addFilePathToError(rm.filePath, e))
}
return coderResourceReadme{}, remapped
}
yml := coderResourceFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
}
return coderResourceReadme{
@@ -234,13 +185,17 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead
}, nil
}
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) {
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) ([]coderResourceReadme, error) {
if !slices.Contains(supportedResourceTypes, resourceType) {
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
}
resources := map[string]coderResourceReadme{}
var yamlParsingErrs []error
for _, rm := range rms {
p, err := parseCoderResourceReadme(resourceType, rm)
if err != nil {
yamlParsingErrs = append(yamlParsingErrs, err)
p, errs := parseCoderResourceReadme(resourceType, rm)
if len(errs) != 0 {
yamlParsingErrs = append(yamlParsingErrs, errs...)
continue
}
@@ -253,30 +208,27 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
}
}
yamlValidationErrors := []error{}
for _, readme := range resources {
errs := validateCoderResourceReadme(readme)
if len(errs) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errs...)
}
var serialized []coderResourceReadme
for _, r := range resources {
serialized = append(serialized, r)
}
if len(yamlValidationErrors) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
return resources, nil
slices.SortFunc(serialized, func(r1 coderResourceReadme, r2 coderResourceReadme) int {
return strings.Compare(r1.filePath, r2.filePath)
})
return serialized, nil
}
// Todo: Need to beef up this function by grabbing each image/video URL from
// the body's AST.
func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error {
func validateCoderResourceRelativeURLs(_ []coderResourceReadme) error {
return nil
}
func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
if !slices.Contains(supportedResourceTypes, resourceType) {
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
}
registryFiles, err := os.ReadDir(rootRegistryPath)
if err != nil {
return nil, err
@@ -325,27 +277,3 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
}
return allReadmeFiles, nil
}
func validateAllCoderResourceFilesOfType(resourceType string) error {
if !slices.Contains(supportedResourceTypes, resourceType) {
return xerrors.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
}
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
if err != nil {
return err
}
logger.Info(context.Background(), "processing README files", "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
logger.Info(context.Background(), "processed README files as valid Coder resources", "num_files", len(resources), "type", resourceType)
if err := validateCoderResourceRelativeURLs(resources); err != nil {
return err
}
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "type", resourceType)
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"bufio"
"context"
"strings"
"golang.org/x/xerrors"
)
func validateCoderTemplateReadmeBody(body string) []error {
var errs []error
trimmed := strings.TrimSpace(body)
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
errs = append(errs, baseErrs...)
}
var nextLine string
foundParagraph := false
isInsideCodeBlock := false
lineNum := 0
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
for lineScanner.Scan() {
lineNum++
nextLine = lineScanner.Text()
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
// need to check deeper if the first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
}
continue
}
if strings.HasPrefix(nextLine, "```") {
isInsideCodeBlock = !isInsideCodeBlock
if strings.HasPrefix(nextLine, "```hcl") {
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
}
continue
}
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
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.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
}
if !foundParagraph {
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
}
if isInsideCodeBlock {
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
}
return errs
}
func validateCoderTemplateReadme(rm coderResourceReadme) []error {
var errs []error
for _, err := range validateCoderTemplateReadmeBody(rm.body) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if fmErrs := validateCoderResourceFrontmatter("templates", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
errs = append(errs, fmErrs...)
}
return errs
}
func validateAllCoderTemplateReadmes(resources []coderResourceReadme) error {
var yamlValidationErrors []error
for _, readme := range resources {
errs := validateCoderTemplateReadme(readme)
if len(errs) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errs...)
}
}
if len(yamlValidationErrors) != 0 {
return validationPhaseError{
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
return nil
}
func validateAllCoderTemplates() error {
const resourceType = "templates"
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
if err != nil {
return err
}
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
err = validateAllCoderTemplateReadmes(resources)
if err != nil {
return err
}
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
if err := validateCoderResourceRelativeURLs(resources); err != nil {
return err
}
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
return nil
}
+46 -13
View File
@@ -19,11 +19,16 @@ type contributorProfileFrontmatter struct {
Bio string `yaml:"bio"`
ContributorStatus string `yaml:"status"`
AvatarURL *string `yaml:"avatar"`
GithubUsername *string `yaml:"github"`
LinkedinURL *string `yaml:"linkedin"`
WebsiteURL *string `yaml:"website"`
SupportEmail *string `yaml:"support_email"`
}
// A slice version of the struct tags from contributorProfileFrontmatter. Might be worth using reflection to generate
// this list at runtime in the future, but this should be okay for now
var supportedContributorProfileStructKeys = []string{"display_name", "bio", "status", "avatar", "linkedin", "github", "website", "support_email"}
type contributorProfileReadme struct {
frontmatter contributorProfileFrontmatter
namespace string
@@ -50,6 +55,22 @@ func validateContributorLinkedinURL(linkedinURL *string) error {
return nil
}
func validateGithubUsername(username *string) error {
if username == nil {
return nil
}
name := *username
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return xerrors.New("username must have non-whitespace characters")
}
if name != trimmed {
return xerrors.Errorf("username %q has extra whitespace", trimmed)
}
return nil
}
// validateContributorSupportEmail does best effort validation of a contributors email address. We can't 100% validate
// that this is correct without actually sending an email, especially because some contributors are individual developers
// and we don't want to do that on every single run of the CI pipeline. The best we can do is verify the general structure.
@@ -58,7 +79,7 @@ func validateContributorSupportEmail(email *string) []error {
return nil
}
errs := []error{}
var errs []error
username, server, ok := strings.Cut(*email, "@")
if !ok {
@@ -119,7 +140,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
return []error{xerrors.New("avatar URL must be omitted or non-empty string")}
}
errs := []error{}
var errs []error
// Have to use .Parse instead of .ParseRequestURI because this is the one field that's allowed to be a relative URL.
if _, err := url.Parse(*avatarURL); err != nil {
errs = append(errs, xerrors.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
@@ -145,7 +166,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
}
func validateContributorReadme(rm contributorProfileReadme) []error {
allErrs := []error{}
var allErrs []error
if err := validateContributorDisplayName(rm.frontmatter.DisplayName); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
@@ -153,6 +174,9 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
if err := validateContributorLinkedinURL(rm.frontmatter.LinkedinURL); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
}
if err := validateGithubUsername(rm.frontmatter.GithubUsername); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
}
if err := validateContributorWebsite(rm.frontmatter.WebsiteURL); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
}
@@ -170,15 +194,24 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
return allErrs
}
func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
func parseContributorProfile(rm readme) (contributorProfileReadme, []error) {
fm, _, err := separateFrontmatter(rm.rawText)
if err != nil {
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
}
keyErrs := validateFrontmatterYamlKeys(fm, supportedContributorProfileStructKeys)
if len(keyErrs) != 0 {
var remapped []error
for _, e := range keyErrs {
remapped = append(remapped, addFilePathToError(rm.filePath, e))
}
return contributorProfileReadme{}, remapped
}
yml := contributorProfileFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
}
return contributorProfileReadme{
@@ -190,11 +223,11 @@ func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfileReadme, error) {
profilesByNamespace := map[string]contributorProfileReadme{}
yamlParsingErrors := []error{}
var yamlParsingErrors []error
for _, rm := range readmeEntries {
p, err := parseContributorProfile(rm)
if err != nil {
yamlParsingErrors = append(yamlParsingErrors, err)
p, errs := parseContributorProfile(rm)
if len(errs) != 0 {
yamlParsingErrors = append(yamlParsingErrors, errs...)
continue
}
@@ -211,7 +244,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
}
}
yamlValidationErrors := []error{}
var yamlValidationErrors []error
for _, p := range profilesByNamespace {
if errors := validateContributorReadme(p); len(errors) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errors...)
@@ -234,8 +267,8 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
return nil, err
}
allReadmeFiles := []readme{}
errs := []error{}
var allReadmeFiles []readme
var errs []error
dirPath := ""
for _, e := range dirEntries {
if !e.IsDir() {
+5 -1
View File
@@ -31,7 +31,11 @@ func main() {
if err != nil {
errs = append(errs, err)
}
err = validateAllCoderResourceFilesOfType("modules")
err = validateAllCoderModules()
if err != nil {
errs = append(errs, err)
}
err = validateAllCoderTemplates()
if err != nil {
errs = append(errs, err)
}
+26 -1
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"fmt"
"regexp"
"slices"
"strings"
"golang.org/x/xerrors"
@@ -39,7 +40,9 @@ const (
var (
supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
// Matches markdown headers, must be at the beginning of a line, such as "# " or "### ".
// Matches markdown headers placed at the beginning of a line (e.g., "# " or "### "). To make the logic for
// validateReadmeBody easier, this pattern deliberately matches on invalid headers (header levels must be in the
// range 16 to be valid). The function has checks to see if the level is correct.
readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`)
)
@@ -168,3 +171,25 @@ func validateReadmeBody(body string) []error {
return errs
}
func validateFrontmatterYamlKeys(frontmatter string, allowedKeys []string) []error {
if len(allowedKeys) == 0 {
return []error{xerrors.New("Set of allowed keys is empty")}
}
var key string
var cutOk bool
var line string
var errs []error
lineScanner := bufio.NewScanner(strings.NewReader(frontmatter))
for lineScanner.Scan() {
line = lineScanner.Text()
key, _, cutOk = strings.Cut(line, ":")
if !cutOk || slices.Contains(allowedKeys, key) {
continue
}
errs = append(errs, xerrors.Errorf("detected unknown key %q", key))
}
return errs
}
+32 -21
View File
@@ -10,18 +10,21 @@ import (
"golang.org/x/xerrors"
)
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images")
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
// validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all
// expected file conventions
func validateCoderResourceSubdirectory(dirPath string) []error {
subDir, err := os.Stat(dirPath)
resourceDir, err := os.Stat(dirPath)
if err != nil {
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow specific rules.
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow
// specific rules.
if !errors.Is(err, os.ErrNotExist) {
return []error{addFilePathToError(dirPath, err)}
}
}
if !subDir.IsDir() {
if !resourceDir.IsDir() {
return []error{xerrors.Errorf("%q: path is not a directory", dirPath)}
}
@@ -30,10 +33,11 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
return []error{addFilePathToError(dirPath, err)}
}
errs := []error{}
var errs []error
for _, f := range files {
// The .coder subdirectories are sometimes generated as part of Bun tests. These subdirectories will never be
// committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over them.
// The .coder subdirectories are sometimes generated as part of our Bun tests. These subdirectories will never
// be committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over
// them.
if !f.IsDir() || f.Name() == ".coder" {
continue
}
@@ -59,49 +63,53 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
return errs
}
// validateRegistryDirectory validates that the contents of `/registry` follow all expected file conventions. This
// includes the top-level structure of the individual namespace directories.
func validateRegistryDirectory() []error {
userDirs, err := os.ReadDir(rootRegistryPath)
namespaceDirs, err := os.ReadDir(rootRegistryPath)
if err != nil {
return []error{err}
}
allErrs := []error{}
for _, d := range userDirs {
dirPath := path.Join(rootRegistryPath, d.Name())
if !d.IsDir() {
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
var allErrs []error
for _, nDir := range namespaceDirs {
namespacePath := path.Join(rootRegistryPath, nDir.Name())
if !nDir.IsDir() {
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", namespacePath))
continue
}
contributorReadmePath := path.Join(dirPath, "README.md")
contributorReadmePath := path.Join(namespacePath, "README.md")
if _, err := os.Stat(contributorReadmePath); err != nil {
allErrs = append(allErrs, err)
}
files, err := os.ReadDir(dirPath)
files, err := os.ReadDir(namespacePath)
if err != nil {
allErrs = append(allErrs, err)
continue
}
for _, f := range files {
// TODO: Decide if there's anything more formal that we want to ensure about non-directories scoped to user namespaces.
// TODO: Decide if there's anything more formal that we want to ensure about non-directories at the top
// level of each user namespace.
if !f.IsDir() {
continue
}
segment := f.Name()
filePath := path.Join(dirPath, segment)
filePath := path.Join(namespacePath, segment)
if !slices.Contains(supportedUserNameSpaceDirectories, segment) {
allErrs = append(allErrs, xerrors.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", ")))
continue
}
if !slices.Contains(supportedResourceTypes, segment) {
continue
}
if slices.Contains(supportedResourceTypes, segment) {
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
allErrs = append(allErrs, errs...)
}
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
allErrs = append(allErrs, errs...)
}
}
}
@@ -109,6 +117,9 @@ func validateRegistryDirectory() []error {
return allErrs
}
// validateRepoStructure validates that the structure of the repo is "correct enough" to do all necessary validation
// checks. It is NOT an exhaustive validation of the entire repo structure it only checks the parts of the repo that
// are relevant for the main validation steps
func validateRepoStructure() error {
var errs []error
if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 {
+21
View File
@@ -0,0 +1,21 @@
run "plan_with_required_vars" {
command = plan
variables {
agent_id = "example-agent-id"
}
}
run "app_url_uses_port" {
command = plan
variables {
agent_id = "example-agent-id"
port = 19999
}
assert {
condition = resource.coder_app.MODULE_NAME.url == "http://localhost:19999"
error_message = "Expected MODULE_NAME app URL to include configured port"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"fmt": "bun x prettier --write **/*.sh **/*.ts **/*.md *.md && terraform fmt -recursive -diff",
"fmt:ci": "bun x prettier --check **/*.sh **/*.ts **/*.md *.md && terraform fmt -check -recursive -diff",
"terraform-validate": "./scripts/terraform_validate.sh",
"test": "bun test",
"test": "./scripts/terraform_test_all.sh",
"update-version": "./update-version.sh"
},
"devDependencies": {
+100 -37
View File
@@ -1,36 +1,41 @@
---
display_name: Gemini CLI
description: Run Gemini CLI in your workspace for AI pair programming
icon: ../../../../.icons/gemini.svg
description: Run Gemini CLI in your workspace with AgentAPI integration
verified: true
tags: [agent, gemini, ai, google, tasks]
---
# Gemini CLI
Run [Gemini CLI](https://ai.google.dev/gemini-api/docs/cli) in your workspace to access Google's Gemini AI models, and custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility.
Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace to access Google's Gemini AI models for interactive coding assistance and automated task execution.
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
gemini_model = "gemini-2.5-pro"
install_gemini = true
gemini_version = "latest"
agentapi_version = "latest"
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
```
## Features
- **Interactive AI Assistance**: Run Gemini CLI directly in your terminal for coding help
- **Automated Task Execution**: Execute coding tasks automatically via AgentAPI integration
- **Multiple AI Models**: Support for Gemini 2.5 Pro, Flash, and other Google AI models
- **API Key Integration**: Seamless authentication with Gemini API
- **MCP Server Integration**: Built-in Coder MCP server for task reporting
- **Persistent Sessions**: Maintain context across workspace sessions
## Prerequisites
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login/coder) module to your template
- Node.js and npm will be installed automatically if not present
- The [Coder Login](https://registry.coder.com/modules/coder/coder-login) module is required
## Usage Example
## Examples
- Example 1:
### Basic setup
```tf
variable "gemini_api_key" {
@@ -40,39 +45,97 @@ variable "gemini_api_key" {
}
module "gemini" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key # we recommend providing this parameter inorder to have a smoother experience (i.e. no google sign-in)
gemini_model = "gemini-2.5-flash"
install_gemini = true
gemini_version = "latest"
gemini_instruction_prompt = "Start every response with `Gemini says:`"
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
}
```
## How it Works
This basic setup will:
- **Install**: The module installs Gemini CLI using npm (installs Node.js via NVM if needed)
- **Instruction Prompt**: If `GEMINI_INSTRUCTION_PROMPT` and `GEMINI_START_DIRECTORY` are set, creates the directory (if needed) and writes the prompt to `GEMINI.md`
- **Start**: Launches Gemini CLI in the specified directory, wrapped by AgentAPI
- **Environment**: Sets `GEMINI_API_KEY`, `GOOGLE_GENAI_USE_VERTEXAI`, `GEMINI_MODEL` for the CLI (if variables provided)
- Install Gemini CLI in the workspace
- Configure authentication with your API key
- Set Gemini to run in `/home/coder/project` directory
- Enable interactive use from the terminal
- Set up MCP server integration for task reporting
### Automated task execution (Experimental)
> This functionality is in early access and is still evolving.
> For now, we recommend testing it in a demo or staging environment,
> rather than deploying to production
>
> Learn more in [the Coder documentation](https://coder.com/docs/ai-coder)
```tf
variable "gemini_api_key" {
type = string
description = "Gemini API key"
sensitive = true
}
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/coder-login/coder"
version = "~> 1.0"
agent_id = coder_agent.example.id
}
data "coder_parameter" "ai_prompt" {
type = "string"
name = "AI Prompt"
default = ""
description = "Task prompt for automated Gemini execution"
mutable = true
}
module "gemini" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
gemini_model = "gemini-2.5-flash"
folder = "/home/coder/project"
task_prompt = data.coder_parameter.ai_prompt.value
enable_yolo_mode = true # Auto-approve all tool calls for automation
gemini_system_prompt = <<-EOT
You are a helpful coding assistant. Always explain your code changes clearly.
YOU MUST REPORT ALL TASKS TO CODER.
EOT
}
```
> [!WARNING]
> YOLO mode automatically approves all tool calls without user confirmation. The agent has access to your machine's file system and terminal. Only enable in trusted, isolated environments.
### Using Vertex AI (Enterprise)
For enterprise users who prefer Google's Vertex AI platform:
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
use_vertexai = true
}
```
## Troubleshooting
- If Gemini CLI is not found, ensure `install_gemini = true` and your API key is valid
- Node.js and npm are installed automatically if missing (using NVM)
- Check logs in `/home/coder/.gemini-module/` for install/start output
- We highly recommend using the `gemini_api_key` variable, this also ensures smooth tasks running without needing to sign in to Google.
- If Gemini CLI is not found, ensure your API key is valid (`install_gemini` defaults to `true`)
- Check logs in `~/.gemini-module/` for install/start output
- Use the `gemini_api_key` variable to avoid requiring Google sign-in
> [!IMPORTANT]
> To use tasks with Gemini CLI, ensure you have the `gemini_api_key` variable set, and **you pass the `AI Prompt` Parameter**.
> By default we inject the "theme": "Default" and "selectedAuthType": "gemini-api-key" to your ~/.gemini/settings.json along with the coder mcp server.
> In `gemini_instruction_prompt` and `AI Prompt` text we recommend using (\`\`) backticks instead of quotes to avoid escaping issues. Eg: gemini_instruction_prompt = "Start every response with \`Gemini says:\` "
The module creates log files in the workspace's `~/.gemini-module` directory for debugging purposes.
## References
- [Gemini CLI Documentation](https://ai.google.dev/gemini-api/docs/cli)
- [Gemini CLI Documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/index.md)
- [AgentAPI Documentation](https://github.com/coder/agentapi)
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
- [Coder AI Agents Guide](https://coder.com/docs/ai-coder)
@@ -8,7 +8,6 @@ import {
} from "bun:test";
import { execContainer, readFileContainer, runTerraformInit } from "~test";
import {
loadTestFile,
writeExecutable,
setup as setupUtil,
execModuleScript,
@@ -54,10 +53,24 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
agentapiMockScript: props?.agentapiMockScript,
});
if (!props?.skipGeminiMock) {
const geminiMockContent = `#!/bin/bash
if [[ "$1" == "--version" ]]; then
echo "HELLO: $(bash -c env)"
echo "gemini version v2.5.0"
exit 0
fi
set -e
while true; do
echo "$(date) - gemini-mock"
sleep 15
done`;
await writeExecutable({
containerId: id,
filePath: "/usr/bin/gemini",
content: await loadTestFile(import.meta.dir, "gemini-mock.sh"),
content: geminiMockContent,
});
}
return { id };
@@ -70,7 +83,7 @@ describe("gemini", async () => {
await runTerraformInit(import.meta.dir);
});
test("happy-path", async () => {
test("agent-api", async () => {
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
@@ -117,7 +130,7 @@ describe("gemini", async () => {
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.gemini-module/agentapi-start.log");
expect(resp).toContain("gemini_api_key provided !");
expect(resp).toContain("Using direct Gemini API with API key");
});
test("use-vertexai", async () => {
@@ -197,6 +210,20 @@ describe("gemini", async () => {
expect(resp).toContain(prompt);
});
test("task-prompt", async () => {
const taskPrompt = "Create a simple Hello World function";
const { id } = await setup({
moduleVariables: {
task_prompt: taskPrompt,
},
});
await execModuleScript(id, {
GEMINI_TASK_PROMPT: taskPrompt,
});
const resp = await readFileContainer(id, "/home/coder/.gemini-module/agentapi-start.log");
expect(resp).toContain("Running automated task:");
});
test("start-without-prompt", async () => {
const { id } = await setup();
await execModuleScript(id);
+36 -25
View File
@@ -74,14 +74,14 @@ variable "use_vertexai" {
variable "install_agentapi" {
type = bool
description = "Whether to install AgentAPI."
description = "Whether to install AgentAPI for web UI and task automation."
default = true
}
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.3.0"
default = "v0.2.3"
}
variable "gemini_model" {
@@ -102,12 +102,10 @@ variable "post_install_script" {
default = null
}
data "coder_parameter" "ai_prompt" {
type = "string"
name = "AI Prompt"
variable "task_prompt" {
type = string
description = "Task prompt for automated Gemini execution"
default = ""
description = "Initial prompt for the Gemini CLI"
mutable = true
}
variable "additional_extensions" {
@@ -122,12 +120,24 @@ variable "gemini_system_prompt" {
default = ""
}
variable "enable_yolo_mode" {
type = bool
description = "Enable YOLO mode to automatically approve all tool calls without user confirmation. Use with caution."
default = false
}
resource "coder_env" "gemini_api_key" {
agent_id = var.agent_id
name = "GEMINI_API_KEY"
value = var.gemini_api_key
}
resource "coder_env" "google_api_key" {
agent_id = var.agent_id
name = "GOOGLE_API_KEY"
value = var.gemini_api_key
}
resource "coder_env" "gemini_use_vertex_ai" {
agent_id = var.agent_id
name = "GOOGLE_GENAI_USE_VERTEXAI"
@@ -166,7 +176,7 @@ EOT
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.0.0"
version = "1.1.1"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -181,22 +191,7 @@ module "agentapi" {
agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
chmod +x /tmp/start.sh
GEMINI_API_KEY='${var.gemini_api_key}' \
GOOGLE_GENAI_USE_VERTEXAI='${var.use_vertexai}' \
GEMINI_MODEL='${var.gemini_model}' \
GEMINI_START_DIRECTORY='${var.folder}' \
GEMINI_TASK_PROMPT='${base64encode(data.coder_parameter.ai_prompt.value)}' \
/tmp/start.sh
EOT
install_script = <<-EOT
install_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
@@ -209,7 +204,23 @@ module "agentapi" {
BASE_EXTENSIONS='${base64encode(replace(local.base_extensions, "'", "'\\''"))}' \
ADDITIONAL_EXTENSIONS='${base64encode(replace(var.additional_extensions != null ? var.additional_extensions : "", "'", "'\\''"))}' \
GEMINI_START_DIRECTORY='${var.folder}' \
GEMINI_INSTRUCTION_PROMPT='${base64encode(var.gemini_system_prompt)}' \
GEMINI_SYSTEM_PROMPT='${base64encode(var.gemini_system_prompt)}' \
/tmp/install.sh
EOT
start_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
chmod +x /tmp/start.sh
GEMINI_API_KEY='${var.gemini_api_key}' \
GOOGLE_API_KEY='${var.gemini_api_key}' \
GOOGLE_GENAI_USE_VERTEXAI='${var.use_vertexai}' \
GEMINI_YOLO_MODE='${var.enable_yolo_mode}' \
GEMINI_MODEL='${var.gemini_model}' \
GEMINI_START_DIRECTORY='${var.folder}' \
GEMINI_TASK_PROMPT='${var.task_prompt}' \
/tmp/start.sh
EOT
}
@@ -2,7 +2,6 @@
BOLD='\033[0;1m'
# Function to check if a command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
@@ -12,7 +11,7 @@ set -o nounset
ARG_GEMINI_CONFIG=$(echo -n "$ARG_GEMINI_CONFIG" | base64 -d)
BASE_EXTENSIONS=$(echo -n "$BASE_EXTENSIONS" | base64 -d)
ADDITIONAL_EXTENSIONS=$(echo -n "$ADDITIONAL_EXTENSIONS" | base64 -d)
GEMINI_INSTRUCTION_PROMPT=$(echo -n "$GEMINI_INSTRUCTION_PROMPT" | base64 -d)
GEMINI_SYSTEM_PROMPT=$(echo -n "$GEMINI_SYSTEM_PROMPT" | base64 -d)
echo "--------------------------------"
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
@@ -23,7 +22,6 @@ echo "--------------------------------"
set +o nounset
function install_node() {
# borrowed from claude-code module
if ! command_exists npm; then
printf "npm not found, checking for Node.js installation...\n"
if ! command_exists node; then
@@ -52,24 +50,15 @@ function install_node() {
function install_gemini() {
if [ "${ARG_INSTALL}" = "true" ]; then
# we need node to install and run gemini-cli
install_node
# If nvm does not exist, we will create a global npm directory (this os to prevent the possibility of EACCESS issues on npm -g)
if ! command_exists nvm; then
printf "which node: %s\n" "$(which node)"
printf "which npm: %s\n" "$(which npm)"
# Create a directory for global packages
mkdir -p "$HOME"/.npm-global
# Configure npm to use it
npm config set prefix "$HOME/.npm-global"
# Add to PATH for current session
export PATH="$HOME/.npm-global/bin:$PATH"
# Add to shell profile for future sessions
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
fi
@@ -108,7 +97,6 @@ function append_extensions_to_settings_json() {
fi
if [ ! -f "$SETTINGS_PATH" ]; then
printf "%s does not exist. Creating with merged mcpServers structure.\n" "$SETTINGS_PATH"
# If ADDITIONAL_EXTENSIONS is not set or empty, use '{}'
ADD_EXT_JSON='{}'
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
ADD_EXT_JSON="$ADDITIONAL_EXTENSIONS"
@@ -116,10 +104,7 @@ function append_extensions_to_settings_json() {
printf '{"mcpServers":%s}\n' "$(jq -s 'add' <(echo "$BASE_EXTENSIONS") <(echo "$ADD_EXT_JSON"))" > "$SETTINGS_PATH"
fi
# Prepare temp files
TMP_SETTINGS=$(mktemp)
# If ADDITIONAL_EXTENSIONS is not set or empty, use '{}'
ADD_EXT_JSON='{}'
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
printf "[append_extensions_to_settings_json] ADDITIONAL_EXTENSIONS is set.\n"
@@ -133,14 +118,13 @@ function append_extensions_to_settings_json() {
'.mcpServers = (.mcpServers // {} + $base + $add)' \
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
# Add theme and selectedAuthType fields
jq '.theme = "Default" | .selectedAuthType = "gemini-api-key"' "$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
printf "[append_extensions_to_settings_json] Merge complete.\n"
}
function add_instruction_prompt_if_exists() {
if [ -n "${GEMINI_INSTRUCTION_PROMPT:-}" ]; then
function add_system_prompt_if_exists() {
if [ -n "${GEMINI_SYSTEM_PROMPT:-}" ]; then
if [ -d "${GEMINI_START_DIRECTORY}" ]; then
printf "Directory '%s' exists. Changing to it.\\n" "${GEMINI_START_DIRECTORY}"
cd "${GEMINI_START_DIRECTORY}" || {
@@ -160,16 +144,21 @@ function add_instruction_prompt_if_exists() {
fi
touch GEMINI.md
printf "Setting GEMINI.md\n"
echo "${GEMINI_INSTRUCTION_PROMPT}" > GEMINI.md
echo "${GEMINI_SYSTEM_PROMPT}" > GEMINI.md
else
printf "GEMINI.md is not set.\n"
fi
}
function configure_mcp() {
export CODER_MCP_APP_STATUS_SLUG="gemini"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
coder exp mcp configure gemini "${GEMINI_START_DIRECTORY}"
}
# Install Gemini
install_gemini
gemini --version
populate_settings_json
add_instruction_prompt_if_exists
add_system_prompt_if_exists
configure_mcp
@@ -1,6 +1,7 @@
#!/bin/bash
set -o errexit
set -o pipefail
# Load shell environment
source "$HOME"/.bashrc
command_exists() {
@@ -15,7 +16,8 @@ fi
printf "Version: %s\n" "$(gemini --version)"
GEMINI_TASK_PROMPT=$(echo -n "$GEMINI_TASK_PROMPT" | base64 -d)
MODULE_DIR="$HOME/.gemini-module"
mkdir -p "$MODULE_DIR"
if command_exists gemini; then
printf "Gemini is installed\n"
@@ -43,20 +45,30 @@ else
fi
if [ -n "$GEMINI_TASK_PROMPT" ]; then
printf "Running the task prompt %s\n" "$GEMINI_TASK_PROMPT"
printf "Running automated task: %s\n" "$GEMINI_TASK_PROMPT"
PROMPT="Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GEMINI_TASK_PROMPT"
PROMPT_FILE="$MODULE_DIR/prompt.txt"
echo -n "$PROMPT" >"$PROMPT_FILE"
GEMINI_ARGS=(--prompt-interactive "$PROMPT")
else
printf "No task prompt given.\n"
printf "Starting Gemini CLI in interactive mode.\n"
GEMINI_ARGS=()
fi
if [ -n "$GEMINI_API_KEY" ]; then
printf "gemini_api_key provided !\n"
else
printf "gemini_api_key not provided\n"
if [ -n "$GEMINI_YOLO_MODE" ] && [ "$GEMINI_YOLO_MODE" = "true" ]; then
printf "YOLO mode enabled - will auto-approve all tool calls\n"
GEMINI_ARGS+=(--yolo)
fi
# use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters
# are visible in the terminal screen by default.
agentapi server --term-width 67 --term-height 1190 -- gemini "${GEMINI_ARGS[@]}"
if [ -n "$GEMINI_API_KEY" ] || [ -n "$GOOGLE_API_KEY" ]; then
if [ -n "$GOOGLE_GENAI_USE_VERTEXAI" ] && [ "$GOOGLE_GENAI_USE_VERTEXAI" = "true" ]; then
printf "Using Vertex AI with API key\n"
else
printf "Using direct Gemini API with API key\n"
fi
else
printf "No API key provided (neither GEMINI_API_KEY nor GOOGLE_API_KEY)\n"
fi
agentapi server --term-width 67 --term-height 1190 -- \
bash -c "$(printf '%q ' gemini "${GEMINI_ARGS[@]}")"
@@ -1,14 +0,0 @@
#!/bin/bash
if [[ "$1" == "--version" ]]; then
echo "HELLO: $(bash -c env)"
echo "gemini version v2.5.0"
exit 0
fi
set -e
while true; do
echo "$(date) - gemini-mock"
sleep 15
done
@@ -8,6 +8,10 @@ tags: [docker, container, dockerfile]
# Remote Development on Docker Containers (Build from Dockerfile)
> [!NOTE]
> This template is designed to be a starting point for testing purposes.
> In a production environment, you would want to move away from storing the Dockerfile in-template and move towards using a centralized image registry.
Build and provision Docker containers from a Dockerfile as [Coder workspaces](https://coder.com/docs/workspaces) with this example template.
This template builds a custom Docker image from the included Dockerfile, allowing you to customize the development environment by modifying the Dockerfile rather than using a pre-built image.
@@ -18,7 +22,22 @@ This template builds a custom Docker image from the included Dockerfile, allowin
### Infrastructure
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
#### Running Coder inside Docker
If you installed Coder as a container within Docker, you will have to do the following things:
- Make the the Docker socket available to the container
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
- You can get the GID by running `getent group docker` on the **host** machine
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
#### Running Coder outside of Docker
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
```sh
# Add coder user to Docker group
@@ -63,7 +63,7 @@ Visit this URL for your Coder deployment:
https://coder.example.com/templates/new?exampleId=scratch
```
After creating the template, paste the contents from [main.tf](./main.tf) into the template editor and save.
After creating the template, paste the contents from [main.tf](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/tasks-docker/main.tf) into the template editor and save.
Alternatively, you can use the Coder CLI to [push the template](https://coder.com/docs/reference/cli/templates_push)
+3 -4
View File
@@ -13,7 +13,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 = "2.0.7"
version = "2.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -28,7 +28,6 @@ module "claude-code" {
## Prerequisites
- Node.js and npm must be installed in your workspace to install Claude Code
- 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.
@@ -84,7 +83,7 @@ resource "coder_agent" "main" {
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
version = "2.0.7"
version = "2.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -102,7 +101,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 = "2.0.7"
version = "2.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
+19 -34
View File
@@ -111,7 +111,7 @@ locals {
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
agentapi_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-start.sh"))
agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.js"))
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.sh"))
claude_code_app_slug = "ccw"
}
@@ -129,6 +129,21 @@ resource "coder_script" "claude_code" {
command -v "$1" >/dev/null 2>&1
}
function install_claude_code_cli() {
echo "Installing Claude Code via official installer"
set +e
curl -fsSL claude.ai/install.sh | bash -s -- "${var.claude_code_version}" 2>&1
CURL_EXIT=$${PIPESTATUS[0]}
set -e
if [ $CURL_EXIT -ne 0 ]; then
echo "Claude Code installer failed with exit code $$CURL_EXIT"
fi
# Ensure binaries are discoverable.
export PATH="~/.local/bin:$PATH"
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
}
if [ ! -d "${local.workdir}" ]; then
echo "Warning: The specified folder '${local.workdir}' does not exist."
echo "Creating the folder..."
@@ -143,37 +158,7 @@ resource "coder_script" "claude_code" {
fi
if [ "${var.install_claude_code}" = "true" ]; then
if ! command_exists npm; then
echo "npm not found, checking for Node.js installation..."
if ! command_exists node; then
echo "Node.js not found, installing Node.js via NVM..."
export NVM_DIR="$HOME/.nvm"
if [ ! -d "$NVM_DIR" ]; then
mkdir -p "$NVM_DIR"
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
else
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
fi
nvm install --lts
nvm use --lts
nvm alias default node
echo "Node.js installed: $(node --version)"
echo "npm installed: $(npm --version)"
else
echo "Node.js is installed but npm is not available. Please install npm manually."
exit 1
fi
fi
echo "Installing Claude Code..."
npm install -g @anthropic-ai/claude-code@${var.claude_code_version}
fi
if ! command_exists node; then
echo "Error: Node.js is not installed. Please install Node.js manually."
exit 1
install_claude_code_cli
fi
# Install AgentAPI if enabled
@@ -214,7 +199,7 @@ resource "coder_script" "claude_code" {
echo -n "${local.agentapi_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-start.sh"
echo -n "${local.agentapi_wait_for_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-wait-for-start.sh"
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.js"
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.sh"
chmod +x "$module_path/scripts/agentapi-start.sh"
chmod +x "$module_path/scripts/agentapi-wait-for-start.sh"
@@ -292,4 +277,4 @@ resource "coder_ai_task" "claude_code" {
sidebar_app {
id = coder_app.claude_code_web.id
}
}
}
@@ -19,10 +19,10 @@ if [ -f "$log_file_path" ]; then
mv "$log_file_path" "$log_file_path"".$(date +%s)"
fi
# see the remove-last-session-id.js script for details
# see the remove-last-session-id.sh script for details
# about why we need it
# avoid exiting if the script fails
node "$scripts_dir/remove-last-session-id.js" "$(pwd)" || true
bash "$scripts_dir/remove-last-session-id.sh" "$(pwd)" 2>/dev/null || true
# we'll be manually handling errors from this point on
set +o errexit
@@ -1,40 +0,0 @@
// If lastSessionId is present in .claude.json, claude --continue will start a
// conversation starting from that session. The problem is that lastSessionId
// doesn't always point to the last session. The field is updated by claude only
// at the point of normal CLI exit. If Claude exits with an error, or if the user
// restarts the Coder workspace, lastSessionId will be stale, and claude --continue
// will start from an old session.
//
// If lastSessionId is missing, claude seems to accurately figure out where to
// start using the conversation history - even if the CLI previously exited with
// an error.
//
// This script removes the lastSessionId field from .claude.json.
const path = require("path")
const fs = require("fs")
const workingDirArg = process.argv[2]
if (!workingDirArg) {
console.log("No working directory provided - it must be the first argument")
process.exit(1)
}
const workingDir = path.resolve(workingDirArg)
console.log("workingDir", workingDir)
const claudeJsonPath = path.join(process.env.HOME, ".claude.json")
console.log(".claude.json path", claudeJsonPath)
if (!fs.existsSync(claudeJsonPath)) {
console.log("No .claude.json file found")
process.exit(0)
}
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8"))
if ("projects" in claudeJson && workingDir in claudeJson.projects && "lastSessionId" in claudeJson.projects[workingDir]) {
delete claudeJson.projects[workingDir].lastSessionId
fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2))
console.log("Removed lastSessionId from .claude.json")
} else {
console.log("No lastSessionId found in .claude.json - nothing to do")
}
@@ -0,0 +1,40 @@
# If lastSessionId is present in .claude.json, claude --continue will start a
# conversation starting from that session. The problem is that lastSessionId
# doesn't always point to the last session. The field is updated by claude only
# at the point of normal CLI exit. If Claude exits with an error, or if the user
# restarts the Coder workspace, lastSessionId will be stale, and claude --continue
# will start from an old session.
#
# If lastSessionId is missing, claude seems to accurately figure out where to
# start using the conversation history - even if the CLI previously exited with
# an error.
#
# This script removes the lastSessionId field from .claude.json.
if [ $# -eq 0 ]; then
echo "No working directory provided - it must be the first argument"
exit 1
fi
# Get absolute path of working directory
working_dir=$(realpath "$1")
echo "workingDir $working_dir"
# Path to .claude.json
claude_json_path="$HOME/.claude.json"
echo ".claude.json path $claude_json_path"
# Check if .claude.json exists
if [ ! -f "$claude_json_path" ]; then
echo "No .claude.json file found"
exit 0
fi
# Use jq to check if lastSessionId exists for the working directory and remove it
if jq -e ".projects[\"$working_dir\"].lastSessionId" "$claude_json_path" > /dev/null 2>&1; then
# Remove lastSessionId and update the file
jq "del(.projects[\"$working_dir\"].lastSessionId)" "$claude_json_path" > "${claude_json_path}.tmp" && mv "${claude_json_path}.tmp" "$claude_json_path"
echo "Removed lastSessionId from .claude.json"
else
echo "No lastSessionId found in .claude.json - nothing to do"
fi
@@ -0,0 +1,50 @@
run "required_vars" {
command = plan
variables {
agent_id = "foo"
}
}
run "offline_and_use_cached_conflict" {
command = plan
variables {
agent_id = "foo"
use_cached = true
offline = true
}
expect_failures = [
resource.coder_script.code-server
]
}
run "offline_disallows_extensions" {
command = plan
variables {
agent_id = "foo"
offline = true
extensions = ["ms-python.python", "golang.go"]
}
expect_failures = [
resource.coder_script.code-server
]
}
run "url_with_folder_query" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder/project"
port = 13337
}
assert {
condition = resource.coder_app.code-server.url == "http://localhost:13337/?folder=%2Fhome%2Fcoder%2Fproject"
error_message = "coder_app URL must include encoded folder query param"
}
}
+33 -2
View File
@@ -16,7 +16,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.2.1"
version = "1.3.1"
agent_id = coder_agent.example.id
}
```
@@ -29,8 +29,39 @@ module "cursor" {
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.2.1"
version = "1.3.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
```
### Configure MCP servers for Cursor
Provide a JSON-encoded string via the `mcp` input. When set, the module writes the value to `~/.cursor/mcp.json` using a `coder_script` on workspace start.
The following example configures Cursor to use the GitHub MCP server with authentication facilitated by the [`coder_external_auth`](https://coder.com/docs/admin/external-auth#configure-a-github-oauth-app) resource.
```tf
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.3.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
mcp = jsonencode({
mcpServers = {
"github" : {
"url" : "https://api.githubcopilot.com/mcp/",
"headers" : {
"Authorization" : "Bearer ${data.coder_external_auth.github.access_token}",
},
"type" : "http"
}
}
})
}
data "coder_external_auth" "github" {
id = "github"
}
```
+28 -1
View File
@@ -1,8 +1,13 @@
import { describe, expect, it } from "bun:test";
import { describe, it, expect } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
runContainer,
execContainer,
removeContainer,
findResourceInstance,
readFileContainer,
} from "~test";
describe("cursor", async () => {
@@ -85,4 +90,26 @@ describe("cursor", async () => {
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBe(22);
});
it("writes ~/.cursor/mcp.json when mcp provided", async () => {
const id = await runContainer("alpine");
try {
const mcp = JSON.stringify({ servers: { demo: { url: "http://localhost:1234" } } });
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
mcp,
});
const script = findResourceInstance(state, "coder_script", "cursor_mcp").script;
const resp = await execContainer(id, ["sh", "-c", script]);
if (resp.exitCode !== 0) {
console.log(resp.stdout);
console.log(resp.stderr);
}
expect(resp.exitCode).toBe(0);
const content = await readFileContainer(id, "/root/.cursor/mcp.json");
expect(content).toBe(mcp);
} finally {
await removeContainer(id);
}
});
});
+26
View File
@@ -50,9 +50,20 @@ variable "display_name" {
default = "Cursor Desktop"
}
variable "mcp" {
type = string
description = "JSON-encoded string to configure MCP servers for Cursor. When set, writes ~/.cursor/mcp.json."
default = ""
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
locals {
mcp_b64 = var.mcp != "" ? base64encode(var.mcp) : ""
}
resource "coder_app" "cursor" {
agent_id = var.agent_id
external = true
@@ -75,6 +86,21 @@ resource "coder_app" "cursor" {
])
}
resource "coder_script" "cursor_mcp" {
count = var.mcp != "" ? 1 : 0
agent_id = var.agent_id
display_name = "Cursor MCP"
icon = "/icon/cursor.svg"
run_on_start = true
start_blocks_login = false
script = <<-EOT
#!/bin/sh
set -eu
mkdir -p "$HOME/.cursor"
echo -n "${local.mcp_b64}" | base64 -d > "$HOME/.cursor/mcp.json"
EOT
}
output "cursor_url" {
value = coder_app.cursor.url
description = "Cursor IDE Desktop URL."
+2 -2
View File
@@ -13,7 +13,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 = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
@@ -79,7 +79,7 @@ resource "coder_agent" "main" {
module "goose" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/goose/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
+1 -1
View File
@@ -139,7 +139,7 @@ EOT
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = var.agent_id
web_app_slug = local.app_slug
+6 -6
View File
@@ -14,7 +14,7 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -39,7 +39,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
@@ -52,7 +52,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
# Show parameter with limited options
@@ -66,7 +66,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -81,7 +81,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/workspace/project"
@@ -107,7 +107,7 @@ module "jetbrains" {
module "jetbrains_pycharm" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/workspace/project"
@@ -0,0 +1,131 @@
run "requires_agent_and_folder" {
command = plan
# Setting both required vars should plan
variables {
agent_id = "foo"
folder = "/home/coder"
}
}
run "creates_parameter_when_default_empty_latest" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
major_version = "latest"
}
# When default is empty, a coder_parameter should be created
assert {
condition = can(data.coder_parameter.jetbrains_ides[0].type)
error_message = "Expected data.coder_parameter.jetbrains_ides to exist when default is empty"
}
}
run "no_apps_when_default_empty" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
}
assert {
condition = length(resource.coder_app.jetbrains) == 0
error_message = "Expected no coder_app resources when default is empty"
}
}
run "single_app_when_default_GO" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
}
assert {
condition = length(resource.coder_app.jetbrains) == 1
error_message = "Expected exactly one coder_app when default contains GO"
}
}
run "url_contains_required_params" {
command = apply
variables {
agent_id = "test-agent-123"
folder = "/custom/project/path"
default = ["GO"]
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("jetbrains://gateway/coder", app.url)) > 0])
error_message = "URL must contain jetbrains scheme"
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&folder=/custom/project/path", app.url)) > 0])
error_message = "URL must include folder path"
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_product_code=GO", app.url)) > 0])
error_message = "URL must include product code"
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_build_number=", app.url)) > 0])
error_message = "URL must include build number"
}
}
run "includes_agent_name_when_set" {
command = apply
variables {
agent_id = "test-agent-123"
agent_name = "main-agent"
folder = "/custom/project/path"
default = ["GO"]
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&agent_name=main-agent", app.url)) > 0])
error_message = "URL must include agent_name when provided"
}
}
run "parameter_order_when_default_empty" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
coder_parameter_order = 5
}
assert {
condition = data.coder_parameter.jetbrains_ides[0].order == 5
error_message = "Expected coder_parameter order to be set to 5"
}
}
run "app_order_when_default_not_empty" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
coder_app_order = 10
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.order == 10])
error_message = "Expected coder_app order to be set to 10"
}
}
@@ -0,0 +1,33 @@
---
display_name: VSCode Desktop Core
description: Building block for modules that need to link to an external VSCode-based IDE
icon: ../../../../.icons/coder.svg
verified: true
tags: [internal, library]
---
# VS Code Desktop Core
> [!CAUTION]
> We do not recommend using this module directly. Instead, please consider using one of our [Desktop IDE modules](https://registry.coder.com/modules?search=tag%3Aide).
The VSCode Desktop Core module is a building block for modules that need to expose access to VSCode-based IDEs. It is intended primarily to be used as a library to create modules for VSCode-based IDEs.
```tf
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
agent_id = var.agent_id
coder_app_icon = "/icon/code.svg"
coder_app_slug = "vscode"
coder_app_display_name = "VS Code Desktop"
coder_app_order = var.order
coder_app_group = var.group
folder = var.folder
open_recent = var.open_recent
protocol = "vscode"
}
```
@@ -0,0 +1,100 @@
import { describe, expect, it } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
// hardcoded coder_app name in main.tf
const appName = "vscode-desktop";
const defaultVariables = {
agent_id: "foo",
coder_app_icon: "/icon/code.svg",
coder_app_slug: "vscode",
coder_app_display_name: "VS Code Desktop",
protocol: "vscode",
}
describe("vscode-desktop-core", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, defaultVariables);
it("default output", async () => {
const state = await runTerraformApply(import.meta.dir, defaultVariables);
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === appName,
);
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBeNull();
});
it("adds folder", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
...defaultVariables
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("adds folder and open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
open_recent: "true",
...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("adds folder but not open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
openRecent: "false",
...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("adds open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
open_recent: "true",
...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("expect order to be set", async () => {
const state = await runTerraformApply(import.meta.dir, {
coder_app_order: "22",
...defaultVariables
});
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === appName,
);
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBe(22);
});
});
@@ -0,0 +1,92 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "folder" {
type = string
description = "The folder to open in the IDE."
default = ""
}
variable "open_recent" {
type = bool
description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open."
default = false
}
variable "protocol" {
type = string
description = "The URI protocol for the IDE."
}
variable "coder_app_icon" {
type = string
description = "The icon of the coder_app."
}
variable "coder_app_slug" {
type = string
description = "The slug of the coder_app."
}
variable "coder_app_display_name" {
type = string
description = "The display name of the coder_app."
}
variable "coder_app_order" {
type = number
description = "The order of the coder_app."
default = null
}
variable "coder_app_group" {
type = string
description = "The group of the coder_app."
default = null
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_app" "vscode-desktop" {
agent_id = var.agent_id
external = true
icon = var.coder_app_icon
slug = var.coder_app_slug
display_name = var.coder_app_display_name
order = var.coder_app_order
group = var.coder_app_group
# While the call to "join" is not strictly necessary, it makes the URL more readable.
url = join("", [
"${var.protocol}://coder.coder-remote/open",
"?owner=${data.coder_workspace_owner.me.name}",
"&workspace=${data.coder_workspace.me.name}",
var.folder != "" ? join("", ["&folder=", var.folder]) : "",
var.open_recent ? "&openRecent" : "",
"&url=${data.coder_workspace.me.access_url}",
# NOTE: There is a protocol whitelist for the token replacement, so this will only work with the protocols hardcoded in the front-end.
# (https://github.com/coder/coder/blob/6ba4b5bbc95e2e528d7f5b1e31fffa200ae1a6db/site/src/modules/apps/apps.ts#L18)
"&token=$SESSION_TOKEN",
])
}
output "ide_uri" {
value = coder_app.vscode-desktop.url
description = "IDE URI."
}
+32 -4
View File
@@ -19,7 +19,7 @@ Zed is a high-performance, multiplayer code editor from the creators of Atom and
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -32,7 +32,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -44,7 +44,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
display_name = "Zed Editor"
order = 1
@@ -57,8 +57,36 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
agent_name = coder_agent.example.name
}
```
### Configure Zed settings including MCP servers
Zed stores settings at `~/.config/zed/settings.json` by default. If `XDG_CONFIG_HOME` is set on Linux, settings will be at `$XDG_CONFIG_HOME/zed/settings.json`.
You can declaratively set/merge settings with the `settings` input. Provide a JSON string (e.g., via `jsonencode(...)`). For example, to configure MCP servers:
```tf
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
settings = jsonencode({
context_servers = {
your-mcp-server = {
source = "custom"
command = "some-command"
args = ["arg-1", "arg-2"]
env = {}
}
}
})
}
```
See Zeds settings files documentation: https://zed.dev/docs/configuring-zed#settings-files
+31
View File
@@ -50,7 +50,14 @@ variable "display_name" {
default = "Zed"
}
variable "settings" {
type = string
description = "JSON encoded settings.json"
default = ""
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
locals {
@@ -60,6 +67,30 @@ locals {
hostname = var.agent_name != "" ? "${local.agent_name}.${local.workspace_name}.${local.owner_name}.coder" : "${local.workspace_name}.coder"
}
resource "coder_script" "zed_settings" {
agent_id = var.agent_id
display_name = "Configure Zed settings"
icon = "/icon/zed.svg"
run_on_start = true
script = <<-EOT
set -eu
SETTINGS_JSON='${replace(var.settings, "\"", "\\\"")}'
if [ -z "$${SETTINGS_JSON}" ] || [ "$${SETTINGS_JSON}" = "{}" ]; then
exit 0
fi
CONFIG_HOME="$${XDG_CONFIG_HOME:-$HOME/.config}"
ZED_DIR="$${CONFIG_HOME}/zed"
mkdir -p "$${ZED_DIR}"
SETTINGS_FILE="$${ZED_DIR}/settings.json"
if command -v jq >/dev/null 2>&1 && [ -s "$${SETTINGS_FILE}" ]; then
tmpfile="$(mktemp)"
jq -s '.[0] * .[1]' "$${SETTINGS_FILE}" <(printf '%s\n' "$${SETTINGS_JSON}") > "$${tmpfile}" && mv "$${tmpfile}" "$${SETTINGS_FILE}"
else
printf '%s\n' "$${SETTINGS_JSON}" > "$${SETTINGS_FILE}"
fi
EOT
}
resource "coder_app" "zed" {
agent_id = var.agent_id
display_name = var.display_name
+40
View File
@@ -0,0 +1,40 @@
run "default_output" {
command = apply
variables {
agent_id = "foo"
}
assert {
condition = output.zed_url == "zed://ssh/default.coder"
error_message = "zed_url did not match expected default URL"
}
}
run "adds_folder" {
command = apply
variables {
agent_id = "foo"
folder = "/foo/bar"
}
assert {
condition = output.zed_url == "zed://ssh/default.coder/foo/bar"
error_message = "zed_url did not include provided folder path"
}
}
run "adds_agent_name" {
command = apply
variables {
agent_id = "foo"
agent_name = "myagent"
}
assert {
condition = output.zed_url == "zed://ssh/myagent.default.default.coder"
error_message = "zed_url did not include agent_name in hostname"
}
}
@@ -14,11 +14,26 @@ Provision Devcontainers as [Coder workspaces](https://coder.com/docs/workspaces)
### Infrastructure
Coder must have access to a running Docker socket, and the `coder` user must be a member of the `docker` group:
#### Running Coder inside Docker
```shell
If you installed Coder as a container within Docker, you will have to do the following things:
- Make the the Docker socket available to the container
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
- You can get the GID by running `getent group docker` on the **host** machine
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
#### Running Coder outside of Docker
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
```sh
# Add coder user to Docker group
sudo usermod -aG docker coder
sudo adduser coder docker
# Restart Coder server
sudo systemctl restart coder
+16 -1
View File
@@ -16,7 +16,22 @@ Provision Docker containers as [Coder workspaces](https://coder.com/docs/workspa
### Infrastructure
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
#### Running Coder inside Docker
If you installed Coder as a container within Docker, you will have to do the following things:
- Make the the Docker socket available to the container
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
- You can get the GID by running `getent group docker` on the **host** machine
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
#### Running Coder outside of Docker
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
```sh
# Add coder user to Docker group
@@ -8,6 +8,8 @@ tags: [vm, linux, gcp, devcontainer]
# Remote Development in a Devcontainer on Google Compute Engine
Provision a Devcontainer on Google Compute Engine instances as Coder workspaces
![Architecture Diagram](../../.images/gcp-devcontainer-architecture.svg)
## Prerequisites
@@ -8,6 +8,8 @@ tags: [vm, linux, gcp]
# Remote Development on Google Compute Engine (Linux)
Provision Google Compute Engine instances as Coder workspaces
## Prerequisites
### Authentication
@@ -8,6 +8,8 @@ tags: [vm-container, linux, gcp]
# Remote Development on Google Compute Engine (VM Container)
Provision Google Compute Engine instances as Coder workspaces.
## Prerequisites
### Authentication
@@ -8,6 +8,8 @@ tags: [vm, windows, gcp]
# Remote Development on Google Compute Engine (Windows)
Provision Google Compute Engine instances as Coder workspaces
## Prerequisites
### Authentication
@@ -8,6 +8,8 @@ tags: [kubernetes, containers, docker-in-docker]
# envbox
Provision envbox pods as Coder workspaces
## Introduction
`envbox` is an image that enables creating non-privileged containers capable of running system-level software (e.g. `dockerd`, `systemd`, etc) in Kubernetes.
@@ -1,27 +1,29 @@
---
display_name: "Claude Code AI Agent Template"
description: The goal is to try the experimental ai agent integration with Claude CodeAI agent
description: An experimental AI agent integration with Claude CodeAI agent
icon: "../../../../.icons/claude.svg"
verified: false
tags: ["ai", "docker", "container", "claude", "agent", "tasks"]
---
# ai agent template for a workspace in a container on a Docker host
# AI agent template for a workspace in a container on a Docker host
### Docker image
An experimental AI agent integration with Claude CodeAI agent
## Docker image
1. Based on Coder-managed image `codercom/example-universal:ubuntu`
[Image on DockerHub](https://hub.docker.com/r/codercom/example-universal)
### Apps included
## Apps included
1. A web-based terminal
1. code-server Web IDE
1. A [sample app](https://github.com/gothinkster/realworld) to test the environment
1. [Claude Code AI agent](https://www.anthropic.com/claude-code) to assist with development tasks
### Resources
## Resources
[Coder docs on AI agents and tasks](https://coder.com/docs/ai-coder/tasks)
+486 -97
View File
@@ -2,32 +2,221 @@
# Tag Release Script
# Automatically detects modules that need tagging and creates release tags
# Usage: ./tag_release.sh
# Usage: ./tag_release.sh [OPTIONS]
# Operates on the current checked-out commit
set -euo pipefail
MODULES_TO_TAG=()
AUTO_APPROVE=false
DRY_RUN=false
VERBOSE=false
QUIET=false
OUTPUT_FORMAT="plain"
TARGET_NAMESPACE=""
TARGET_MODULE=""
SKIP_PUSH=false
JSON_OUTPUT='{
"metadata": {},
"summary": {},
"modules": [],
"warnings": [],
"errors": []
}'
readonly EXIT_SUCCESS=0
readonly EXIT_ERROR=1
readonly EXIT_NO_ACTION_NEEDED=2
readonly EXIT_VALIDATION_FAILED=3
usage() {
echo "Usage: $0"
echo ""
echo "This script will:"
echo " 1. Scan all modules in the registry"
echo " 2. Check which modules need new release tags"
echo " 3. Extract version information from README files"
echo " 4. Generate a report for confirmation"
echo " 5. Create and push release tags after confirmation"
echo ""
echo "The script operates on the current checked-out commit."
echo "Make sure you have checked out the commit you want to tag before running."
exit 1
cat << EOF
Usage: $0 [OPTIONS]
OPTIONS:
-y, --auto-approve Skip confirmation prompt
-d, --dry-run Preview without creating tags
-v, --verbose Detailed output
-q, --quiet Minimal output
-f, --format=FORMAT Output format: 'plain' or 'json'
-n, --namespace=NAME Target specific namespace
-m, --module=NAME Target specific module
-s, --skip-push Create tags but don't push
-h, --help Show this help
EXAMPLES:
$0 # Interactive mode
$0 -y -q -f json # CI/CD automation
$0 -d -v # Test with verbose output
$0 -m code-server -d # Target specific module
$0 -n coder -m code-server -d # Target module in namespace
Exit codes: 0=success, 1=error, 2=no action needed, 3=validation failed
EOF
exit 0
}
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
case "$level" in
"ERROR")
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
add_json_error "script_error" "$message"
elif [[ "$QUIET" != "true" ]]; then
echo "$message" >&2
fi
;;
"WARN")
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
add_json_warning "" "$message" "warning"
elif [[ "$QUIET" != "true" ]]; then
echo "⚠️ $message" >&2
fi
;;
"INFO")
if [[ "$QUIET" != "true" && "$OUTPUT_FORMAT" != "json" ]]; then
echo "$message"
fi
;;
"SUCCESS")
if [[ "$QUIET" != "true" && "$OUTPUT_FORMAT" != "json" ]]; then
echo "$message"
fi
;;
"DEBUG")
if [[ "$VERBOSE" == "true" && "$OUTPUT_FORMAT" != "json" ]]; then
echo "🔍 [$timestamp] $message" >&2
fi
;;
esac
}
add_json_error() {
local type="$1"
local message="$2"
local details="${3:-}"
local exit_code="${4:-1}"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg type "$type" --arg msg "$message" --arg details "$details" --argjson code "$exit_code" \
'.errors += [{"type": $type, "message": $msg, "details": $details, "exit_code": $code}]')
}
add_json_warning() {
local module="$1"
local message="$2"
local type="$3"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg module "$module" --arg msg "$message" --arg type "$type" \
'.warnings += [{"module": $module, "message": $msg, "type": $type}]')
}
add_json_module() {
local namespace="$1"
local module_name="$2"
local path="$3"
local version="$4"
local tag_name="$5"
local status="$6"
local already_existed="$7"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg ns "$namespace" --arg name "$module_name" --arg path "$path" \
--arg version "$version" --arg tag "$tag_name" --arg status "$status" --argjson existed "$already_existed" \
'.modules += [{"namespace": $ns, "module_name": $name, "path": $path, "version": $version, "tag_name": $tag, "status": $status, "already_existed": $existed}]')
}
parse_arguments() {
while [[ $# -gt 0 ]]; do
case $1 in
-y | --auto-approve)
AUTO_APPROVE=true
shift
;;
-d | --dry-run)
DRY_RUN=true
shift
;;
-v | --verbose)
VERBOSE=true
shift
;;
-q | --quiet)
QUIET=true
shift
;;
-f | --format=* | --format)
if [[ "$1" == "-f" || "$1" == "--format" ]]; then
if [[ -z "$2" ]]; then
log "ERROR" "Option $1 requires a value"
exit $EXIT_ERROR
fi
OUTPUT_FORMAT="$2"
shift 2
else
OUTPUT_FORMAT="${1#*=}"
shift
fi
if [[ "$OUTPUT_FORMAT" != "plain" && "$OUTPUT_FORMAT" != "json" ]]; then
log "ERROR" "Invalid format '$OUTPUT_FORMAT'. Must be 'plain' or 'json'"
exit $EXIT_ERROR
fi
;;
-n | --namespace=* | --namespace)
if [[ "$1" == "-n" || "$1" == "--namespace" ]]; then
if [[ -z "$2" ]]; then
log "ERROR" "Option $1 requires a value"
exit $EXIT_ERROR
fi
TARGET_NAMESPACE="$2"
shift 2
else
TARGET_NAMESPACE="${1#*=}"
shift
fi
;;
-m | --module=* | --module)
if [[ "$1" == "-m" || "$1" == "--module" ]]; then
if [[ -z "$2" ]]; then
log "ERROR" "Option $1 requires a value"
exit $EXIT_ERROR
fi
TARGET_MODULE="$2"
shift 2
else
TARGET_MODULE="${1#*=}"
shift
fi
;;
-s | --skip-push)
SKIP_PUSH=true
shift
;;
-h | --help)
usage
;;
*)
log "ERROR" "Unknown option: $1"
echo "Use --help for usage information."
exit $EXIT_ERROR
;;
esac
done
if [[ "$VERBOSE" == "true" && "$QUIET" == "true" ]]; then
echo "❌ --verbose and --quiet cannot be used together" >&2
exit $EXIT_ERROR
fi
}
validate_version() {
local version="$1"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid version format: '$version'. Expected X.Y.Z format." >&2
log "DEBUG" "Invalid version format: '$version'. Expected X.Y.Z format."
return 1
fi
return 0
@@ -38,7 +227,12 @@ extract_version_from_readme() {
local namespace="$2"
local module_name="$3"
[ ! -f "$readme_path" ] && return 1
log "DEBUG" "Extracting version from $readme_path for $namespace/$module_name"
[ ! -f "$readme_path" ] && {
log "DEBUG" "README file not found: $readme_path"
return 1
}
local version_line
version_line=$(grep -E "source\s*=\s*\"registry\.coder\.com/${namespace}/${module_name}" "$readme_path" | head -1 || echo "")
@@ -47,6 +241,7 @@ extract_version_from_readme() {
local version
version=$(echo "$version_line" | sed -n 's/.*version\s*=\s*"\([^"]*\)".*/\1/p')
if [ -n "$version" ]; then
log "DEBUG" "Found version '$version' from source line: $version_line"
echo "$version"
return 0
fi
@@ -56,10 +251,12 @@ extract_version_from_readme() {
fallback_version=$(grep -E 'version\s*=\s*"[0-9]+\.[0-9]+\.[0-9]+"' "$readme_path" | head -1 | sed 's/.*version\s*=\s*"\([^"]*\)".*/\1/' || echo "")
if [ -n "$fallback_version" ]; then
log "DEBUG" "Found fallback version '$fallback_version'"
echo "$fallback_version"
return 0
fi
log "DEBUG" "No version found in $readme_path"
return 1
}
@@ -70,29 +267,54 @@ check_module_needs_tagging() {
local tag_name="release/${namespace}/${module_name}/v${readme_version}"
log "DEBUG" "Checking if tag exists: $tag_name"
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
log "DEBUG" "Tag $tag_name already exists"
return 1
else
log "DEBUG" "Tag $tag_name needs to be created"
return 0
fi
}
should_process_module() {
local namespace="$1"
local module_name="$2"
if [[ -n "$TARGET_NAMESPACE" && "$TARGET_NAMESPACE" != "$namespace" ]]; then
log "DEBUG" "Skipping $namespace/$module_name: namespace filter"
return 1
fi
if [[ -n "$TARGET_MODULE" && "$TARGET_MODULE" != "$module_name" ]]; then
log "DEBUG" "Skipping $namespace/$module_name: module filter"
return 1
fi
return 0
}
detect_modules_needing_tags() {
MODULES_TO_TAG=()
echo "🔍 Scanning all modules for missing release tags..."
echo ""
log "INFO" "🔍 Scanning all modules for missing release tags..."
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
fi
local all_modules
all_modules=$(find registry -mindepth 3 -maxdepth 3 -type d -path "*/modules/*" | sort -u || echo "")
[ -z "$all_modules" ] && {
echo "❌ No modules found to check"
return 1
log "ERROR" "No modules found to check"
return $EXIT_ERROR
}
local total_checked=0
local needs_tagging=0
local already_tagged=0
local skipped=0
while IFS= read -r module_path; do
if [ -z "$module_path" ]; then continue; fi
@@ -102,64 +324,133 @@ detect_modules_needing_tags() {
local module_name
module_name=$(echo "$module_path" | cut -d'/' -f4)
if ! should_process_module "$namespace" "$module_name"; then
skipped=$((skipped + 1))
continue
fi
total_checked=$((total_checked + 1))
local readme_path="$module_path/README.md"
local readme_version
if ! readme_version=$(extract_version_from_readme "$readme_path" "$namespace" "$module_name"); then
echo "⚠️ $namespace/$module_name: No version found in README, skipping"
log "WARN" "$namespace/$module_name: No version found in README, skipping"
add_json_warning "$namespace/$module_name" "No version found in README, skipping" "missing_version"
skipped=$((skipped + 1))
continue
fi
if ! validate_version "$readme_version"; then
echo "⚠️ $namespace/$module_name: Invalid version format '$readme_version', skipping"
log "WARN" "$namespace/$module_name: Invalid version format '$readme_version', skipping"
add_json_warning "$namespace/$module_name" "Invalid version format '$readme_version', skipping" "invalid_version"
skipped=$((skipped + 1))
continue
fi
local tag_name="release/$namespace/$module_name/v$readme_version"
if check_module_needs_tagging "$namespace" "$module_name" "$readme_version"; then
echo "📦 $namespace/$module_name: v$readme_version (needs tag)"
log "INFO" "📦 $namespace/$module_name: v$readme_version (needs tag)"
MODULES_TO_TAG+=("$module_path:$namespace:$module_name:$readme_version")
needs_tagging=$((needs_tagging + 1))
local status="needs_tagging"
if [[ "$DRY_RUN" == "true" ]]; then
status="would_be_tagged"
fi
add_json_module "$namespace" "$module_name" "$module_path" "$readme_version" "$tag_name" "$status" false
else
echo "$namespace/$module_name: v$readme_version (already tagged)"
log "SUCCESS" "$namespace/$module_name: v$readme_version (already tagged)"
already_tagged=$((already_tagged + 1))
add_json_module "$namespace" "$module_name" "$module_path" "$readme_version" "$tag_name" "already_tagged" true
fi
done <<< "$all_modules"
echo ""
echo "📊 Summary: $needs_tagging of $total_checked modules need tagging"
echo ""
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson total "$total_checked" --argjson needs "$needs_tagging" \
--argjson tagged "$already_tagged" --argjson skip "$skipped" \
'.summary.total_scanned = $total | .summary.needs_tagging = $needs | .summary.already_tagged = $tagged | .summary.skipped = $skip')
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
log "INFO" "📊 Summary: $needs_tagging of $total_checked modules need tagging"
echo ""
fi
[ $needs_tagging -eq 0 ] && {
echo "🎉 All modules are up to date! No tags needed."
return 0
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
log "SUCCESS" "🎉 All modules are up to date! No tags needed."
fi
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "no_action_needed"')
return $EXIT_NO_ACTION_NEEDED
}
echo "## Tags to be created:"
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
echo "- \`release/$namespace/$module_name/v$version\`"
done
echo ""
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo "## Tags to be created:"
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
echo "- \`release/$namespace/$module_name/v$version\`"
done
echo ""
fi
return 0
return $EXIT_SUCCESS
}
pre_flight_checks() {
log "DEBUG" "Running pre-flight checks..."
if ! git rev-parse --git-dir > /dev/null 2>&1; then
log "ERROR" "Not in a git repository"
return $EXIT_ERROR
fi
if ! git remote get-url origin > /dev/null 2>&1; then
log "ERROR" "No 'origin' remote found"
return $EXIT_ERROR
fi
if [[ "$SKIP_PUSH" != "true" && "$DRY_RUN" != "true" ]]; then
log "DEBUG" "Testing remote connectivity..."
if ! git ls-remote --exit-code origin > /dev/null 2>&1; then
log "ERROR" "Cannot connect to remote repository"
return $EXIT_ERROR
fi
fi
if ! git rev-parse HEAD > /dev/null 2>&1; then
log "ERROR" "Cannot determine current commit"
return $EXIT_ERROR
fi
log "DEBUG" "Pre-flight checks passed"
return $EXIT_SUCCESS
}
create_and_push_tags() {
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
echo "❌ No modules to tag found"
return 1
log "ERROR" "No modules to tag found"
return $EXIT_ERROR
}
local current_commit
current_commit=$(git rev-parse HEAD)
echo "🏷️ Creating release tags for commit: $current_commit"
echo ""
if [[ "$DRY_RUN" == "true" ]]; then
log "INFO" "🏷️ [DRY RUN] Would create release tags for commit: $current_commit"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "dry_run" | .summary.tags_created = 0 | .summary.tags_pushed = 0')
return $EXIT_SUCCESS
fi
log "INFO" "🏷️ Creating release tags for commit: $current_commit"
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
fi
local created_tags=0
local failed_tags=0
local created_tag_names=()
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
@@ -167,35 +458,56 @@ create_and_push_tags() {
local tag_name="release/$namespace/$module_name/v$version"
local tag_message="Release $namespace/$module_name v$version"
echo "Creating tag: $tag_name"
log "DEBUG" "Creating tag: $tag_name"
log "INFO" "Creating tag: $tag_name"
if git tag -a "$tag_name" -m "$tag_message" "$current_commit"; then
echo "Created: $tag_name"
if git tag -a "$tag_name" -m "$tag_message" "$current_commit" 2> /dev/null; then
log "SUCCESS" "Created: $tag_name"
created_tags=$((created_tags + 1))
created_tag_names+=("$tag_name")
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created"')
else
echo "Failed to create: $tag_name"
log "ERROR" "Failed to create: $tag_name"
add_json_error "tag_creation_failed" "Failed to create tag: $tag_name" "git tag -a $tag_name -m '$tag_message' $current_commit"
failed_tags=$((failed_tags + 1))
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_creation_failed"')
fi
done
echo ""
echo "📊 Tag creation summary:"
echo " Created: $created_tags"
echo " Failed: $failed_tags"
echo ""
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
log "INFO" "📊 Tag creation summary:"
log "INFO" " Created: $created_tags"
log "INFO" " Failed: $failed_tags"
echo ""
fi
[ $created_tags -eq 0 ] && {
echo "❌ No tags were created successfully"
return 1
log "ERROR" "No tags were created successfully"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed" | .summary.tags_created = 0 | .summary.tags_pushed = 0')
return $EXIT_ERROR
}
echo "🚀 Pushing tags to origin..."
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson created "$created_tags" '.summary.tags_created = $created')
if [[ "$SKIP_PUSH" == "true" ]]; then
log "INFO" "🚫 Skipping push (--skip-push specified)"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "tags_created_not_pushed" | .summary.tags_pushed = 0')
for tag_name in "${created_tag_names[@]}"; do
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created_not_pushed"')
done
return $EXIT_SUCCESS
fi
log "INFO" "🚀 Pushing tags to origin..."
local tags_to_push=()
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
local tag_name="release/$namespace/$module_name/v$version"
for tag_name in "${created_tag_names[@]}"; do
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
tags_to_push+=("$tag_name")
fi
@@ -205,71 +517,148 @@ create_and_push_tags() {
local failed_pushes=0
if [ ${#tags_to_push[@]} -eq 0 ]; then
echo "❌ No valid tags found to push"
log "ERROR" "No valid tags found to push"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed" | .summary.tags_pushed = 0')
else
if git push --atomic origin "${tags_to_push[@]}"; then
echo "Successfully pushed all ${#tags_to_push[@]} tags"
if git push --atomic origin "${tags_to_push[@]}" 2> /dev/null; then
log "SUCCESS" "Successfully pushed all ${#tags_to_push[@]} tags"
pushed_tags=${#tags_to_push[@]}
for tag_name in "${tags_to_push[@]}"; do
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tagged_and_pushed"')
done
else
echo "❌ Failed to push tags"
log "ERROR" "Failed to push tags"
add_json_error "push_failed" "Failed to push tags to remote" "git push --atomic origin ${tags_to_push[*]}"
failed_pushes=${#tags_to_push[@]}
for tag_name in "${tags_to_push[@]}"; do
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created_push_failed"')
done
fi
fi
echo ""
echo "📊 Push summary:"
echo " Pushed: $pushed_tags"
echo " Failed: $failed_pushes"
echo ""
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson pushed "$pushed_tags" '.summary.tags_pushed = $pushed')
if [ $pushed_tags -gt 0 ]; then
echo "🎉 Successfully created and pushed $pushed_tags release tags!"
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
log "INFO" "📊 Push summary:"
log "INFO" " Pushed: $pushed_tags"
log "INFO" " Failed: $failed_pushes"
echo ""
echo "📝 Next steps:"
echo " - Tags will be automatically published to registry.coder.com"
echo " - Monitor the registry website for updates"
echo " - Check GitHub releases for any issues"
fi
return 0
if [ $pushed_tags -gt 0 ]; then
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
log "SUCCESS" "🎉 Successfully created and pushed $pushed_tags release tags!"
echo ""
log "INFO" "📝 Next steps:"
log "INFO" " - Tags will be automatically published to registry.coder.com"
log "INFO" " - Monitor the registry website for updates"
log "INFO" " - Check GitHub releases for any issues"
fi
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "success"')
return $EXIT_SUCCESS
else
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed"')
return $EXIT_ERROR
fi
}
finalize_json_output() {
local timestamp
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
local current_commit
current_commit=$(git rev-parse HEAD 2> /dev/null || echo "unknown")
local command_line="$0 $*"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg ts "$timestamp" --arg commit "$current_commit" \
--arg cmd "$command_line" \
'.metadata.timestamp = $ts | .metadata.commit = $commit | .metadata.command = $cmd')
echo "$JSON_OUTPUT"
}
main() {
[ $# -gt 0 ] && usage
parse_arguments "$@"
echo "🚀 Coder Registry Tag Release Script"
echo "Operating on commit: $(git rev-parse HEAD)"
echo ""
if ! git rev-parse --git-dir > /dev/null 2>&1; then
echo "❌ Not in a git repository"
exit 1
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
if ! command -v jq > /dev/null 2>&1; then
echo '{"error": "jq is required for JSON output format but not found"}' >&2
exit $EXIT_ERROR
fi
fi
detect_modules_needing_tags || exit 1
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
log "INFO" "🚀 Coder Registry Tag Release Script"
log "INFO" "Operating on commit: $(git rev-parse HEAD 2> /dev/null || echo 'unknown')"
echo ""
fi
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
echo "✨ No modules need tagging. All done!"
exit 0
}
if ! pre_flight_checks; then
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "preflight_failed"')
finalize_json_output "$@"
fi
exit $EXIT_ERROR
fi
echo ""
echo "❓ Do you want to proceed with creating and pushing these release tags?"
echo " This will create git tags and push them to the remote repository."
echo ""
read -p "Continue? [y/N]: " -r response
local detect_exit_code
detect_modules_needing_tags
detect_exit_code=$?
case "$response" in
[yY] | [yY][eE][sS])
echo ""
create_and_push_tags
case $detect_exit_code in
$EXIT_NO_ACTION_NEEDED)
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
finalize_json_output "$@"
else
log "SUCCESS" "✨ No modules need tagging. All done!"
fi
exit $EXIT_SUCCESS
;;
*)
echo ""
echo "🚫 Operation cancelled by user"
exit 0
$EXIT_ERROR)
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "scan_failed"')
finalize_json_output "$@"
fi
exit $EXIT_ERROR
;;
esac
if [[ "$AUTO_APPROVE" != "true" && "$OUTPUT_FORMAT" != "json" && "$DRY_RUN" != "true" ]]; then
echo ""
log "INFO" "❓ Do you want to proceed with creating and pushing these release tags?"
log "INFO" " This will create git tags and push them to the remote repository."
echo ""
read -p "Continue? [y/N]: " -r response
case "$response" in
[yY] | [yY][eE][sS])
echo ""
;;
*)
echo ""
log "INFO" "🚫 Operation cancelled by user"
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "cancelled_by_user"')
finalize_json_output "$@"
fi
exit $EXIT_SUCCESS
;;
esac
fi
local create_exit_code
create_and_push_tags
create_exit_code=$?
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
finalize_json_output "$@"
fi
exit $create_exit_code
}
main "$@"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
# Find all directories that contain any .tftest.hcl files and run terraform test in each
run_dir() {
local dir="$1"
echo "==> Running terraform test in $dir"
(cd "$dir" && terraform init -upgrade -input=false -no-color > /dev/null && terraform test -no-color -verbose)
}
mapfile -t test_dirs < <(find . -type f -name "*.tftest.hcl" -print0 | xargs -0 -I{} dirname {} | sort -u)
if [[ ${#test_dirs[@]} -eq 0 ]]; then
echo "No .tftest.hcl tests found."
exit 0
fi
status=0
for d in "${test_dirs[@]}"; do
if ! run_dir "$d"; then
status=1
fi
done
exit $status