Compare commits

..

42 Commits

Author SHA1 Message Date
Atif Ali 32246a99c1 feat(cursor-cli): add Cursor CLI module (#309)
Closes #305

## Summary
- Add new module `registry/coder-labs/modules/cursor-cli` to run Cursor
Agent CLI directly (no AgentAPI)
- Interactive chat by default; supports non-interactive mode (-p) with
output-format
- Supports model (-m) and force (-f) flags, initial prompt, and
CURSOR_API_KEY
- Merges MCP settings into ~/.cursor/settings.json
- Installs via npm, bootstrapping Node via NVM if missing (mirrors
gemini approach)
- Adds Terraform-native tests (.tftest.hcl); all pass locally

## Test plan
- From module dir:
  - terraform init -upgrade
  - terraform test -verbose
- Expect 4 tests passing covering defaults, flag plumbing, and MCP
settings injection
- Basic smoke run: ensure `cursor-agent` is on PATH or set
install_cursor_cli=true

---------

Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: 35C4n0r <work.jaykumar@gmail.com>
Co-authored-by: 35C4n0r <70096901+35C4n0r@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-18 13:08:48 -05:00
blink-so[bot] bb667d2209 fix(tag_release): improve macOS and Linux compatibility (#335)
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2025-08-18 19:09:10 +05:00
dependabot[bot] f08bb30b53 chore(deps): bump actions/checkout from 4 to 5 (#334)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 12:25:16 +00:00
dependabot[bot] 32b039a838 chore(deps): bump crate-ci/typos from 1.35.3 to 1.35.4 (#333)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 12:22:19 +00:00
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
Hugo Dutka 507b73a07e feat(agentapi): use wildcard alllowed hosts (#320)
Since https://github.com/coder/agentapi/pull/49 was merged, agentapi by
default only accepts requests with the `Host` header set to localhost,
127.0.0.1, or [::1]. In Coder, agentapi is served behind a reverse proxy
as a workspace app, so we need to use a wildcard
`AGENTAPI_ALLOWED_HOSTS` for agentapi-based modules to continue working.

This PR updates the claude code and agentapi modules, and a subsequent
PR will update modules that are based on the agentapi module.
2025-08-11 16:23:01 +02:00
Jullian Pepito 814f765313 fix(jetbrains): Ties var.group to the coder_app. (#310)
Co-authored-by: Jullian Pepito <jullian@mac.lan>
2025-08-11 13:22:11 +05:00
Atif Ali 92a154f54a chore: deploy registry on changes to contributers information (#315) 2025-08-10 00:53:39 +05:00
Ben Potter 7aa7dea5ad Fix contributor avatars and docs: use avatar key and correct anomaly image extension (#312)
Co-authored-by: bpmct <22407953+bpmct@users.noreply.github.com>
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2025-08-10 00:27:31 +05:00
sharkymark 59b0472125 feat: sharkymark profile and claude docker template (#304)
Closes #

## Description

registry profile creation and template submission for claude on docker

## Type of Change

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

## Template Information

**Path:** `registry/sharkymark/templates/docker-claude`  
**New version:** `v1.0.0`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

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

## Related Issues

n/a

---------

Co-authored-by: DevCats <christofer@coder.com>
2025-08-09 18:40:04 +02:00
Muhammad Atif Ali 673caf2e95 Revert "chore(examples): add MODULE_NAME.tftest.hcl to new module sample and make run.sh optional"
This reverts commit a5edad7f17.
2025-08-08 17:29:11 +05:00
Muhammad Atif Ali ab5ff4b4be Revert "chore(test): add terraform tests for jetbrains, zed, code-server and keep mixed mode"
This reverts commit fb657b875d.
2025-08-08 17:29:11 +05:00
Muhammad Atif Ali f5a68b500b Revert "chore(test): migrate to terraform test and add initial .tftest for zed"
This reverts commit 016d4dc523.
2025-08-08 17:29:11 +05:00
Muhammad Atif Ali a5edad7f17 chore(examples): add MODULE_NAME.tftest.hcl to new module sample and make run.sh optional 2025-08-08 16:36:30 +05:00
Muhammad Atif Ali fb657b875d chore(test): add terraform tests for jetbrains, zed, code-server and keep mixed mode
- Add .tftest.hcl for jetbrains, zed, and code-server
- Remove Bun tests for these migrated modules only
- Keep Bun tests for other modules during transition
- Update contributing guide to mention terraform test
- Include runner script to execute terraform tests across modules
2025-08-08 16:33:35 +05:00
Muhammad Atif Ali 016d4dc523 chore(test): migrate to terraform test and add initial .tftest for zed
Replace Bun-based test runner with Terraform native testing. Adds script to discover and run tests across modules and updates docs/scripts to use terraform test.
2025-08-08 13:31:35 +05:00
Muhammad Atif Ali c8d99cfba3 fix: correct terraform state arg and log typos
- test/test.ts: ensure `-state` is immediately followed by the state file to avoid apply failures
- readmevalidation: fix two logger message typos (processing/processed)
2025-08-08 13:15:52 +05:00
Cian Johnston 74c8698566 feat: goose: add support for subdomain=false (#299)
Updates https://github.com/coder/coder/issues/18779
Builds on https://github.com/coder/registry/pull/297

## Description

Adds support for specifying `subdomain = false` in the agentapi module.
Change added in https://github.com/coder/registry/pull/297
NOTE: `AGENTAPI_CHAT_BASE_PATH` is exported before running `main.sh` in
agentapi, so this environment variable is available to calling modules
if `var.subdomain = false`.

## 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

https://github.com/coder/coder/issues/18779
2025-08-07 22:12:32 -05:00
DevCats 03333991a4 feat: introduce automated tag and release process in maintainer guide (#280) 2025-08-07 16:06:13 +05:00
Susana Ferreira 2b0dba4ed1 chore: add description to JetBrains IDEs parameter (#303)
## Description

<!-- Briefly describe what this PR does and why -->

This PR adds a description field to the `jetbrains_ides`
`coder_parameter`.

This allows the JetBrains IDEs parameter to display a helpful
description in both the Coder UI and the CLI, improving clarity for
users when selecting which IDEs to configure in a workspace.

<img width="1102" height="252" alt="Screenshot 2025-08-07 at 11 04 13"
src="https://github.com/user-attachments/assets/90c78088-700a-4152-8a16-4b8c88c52e2c"
/>

## 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

https://github.com/coder/coder/issues/19145
2025-08-07 11:42:48 +01:00
Cian Johnston 57c900b2c9 feat(agentapi): Add support for running under a subdomain (#297)
Updates https://github.com/coder/coder/issues/18779

A separate PR will update dependant modules `goose` and `aider`.

## Description

* Adds `subdomain` argument to `agentapi` module
* Updates `agentapi` module to set `AGENTAPI_CHAT_BASE_PATH` to an
autogenerated path if `var.subdomain = false`
* Updates default `agentapi` version to `v0.3.3` to support running
without subdomain

## 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

- https://github.com/coder/coder/issues/18779
2025-08-06 12:38:49 +01:00
Ben Potter 0ccee61192 chore: remove unnecessary parameter (#282)
Co-authored-by: DevCats <christofer@coder.com>
2025-08-06 07:05:18 +05:00
dependabot[bot] 494dc4b8a1 chore(deps): bump google-github-actions/auth from 2.1.11 to 2.1.12 (#288)
Bumps
[google-github-actions/auth](https://github.com/google-github-actions/auth)
from 2.1.11 to 2.1.12.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/google-github-actions/auth/releases">google-github-actions/auth's
releases</a>.</em></p>
<blockquote>
<h2>v2.1.12</h2>
<h2>What's Changed</h2>
<ul>
<li>Add retries for getIDToken by <a
href="https://github.com/sethvargo"><code>@​sethvargo</code></a> in <a
href="https://redirect.github.com/google-github-actions/auth/pull/502">google-github-actions/auth#502</a></li>
<li>Release: v2.1.12 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/auth/pull/503">google-github-actions/auth#503</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/google-github-actions/auth/compare/v2.1.11...v2.1.12">https://github.com/google-github-actions/auth/compare/v2.1.11...v2.1.12</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/google-github-actions/auth/commit/b7593ed2efd1c1617e1b0254da33b86225adb2a5"><code>b7593ed</code></a>
Release: v2.1.12 (<a
href="https://redirect.github.com/google-github-actions/auth/issues/503">#503</a>)</li>
<li><a
href="https://github.com/google-github-actions/auth/commit/c1ee334b4fb145a02e9d8343bb2e9f0dd06e586b"><code>c1ee334</code></a>
Add retries for getIDToken (<a
href="https://redirect.github.com/google-github-actions/auth/issues/502">#502</a>)</li>
<li>See full diff in <a
href="https://github.com/google-github-actions/auth/compare/140bb5113ffb6b65a7e9b937a81fa96cf5064462...b7593ed2efd1c1617e1b0254da33b86225adb2a5">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google-github-actions/auth&package-manager=github_actions&previous-version=2.1.11&new-version=2.1.12)](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-05 21:01:49 -05:00
Hugo Dutka 3b135ad4a4 fix(claude-code): revert workaround (#298)
The workaround introduced in https://github.com/coder/registry/pull/283
sometimes causes Coder to associate the Coder agent with the
`terraform_data` resource instead of a compute resource. Additionally,
it creates a new agent once a workspace is stopped.

See https://codercom.slack.com/archives/C08PHACTZRB/p1754391012982629
for more details.

<img width="2480" height="1312" alt="image (6)"
src="https://github.com/user-attachments/assets/6ccf2b40-92e7-4c67-b61f-b26888753e72"
/>

Related to https://github.com/coder/coder/issues/18776.
2025-08-05 13:28:58 +02:00
Danielle Maywood 258591833f fix(devcontainers-cli): allow yarn to install when packageManager not yarn (#287)
On our dogfood workspaces, we fail to install `@devcontainers/cli` with
`yarn` because our agent directory `/home/coder/coder` contains a
`package.json` with `packageManager` being set to `pnpm`. This change
instead ensures to run `yarn global add` inside the
`$CODER_SCRIPT_DATA_DIR` so that we don't read a `package.json` and
cause things to break.
2025-08-04 13:00:13 +01:00
Michael Orlov 3efc22c589 fix/amazon-q mcp integration (#248)
Co-authored-by: Michael Orlov <michaelo@amdocs.com>
Co-authored-by: DevCats <christofer@coder.com>
2025-08-03 06:27:46 +00:00
Hugo Dutka 8ba4c323c2 fix(claude-code): workaround for a coder bug (#283)
Workaround to address https://github.com/coder/coder/issues/18776
2025-08-02 16:21:13 +02:00
84 changed files with 3698 additions and 733 deletions
-275
View File
@@ -1,275 +0,0 @@
#!/bin/bash
# Tag Release Script
# Automatically detects modules that need tagging and creates release tags
# Usage: ./tag_release.sh
# Operates on the current checked-out commit
set -euo pipefail
MODULES_TO_TAG=()
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
}
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
return 1
fi
return 0
}
extract_version_from_readme() {
local readme_path="$1"
local namespace="$2"
local module_name="$3"
[ ! -f "$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 "")
if [ -n "$version_line" ]; then
local version
version=$(echo "$version_line" | sed -n 's/.*version\s*=\s*"\([^"]*\)".*/\1/p')
if [ -n "$version" ]; then
echo "$version"
return 0
fi
fi
local fallback_version
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
echo "$fallback_version"
return 0
fi
return 1
}
check_module_needs_tagging() {
local namespace="$1"
local module_name="$2"
local readme_version="$3"
local tag_name="release/${namespace}/${module_name}/v${readme_version}"
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
return 1
else
return 0
fi
}
detect_modules_needing_tags() {
MODULES_TO_TAG=()
echo "🔍 Scanning all modules for missing release tags..."
echo ""
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
}
local total_checked=0
local needs_tagging=0
while IFS= read -r module_path; do
if [ -z "$module_path" ]; then continue; fi
local namespace
namespace=$(echo "$module_path" | cut -d'/' -f2)
local module_name
module_name=$(echo "$module_path" | cut -d'/' -f4)
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"
continue
fi
if ! validate_version "$readme_version"; then
echo "⚠️ $namespace/$module_name: Invalid version format '$readme_version', skipping"
continue
fi
if check_module_needs_tagging "$namespace" "$module_name" "$readme_version"; then
echo "📦 $namespace/$module_name: v$readme_version (needs tag)"
MODULES_TO_TAG+=("$module_path:$namespace:$module_name:$readme_version")
needs_tagging=$((needs_tagging + 1))
else
echo "$namespace/$module_name: v$readme_version (already tagged)"
fi
done <<< "$all_modules"
echo ""
echo "📊 Summary: $needs_tagging of $total_checked modules need tagging"
echo ""
[ $needs_tagging -eq 0 ] && {
echo "🎉 All modules are up to date! No tags needed."
return 0
}
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 ""
return 0
}
create_and_push_tags() {
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
echo "❌ No modules to tag found"
return 1
}
local current_commit
current_commit=$(git rev-parse HEAD)
echo "🏷️ Creating release tags for commit: $current_commit"
echo ""
local created_tags=0
local failed_tags=0
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"
local tag_message="Release $namespace/$module_name v$version"
echo "Creating tag: $tag_name"
if git tag -a "$tag_name" -m "$tag_message" "$current_commit"; then
echo "✅ Created: $tag_name"
created_tags=$((created_tags + 1))
else
echo "❌ Failed to create: $tag_name"
failed_tags=$((failed_tags + 1))
fi
done
echo ""
echo "📊 Tag creation summary:"
echo " Created: $created_tags"
echo " Failed: $failed_tags"
echo ""
[ $created_tags -eq 0 ] && {
echo "❌ No tags were created successfully"
return 1
}
echo "🚀 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"
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
tags_to_push+=("$tag_name")
fi
done
local pushed_tags=0
local failed_pushes=0
if [ ${#tags_to_push[@]} -eq 0 ]; then
echo "❌ No valid tags found to push"
else
if git push --atomic origin "${tags_to_push[@]}"; then
echo "✅ Successfully pushed all ${#tags_to_push[@]} tags"
pushed_tags=${#tags_to_push[@]}
else
echo "❌ Failed to push tags"
failed_pushes=${#tags_to_push[@]}
fi
fi
echo ""
echo "📊 Push summary:"
echo " Pushed: $pushed_tags"
echo " Failed: $failed_pushes"
echo ""
if [ $pushed_tags -gt 0 ]; then
echo "🎉 Successfully created and pushed $pushed_tags release tags!"
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
}
main() {
[ $# -gt 0 ] && usage
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
fi
detect_modules_needing_tags || exit 1
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
echo "✨ No modules need tagging. All done!"
exit 0
}
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
case "$response" in
[yY] | [yY][eE][sS])
echo ""
create_and_push_tags
;;
*)
echo ""
echo "🚫 Operation cancelled by user"
exit 0
;;
esac
}
main "$@"
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Run check.sh
run: |
+4 -4
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@main
- name: Set up Bun
@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
@@ -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.4
with:
config: .github/typos.toml
validate-readme-files:
@@ -59,7 +59,7 @@ jobs:
needs: validate-style
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
with:
+4 -3
View File
@@ -14,6 +14,7 @@ on:
paths:
- ".github/workflows/deploy-registry.yaml"
- "registry/**/templates/**"
- "registry/**/README.md"
- ".icons/**"
jobs:
@@ -27,14 +28,14 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Authenticate with Google Cloud
uses: google-github-actions/auth@140bb5113ffb6b65a7e9b937a81fa96cf5064462
uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5
with:
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
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/setup-go@v5
with:
go-version: stable
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
issues: write
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
+18 -13
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
@@ -89,7 +89,7 @@ Create `registry/[your-username]/README.md`:
---
display_name: "Your Name"
bio: "Brief description of who you are and what you do"
avatar_url: "./.images/avatar.png"
avatar: "./.images/avatar.png"
github: "your-username"
linkedin: "https://www.linkedin.com/in/your-username" # Optional
website: "https://yourwebsite.com" # Optional
@@ -102,7 +102,7 @@ status: "community"
Brief description of who you are and what you do.
```
> **Note**: The `avatar_url` must point to `./.images/avatar.png` or `./.images/avatar.svg`.
> **Note**: The `avatar` must point to `./.images/avatar.png` or `./.images/avatar.svg`.
### 2. Generate Module Files
@@ -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! 🚀
+53 -7
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/`)
@@ -42,12 +42,58 @@ go build ./cmd/readmevalidation && ./readmevalidation
## Making a Release
### Create Release Tags
### Automated Tag and Release Process
After merging a PR:
After merging a PR, use the automated script to create and push release tags:
1. Get the new version from the PR (shown as `old → new`)
2. Checkout the merge commit and create the tag:
**Prerequisites:**
- Ensure all module versions are updated in their respective README files (the script uses this as the source of truth)
- Make sure you have the necessary permissions to push tags to the repository
**Steps:**
1. **Checkout the merge commit:**
```bash
git checkout MERGE_COMMIT_ID
```
2. **Run the tag release script:**
```bash
./scripts/tag_release.sh
```
3. **Review and confirm:**
- The script will automatically scan all modules in the registry
- It will detect which modules need version bumps by comparing README versions to existing tags
- A summary will be displayed showing which modules need tagging
- Confirm the list is correct when prompted
4. **Automatic tagging:**
- After confirmation, the script will automatically create all necessary release tags
- Tags will be pushed to the remote repository
- The script operates on the current checked-out commit
**Example output:**
```text
🔍 Scanning all modules for missing release tags...
📦 coder/code-server: v4.1.2 (needs tag)
✅ coder/dotfiles: v1.0.5 (already tagged)
## Tags to be created:
- `release/coder/code-server/v4.1.2`
❓ Do you want to proceed with creating and pushing these release tags?
Continue? [y/N]: y
```
### Manual Process (Fallback)
If the automated script fails, you can manually tag and release modules:
```bash
# Checkout the merge commit
@@ -81,7 +127,7 @@ tags: ["tag1", "tag2"]
```yaml
display_name: "Your Name"
bio: "Brief description of who you are and what you do"
avatar_url: "./.images/avatar.png"
avatar: "./.images/avatar.png"
github: "username"
linkedin: "https://www.linkedin.com/in/username" # Optional
website: "https://yourwebsite.com" # Optional
+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(), "rocessing README files", "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
logger.Info(context.Background(), "rocessed 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": {
+1 -1
View File
@@ -1,7 +1,7 @@
---
display_name: "Jay Kumar"
bio: "I'm a Software Engineer :)"
avatar_url: "./.images/avatar.png"
avatar: "./.images/avatar.jpeg"
github: "35C4n0r"
linkedin: "https://www.linkedin.com/in/jaykum4r"
support_email: "work.jaykumar@gmail.com"
@@ -0,0 +1,123 @@
---
display_name: Cursor CLI
icon: ../../../../.icons/cursor.svg
description: Run Cursor CLI agent in your workspace (no AgentAPI)
verified: true
tags: [agent, cursor, ai, cli]
---
# Cursor CLI
Run the Cursor Coding Agent in your workspace using the Cursor CLI directly.
A full example with MCP, rules, and pre/post install scripts:
```tf
data "coder_parameter" "ai_prompt" {
type = "string"
name = "AI Prompt"
default = ""
description = "Build a Minesweeper in Python."
mutable = true
}
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/coder-login/coder"
version = "1.0.31"
agent_id = coder_agent.main.id
}
module "cursor_cli" {
source = "registry.coder.com/coder-labs/cursor-cli/coder"
version = "0.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
# Optional
install_cursor_cli = true
force = true
model = "gpt-5"
ai_prompt = data.coder_parameter.ai_prompt.value
# Minimal MCP server (writes `folder/.cursor/mcp.json`):
mcp = jsonencode({
mcpServers = {
playwright = {
command = "npx"
args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated", "--no-sandbox"]
}
desktop-commander = {
command = "npx"
args = ["-y", "@wonderwhy-er/desktop-commander"]
}
}
})
# Use a pre_install_script to install the CLI
pre_install_script = <<-EOT
#!/usr/bin/env bash
set -euo pipefail
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
EOT
# Use post_install_script to wait for the repo to be ready
post_install_script = <<-EOT
#!/usr/bin/env bash
set -euo pipefail
TARGET="$${FOLDER}/.git/config"
echo "[cursor-cli] waiting for $${TARGET}..."
for i in $(seq 1 600); do
[ -f "$TARGET" ] && { echo "ready"; exit 0; }
sleep 1
done
echo "timeout waiting for $${TARGET}" >&2
EOT
# Provide a map of file name to content; files are written to `folder/.cursor/rules/<name>`.
rules_files = {
"python.mdc" = <<-EOT
---
description: RPC Service boilerplate
globs:
alwaysApply: false
---
- Use our internal RPC pattern when defining services
- Always use snake_case for service names.
@service-template.ts
EOT
"frontend.mdc" = <<-EOT
---
description: RPC Service boilerplate
globs:
alwaysApply: false
---
- Use our internal RPC pattern when defining services
- Always use snake_case for service names.
@service-template.ts
EOT
}
}
```
> [!NOTE]
> A `.cursor` directory will be created in the specified `folder`, containing the MCP configuration, rules.
> To use this module with tasks, please pass the API Key obtained from Cursor to the `api_key` variable. To obtain the api key follow the instructions [here](https://docs.cursor.com/en/cli/reference/authentication#step-1%3A-generate-an-api-key)
## References
- See Cursor CLI docs: `https://docs.cursor.com/en/cli/overview`
- For MCP project config, see `https://docs.cursor.com/en/context/mcp#using-mcp-json`. This module writes your `mcp_json` into `folder/.cursor/mcp.json`.
- For Rules, see `https://docs.cursor.com/en/context/rules#project-rules`. Provide `rules_files` (map of file name to content) to populate `folder/.cursor/rules/`.
## Troubleshooting
- Ensure the CLI is installed (enable `install_cursor_cli = true` or preinstall it in your image)
- Logs are written to `~/.cursor-cli-module/`
@@ -0,0 +1,152 @@
run "test_cursor_cli_basic" {
command = plan
variables {
agent_id = "test-agent-123"
folder = "/home/coder/projects"
}
assert {
condition = coder_env.status_slug.name == "CODER_MCP_APP_STATUS_SLUG"
error_message = "Status slug environment variable should be set correctly"
}
assert {
condition = coder_env.status_slug.value == "cursorcli"
error_message = "Status slug value should be 'cursorcli'"
}
assert {
condition = var.folder == "/home/coder/projects"
error_message = "Folder variable should be set correctly"
}
assert {
condition = var.agent_id == "test-agent-123"
error_message = "Agent ID variable should be set correctly"
}
}
run "test_cursor_cli_with_api_key" {
command = plan
variables {
agent_id = "test-agent-456"
folder = "/home/coder/workspace"
api_key = "test-api-key-123"
}
assert {
condition = coder_env.cursor_api_key[0].name == "CURSOR_API_KEY"
error_message = "Cursor API key environment variable should be set correctly"
}
assert {
condition = coder_env.cursor_api_key[0].value == "test-api-key-123"
error_message = "Cursor API key value should match the input"
}
}
run "test_cursor_cli_with_custom_options" {
command = plan
variables {
agent_id = "test-agent-789"
folder = "/home/coder/custom"
order = 5
group = "development"
icon = "/icon/custom.svg"
model = "sonnet-4"
ai_prompt = "Help me write better code"
force = false
install_cursor_cli = false
install_agentapi = false
}
assert {
condition = var.order == 5
error_message = "Order variable should be set to 5"
}
assert {
condition = var.group == "development"
error_message = "Group variable should be set to 'development'"
}
assert {
condition = var.icon == "/icon/custom.svg"
error_message = "Icon variable should be set to custom icon"
}
assert {
condition = var.model == "sonnet-4"
error_message = "Model variable should be set to 'sonnet-4'"
}
assert {
condition = var.ai_prompt == "Help me write better code"
error_message = "AI prompt variable should be set correctly"
}
assert {
condition = var.force == false
error_message = "Force variable should be set to false"
}
}
run "test_cursor_cli_with_mcp_and_rules" {
command = plan
variables {
agent_id = "test-agent-mcp"
folder = "/home/coder/mcp-test"
mcp = jsonencode({
mcpServers = {
test = {
command = "test-server"
args = ["--config", "test.json"]
}
}
})
rules_files = {
"general.md" = "# General coding rules\n- Write clean code\n- Add comments"
"security.md" = "# Security rules\n- Never commit secrets\n- Validate inputs"
}
}
assert {
condition = var.mcp != null
error_message = "MCP configuration should be provided"
}
assert {
condition = var.rules_files != null
error_message = "Rules files should be provided"
}
assert {
condition = length(var.rules_files) == 2
error_message = "Should have 2 rules files"
}
}
run "test_cursor_cli_with_scripts" {
command = plan
variables {
agent_id = "test-agent-scripts"
folder = "/home/coder/scripts"
pre_install_script = "echo 'Pre-install script'"
post_install_script = "echo 'Post-install script'"
}
assert {
condition = var.pre_install_script == "echo 'Pre-install script'"
error_message = "Pre-install script should be set correctly"
}
assert {
condition = var.post_install_script == "echo 'Post-install script'"
error_message = "Post-install script should be set correctly"
}
}
@@ -0,0 +1,212 @@
import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
import { execContainer, runTerraformInit, writeFileContainer } from "~test";
import {
execModuleScript,
expectAgentAPIStarted,
loadTestFile,
setup as setupUtil
} from "../../../coder/modules/agentapi/test-util";
import { setupContainer, writeExecutable } from "../../../coder/modules/agentapi/test-util";
let cleanupFns: (() => Promise<void>)[] = [];
const registerCleanup = (fn: () => Promise<void>) => cleanupFns.push(fn);
afterEach(async () => {
const fns = cleanupFns.slice().reverse();
cleanupFns = [];
for (const fn of fns) {
try {
await fn();
} catch (err) {
console.error(err);
}
}
});
interface SetupProps {
skipAgentAPIMock?: boolean;
skipCursorCliMock?: boolean;
moduleVariables?: Record<string, string>;
agentapiMockScript?: string;
}
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
const projectDir = "/home/coder/project";
const { id } = await setupUtil({
moduleDir: import.meta.dir,
moduleVariables: {
enable_agentapi: "true",
install_cursor_cli: props?.skipCursorCliMock ? "true" : "false",
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
folder: projectDir,
...props?.moduleVariables,
},
registerCleanup,
projectDir,
skipAgentAPIMock: props?.skipAgentAPIMock,
agentapiMockScript: props?.agentapiMockScript,
});
if (!props?.skipCursorCliMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/cursor-agent",
content: await loadTestFile(import.meta.dir, "cursor-cli-mock.sh"),
});
}
return { id };
};
setDefaultTimeout(180 * 1000);
describe("cursor-cli", async () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
test("agentapi-happy-path", async () => {
const { id } = await setup({});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
await expectAgentAPIStarted(id);
});
test("agentapi-mcp-json", async () => {
const mcpJson = '{"mcpServers": {"test": {"command": "test-cmd", "type": "stdio"}}}';
const { id } = await setup({
moduleVariables: {
mcp: mcpJson,
}
});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
const mcpContent = await execContainer(id, [
"bash",
"-c",
`cat '/home/coder/project/.cursor/mcp.json'`,
]);
expect(mcpContent.exitCode).toBe(0);
expect(mcpContent.stdout).toContain("mcpServers");
expect(mcpContent.stdout).toContain("test");
expect(mcpContent.stdout).toContain("test-cmd");
expect(mcpContent.stdout).toContain("/tmp/mcp-hack.sh");
expect(mcpContent.stdout).toContain("coder");
});
test("agentapi-rules-files", async () => {
const rulesContent = "Always use TypeScript";
const { id } = await setup({
moduleVariables: {
rules_files: JSON.stringify({ "typescript.md": rulesContent }),
}
});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
const rulesFile = await execContainer(id, [
"bash",
"-c",
`cat '/home/coder/project/.cursor/rules/typescript.md'`,
]);
expect(rulesFile.exitCode).toBe(0);
expect(rulesFile.stdout).toContain(rulesContent);
});
test("agentapi-api-key", async () => {
const apiKey = "test-cursor-api-key-123";
const { id } = await setup({
moduleVariables: {
api_key: apiKey,
}
});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
const envCheck = await execContainer(id, [
"bash",
"-c",
`env | grep CURSOR_API_KEY || echo "CURSOR_API_KEY not found"`,
]);
expect(envCheck.stdout).toContain("CURSOR_API_KEY");
});
test("agentapi-model-and-force-flags", async () => {
const model = "sonnet-4";
const { id } = await setup({
moduleVariables: {
model: model,
force: "true",
ai_prompt: "test prompt",
}
});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
const startLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.cursor-cli-module/agentapi-start.log || cat /home/coder/.cursor-cli-module/start.log || true",
]);
expect(startLog.stdout).toContain(`-m ${model}`);
expect(startLog.stdout).toContain("-f");
expect(startLog.stdout).toContain("test prompt");
});
test("agentapi-pre-post-install-scripts", async () => {
const { id } = await setup({
moduleVariables: {
pre_install_script: "#!/bin/bash\necho 'cursor-pre-install-script'",
post_install_script: "#!/bin/bash\necho 'cursor-post-install-script'",
}
});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
const preInstallLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.cursor-cli-module/pre_install.log || true",
]);
expect(preInstallLog.stdout).toContain("cursor-pre-install-script");
const postInstallLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.cursor-cli-module/post_install.log || true",
]);
expect(postInstallLog.stdout).toContain("cursor-post-install-script");
});
test("agentapi-folder-variable", async () => {
const folder = "/tmp/cursor-test-folder";
const { id } = await setup({
moduleVariables: {
folder: folder,
}
});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
const installLog = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.cursor-cli-module/install.log || true",
]);
expect(installLog.stdout).toContain(folder);
});
test("install-test-cursor-cli-latest", async () => {
const { id } = await setup({
skipCursorCliMock: true,
skipAgentAPIMock: true,
});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
await expectAgentAPIStarted(id);
})
});
@@ -0,0 +1,179 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.7"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "icon" {
type = string
description = "The icon to use for the app."
default = "/icon/cursor.svg"
}
variable "folder" {
type = string
description = "The folder to run Cursor CLI in."
}
variable "install_cursor_cli" {
type = bool
description = "Whether to install Cursor CLI."
default = true
}
variable "install_agentapi" {
type = bool
description = "Whether to install AgentAPI."
default = true
}
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.5.0"
}
variable "force" {
type = bool
description = "Force allow commands unless explicitly denied"
default = true
}
variable "model" {
type = string
description = "Model to use (e.g., sonnet-4, sonnet-4-thinking, gpt-5)"
default = ""
}
variable "ai_prompt" {
type = string
description = "AI prompt/task passed to cursor-agent."
default = ""
}
variable "api_key" {
type = string
description = "API key for Cursor CLI."
default = ""
sensitive = true
}
variable "mcp" {
type = string
description = "Workspace-specific MCP JSON to write to folder/.cursor/mcp.json. See https://docs.cursor.com/en/context/mcp#using-mcp-json"
default = null
}
variable "rules_files" {
type = map(string)
description = "Optional map of rule file name to content. Files will be written to folder/.cursor/rules/<name>. See https://docs.cursor.com/en/context/rules#project-rules"
default = null
}
variable "pre_install_script" {
type = string
description = "Optional script to run before installing Cursor CLI."
default = null
}
variable "post_install_script" {
type = string
description = "Optional script to run after installing Cursor CLI."
default = null
}
locals {
app_slug = "cursorcli"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".cursor-cli-module"
}
# Expose status slug and API key to the agent environment
resource "coder_env" "status_slug" {
agent_id = var.agent_id
name = "CODER_MCP_APP_STATUS_SLUG"
value = local.app_slug
}
resource "coder_env" "cursor_api_key" {
count = var.api_key != "" ? 1 : 0
agent_id = var.agent_id
name = "CURSOR_API_KEY"
value = var.api_key
}
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.1.1"
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = "Cursor CLI"
cli_app_slug = local.app_slug
cli_app_display_name = "Cursor CLI"
module_dir_name = local.module_dir_name
install_agentapi = var.install_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
ARG_FORCE='${var.force}' \
ARG_MODEL='${var.model}' \
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
ARG_MODULE_DIR_NAME='${local.module_dir_name}' \
ARG_FOLDER='${var.folder}' \
/tmp/start.sh
EOT
install_script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
chmod +x /tmp/install.sh
ARG_INSTALL='${var.install_cursor_cli}' \
ARG_WORKSPACE_MCP_JSON='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
ARG_WORKSPACE_RULES_JSON='${var.rules_files != null ? base64encode(jsonencode(var.rules_files)) : ""}' \
ARG_MODULE_DIR_NAME='${local.module_dir_name}' \
ARG_FOLDER='${var.folder}' \
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
/tmp/install.sh
EOT
}
@@ -0,0 +1,122 @@
#!/bin/bash
set -o errexit
set -o pipefail
command_exists() {
command -v "$1" > /dev/null 2>&1
}
# Inputs
ARG_INSTALL=${ARG_INSTALL:-true}
ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module}
ARG_FOLDER=${ARG_FOLDER:-$HOME}
ARG_CODER_MCP_APP_STATUS_SLUG=${ARG_CODER_MCP_APP_STATUS_SLUG:-}
mkdir -p "$HOME/$ARG_MODULE_DIR_NAME"
ARG_WORKSPACE_MCP_JSON=$(echo -n "$ARG_WORKSPACE_MCP_JSON" | base64 -d)
ARG_WORKSPACE_RULES_JSON=$(echo -n "$ARG_WORKSPACE_RULES_JSON" | base64 -d)
echo "--------------------------------"
echo "install: $ARG_INSTALL"
echo "folder: $ARG_FOLDER"
echo "coder_mcp_app_status_slug: $ARG_CODER_MCP_APP_STATUS_SLUG"
echo "module_dir_name: $ARG_MODULE_DIR_NAME"
echo "--------------------------------"
# Install Cursor via official installer if requested
function install_cursor_cli() {
if [ "$ARG_INSTALL" = "true" ]; then
echo "Installing Cursor via official installer..."
set +e
curl https://cursor.com/install -fsS | bash 2>&1
CURL_EXIT=${PIPESTATUS[0]}
set -e
if [ $CURL_EXIT -ne 0 ]; then
echo "Cursor installer failed with exit code $CURL_EXIT"
fi
# Ensure binaries are discoverable; create stable symlink to cursor-agent
CANDIDATES=(
"$(command -v cursor-agent || true)"
"$HOME/.cursor/bin/cursor-agent"
)
FOUND_BIN=""
for c in "${CANDIDATES[@]}"; do
if [ -n "$c" ] && [ -x "$c" ]; then
FOUND_BIN="$c"
break
fi
done
mkdir -p "$HOME/.local/bin"
if [ -n "$FOUND_BIN" ]; then
ln -sf "$FOUND_BIN" "$HOME/.local/bin/cursor-agent"
fi
echo "Installed cursor-agent at: $(command -v cursor-agent || true) (resolved: $FOUND_BIN)"
fi
}
# Write MCP config to user's home if provided (ARG_FOLDER/.cursor/mcp.json)
function write_mcp_config() {
TARGET_DIR="$ARG_FOLDER/.cursor"
TARGET_FILE="$TARGET_DIR/mcp.json"
mkdir -p "$TARGET_DIR"
CURSOR_MCP_HACK_SCRIPT=$(
cat << EOF
#!/usr/bin/env bash
set -e
# --- Set environment variables ---
export CODER_MCP_APP_STATUS_SLUG="${ARG_CODER_MCP_APP_STATUS_SLUG}"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
export CODER_AGENT_URL="${CODER_AGENT_URL}"
export CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN}"
# --- Launch the MCP server ---
exec coder exp mcp server
EOF
)
echo "$CURSOR_MCP_HACK_SCRIPT" > "/tmp/mcp-hack.sh"
chmod +x /tmp/mcp-hack.sh
CODER_MCP=$(
cat << EOF
{
"coder": {
"args": [],
"command": "/tmp/mcp-hack.sh",
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
"name": "Coder",
"timeout": 3000,
"type": "stdio",
"trust": true
}
}
EOF
)
echo "${ARG_WORKSPACE_MCP_JSON:-{}}" | jq --argjson base "$CODER_MCP" \
'.mcpServers = ((.mcpServers // {}) + $base)' > "$TARGET_FILE"
echo "Wrote workspace MCP to $TARGET_FILE"
}
# Write rules files to user's home (FOLDER/.cursor/rules)
function write_rules_file() {
if [ -n "$ARG_WORKSPACE_RULES_JSON" ]; then
RULES_DIR="$ARG_FOLDER/.cursor/rules"
mkdir -p "$RULES_DIR"
echo "$ARG_WORKSPACE_RULES_JSON" | jq -r 'to_entries[] | @base64' | while read -r entry; do
_jq() { echo "${entry}" | base64 -d | jq -r ${1}; }
NAME=$(_jq '.key')
CONTENT=$(_jq '.value')
echo "$CONTENT" > "$RULES_DIR/$NAME"
echo "Wrote rule: $RULES_DIR/$NAME"
done
fi
}
install_cursor_cli
write_mcp_config
write_rules_file
@@ -0,0 +1,67 @@
#!/bin/bash
set -o errexit
set -o pipefail
command_exists() {
command -v "$1" > /dev/null 2>&1
}
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d)
ARG_FORCE=${ARG_FORCE:-false}
ARG_MODEL=${ARG_MODEL:-}
ARG_OUTPUT_FORMAT=${ARG_OUTPUT_FORMAT:-json}
ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module}
ARG_FOLDER=${ARG_FOLDER:-$HOME}
echo "--------------------------------"
echo "install: $ARG_INSTALL"
echo "version: $ARG_VERSION"
echo "folder: $ARG_FOLDER"
echo "ai_prompt: $ARG_AI_PROMPT"
echo "force: $ARG_FORCE"
echo "model: $ARG_MODEL"
echo "output_format: $ARG_OUTPUT_FORMAT"
echo "module_dir_name: $ARG_MODULE_DIR_NAME"
echo "folder: $ARG_FOLDER"
echo "--------------------------------"
mkdir -p "$HOME/$ARG_MODULE_DIR_NAME"
# Find cursor agent cli
if command_exists cursor-agent; then
CURSOR_CMD=cursor-agent
elif [ -x "$HOME/.local/bin/cursor-agent" ]; then
CURSOR_CMD="$HOME/.local/bin/cursor-agent"
else
echo "Error: cursor-agent not found. Install it or set install_cursor_cli=true."
exit 1
fi
# Ensure working directory exists
if [ -d "$ARG_FOLDER" ]; then
cd "$ARG_FOLDER"
else
mkdir -p "$ARG_FOLDER"
cd "$ARG_FOLDER"
fi
ARGS=()
# global flags
if [ -n "$ARG_MODEL" ]; then
ARGS+=("-m" "$ARG_MODEL")
fi
if [ "$ARG_FORCE" = "true" ]; then
ARGS+=("-f")
fi
if [ -n "$ARG_AI_PROMPT" ]; then
printf "AI prompt provided\n"
ARGS+=("Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_AI_PROMPT")
fi
# Log and run in background, redirecting all output to the log file
printf "Running: %q %s\n" "$CURSOR_CMD" "$(printf '%q ' "${ARGS[@]}")"
agentapi server --type cursor --term-width 67 --term-height 1190 -- "$CURSOR_CMD" "${ARGS[@]}"
@@ -2,13 +2,13 @@
if [[ "$1" == "--version" ]]; then
echo "HELLO: $(bash -c env)"
echo "gemini version v2.5.0"
echo "cursor-agent version v2.5.0"
exit 0
fi
set -e
while true; do
echo "$(date) - gemini-mock"
echo "$(date) - cursor-agent-mock"
sleep 15
done
+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[@]}")"
@@ -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)
@@ -118,7 +118,6 @@ data "coder_workspace_preset" "default" {
EOT
"preview_port" = "4200"
"container_image" = "codercom/example-universal:ubuntu"
"jetbrains_ide" = "PY"
}
# Pre-builds is a Coder Premium
+1 -1
View File
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
```tf
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.0.2"
version = "1.1.1"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -148,4 +148,105 @@ describe("agentapi", async () => {
]);
expect(respAgentAPI.exitCode).toBe(0);
});
test("no-subdomain-base-path", async () => {
const { id } = await setup({
moduleVariables: {
agentapi_subdomain: "false",
},
});
const respModuleScript = await execModuleScript(id);
expect(respModuleScript.exitCode).toBe(0);
await expectAgentAPIStarted(id);
const agentApiStartLog = await readFileContainer(
id,
"/home/coder/test-agentapi-start.log",
);
expect(agentApiStartLog).toContain("Using AGENTAPI_CHAT_BASE_PATH: /@default/default.foo/apps/agentapi-web/chat");
});
test("validate-agentapi-version", async () => {
const cases = [
{
moduleVariables: {
agentapi_version: "v0.3.2",
},
shouldThrow: "",
},
{
moduleVariables: {
agentapi_version: "v0.3.3",
},
shouldThrow: "",
},
{
moduleVariables: {
agentapi_version: "v0.0.1",
agentapi_subdomain: "false",
},
shouldThrow: "Running with subdomain = false is only supported by agentapi >= v0.3.3.",
},
{
moduleVariables: {
agentapi_version: "v0.3.2",
agentapi_subdomain: "false",
},
shouldThrow: "Running with subdomain = false is only supported by agentapi >= v0.3.3.",
},
{
moduleVariables: {
agentapi_version: "v0.3.3",
agentapi_subdomain: "false",
},
shouldThrow: "",
},
{
moduleVariables: {
agentapi_version: "v0.3.999",
agentapi_subdomain: "false",
},
shouldThrow: "",
},
{
moduleVariables: {
agentapi_version: "v0.999.999",
agentapi_subdomain: "false",
},
},
{
moduleVariables: {
agentapi_version: "v999.999.999",
agentapi_subdomain: "false",
},
},
{
moduleVariables: {
agentapi_version: "arbitrary-string-bypasses-validation",
},
shouldThrow: "",
}
];
for (const { moduleVariables, shouldThrow } of cases) {
if (shouldThrow) {
expect(setup({ moduleVariables: moduleVariables as Record<string, string> })).rejects.toThrow(shouldThrow);
} else {
expect(setup({ moduleVariables: moduleVariables as Record<string, string> })).resolves.toBeDefined();
}
}
});
test("agentapi-allowed-hosts", async () => {
// verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable
// set in main.sh
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
const agentApiStartLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
});
});
+36 -3
View File
@@ -117,7 +117,7 @@ variable "install_agentapi" {
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.2.3"
default = "v0.3.3"
}
variable "agentapi_port" {
@@ -126,6 +126,31 @@ variable "agentapi_port" {
default = 3284
}
locals {
# agentapi_subdomain_false_min_version_expr matches a semantic version >= v0.3.3.
# Initial support was added in v0.3.1 but configuration via environment variable
# was added in v0.3.3.
# This is unfortunately a regex because there is no builtin way to compare semantic versions in Terraform.
# See: https://regex101.com/r/oHPyRa/1
agentapi_subdomain_false_min_version_expr = "^v(0\\.(3\\.[3-9]|3.[1-9]\\d+|[4-9]\\.\\d+|[1-9]\\d+\\.\\d+)|[1-9]\\d*\\.\\d+\\.\\d+)$"
}
variable "agentapi_subdomain" {
type = bool
description = "Whether to use a subdomain for AgentAPI."
default = true
validation {
condition = var.agentapi_subdomain || (
# If version doesn't look like a valid semantic version, just allow it.
# Note that boolean operators do not short-circuit in Terraform.
can(regex("^v\\d+\\.\\d+\\.\\d+$", var.agentapi_version)) ?
can(regex(local.agentapi_subdomain_false_min_version_expr, var.agentapi_version)) :
true
)
error_message = "Running with subdomain = false is only supported by agentapi >= v0.3.3."
}
}
variable "module_dir_name" {
type = string
description = "Name of the subdirectory in the home directory for module files."
@@ -140,7 +165,14 @@ locals {
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
agentapi_start_script_b64 = base64encode(var.start_script)
agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
main_script = file("${path.module}/scripts/main.sh")
// Chat base path is only set if not using a subdomain.
// NOTE:
// - Initial support for --chat-base-path was added in v0.3.1 but configuration
// via environment variable AGENTAPI_CHAT_BASE_PATH was added in v0.3.3.
// - As CODER_WORKSPACE_AGENT_NAME is a recent addition we use agent ID
// for backward compatibility.
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
main_script = file("${path.module}/scripts/main.sh")
}
resource "coder_script" "agentapi" {
@@ -165,6 +197,7 @@ resource "coder_script" "agentapi" {
ARG_WAIT_FOR_START_SCRIPT="$(echo -n '${local.agentapi_wait_for_start_script_b64}' | base64 -d)" \
ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
/tmp/main.sh
EOT
run_on_start = true
@@ -178,7 +211,7 @@ resource "coder_app" "agentapi_web" {
icon = var.web_app_icon
order = var.web_app_order
group = var.web_app_group
subdomain = true
subdomain = var.agentapi_subdomain
healthcheck {
url = "http://localhost:${var.agentapi_port}/status"
interval = 3
@@ -13,6 +13,7 @@ START_SCRIPT="$ARG_START_SCRIPT"
WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT"
POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT"
AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
set +o nounset
command_exists() {
@@ -92,5 +93,9 @@ export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
cd "${WORKDIR}"
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*"
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &>"$module_path/agentapi-start.log" &
"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
+10 -1
View File
@@ -24,7 +24,16 @@ export const setupContainer = async ({
});
const coderScript = findResourceInstance(state, "coder_script");
const id = await runContainer(image ?? "codercom/enterprise-node:latest");
return { id, coderScript, cleanup: () => removeContainer(id) };
return {
id, coderScript, cleanup: async () => {
if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1" || process.env["DEBUG"] === "yes") {
console.log(`Not removing container ${id} in debug mode`);
console.log(`Run "docker rm -f ${id}" to remove it manually.`);
} else {
await removeContainer(id);
}
}
};
};
export const loadTestFile = async (
@@ -1,11 +1,13 @@
#!/usr/bin/env node
const http = require("http");
const fs = require("fs");
const args = process.argv.slice(2);
const portIdx = args.findIndex((arg) => arg === "--port") + 1;
const port = portIdx ? args[portIdx] : 3284;
console.log(`starting server on port ${port}`);
fs.writeFileSync("/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`);
http
.createServer(function (_request, response) {
@@ -11,6 +11,12 @@ log_file_path="$module_path/agentapi.log"
echo "using prompt: $use_prompt" >>/home/coder/test-agentapi-start.log
echo "using port: $port" >>/home/coder/test-agentapi-start.log
AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then
echo "Using AGENTAPI_CHAT_BASE_PATH: $AGENTAPI_CHAT_BASE_PATH" >>/home/coder/test-agentapi-start.log
export AGENTAPI_CHAT_BASE_PATH
fi
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
bash -c aiagent \
>"$log_file_path" 2>&1
+7 -25
View File
@@ -125,24 +125,7 @@ variable "ai_prompt" {
locals {
encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : ""
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
# We need to use allowed tools to limit the context Amazon Q receives.
# Amazon Q can't handle big contexts, and the `create_template_version` tool
# has a description that's too long.
mcp_json = <<EOT
{
"mcpServers": {
"coder": {
"command": "coder",
"args": ["exp", "mcp", "server", "--allowed-tools", "coder_report_task"],
"env": {
"CODER_MCP_APP_STATUS_SLUG": "amazon-q"
}
}
}
}
EOT
encoded_mcp_json = base64encode(local.mcp_json)
full_prompt = <<-EOT
full_prompt = <<-EOT
${var.system_prompt}
Your first task is:
@@ -211,6 +194,12 @@ resource "coder_script" "amazon_q" {
cd "$PREV_DIR"
echo "Extracted auth tarball"
if [ "${var.experiment_report_tasks}" = "true" ]; then
echo "Configuring Amazon Q to report tasks via Coder MCP..."
q mcp add --name coder --command "coder" --args "exp,mcp,server,--allowed-tools,coder_report_task" --env "CODER_MCP_APP_STATUS_SLUG=amazon-q" --scope global --force
echo "Added Coder MCP server to Amazon Q configuration"
fi
if [ -n "${local.encoded_post_install_script}" ]; then
echo "Running post-install script..."
echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh
@@ -218,13 +207,6 @@ resource "coder_script" "amazon_q" {
/tmp/post_install.sh
fi
if [ "${var.experiment_report_tasks}" = "true" ]; then
echo "Configuring Amazon Q to report tasks via Coder MCP..."
mkdir -p ~/.aws/amazonq
echo "${local.encoded_mcp_json}" | base64 -d > ~/.aws/amazonq/mcp.json
echo "Created the ~/.aws/amazonq/mcp.json configuration file"
fi
if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then
echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously."
echo "Please set only one of them to true."
+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.4"
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.4"
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.4"
version = "2.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -10,6 +10,7 @@ import path from "path";
import {
execContainer,
findResourceInstance,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
@@ -319,4 +320,21 @@ describe("claude-code", async () => {
agentApiUrl: "http://localhost:3284",
});
});
// verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable
// set in main.tf
test("agentapi-allowed-hosts", async () => {
const { id } = await setup();
const respModuleScript = await execModuleScript(id);
expect(respModuleScript.exitCode).toBe(0);
await expectAgentAPIStarted(id);
const agentApiStartLog = await readFileContainer(
id,
"/home/coder/agentapi-mock.log",
);
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
});
});
+23 -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"
@@ -241,6 +226,10 @@ resource "coder_script" "claude_code" {
export LC_ALL=en_US.UTF-8
cd "${local.workdir}"
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*"
nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" &
"$module_path/scripts/agentapi-wait-for-start.sh"
EOT
@@ -288,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
@@ -20,6 +20,8 @@ if (
process.exit(1);
}
fs.writeFileSync("/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`);
console.log(`starting server on port ${port}`);
http
@@ -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."
@@ -15,7 +15,7 @@ The devcontainers-cli module provides an easy way to install [`@devcontainers/cl
```tf
module "devcontainers-cli" {
source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.0.31"
version = "1.0.32"
agent_id = coder_agent.example.id
}
```
@@ -45,6 +45,8 @@ const executeScriptInContainerWithPackageManager = async (
console.log(path);
await execContainer(id, [shell, "-c", "mkdir -p /tmp/coder-script-data"]);
const resp = await execContainer(
id,
[shell, "-c", instance.script],
@@ -52,6 +54,8 @@ const executeScriptInContainerWithPackageManager = async (
"--env",
"CODER_SCRIPT_BIN_DIR=/tmp/coder-script-data/bin",
"--env",
"CODER_SCRIPT_DATA_DIR=/tmp/coder-script-data",
"--env",
`PATH=${path}:/tmp/coder-script-data/bin`,
],
);
+7 -1
View File
@@ -1,5 +1,11 @@
#!/usr/bin/env sh
# We want to cd into `$CODER_SCRIPT_DATA_DIR` as the current directory
# might contain a `package.json` with `packageManager` set to something
# other than the detected package manager. When this happens, it can
# cause the installation to fail.
cd "$CODER_SCRIPT_DATA_DIR"
# If @devcontainers/cli is already installed, we can skip
if command -v devcontainer >/dev/null 2>&1; then
echo "🥳 @devcontainers/cli is already installed into $(which devcontainer)!"
@@ -34,7 +40,7 @@ install() {
# so that the devcontainer command is available
if [ -z "$PNPM_HOME" ]; then
PNPM_HOME="$CODER_SCRIPT_BIN_DIR"
export M_HOME
export PNPM_HOME
fi
pnpm add -g @devcontainers/cli
elif [ "$PACKAGE_MANAGER" = "yarn" ]; then
+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.0.1"
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.0.1"
version = "2.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
+17
View File
@@ -251,4 +251,21 @@ describe("goose", async () => {
expect(prompt.exitCode).not.toBe(0);
expect(prompt.stderr).toContain("No such file or directory");
});
test("subdomain-false", async () => {
const { id } = await setup({
agentapiMockScript: await loadTestFile(
import.meta.dir,
"agentapi-mock-print-args.js",
),
moduleVariables: {
subdomain: "false",
},
});
await execModuleScript(id);
const agentapiMockOutput = await readFileContainer(id, agentapiStartLog);
expect(agentapiMockOutput).toContain("AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/goose/chat");
});
});
+9 -2
View File
@@ -63,7 +63,13 @@ variable "install_agentapi" {
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.2.3"
default = "v0.3.3"
}
variable "subdomain" {
type = bool
description = "Whether to use a subdomain for AgentAPI."
default = true
}
variable "goose_provider" {
@@ -133,7 +139,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
@@ -146,6 +152,7 @@ module "agentapi" {
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
agentapi_subdomain = var.subdomain
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = local.start_script
@@ -3,6 +3,7 @@
const http = require("http");
const args = process.argv.slice(2);
console.log(args);
console.log(`AGENTAPI_CHAT_BASE_PATH=${process.env["AGENTAPI_CHAT_BASE_PATH"]}`);
const port = 3284;
console.log(`starting server on port ${port}`);
+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.1"
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.1"
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.1"
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.1"
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.1"
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.1"
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"
}
}
+2
View File
@@ -202,6 +202,7 @@ data "coder_parameter" "jetbrains_ides" {
count = length(var.default) == 0 ? 1 : 0
type = "list(string)"
name = "jetbrains_ides"
description = "Select which JetBrains IDEs to configure for use in this workspace."
display_name = "JetBrains IDEs"
icon = "/icon/jetbrains-toolbox.svg"
mutable = true
@@ -230,6 +231,7 @@ resource "coder_app" "jetbrains" {
icon = local.options_metadata[each.key].icon
external = true
order = var.coder_app_order
group = var.group
url = join("", [
"jetbrains://gateway/coder?&workspace=", # requires 2.6.3+ version of Toolbox
data.coder_workspace.me.name,
@@ -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 -1
View File
@@ -1,7 +1,7 @@
---
display_name: "Eric Paulsen"
bio: "Field CTO, EMEA @ Coder"
avatar_url: "./.images/avatar.png"
avatar: "./.images/avatar.png"
github: "ericpaulsen"
linkedin: "https://www.linkedin.com/in/ericpaulsen17" # Optional
website: "https://ericpaulsen.io" # Optional
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+32
View File
@@ -0,0 +1,32 @@
---
display_name: "Mark Milligan"
bio: "VP of Revenue at https://nuon.co. Former VP of Sales at Coder. Love building startup revenue teams and tinkering with technology."
avatar: "./.images/avatar.png"
github: "sharkymark"
linkedin: "https://www.linkedin.com/in/marktmilligan" # Optional
website: "https://markmilligan.io" # Optional
support_email: "mtm20176@gmail.com" # Optional
status: "community"
---
# Mark Milligan
Former VP of Sales at Coder for 4 years, and now VP of Revenue at Nuon. I love building startup revenue teams and tinkering with technology.
## About Me
Visit my [website](https://markmilligan.io) to learn more about my work and interests.
## Links
[My presentation about Great White Sharks](https://docs.google.com/presentation/d/13I3Af7l-ZSVCh-ovEvOKIM30ABIvNKhkRC3CnYZN450/edit?slide=id.p#slide=id.p) - given twice in 2020 and 2021 to the Coder team.
[NOAA Radar](https://radar.weather.gov/)
[Flight Radar](https://www.flightradar24.com/airport/aus)
### Webcams
[Austin - facing south](https://cctv.austinmobility.io/image/51.jpg)
[Austin - facing north](https://cctv.austinmobility.io/image/52.jpg)
@@ -0,0 +1,36 @@
---
display_name: "Claude Code AI Agent Template"
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
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
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
[Coder docs on AI agents and tasks](https://coder.com/docs/ai-coder/tasks)
[main.tf for Coder example](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/tasks-docker/main.tf)
[Claude Code Coder Terraform module](https://registry.coder.com/modules/coder/claude-code)
[Docker Terraform provider](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs)
[Coder Terraform provider](https://registry.terraform.io/providers/coder/coder/latest/docs)
@@ -0,0 +1,363 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
}
}
provider "docker" {
host = var.socket
}
provider "coder" {
}
data "coder_workspace" "me" {
}
data "coder_workspace_owner" "me" {
}
data "coder_provisioner" "me" {
}
variable "socket" {
type = string
description = <<-EOF
The Unix socket that the Docker daemon listens on and how containers
communicate with the Docker daemon.
Either Unix or TCP
e.g., unix:///var/run/docker.sock
EOF
default = "unix:///var/run/docker.sock"
}
variable "anthropic_api_key" {
type = string
description = "Generate one at: https://console.anthropic.com/settings/keys"
sensitive = true
}
resource "coder_env" "anthropic_api_key" {
agent_id = coder_agent.dev.id
name = "CODER_MCP_CLAUDE_API_KEY"
value = var.anthropic_api_key
}
# The Claude Code module does the automatic task reporting
# Other agent modules: https://registry.coder.com/modules?search=agent
# Or use a custom agent:
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
version = "2.0.0"
agent_id = coder_agent.dev.id
folder = "/home/coder/projects"
install_claude_code = true
claude_code_version = "latest"
order = 999
experiment_post_install_script = data.coder_parameter.setup_script.value
# This enables Coder Tasks
experiment_report_tasks = true
}
# We are using presets to set the prompts, image, and set up instructions
# See https://coder.com/docs/admin/templates/extending-templates/parameters#workspace-presets
data "coder_workspace_preset" "default" {
name = "Real World App: Angular + Django"
default = true
parameters = {
"system_prompt" = <<-EOT
-- Framing --
You are a helpful assistant that can help with code. You are running inside a Coder Workspace and provide status updates to the user via Coder MCP. Stay on track, feel free to debug, but when the original plan fails, do not choose a different route/architecture without checking the user first.
-- Tool Selection --
- playwright: previewing your changes after you made them
to confirm it worked as expected
- desktop-commander - use only for commands that keep running
(servers, dev watchers, GUI apps).
- Built-in tools - use for everything else:
(file operations, git commands, builds & installs, one-off shell commands)
Remember this decision rule:
- Stays running? desktop-commander
- Finishes immediately? built-in tools
-- Context --
There is an existing app and tmux dev server running on port 8000. Be sure to read it's CLAUDE.md (./realworld-django-rest-framework-angular/CLAUDE.md) to learn more about it.
Since this app is for demo purposes and the user is previewing the homepage and subsequent pages, aim to make the first visual change/prototype very quickly so the user can preview it, then focus on backend or logic which can be a more involved, long-running architecture plan.
EOT
"setup_script" = <<-EOT
# Set up projects dir
mkdir -p /home/coder/projects
cd $HOME/projects
# Packages: Install additional packages
sudo apt-get update && sudo apt-get install -y tmux
if ! command -v google-chrome >/dev/null 2>&1; then
yes | npx playwright install chrome
fi
# MCP: Install and configure MCP Servers
npm install -g @wonderwhy-er/desktop-commander
claude mcp add playwright npx -- @playwright/mcp@latest --headless --isolated --no-sandbox
claude mcp add desktop-commander desktop-commander
# Repo: Clone and pull changes from the git repository
if [ ! -d "realworld-django-rest-framework-angular" ]; then
git clone https://github.com/coder-contrib/realworld-django-rest-framework-angular.git
else
cd realworld-django-rest-framework-angular
git fetch
# Check for uncommitted changes
if git diff-index --quiet HEAD -- && \
[ -z "$(git status --porcelain --untracked-files=no)" ] && \
[ -z "$(git log --branches --not --remotes)" ]; then
echo "Repo is clean. Pulling latest changes..."
git pull
else
echo "Repo has uncommitted or unpushed changes. Skipping pull."
fi
cd ..
fi
# Initialize: Start the development server
cd realworld-django-rest-framework-angular && ./start-dev.sh
EOT
"preview_port" = "4200"
"container_image" = "codercom/example-universal:ubuntu"
}
}
# Advanced parameters (these are all set via preset)
data "coder_parameter" "system_prompt" {
name = "system_prompt"
display_name = "System Prompt"
type = "string"
form_type = "textarea"
description = "System prompt for the agent with generalized instructions"
mutable = false
}
data "coder_parameter" "ai_prompt" {
type = "string"
name = "AI Prompt"
default = ""
description = "Write a prompt for Claude Code"
mutable = true
}
data "coder_parameter" "setup_script" {
name = "setup_script"
display_name = "Setup Script"
type = "string"
form_type = "textarea"
description = "Script to run before running the agent"
mutable = false
}
data "coder_parameter" "container_image" {
name = "container_image"
display_name = "Container Image"
type = "string"
default = "codercom/example-universal:ubuntu"
mutable = false
}
data "coder_parameter" "preview_port" {
name = "preview_port"
display_name = "Preview Port"
description = "The port the web app is running to preview in Tasks"
type = "number"
default = "3000"
mutable = false
}
# Other variables for Claude Code
resource "coder_env" "claude_task_prompt" {
agent_id = coder_agent.dev.id
name = "CODER_MCP_CLAUDE_TASK_PROMPT"
value = data.coder_parameter.ai_prompt.value
}
resource "coder_env" "app_status_slug" {
agent_id = coder_agent.dev.id
name = "CODER_MCP_APP_STATUS_SLUG"
value = "claude-code"
}
resource "coder_env" "claude_system_prompt" {
agent_id = coder_agent.dev.id
name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT"
value = data.coder_parameter.system_prompt.value
}
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/coder-login/coder"
agent_id = coder_agent.dev.id
}
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
agent_id = coder_agent.dev.id
}
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
agent_id = coder_agent.dev.id
folder = "/home/coder/projects"
}
module "git-config" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-config/coder"
agent_id = coder_agent.dev.id
}
resource "coder_agent" "dev" {
arch = data.coder_provisioner.me.arch
os = "linux"
# The following metadata blocks are optional. They are used to display
# information about your workspace in the dashboard. You can remove them
# if you don't want to display any information.
# For basic resources, you can use the `coder stat` command.
# If you need more control, you can write your own script.
metadata {
display_name = "CPU Usage"
key = "0_cpu_usage"
script = "coder stat cpu"
interval = 10
timeout = 1
}
metadata {
display_name = "RAM Usage"
key = "1_ram_usage"
script = "coder stat mem"
interval = 10
timeout = 1
}
metadata {
display_name = "Home Disk"
key = "3_home_disk"
script = "coder stat disk --path $${HOME}"
interval = 60
timeout = 1
}
metadata {
display_name = "CPU Usage (Host)"
key = "4_cpu_usage_host"
script = "coder stat cpu --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Memory Usage (Host)"
key = "5_mem_usage_host"
script = "coder stat mem --host"
interval = 10
timeout = 1
}
display_apps {
vscode = true
vscode_insiders = false
ssh_helper = false
port_forwarding_helper = true
web_terminal = true
}
startup_script_behavior = "non-blocking"
connection_timeout = 300
env = {
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
}
startup_script = <<EOT
#!/bin/sh
EOT
}
resource "coder_app" "preview" {
agent_id = coder_agent.dev.id
slug = "preview"
display_name = "Preview your app"
icon = "${data.coder_workspace.me.access_url}/emojis/1f50e.png"
url = "http://localhost:${data.coder_parameter.preview_port.value}"
share = "authenticated"
subdomain = true
open_in = "tab"
order = 0
healthcheck {
url = "http://localhost:${data.coder_parameter.preview_port.value}/"
interval = 5
threshold = 15
}
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = data.coder_parameter.container_image.value
# Uses lower() to avoid Docker restriction on container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
hostname = lower(data.coder_workspace.me.name)
dns = ["1.1.1.1"]
# Use the docker gateway if the access URL is 127.0.0.1
#entrypoint = ["sh", "-c", replace(coder_agent.dev.init_script, "127.0.0.1", "host.docker.internal")]
# Use the docker gateway if the access URL is 127.0.0.1
command = [
"sh", "-c",
<<EOT
trap '[ $? -ne 0 ] && echo === Agent script exited with non-zero code. Sleeping infinitely to preserve logs... && sleep infinity' EXIT
${replace(coder_agent.dev.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")}
EOT
]
env = ["CODER_AGENT_TOKEN=${coder_agent.dev.token}"]
volumes {
container_path = "/home/coder/"
volume_name = docker_volume.coder_volume.name
read_only = false
}
host {
host = "host.docker.internal"
ip = "host-gateway"
}
}
resource "docker_volume" "coder_volume" {
name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}"
}
resource "coder_metadata" "workspace_info" {
count = data.coder_workspace.me.start_count
resource_id = docker_container.workspace[0].id
item {
key = "image"
value = data.coder_parameter.container_image.value
}
}
+660
View File
@@ -0,0 +1,660 @@
#!/bin/bash
# Tag Release Script
# Automatically detects modules that need tagging and creates release tags
# 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() {
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
log "DEBUG" "Invalid version format: '$version'. Expected X.Y.Z format."
return 1
fi
return 0
}
extract_version_from_readme() {
local readme_path="$1"
local namespace="$2"
local module_name="$3"
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[[:space:]]*=[[:space:]]*\"registry\.coder\.com/${namespace}/${module_name}" "$readme_path" | head -1 || echo "")
if [ -n "$version_line" ]; then
local version
version=$(echo "$version_line" | sed -n 's/.*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p')
if [ -n "$version" ]; then
log "DEBUG" "Found version '$version' from source line: $version_line"
echo "$version"
return 0
fi
fi
local fallback_version
fallback_version=$(grep -E 'version[[:space:]]*=[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' "$readme_path" | head -1 | sed 's/.*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\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
}
check_module_needs_tagging() {
local namespace="$1"
local module_name="$2"
local readme_version="$3"
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=()
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" ] && {
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
local namespace
namespace=$(echo "$module_path" | cut -d'/' -f2)
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
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
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
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
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"
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 ] && {
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
}
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 $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 ] && {
log "ERROR" "No modules to tag found"
return $EXIT_ERROR
}
local current_commit
current_commit=$(git rev-parse HEAD)
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"
local tag_name="release/$namespace/$module_name/v$version"
local tag_message="Release $namespace/$module_name v$version"
log "DEBUG" "Creating tag: $tag_name"
log "INFO" "Creating tag: $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
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
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 ] && {
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
}
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 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
done
local pushed_tags=0
local failed_pushes=0
if [ ${#tags_to_push[@]} -eq 0 ]; then
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[@]}" 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
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
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson pushed "$pushed_tags" '.summary.tags_pushed = $pushed')
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
log "INFO" "📊 Push summary:"
log "INFO" " Pushed: $pushed_tags"
log "INFO" " Failed: $failed_pushes"
echo ""
fi
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() {
parse_arguments "$@"
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
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
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
local detect_exit_code
detect_modules_needing_tags
detect_exit_code=$?
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
;;
$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
+1 -1
View File
@@ -247,8 +247,8 @@ export const runTerraformApply = async <TVars extends TerraformVariables>(
"-compact-warnings",
"-input=false",
"-auto-approve",
"-state",
"-no-color",
"-state",
stateFile,
],
{