Compare commits

...

25 Commits

Author SHA1 Message Date
Atif Ali 4ea87a6e01 chore: use light openai logo (#341)
Co-authored-by: DevelopmentCats <christofer@coder.com>
2025-08-19 19:24:45 +05:00
Atif Ali f5a571679a chore: fix module detection to exclude hidden dirs (#339)
## Before
```console
🚀 Coder Registry Tag Release Script
       Operating on commit: 4238f38353

       🔍 Scanning all modules for missing release tags...

       ⚠️  anomaly/.coder: No version found in README, skipping
        anomaly/tmux: v1.0.0 (already tagged)
       ⚠️  coder-labs/.coder: No version found in README, skipping
        coder-labs/cursor-cli: v0.1.1 (already tagged)
        coder-labs/gemini: v1.1.0 (already tagged)
       ⚠️  coder-labs/jetbrains-fleet: No version found in README, skipping
       ⚠️  coder/.coder: No version found in README, skipping
        coder/agentapi: v1.1.1 (already tagged)
        coder/aider: v1.1.2 (already tagged)
        coder/amazon-dcv-windows: v1.1.1 (already tagged)
        coder/amazon-q: v1.1.2 (already tagged)
        coder/aws-region: v1.0.31 (already tagged)
        coder/azure-region: v1.0.31 (already tagged)
        coder/claude-code: v2.1.0 (already tagged)
        coder/code-server: v1.3.1 (already tagged)
        coder/coder-login: v1.0.31 (already tagged)
        coder/cursor: v1.3.1 (already tagged)
        coder/devcontainers-cli: v1.0.32 (already tagged)
        coder/dotfiles: v1.2.1 (already tagged)
        coder/filebrowser: v1.1.2 (already tagged)
        coder/fly-region: v1.0.31 (already tagged)
        coder/gcp-region: v1.0.31 (already tagged)
        coder/git-clone: v1.1.1 (already tagged)
        coder/git-commit-signing: v1.0.31 (already tagged)
        coder/git-config: v1.0.31 (already tagged)
        coder/github-upload-public-key: v1.0.31 (already tagged)
        coder/goose: v2.1.1 (already tagged)
        coder/hcp-vault-secrets: v1.0.34 (already tagged)
        coder/jetbrains: v1.0.3 (already tagged)
        coder/jetbrains-fleet: v1.0.1 (already tagged)
        coder/jetbrains-gateway: v1.2.2 (already tagged)
        coder/jfrog-oauth: v1.0.31 (already tagged)
        coder/jfrog-token: v1.0.31 (already tagged)
        coder/jupyter-notebook: v1.2.0 (already tagged)
        coder/jupyterlab: v1.1.1 (already tagged)
        coder/kasmvnc: v1.2.1 (already tagged)
        coder/kiro: v1.0.0 (already tagged)
        coder/local-windows-rdp: v1.0.2 (already tagged)
        coder/personalize: v1.0.31 (already tagged)
        coder/slackme: v1.0.31 (already tagged)
        coder/vault-github: v1.0.31 (already tagged)
        coder/vault-jwt: v1.1.1 (already tagged)
        coder/vault-token: v1.2.1 (already tagged)
        coder/vscode-desktop: v1.1.1 (already tagged)
        coder/vscode-desktop-core: v1.0.0 (already tagged)
        coder/vscode-web: v1.3.1 (already tagged)
        coder/windows-rdp: v1.2.3 (already tagged)
        coder/windsurf: v1.1.1 (already tagged)
        coder/zed: v1.1.0 (already tagged)
        nataindata/apache-airflow: v1.0.14 (already tagged)
        thezoker/nodejs: v1.0.11 (already tagged)
       ⚠️  whizus/.coder: No version found in README, skipping
        whizus/exoscale-instance-type: v1.0.13 (already tagged)
        whizus/exoscale-zone: v1.0.13 (already tagged)

       📊 Summary: 0 of 54 modules need tagging

        🎉 All modules are up to date! No tags needed.
```

## After

```console
🚀 Coder Registry Tag Release Script
Operating on commit: 7f9725209f

🔍 Scanning all modules for missing release tags...

 anomaly/tmux: v1.0.0 (already tagged)
 coder-labs/cursor-cli: v0.1.1 (already tagged)
 coder-labs/gemini: v1.1.0 (already tagged)
 coder/agentapi: v1.1.1 (already tagged)
 coder/aider: v1.1.2 (already tagged)
 coder/amazon-dcv-windows: v1.1.1 (already tagged)
 coder/amazon-q: v1.1.2 (already tagged)
 coder/aws-region: v1.0.31 (already tagged)
 coder/azure-region: v1.0.31 (already tagged)
 coder/claude-code: v2.1.0 (already tagged)
 coder/code-server: v1.3.1 (already tagged)
 coder/coder-login: v1.0.31 (already tagged)
 coder/cursor: v1.3.1 (already tagged)
 coder/devcontainers-cli: v1.0.32 (already tagged)
 coder/dotfiles: v1.2.1 (already tagged)
 coder/filebrowser: v1.1.2 (already tagged)
 coder/fly-region: v1.0.31 (already tagged)
 coder/gcp-region: v1.0.31 (already tagged)
 coder/git-clone: v1.1.1 (already tagged)
 coder/git-commit-signing: v1.0.31 (already tagged)
 coder/git-config: v1.0.31 (already tagged)
 coder/github-upload-public-key: v1.0.31 (already tagged)
 coder/goose: v2.1.1 (already tagged)
 coder/hcp-vault-secrets: v1.0.34 (already tagged)
 coder/jetbrains: v1.0.3 (already tagged)
 coder/jetbrains-fleet: v1.0.1 (already tagged)
 coder/jetbrains-gateway: v1.2.2 (already tagged)
 coder/jfrog-oauth: v1.0.31 (already tagged)
 coder/jfrog-token: v1.0.31 (already tagged)
 coder/jupyter-notebook: v1.2.0 (already tagged)
 coder/jupyterlab: v1.1.1 (already tagged)
 coder/kasmvnc: v1.2.1 (already tagged)
 coder/kiro: v1.0.0 (already tagged)
 coder/local-windows-rdp: v1.0.2 (already tagged)
 coder/personalize: v1.0.31 (already tagged)
 coder/slackme: v1.0.31 (already tagged)
 coder/vault-github: v1.0.31 (already tagged)
 coder/vault-jwt: v1.1.1 (already tagged)
 coder/vault-token: v1.2.1 (already tagged)
 coder/vscode-desktop: v1.1.1 (already tagged)
 coder/vscode-desktop-core: v1.0.0 (already tagged)
 coder/vscode-web: v1.3.1 (already tagged)
 coder/windows-rdp: v1.2.3 (already tagged)
 coder/windsurf: v1.1.1 (already tagged)
 coder/zed: v1.1.0 (already tagged)
 nataindata/apache-airflow: v1.0.14 (already tagged)
 thezoker/nodejs: v1.0.11 (already tagged)
 whizus/exoscale-instance-type: v1.0.13 (already tagged)
 whizus/exoscale-zone: v1.0.13 (already tagged)

📊 Summary: 0 of 49 modules need tagging

 🎉 All modules are up to date! No tags needed.
```
2025-08-19 17:34:54 +05:00
35C4n0r 0e1dcd3a80 feat: support codex cli (#281)
Co-authored-by: Hugo Dutka <dutkahugo@gmail.com>
Co-authored-by: DevCats <christofer@coder.com>
2025-08-19 13:59:00 +05:00
35C4n0r 4238f38353 fix: reformat cursor cli readme (#336)
Closes #

## Description

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

## Type of Change

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

## Module Information

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

**Path:** `registry/[namespace]/modules/[module-name]`  
**New version:** `v1.0.0`  
**Breaking change:** [ ] Yes [ ] No

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

---------

Co-authored-by: Atif Ali <me@matifali.dev>
Co-authored-by: DevCats <christofer@coder.com>
2025-08-18 14:43:32 -05:00
DevCats 858799ce20 fix: update version extraction to be more robust, ensure compatibility (#337)
## Description

Update version detection to always detect named module block, and
extract version from same module block.
Ensure that script is completely compatible for all Unix environments.
<!-- Briefly describe what this PR does and why -->

## Type of Change

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

## Testing & Validation

- [ ] Tests pass (`bun test`)
- [X] Code formatted (`bun run fmt`)
- [X] Changes tested locally
2025-08-18 14:36:29 -05:00
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
67 changed files with 3765 additions and 531 deletions
@@ -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:
+2 -2
View File
@@ -28,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@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 }}
+15
View File
@@ -0,0 +1,15 @@
<svg width="721" height="721" viewBox="0 0 721 721" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1637_2935)">
<g clip-path="url(#clip1_1637_2935)">
<path d="M304.246 295.411V249.828C304.246 245.989 305.687 243.109 309.044 241.191L400.692 188.412C413.167 181.215 428.042 177.858 443.394 177.858C500.971 177.858 537.44 222.482 537.44 269.982C537.44 273.34 537.44 277.179 536.959 281.018L441.954 225.358C436.197 222 430.437 222 424.68 225.358L304.246 295.411ZM518.245 472.945V364.024C518.245 357.304 515.364 352.507 509.608 349.149L389.174 279.096L428.519 256.543C431.877 254.626 434.757 254.626 438.115 256.543L529.762 309.323C556.154 324.679 573.905 357.304 573.905 388.971C573.905 425.436 552.315 459.024 518.245 472.941V472.945ZM275.937 376.982L236.592 353.952C233.235 352.034 231.794 349.154 231.794 345.315V239.756C231.794 188.416 271.139 149.548 324.4 149.548C344.555 149.548 363.264 156.268 379.102 168.262L284.578 222.964C278.822 226.321 275.942 231.119 275.942 237.838V376.986L275.937 376.982ZM360.626 425.922L304.246 394.255V327.083L360.626 295.416L417.002 327.083V394.255L360.626 425.922ZM396.852 571.789C376.698 571.789 357.989 565.07 342.151 553.075L436.674 498.374C442.431 495.017 445.311 490.219 445.311 483.499V344.352L485.138 367.382C488.495 369.299 489.936 372.179 489.936 376.018V481.577C489.936 532.917 450.109 571.785 396.852 571.785V571.789ZM283.134 464.79L191.486 412.01C165.094 396.654 147.343 364.029 147.343 332.362C147.343 295.416 169.415 262.309 203.48 248.393V357.791C203.48 364.51 206.361 369.308 212.117 372.665L332.074 442.237L292.729 464.79C289.372 466.707 286.491 466.707 283.134 464.79ZM277.859 543.48C223.639 543.48 183.813 502.695 183.813 452.314C183.813 448.475 184.294 444.636 184.771 440.797L279.295 495.498C285.051 498.856 290.812 498.856 296.568 495.498L417.002 425.927V471.509C417.002 475.349 415.562 478.229 412.204 480.146L320.557 532.926C308.081 540.122 293.206 543.48 277.854 543.48H277.859ZM396.852 600.576C454.911 600.576 503.37 559.313 514.41 504.612C568.149 490.696 602.696 440.315 602.696 388.976C602.696 355.387 588.303 322.762 562.392 299.25C564.791 289.173 566.231 279.096 566.231 269.024C566.231 200.411 510.571 149.067 446.274 149.067C433.322 149.067 420.846 150.984 408.37 155.305C386.775 134.192 357.026 120.758 324.4 120.758C266.342 120.758 217.883 162.02 206.843 216.721C153.104 230.637 118.557 281.018 118.557 332.357C118.557 365.946 132.95 398.571 158.861 422.083C156.462 432.16 155.022 442.237 155.022 452.309C155.022 520.922 210.682 572.266 274.978 572.266C287.931 572.266 300.407 570.349 312.883 566.028C334.473 587.141 364.222 600.576 396.852 600.576Z" fill="white"/>
</g>
</g>
<defs>
<clipPath id="clip0_1637_2935">
<rect width="720" height="720" fill="white" transform="translate(0.606934 0.899902)"/>
</clipPath>
<clipPath id="clip1_1637_2935">
<rect width="484.139" height="479.818" fill="white" transform="translate(118.557 120.758)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+16 -11
View File
@@ -24,7 +24,7 @@ The Coder Registry is a collection of Terraform modules and templates for Coder
### Install Dependencies
Install Bun:
Install Bun (for formatting and scripts):
```bash
curl -fsSL https://bun.sh/install | bash
@@ -124,19 +124,23 @@ This script generates:
- Accurate description and usage examples
- Correct icon path (usually `../../../../.icons/your-icon.svg`)
- Proper tags that describe your module
3. **Create `main.test.ts`** to test your module
3. **Create at least one `.tftest.hcl`** to test your module with `terraform test`
4. **Add any scripts** or additional files your module needs
### 4. Test and Submit
```bash
# Test your module
bun test -t 'module-name'
# Test your module (from the module directory)
terraform init -upgrade
terraform test -verbose
# Or run all tests in the repo
./scripts/terraform_test_all.sh
# Format code
bun fmt
bun run fmt
# Commit and create PR
# Commit and create PR (do not push to main directly)
git add .
git commit -m "Add [module-name] module"
git push origin your-branch
@@ -335,11 +339,12 @@ coder templates push test-[template-name] -d .
### 2. Test Your Changes
```bash
# Test a specific module
bun test -t 'module-name'
# Test a specific module (from the module directory)
terraform init -upgrade
terraform test -verbose
# Test all modules
bun test
./scripts/terraform_test_all.sh
```
### 3. Maintain Backward Compatibility
@@ -388,7 +393,7 @@ Example: `https://github.com/coder/registry/compare/main...your-branch?template=
### Every Module Must Have
- `main.tf` - Terraform code
- `main.test.ts` - Working tests
- One or more `.tftest.hcl` files - Working tests with `terraform test`
- `README.md` - Documentation with frontmatter
### Every Template Must Have
@@ -488,6 +493,6 @@ When reporting bugs, include:
2. **No tests** or broken tests
3. **Hardcoded values** instead of variables
4. **Breaking changes** without defaults
5. **Not running** `bun fmt` before submitting
5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`) before submitting
Happy contributing! 🚀
+2 -2
View File
@@ -18,9 +18,9 @@ sudo apt install golang-go
Check that PRs have:
- [ ] All required files (`main.tf`, `main.test.ts`, `README.md`)
- [ ] All required files (`main.tf`, `README.md`, at least one `.tftest.hcl`)
- [ ] Proper frontmatter in README
- [ ] Working tests (`bun test`)
- [ ] Working tests (`terraform test`)
- [ ] Formatted code (`bun run fmt`)
- [ ] Avatar image for new namespaces (`avatar.png` or `avatar.svg` in `.images/`)
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"bufio"
"context"
"strings"
"golang.org/x/xerrors"
)
func validateCoderModuleReadmeBody(body string) []error {
var errs []error
trimmed := strings.TrimSpace(body)
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
errs = append(errs, baseErrs...)
}
foundParagraph := false
terraformCodeBlockCount := 0
foundTerraformVersionRef := false
lineNum := 0
isInsideCodeBlock := false
isInsideTerraform := false
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
for lineScanner.Scan() {
lineNum++
nextLine := lineScanner.Text()
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
// need to check deeper if the first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
}
continue
}
if strings.HasPrefix(nextLine, "```") {
isInsideCodeBlock = !isInsideCodeBlock
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
if isInsideTerraform {
terraformCodeBlockCount++
}
if strings.HasPrefix(nextLine, "```hcl") {
errs = append(errs, xerrors.New("all hcl code blocks must be converted to tf"))
}
continue
}
if isInsideCodeBlock {
if isInsideTerraform {
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
}
continue
}
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
break
}
// Code assumes that if we've reached this point, the only other options are:
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
}
if terraformCodeBlockCount == 0 {
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
} else {
if terraformCodeBlockCount > 1 {
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
}
if !foundTerraformVersionRef {
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
}
}
if !foundParagraph {
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
}
if isInsideCodeBlock {
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
}
return errs
}
func validateCoderModuleReadme(rm coderResourceReadme) []error {
var errs []error
for _, err := range validateCoderModuleReadmeBody(rm.body) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
errs = append(errs, fmErrs...)
}
return errs
}
func validateAllCoderModuleReadmes(resources []coderResourceReadme) error {
var yamlValidationErrors []error
for _, readme := range resources {
errs := validateCoderModuleReadme(readme)
if len(errs) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errs...)
}
}
if len(yamlValidationErrors) != 0 {
return validationPhaseError{
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
return nil
}
func validateAllCoderModules() error {
const resourceType = "modules"
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
if err != nil {
return err
}
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
err = validateAllCoderModuleReadmes(resources)
if err != nil {
return err
}
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
if err := validateCoderResourceRelativeURLs(resources); err != nil {
return err
}
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
return nil
}
@@ -14,7 +14,7 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) {
t.Run("Parses a valid README body with zero issues", func(t *testing.T) {
t.Parallel()
errs := validateCoderResourceReadmeBody(testBody)
errs := validateCoderModuleReadmeBody(testBody)
for _, e := range errs {
t.Error(e)
}
+78 -150
View File
@@ -1,8 +1,6 @@
package main
import (
"bufio"
"context"
"errors"
"net/url"
"os"
@@ -17,6 +15,7 @@ import (
var (
supportedResourceTypes = []string{"modules", "templates"}
operatingSystems = []string{"windows", "macos", "linux"}
// TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but
// realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's
@@ -25,11 +24,21 @@ var (
)
type coderResourceFrontmatter struct {
Description string `yaml:"description"`
IconURL string `yaml:"icon"`
DisplayName *string `yaml:"display_name"`
Verified *bool `yaml:"verified"`
Tags []string `yaml:"tags"`
Description string `yaml:"description"`
IconURL string `yaml:"icon"`
DisplayName *string `yaml:"display_name"`
Verified *bool `yaml:"verified"`
Tags []string `yaml:"tags"`
OperatingSystems []string `yaml:"supported_os"`
}
// A slice version of the struct tags from coderResourceFrontmatter. Might be worth using reflection to generate this
// list at runtime in the future, but this should be okay for now
var supportedCoderResourceStructKeys = []string{
"description", "icon", "display_name", "verified", "tags", "supported_os",
// TODO: This is an old, officially deprecated key from the archived coder/modules repo. We can remove this once we
// make sure that the Registry Server is no longer checking this field.
"maintainer_github",
}
// coderResourceReadme represents a README describing a Terraform resource used
@@ -42,6 +51,17 @@ type coderResourceReadme struct {
frontmatter coderResourceFrontmatter
}
func validateSupportedOperatingSystems(systems []string) []error {
var errs []error
for _, s := range systems {
if slices.Contains(operatingSystems, s) {
continue
}
errs = append(errs, xerrors.Errorf("detected unknown operating system %q", s))
}
return errs
}
func validateCoderResourceDisplayName(displayName *string) error {
if displayName != nil && *displayName == "" {
return xerrors.New("if defined, display_name must not be empty string")
@@ -67,7 +87,7 @@ func validateCoderResourceIconURL(iconURL string) []error {
return []error{xerrors.New("icon URL cannot be empty")}
}
errs := []error{}
var errs []error
// If the URL does not have a relative path.
if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") {
@@ -98,7 +118,7 @@ func validateCoderResourceTags(tags []string) error {
// All of these tags are used for the module/template filter controls in the Registry site. Need to make sure they
// can all be placed in the browser URL without issue.
invalidTags := []string{}
var invalidTags []string
for _, t := range tags {
if t != url.QueryEscape(t) {
invalidTags = append(invalidTags, t)
@@ -111,119 +131,50 @@ func validateCoderResourceTags(tags []string) error {
return nil
}
func validateCoderResourceReadmeBody(body string) []error {
func validateCoderResourceFrontmatter(resourceType string, filePath string, fm coderResourceFrontmatter) []error {
if !slices.Contains(supportedResourceTypes, resourceType) {
return []error{xerrors.Errorf("cannot process unknown resource type %q", resourceType)}
}
var errs []error
trimmed := strings.TrimSpace(body)
// TODO: this may cause unexpected behavior since the errors slice may have a 0 length. Add a test.
errs = append(errs, validateReadmeBody(trimmed)...)
foundParagraph := false
terraformCodeBlockCount := 0
foundTerraformVersionRef := false
lineNum := 0
isInsideCodeBlock := false
isInsideTerraform := false
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
for lineScanner.Scan() {
lineNum++
nextLine := lineScanner.Text()
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
// need to check deeper if the first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
}
continue
}
if strings.HasPrefix(nextLine, "```") {
isInsideCodeBlock = !isInsideCodeBlock
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
if isInsideTerraform {
terraformCodeBlockCount++
}
if strings.HasPrefix(nextLine, "```hcl") {
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
}
continue
}
if isInsideCodeBlock {
if isInsideTerraform {
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
}
continue
}
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
break
}
// Code assumes that if we've reached this point, the only other options are:
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
if err := validateCoderResourceDisplayName(fm.DisplayName); err != nil {
errs = append(errs, addFilePathToError(filePath, err))
}
if err := validateCoderResourceDescription(fm.Description); err != nil {
errs = append(errs, addFilePathToError(filePath, err))
}
if err := validateCoderResourceTags(fm.Tags); err != nil {
errs = append(errs, addFilePathToError(filePath, err))
}
if terraformCodeBlockCount == 0 {
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
} else {
if terraformCodeBlockCount > 1 {
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
}
if !foundTerraformVersionRef {
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
}
for _, err := range validateCoderResourceIconURL(fm.IconURL) {
errs = append(errs, addFilePathToError(filePath, err))
}
if !foundParagraph {
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
}
if isInsideCodeBlock {
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) {
errs = append(errs, addFilePathToError(filePath, err))
}
return errs
}
func validateCoderResourceReadme(rm coderResourceReadme) []error {
var errs []error
for _, err := range validateCoderResourceReadmeBody(rm.body) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if err := validateCoderResourceDisplayName(rm.frontmatter.DisplayName); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if err := validateCoderResourceDescription(rm.frontmatter.Description); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if err := validateCoderResourceTags(rm.frontmatter.Tags); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
return errs
}
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, error) {
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, []error) {
fm, body, err := separateFrontmatter(rm.rawText)
if err != nil {
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
}
keyErrs := validateFrontmatterYamlKeys(fm, supportedCoderResourceStructKeys)
if len(keyErrs) != 0 {
var remapped []error
for _, e := range keyErrs {
remapped = append(remapped, addFilePathToError(rm.filePath, e))
}
return coderResourceReadme{}, remapped
}
yml := coderResourceFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
}
return coderResourceReadme{
@@ -234,13 +185,17 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead
}, nil
}
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) {
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) ([]coderResourceReadme, error) {
if !slices.Contains(supportedResourceTypes, resourceType) {
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
}
resources := map[string]coderResourceReadme{}
var yamlParsingErrs []error
for _, rm := range rms {
p, err := parseCoderResourceReadme(resourceType, rm)
if err != nil {
yamlParsingErrs = append(yamlParsingErrs, err)
p, errs := parseCoderResourceReadme(resourceType, rm)
if len(errs) != 0 {
yamlParsingErrs = append(yamlParsingErrs, errs...)
continue
}
@@ -253,30 +208,27 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
}
}
yamlValidationErrors := []error{}
for _, readme := range resources {
errs := validateCoderResourceReadme(readme)
if len(errs) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errs...)
}
var serialized []coderResourceReadme
for _, r := range resources {
serialized = append(serialized, r)
}
if len(yamlValidationErrors) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
return resources, nil
slices.SortFunc(serialized, func(r1 coderResourceReadme, r2 coderResourceReadme) int {
return strings.Compare(r1.filePath, r2.filePath)
})
return serialized, nil
}
// Todo: Need to beef up this function by grabbing each image/video URL from
// the body's AST.
func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error {
func validateCoderResourceRelativeURLs(_ []coderResourceReadme) error {
return nil
}
func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
if !slices.Contains(supportedResourceTypes, resourceType) {
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
}
registryFiles, err := os.ReadDir(rootRegistryPath)
if err != nil {
return nil, err
@@ -325,27 +277,3 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
}
return allReadmeFiles, nil
}
func validateAllCoderResourceFilesOfType(resourceType string) error {
if !slices.Contains(supportedResourceTypes, resourceType) {
return xerrors.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
}
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
if err != nil {
return err
}
logger.Info(context.Background(), "processing README files", "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
logger.Info(context.Background(), "processed README files as valid Coder resources", "num_files", len(resources), "type", resourceType)
if err := validateCoderResourceRelativeURLs(resources); err != nil {
return err
}
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "type", resourceType)
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"bufio"
"context"
"strings"
"golang.org/x/xerrors"
)
func validateCoderTemplateReadmeBody(body string) []error {
var errs []error
trimmed := strings.TrimSpace(body)
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
errs = append(errs, baseErrs...)
}
var nextLine string
foundParagraph := false
isInsideCodeBlock := false
lineNum := 0
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
for lineScanner.Scan() {
lineNum++
nextLine = lineScanner.Text()
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
// need to check deeper if the first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
}
continue
}
if strings.HasPrefix(nextLine, "```") {
isInsideCodeBlock = !isInsideCodeBlock
if strings.HasPrefix(nextLine, "```hcl") {
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
}
continue
}
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
break
}
// Code assumes that if we've reached this point, the only other options are:
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
}
if !foundParagraph {
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
}
if isInsideCodeBlock {
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
}
return errs
}
func validateCoderTemplateReadme(rm coderResourceReadme) []error {
var errs []error
for _, err := range validateCoderTemplateReadmeBody(rm.body) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if fmErrs := validateCoderResourceFrontmatter("templates", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
errs = append(errs, fmErrs...)
}
return errs
}
func validateAllCoderTemplateReadmes(resources []coderResourceReadme) error {
var yamlValidationErrors []error
for _, readme := range resources {
errs := validateCoderTemplateReadme(readme)
if len(errs) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errs...)
}
}
if len(yamlValidationErrors) != 0 {
return validationPhaseError{
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
return nil
}
func validateAllCoderTemplates() error {
const resourceType = "templates"
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
if err != nil {
return err
}
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
err = validateAllCoderTemplateReadmes(resources)
if err != nil {
return err
}
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
if err := validateCoderResourceRelativeURLs(resources); err != nil {
return err
}
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
return nil
}
+46 -13
View File
@@ -19,11 +19,16 @@ type contributorProfileFrontmatter struct {
Bio string `yaml:"bio"`
ContributorStatus string `yaml:"status"`
AvatarURL *string `yaml:"avatar"`
GithubUsername *string `yaml:"github"`
LinkedinURL *string `yaml:"linkedin"`
WebsiteURL *string `yaml:"website"`
SupportEmail *string `yaml:"support_email"`
}
// A slice version of the struct tags from contributorProfileFrontmatter. Might be worth using reflection to generate
// this list at runtime in the future, but this should be okay for now
var supportedContributorProfileStructKeys = []string{"display_name", "bio", "status", "avatar", "linkedin", "github", "website", "support_email"}
type contributorProfileReadme struct {
frontmatter contributorProfileFrontmatter
namespace string
@@ -50,6 +55,22 @@ func validateContributorLinkedinURL(linkedinURL *string) error {
return nil
}
func validateGithubUsername(username *string) error {
if username == nil {
return nil
}
name := *username
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return xerrors.New("username must have non-whitespace characters")
}
if name != trimmed {
return xerrors.Errorf("username %q has extra whitespace", trimmed)
}
return nil
}
// validateContributorSupportEmail does best effort validation of a contributors email address. We can't 100% validate
// that this is correct without actually sending an email, especially because some contributors are individual developers
// and we don't want to do that on every single run of the CI pipeline. The best we can do is verify the general structure.
@@ -58,7 +79,7 @@ func validateContributorSupportEmail(email *string) []error {
return nil
}
errs := []error{}
var errs []error
username, server, ok := strings.Cut(*email, "@")
if !ok {
@@ -119,7 +140,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
return []error{xerrors.New("avatar URL must be omitted or non-empty string")}
}
errs := []error{}
var errs []error
// Have to use .Parse instead of .ParseRequestURI because this is the one field that's allowed to be a relative URL.
if _, err := url.Parse(*avatarURL); err != nil {
errs = append(errs, xerrors.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
@@ -145,7 +166,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
}
func validateContributorReadme(rm contributorProfileReadme) []error {
allErrs := []error{}
var allErrs []error
if err := validateContributorDisplayName(rm.frontmatter.DisplayName); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
@@ -153,6 +174,9 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
if err := validateContributorLinkedinURL(rm.frontmatter.LinkedinURL); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
}
if err := validateGithubUsername(rm.frontmatter.GithubUsername); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
}
if err := validateContributorWebsite(rm.frontmatter.WebsiteURL); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
}
@@ -170,15 +194,24 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
return allErrs
}
func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
func parseContributorProfile(rm readme) (contributorProfileReadme, []error) {
fm, _, err := separateFrontmatter(rm.rawText)
if err != nil {
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
}
keyErrs := validateFrontmatterYamlKeys(fm, supportedContributorProfileStructKeys)
if len(keyErrs) != 0 {
var remapped []error
for _, e := range keyErrs {
remapped = append(remapped, addFilePathToError(rm.filePath, e))
}
return contributorProfileReadme{}, remapped
}
yml := contributorProfileFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
}
return contributorProfileReadme{
@@ -190,11 +223,11 @@ func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfileReadme, error) {
profilesByNamespace := map[string]contributorProfileReadme{}
yamlParsingErrors := []error{}
var yamlParsingErrors []error
for _, rm := range readmeEntries {
p, err := parseContributorProfile(rm)
if err != nil {
yamlParsingErrors = append(yamlParsingErrors, err)
p, errs := parseContributorProfile(rm)
if len(errs) != 0 {
yamlParsingErrors = append(yamlParsingErrors, errs...)
continue
}
@@ -211,7 +244,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
}
}
yamlValidationErrors := []error{}
var yamlValidationErrors []error
for _, p := range profilesByNamespace {
if errors := validateContributorReadme(p); len(errors) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errors...)
@@ -234,8 +267,8 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
return nil, err
}
allReadmeFiles := []readme{}
errs := []error{}
var allReadmeFiles []readme
var errs []error
dirPath := ""
for _, e := range dirEntries {
if !e.IsDir() {
+5 -1
View File
@@ -31,7 +31,11 @@ func main() {
if err != nil {
errs = append(errs, err)
}
err = validateAllCoderResourceFilesOfType("modules")
err = validateAllCoderModules()
if err != nil {
errs = append(errs, err)
}
err = validateAllCoderTemplates()
if err != nil {
errs = append(errs, err)
}
+26 -1
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"fmt"
"regexp"
"slices"
"strings"
"golang.org/x/xerrors"
@@ -39,7 +40,9 @@ const (
var (
supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
// Matches markdown headers, must be at the beginning of a line, such as "# " or "### ".
// Matches markdown headers placed at the beginning of a line (e.g., "# " or "### "). To make the logic for
// validateReadmeBody easier, this pattern deliberately matches on invalid headers (header levels must be in the
// range 16 to be valid). The function has checks to see if the level is correct.
readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`)
)
@@ -168,3 +171,25 @@ func validateReadmeBody(body string) []error {
return errs
}
func validateFrontmatterYamlKeys(frontmatter string, allowedKeys []string) []error {
if len(allowedKeys) == 0 {
return []error{xerrors.New("Set of allowed keys is empty")}
}
var key string
var cutOk bool
var line string
var errs []error
lineScanner := bufio.NewScanner(strings.NewReader(frontmatter))
for lineScanner.Scan() {
line = lineScanner.Text()
key, _, cutOk = strings.Cut(line, ":")
if !cutOk || slices.Contains(allowedKeys, key) {
continue
}
errs = append(errs, xerrors.Errorf("detected unknown key %q", key))
}
return errs
}
+32 -21
View File
@@ -10,18 +10,21 @@ import (
"golang.org/x/xerrors"
)
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images")
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
// validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all
// expected file conventions
func validateCoderResourceSubdirectory(dirPath string) []error {
subDir, err := os.Stat(dirPath)
resourceDir, err := os.Stat(dirPath)
if err != nil {
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow specific rules.
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow
// specific rules.
if !errors.Is(err, os.ErrNotExist) {
return []error{addFilePathToError(dirPath, err)}
}
}
if !subDir.IsDir() {
if !resourceDir.IsDir() {
return []error{xerrors.Errorf("%q: path is not a directory", dirPath)}
}
@@ -30,10 +33,11 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
return []error{addFilePathToError(dirPath, err)}
}
errs := []error{}
var errs []error
for _, f := range files {
// The .coder subdirectories are sometimes generated as part of Bun tests. These subdirectories will never be
// committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over them.
// The .coder subdirectories are sometimes generated as part of our Bun tests. These subdirectories will never
// be committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over
// them.
if !f.IsDir() || f.Name() == ".coder" {
continue
}
@@ -59,49 +63,53 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
return errs
}
// validateRegistryDirectory validates that the contents of `/registry` follow all expected file conventions. This
// includes the top-level structure of the individual namespace directories.
func validateRegistryDirectory() []error {
userDirs, err := os.ReadDir(rootRegistryPath)
namespaceDirs, err := os.ReadDir(rootRegistryPath)
if err != nil {
return []error{err}
}
allErrs := []error{}
for _, d := range userDirs {
dirPath := path.Join(rootRegistryPath, d.Name())
if !d.IsDir() {
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
var allErrs []error
for _, nDir := range namespaceDirs {
namespacePath := path.Join(rootRegistryPath, nDir.Name())
if !nDir.IsDir() {
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", namespacePath))
continue
}
contributorReadmePath := path.Join(dirPath, "README.md")
contributorReadmePath := path.Join(namespacePath, "README.md")
if _, err := os.Stat(contributorReadmePath); err != nil {
allErrs = append(allErrs, err)
}
files, err := os.ReadDir(dirPath)
files, err := os.ReadDir(namespacePath)
if err != nil {
allErrs = append(allErrs, err)
continue
}
for _, f := range files {
// TODO: Decide if there's anything more formal that we want to ensure about non-directories scoped to user namespaces.
// TODO: Decide if there's anything more formal that we want to ensure about non-directories at the top
// level of each user namespace.
if !f.IsDir() {
continue
}
segment := f.Name()
filePath := path.Join(dirPath, segment)
filePath := path.Join(namespacePath, segment)
if !slices.Contains(supportedUserNameSpaceDirectories, segment) {
allErrs = append(allErrs, xerrors.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", ")))
continue
}
if !slices.Contains(supportedResourceTypes, segment) {
continue
}
if slices.Contains(supportedResourceTypes, segment) {
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
allErrs = append(allErrs, errs...)
}
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
allErrs = append(allErrs, errs...)
}
}
}
@@ -109,6 +117,9 @@ func validateRegistryDirectory() []error {
return allErrs
}
// validateRepoStructure validates that the structure of the repo is "correct enough" to do all necessary validation
// checks. It is NOT an exhaustive validation of the entire repo structure it only checks the parts of the repo that
// are relevant for the main validation steps
func validateRepoStructure() error {
var errs []error
if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 {
+21
View File
@@ -0,0 +1,21 @@
run "plan_with_required_vars" {
command = plan
variables {
agent_id = "example-agent-id"
}
}
run "app_url_uses_port" {
command = plan
variables {
agent_id = "example-agent-id"
port = 19999
}
assert {
condition = resource.coder_app.MODULE_NAME.url == "http://localhost:19999"
error_message = "Expected MODULE_NAME app URL to include configured port"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"fmt": "bun x prettier --write **/*.sh **/*.ts **/*.md *.md && terraform fmt -recursive -diff",
"fmt:ci": "bun x prettier --check **/*.sh **/*.ts **/*.md *.md && terraform fmt -check -recursive -diff",
"terraform-validate": "./scripts/terraform_validate.sh",
"test": "bun test",
"test": "./scripts/terraform_test_all.sh",
"update-version": "./update-version.sh"
},
"devDependencies": {
+155
View File
@@ -0,0 +1,155 @@
---
display_name: Codex CLI
icon: ../../../../.icons/openai.svg
description: Run Codex CLI in your workspace with AgentAPI integration
verified: true
tags: [agent, codex, ai, openai, tasks]
---
# Codex CLI
Run Codex CLI in your workspace to access OpenAI's models through the Codex interface, with custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility.
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "1.0.1"
agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key
folder = "/home/coder/project"
}
```
## Prerequisites
- You must add the [Coder Login](https://registry.coder.com/modules/coder/coder-login) module to your template
- OpenAI API key for Codex access
## Usage Example
- Simple usage Example:
```tf
module "codex" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
version = "1.0.1"
agent_id = coder_agent.example.id
openai_api_key = "..."
codex_model = "o4-mini"
install_codex = true
codex_version = "latest"
folder = "/home/coder/project"
codex_system_prompt = "You are a helpful coding assistant. Start every response with `Codex says:`"
}
```
- Example usage with Tasks:
```tf
# This
data "coder_parameter" "ai_prompt" {
type = "string"
name = "AI Prompt"
default = ""
description = "Initial prompt for the Codex CLI"
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.example.id
}
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_parameter.ai_prompt.value
folder = "/home/coder/project"
approval_policy = "never" # Full auto mode
}
```
> [!WARNING]
> **Security Notice**: This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified folder. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module in trusted environments and be aware of the security implications.
## How it Works
- **Install**: The module installs Codex CLI and sets up the environment
- **System Prompt**: If `codex_system_prompt` and `folder` are set, creates the directory (if needed) and writes the prompt to `AGENTS.md`
- **Start**: Launches Codex CLI in the specified directory, wrapped by AgentAPI
- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided)
## Sandbox Configuration
The module automatically configures Codex with a secure sandbox that allows AI tasks to work effectively:
- **Sandbox Mode**: `workspace-write` - Allows Codex to read/write files in the specified `folder`
- **Approval Policy**: `on-request` - Codex asks for permission before performing potentially risky operations
- **Network Access**: Enabled within the workspace for package installation and API calls
### Customizing Sandbox Behavior
You can customize the sandbox behavior using dedicated variables:
#### **Using Dedicated Variables (Recommended)**
For most use cases, use the dedicated sandbox variables:
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
# ... other variables ...
# Containerized environments (fixes Landlock errors)
sandbox_mode = "danger-full-access"
# Or for read-only mode
# sandbox_mode = "read-only"
# Or for full auto mode
# approval_policy = "never"
# Or disable network access
# network_access = false
}
```
#### **Using extra_codex_settings_toml (Advanced)**
For advanced configuration or when you need to override multiple settings:
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
# ... other variables ...
extra_codex_settings_toml = <<-EOT
# Any custom Codex configuration
model = "gpt-4"
disable_response_storage = true
EOT
}
```
> [!NOTE]
> The dedicated variables (`sandbox_mode`, `approval_policy`, `network_access`) are the recommended way to configure sandbox behavior. Use `extra_codex_settings_toml` only for advanced configuration that isn't covered by the dedicated variables.
## Troubleshooting
- Check installation and startup logs in `~/.codex-module/`
- Ensure your OpenAI API key has access to the specified model
> [!IMPORTANT]
> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set, and **you create a `coder_parameter` named `"AI Prompt"` and pass its value to the codex module's `ai_prompt` variable**. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker).
> The module automatically configures Codex with your API key and model preferences.
> folder is a required variable for the module to function correctly.
## References
- [OpenAI API Documentation](https://platform.openai.com/docs)
- [AgentAPI Documentation](https://github.com/coder/agentapi)
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
@@ -0,0 +1,281 @@
import {
test,
afterEach,
describe,
setDefaultTimeout,
beforeAll,
expect,
} from "bun:test";
import { execContainer, readFileContainer, runTerraformInit } from "~test";
import {
loadTestFile,
writeExecutable,
setup as setupUtil,
execModuleScript,
expectAgentAPIStarted,
} from "../../../coder/modules/agentapi/test-util";
import dedent from "dedent";
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
cleanupFunctions.push(cleanup);
};
afterEach(async () => {
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
cleanupFunctions = [];
for (const cleanup of cleanupFnsCopy) {
try {
await cleanup();
} catch (error) {
console.error("Error during cleanup:", error);
}
}
});
interface SetupProps {
skipAgentAPIMock?: boolean;
skipCodexMock?: 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: {
install_codex: props?.skipCodexMock ? "true" : "false",
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
codex_model: "gpt-4-turbo",
folder: "/home/coder",
...props?.moduleVariables,
},
registerCleanup,
projectDir,
skipAgentAPIMock: props?.skipAgentAPIMock,
agentapiMockScript: props?.agentapiMockScript,
});
if (!props?.skipCodexMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/codex",
content: await loadTestFile(import.meta.dir, "codex-mock.sh"),
});
}
return { id };
};
setDefaultTimeout(60 * 1000);
describe("codex", async () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
test("happy-path", async () => {
const { id } = await setup();
await execModuleScript(id);
await expectAgentAPIStarted(id);
});
test("install-codex-version", async () => {
const version_to_install = "0.10.0";
const { id } = await setup({
skipCodexMock: true,
moduleVariables: {
install_codex: "true",
codex_version: version_to_install,
},
});
await execModuleScript(id);
const resp = await execContainer(id, [
"bash",
"-c",
`cat /home/coder/.codex-module/install.log`,
]);
expect(resp.stdout).toContain(version_to_install);
});
test("check-latest-codex-version-works", async () => {
const { id } = await setup({
skipCodexMock: true,
skipAgentAPIMock: true,
moduleVariables: {
install_codex: "true",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
});
test("codex-config-toml", async () => {
const settings = dedent`
[mcp_servers.CustomMCP]
command = "/Users/jkmr/Documents/work/coder/coder_darwin_arm64"
args = ["exp", "mcp", "server", "app-status-slug=codex"]
env = { "CODER_MCP_APP_STATUS_SLUG" = "codex", "CODER_MCP_AI_AGENTAPI_URL"= "http://localhost:3284" }
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
enabled = true
type = "stdio"
`.trim();
const { id } = await setup({
moduleVariables: {
extra_codex_settings_toml: settings,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
expect(resp).toContain("[mcp_servers.CustomMCP]");
expect(resp).toContain("[mcp_servers.Coder]");
});
test("codex-api-key", async () => {
const apiKey = "test-api-key-123";
const { id } = await setup({
moduleVariables: {
openai_api_key: apiKey,
},
});
await execModuleScript(id);
const resp = await readFileContainer(
id,
"/home/coder/.codex-module/agentapi-start.log",
);
expect(resp).toContain("openai_api_key provided !");
});
test("pre-post-install-scripts", async () => {
const { id } = await setup({
moduleVariables: {
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
post_install_script: "#!/bin/bash\necho 'post-install-script'",
},
});
await execModuleScript(id);
const preInstallLog = await readFileContainer(
id,
"/home/coder/.codex-module/pre_install.log",
);
expect(preInstallLog).toContain("pre-install-script");
const postInstallLog = await readFileContainer(
id,
"/home/coder/.codex-module/post_install.log",
);
expect(postInstallLog).toContain("post-install-script");
});
test("folder-variable", async () => {
const folder = "/tmp/codex-test-folder";
const { id } = await setup({
skipCodexMock: false,
moduleVariables: {
folder,
},
});
await execModuleScript(id);
const resp = await readFileContainer(
id,
"/home/coder/.codex-module/install.log",
);
expect(resp).toContain(folder);
});
test("additional-extensions", async () => {
const additional = dedent`
[mcp_servers.CustomMCP]
command = "/Users/jkmr/Documents/work/coder/coder_darwin_arm64"
args = ["exp", "mcp", "server", "app-status-slug=codex"]
env = { "CODER_MCP_APP_STATUS_SLUG" = "codex", "CODER_MCP_AI_AGENTAPI_URL"= "http://localhost:3284" }
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
enabled = true
type = "stdio"
`.trim();
const { id } = await setup({
moduleVariables: {
additional_extensions: additional,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
expect(resp).toContain("[mcp_servers.CustomMCP]");
expect(resp).toContain("[mcp_servers.Coder]");
});
test("codex-system-prompt", async () => {
const prompt = "This is a system prompt for Codex.";
const { id } = await setup({
moduleVariables: {
codex_system_prompt: prompt,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/AGENTS.md");
expect(resp).toContain(prompt);
});
test("codex-system-prompt-skip-append-if-exists", async () => {
const prompt_1 = "This is a system prompt for Codex.";
const prompt_2 = "This is a system prompt for Goose.";
const prompt_3 = dedent`
This is a system prompt for Codex.
This is a system prompt for Gemini.
`.trim();
const pre_install_script = dedent`
#!/bin/bash
echo -e "${prompt_3}" >> /home/coder/AGENTS.md
`.trim();
const { id } = await setup({
moduleVariables: {
pre_install_script,
codex_system_prompt: prompt_2,
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/AGENTS.md");
expect(resp).toContain(prompt_1);
expect(resp).toContain(prompt_2);
// Re-run with a prompt that already exists, it should not append again
const { id: id_2 } = await setup({
moduleVariables: {
pre_install_script,
codex_system_prompt: prompt_1,
},
});
await execModuleScript(id_2);
const resp_2 = await readFileContainer(id_2, "/home/coder/AGENTS.md");
expect(resp_2).toContain(prompt_1);
const count = (resp_2.match(new RegExp(prompt_1, "g")) || []).length;
expect(count).toBe(1);
});
test("codex-ai-task-prompt", async () => {
const prompt = "This is a system prompt for Codex.";
const { id } = await setup({
moduleVariables: {
ai_prompt: prompt,
},
});
await execModuleScript(id);
const resp = await execContainer(id, [
"bash",
"-c",
`cat /home/coder/.codex-module/agentapi-start.log`,
]);
expect(resp.stdout).toContain(prompt);
});
test("start-without-prompt", async () => {
const { id } = await setup();
await execModuleScript(id);
const prompt = await execContainer(id, [
"ls",
"-l",
"/home/coder/AGENTS.md",
]);
expect(prompt.exitCode).not.toBe(0);
expect(prompt.stderr).toContain("No such file or directory");
});
});
+207
View File
@@ -0,0 +1,207 @@
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/openai.svg"
}
variable "folder" {
type = string
description = "The folder to run Codex in."
}
variable "install_codex" {
type = bool
description = "Whether to install Codex."
default = true
}
variable "codex_version" {
type = string
description = "The version of Codex to install."
default = "" # empty string means the latest available version
}
variable "extra_codex_settings_toml" {
type = string
description = "Settings to append to ~/.codex/config.toml."
default = ""
}
variable "sandbox_mode" {
type = string
description = "The sandbox mode for Codex. Options: workspace-write, read-only, danger-full-access."
default = "workspace-write"
validation {
condition = contains(["workspace-write", "read-only", "danger-full-access"], var.sandbox_mode)
error_message = "sandbox_mode must be one of: workspace-write, read-only, danger-full-access."
}
}
variable "approval_policy" {
type = string
description = "The approval policy for Codex. Options: on-request, never, untrusted."
default = "on-request"
validation {
condition = contains(["on-request", "never", "untrusted"], var.approval_policy)
error_message = "approval_policy must be one of: on-request, never, untrusted."
}
}
variable "network_access" {
type = bool
description = "Whether to allow network access in workspace-write mode."
default = true
}
variable "openai_api_key" {
type = string
description = "Codex API Key"
default = ""
}
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 "codex_model" {
type = string
description = "The model for Codex to use (e.g., o4-mini)."
default = ""
}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing Codex."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing Codex."
default = null
}
variable "ai_prompt" {
type = string
description = "Task prompt for the Codex CLI"
default = ""
}
variable "additional_extensions" {
type = string
description = "Additional extensions configuration in json format to append to the config."
default = ""
}
variable "codex_system_prompt" {
type = string
description = "System prompt for Codex. It will be added to AGENTS.md in the specified folder."
default = ""
}
resource "coder_env" "openai_api_key" {
agent_id = var.agent_id
name = "OPENAI_API_KEY"
value = var.openai_api_key
}
locals {
app_slug = "codex"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".codex-module"
}
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 = "Codex"
cli_app_slug = "${local.app_slug}-cli"
cli_app_display_name = "Codex 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_OPENAI_API_KEY='${var.openai_api_key}' \
ARG_CODEX_MODEL='${var.codex_model}' \
ARG_CODEX_START_DIRECTORY='${var.folder}' \
ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
/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_codex}' \
ARG_CODEX_VERSION='${var.codex_version}' \
ARG_EXTRA_CODEX_CONFIG='${base64encode(var.extra_codex_settings_toml)}' \
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
ARG_ADDITIONAL_EXTENSIONS='${base64encode(var.additional_extensions)}' \
ARG_CODEX_START_DIRECTORY='${var.folder}' \
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
ARG_SANDBOX_MODE='${var.sandbox_mode}' \
ARG_APPROVAL_POLICY='${var.approval_policy}' \
ARG_NETWORK_ACCESS='${var.network_access}' \
/tmp/install.sh
EOT
}
@@ -0,0 +1,171 @@
#!/bin/bash
source "$HOME"/.bashrc
BOLD='\033[0;1m'
# Function to check if a command exists
command_exists() {
command -v "$1" > /dev/null 2>&1
}
set -o errexit
set -o pipefail
set -o nounset
ARG_EXTRA_CODEX_CONFIG=$(echo -n "$ARG_EXTRA_CODEX_CONFIG" | base64 -d)
ARG_ADDITIONAL_EXTENSIONS=$(echo -n "$ARG_ADDITIONAL_EXTENSIONS" | base64 -d)
ARG_CODEX_INSTRUCTION_PROMPT=$(echo -n "$ARG_CODEX_INSTRUCTION_PROMPT" | base64 -d)
echo "--------------------------------"
printf "install: %s\n" "$ARG_INSTALL"
printf "codex_version: %s\n" "$ARG_CODEX_VERSION"
printf "codex_config: %s\n" "$ARG_EXTRA_CODEX_CONFIG"
printf "app_slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG"
printf "additional_extensions: %s\n" "$ARG_ADDITIONAL_EXTENSIONS"
printf "start_directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "instruction_prompt: %s\n" "$ARG_CODEX_INSTRUCTION_PROMPT"
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
printf "Node.js not found, installing Node.js via NVM...\n"
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
printf "Node.js installed: %s\n" "$(node --version)"
printf "npm installed: %s\n" "$(npm --version)"
else
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
exit 1
fi
fi
}
function install_codex() {
if [ "${ARG_INSTALL}" = "true" ]; then
# we need node to install and run codex-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
fi
printf "%s Installing Codex CLI\n" "${BOLD}"
if [ -n "$ARG_CODEX_VERSION" ]; then
npm install -g "@openai/codex@$ARG_CODEX_VERSION"
else
npm install -g "@openai/codex"
fi
printf "%s Successfully installed Codex CLI. Version: %s\n" "${BOLD}" "$(codex --version)"
fi
}
function populate_config_toml() {
CONFIG_PATH="$HOME/.codex/config.toml"
mkdir -p "$(dirname "$CONFIG_PATH")"
printf "Custom codex_config is provided !\n"
BASE_SANDBOX_CONFIG=$(
cat << EOF
# Base sandbox configuration for Codex workspace access
# This ensures Codex can read/write files in the specified folder for AI tasks
sandbox_mode = "${ARG_SANDBOX_MODE}"
approval_policy = "${ARG_APPROVAL_POLICY}"
# Allow network access in workspace-write mode for package installation, API calls, etc.
[sandbox_workspace_write]
network_access = ${ARG_NETWORK_ACCESS}
EOF
)
BASE_EXTENSIONS=$(
cat << EOF
[mcp_servers.Coder]
command = "coder"
args = ["exp", "mcp", "server"]
env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL"= "http://localhost:3284", "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}" }
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
type = "stdio"
EOF
)
echo "
${BASE_SANDBOX_CONFIG}
${ARG_EXTRA_CODEX_CONFIG}
${BASE_EXTENSIONS}
${ARG_ADDITIONAL_EXTENSIONS}
" > "$HOME/.codex/config.toml"
}
function add_instruction_prompt_if_exists() {
if [ -n "${ARG_CODEX_INSTRUCTION_PROMPT:-}" ]; then
if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then
printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
cd "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
else
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
cd "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
fi
# Check if AGENTS.md contains the instruction prompt already
if [ -f AGENTS.md ] && grep -Fxq "${ARG_CODEX_INSTRUCTION_PROMPT}" AGENTS.md; then
printf "AGENTS.md already contains the instruction prompt. Skipping append.\n"
else
printf "Appending instruction prompt to AGENTS.md\n"
echo -e "\n${ARG_CODEX_INSTRUCTION_PROMPT}" >> AGENTS.md
fi
else
printf "AGENTS.md is not set.\n"
fi
}
# Install Codex
install_codex
codex --version
populate_config_toml
add_instruction_prompt_if_exists
@@ -0,0 +1,78 @@
#!/bin/bash
# Load shell environment
source "$HOME"/.bashrc
set -o errexit
set -o pipefail
command_exists() {
command -v "$1" > /dev/null 2>&1
}
if [ -f "$HOME/.nvm/nvm.sh" ]; then
source "$HOME"/.nvm/nvm.sh
else
export PATH="$HOME/.npm-global/bin:$PATH"
fi
printf "Version: %s\n" "$(codex --version)"
set -o nounset
ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d)
echo "--------------------------------"
printf "openai_api_key: %s\n" "$ARG_OPENAI_API_KEY"
printf "codex_model: %s\n" "$ARG_CODEX_MODEL"
printf "start_directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "task_prompt: %s\n" "$ARG_CODEX_TASK_PROMPT"
echo "--------------------------------"
set +o nounset
CODEX_ARGS=()
if command_exists codex; then
printf "Codex is installed\n"
else
printf "Error: Codex is not installed. Please enable install_codex or install it manually\n"
exit 1
fi
if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then
printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
cd "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
else
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
cd "${ARG_CODEX_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
exit 1
}
fi
if [ -n "$ARG_CODEX_MODEL" ]; then
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
fi
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
printf "Running the task prompt %s\n" "$ARG_CODEX_TASK_PROMPT"
PROMPT="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_CODEX_TASK_PROMPT"
CODEX_ARGS+=("$PROMPT")
else
printf "No task prompt given.\n"
fi
if [ -n "$ARG_OPENAI_API_KEY" ]; then
printf "openai_api_key provided !\n"
else
printf "openai_api_key not provided\n"
fi
# use low width to fit in the tasks UI sidebar
# we adjust the height to 930 due to a bug in codex, see: https://github.com/openai/codex/issues/1608
printf "Starting codex with %s\n" "${CODEX_ARGS[@]}"
agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}"
@@ -0,0 +1,14 @@
#!/bin/bash
if [[ "$1" == "--version" ]]; then
echo "HELLO: $(bash -c env)"
echo "codex version v1.0.0"
exit 0
fi
set -e
while true; do
echo "$(date) - codex-mock"
sleep 15
done
@@ -0,0 +1,135 @@
---
display_name: Cursor CLI
icon: ../../../../.icons/cursor.svg
description: Run Cursor Agent CLI in your workspace for AI pair programming
verified: true
tags: [agent, cursor, ai, tasks]
---
# Cursor CLI
Run the Cursor Agent CLI in your workspace for interactive coding assistance and automated task execution.
```tf
module "cursor_cli" {
source = "registry.coder.com/coder-labs/cursor-cli/coder"
version = "0.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
```
## Basic setup
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.1"
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
api_key = "xxxx-xxxx-xxxx" # Required while using tasks, see note below
# 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)
+3 -4
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "2.0.7"
version = "2.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -28,7 +28,6 @@ module "claude-code" {
## Prerequisites
- Node.js and npm must be installed in your workspace to install Claude Code
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces.
@@ -84,7 +83,7 @@ resource "coder_agent" "main" {
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
version = "2.0.7"
version = "2.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -102,7 +101,7 @@ Run Claude Code as a standalone app in your workspace. This will install Claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "2.0.7"
version = "2.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
+19 -34
View File
@@ -111,7 +111,7 @@ locals {
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
agentapi_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-start.sh"))
agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.js"))
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.sh"))
claude_code_app_slug = "ccw"
}
@@ -129,6 +129,21 @@ resource "coder_script" "claude_code" {
command -v "$1" >/dev/null 2>&1
}
function install_claude_code_cli() {
echo "Installing Claude Code via official installer"
set +e
curl -fsSL claude.ai/install.sh | bash -s -- "${var.claude_code_version}" 2>&1
CURL_EXIT=$${PIPESTATUS[0]}
set -e
if [ $CURL_EXIT -ne 0 ]; then
echo "Claude Code installer failed with exit code $$CURL_EXIT"
fi
# Ensure binaries are discoverable.
export PATH="~/.local/bin:$PATH"
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
}
if [ ! -d "${local.workdir}" ]; then
echo "Warning: The specified folder '${local.workdir}' does not exist."
echo "Creating the folder..."
@@ -143,37 +158,7 @@ resource "coder_script" "claude_code" {
fi
if [ "${var.install_claude_code}" = "true" ]; then
if ! command_exists npm; then
echo "npm not found, checking for Node.js installation..."
if ! command_exists node; then
echo "Node.js not found, installing Node.js via NVM..."
export NVM_DIR="$HOME/.nvm"
if [ ! -d "$NVM_DIR" ]; then
mkdir -p "$NVM_DIR"
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
else
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
fi
nvm install --lts
nvm use --lts
nvm alias default node
echo "Node.js installed: $(node --version)"
echo "npm installed: $(npm --version)"
else
echo "Node.js is installed but npm is not available. Please install npm manually."
exit 1
fi
fi
echo "Installing Claude Code..."
npm install -g @anthropic-ai/claude-code@${var.claude_code_version}
fi
if ! command_exists node; then
echo "Error: Node.js is not installed. Please install Node.js manually."
exit 1
install_claude_code_cli
fi
# Install AgentAPI if enabled
@@ -214,7 +199,7 @@ resource "coder_script" "claude_code" {
echo -n "${local.agentapi_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-start.sh"
echo -n "${local.agentapi_wait_for_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-wait-for-start.sh"
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.js"
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.sh"
chmod +x "$module_path/scripts/agentapi-start.sh"
chmod +x "$module_path/scripts/agentapi-wait-for-start.sh"
@@ -292,4 +277,4 @@ resource "coder_ai_task" "claude_code" {
sidebar_app {
id = coder_app.claude_code_web.id
}
}
}
@@ -19,10 +19,10 @@ if [ -f "$log_file_path" ]; then
mv "$log_file_path" "$log_file_path"".$(date +%s)"
fi
# see the remove-last-session-id.js script for details
# see the remove-last-session-id.sh script for details
# about why we need it
# avoid exiting if the script fails
node "$scripts_dir/remove-last-session-id.js" "$(pwd)" || true
bash "$scripts_dir/remove-last-session-id.sh" "$(pwd)" 2>/dev/null || true
# we'll be manually handling errors from this point on
set +o errexit
@@ -1,40 +0,0 @@
// If lastSessionId is present in .claude.json, claude --continue will start a
// conversation starting from that session. The problem is that lastSessionId
// doesn't always point to the last session. The field is updated by claude only
// at the point of normal CLI exit. If Claude exits with an error, or if the user
// restarts the Coder workspace, lastSessionId will be stale, and claude --continue
// will start from an old session.
//
// If lastSessionId is missing, claude seems to accurately figure out where to
// start using the conversation history - even if the CLI previously exited with
// an error.
//
// This script removes the lastSessionId field from .claude.json.
const path = require("path")
const fs = require("fs")
const workingDirArg = process.argv[2]
if (!workingDirArg) {
console.log("No working directory provided - it must be the first argument")
process.exit(1)
}
const workingDir = path.resolve(workingDirArg)
console.log("workingDir", workingDir)
const claudeJsonPath = path.join(process.env.HOME, ".claude.json")
console.log(".claude.json path", claudeJsonPath)
if (!fs.existsSync(claudeJsonPath)) {
console.log("No .claude.json file found")
process.exit(0)
}
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8"))
if ("projects" in claudeJson && workingDir in claudeJson.projects && "lastSessionId" in claudeJson.projects[workingDir]) {
delete claudeJson.projects[workingDir].lastSessionId
fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2))
console.log("Removed lastSessionId from .claude.json")
} else {
console.log("No lastSessionId found in .claude.json - nothing to do")
}
@@ -0,0 +1,40 @@
# If lastSessionId is present in .claude.json, claude --continue will start a
# conversation starting from that session. The problem is that lastSessionId
# doesn't always point to the last session. The field is updated by claude only
# at the point of normal CLI exit. If Claude exits with an error, or if the user
# restarts the Coder workspace, lastSessionId will be stale, and claude --continue
# will start from an old session.
#
# If lastSessionId is missing, claude seems to accurately figure out where to
# start using the conversation history - even if the CLI previously exited with
# an error.
#
# This script removes the lastSessionId field from .claude.json.
if [ $# -eq 0 ]; then
echo "No working directory provided - it must be the first argument"
exit 1
fi
# Get absolute path of working directory
working_dir=$(realpath "$1")
echo "workingDir $working_dir"
# Path to .claude.json
claude_json_path="$HOME/.claude.json"
echo ".claude.json path $claude_json_path"
# Check if .claude.json exists
if [ ! -f "$claude_json_path" ]; then
echo "No .claude.json file found"
exit 0
fi
# Use jq to check if lastSessionId exists for the working directory and remove it
if jq -e ".projects[\"$working_dir\"].lastSessionId" "$claude_json_path" > /dev/null 2>&1; then
# Remove lastSessionId and update the file
jq "del(.projects[\"$working_dir\"].lastSessionId)" "$claude_json_path" > "${claude_json_path}.tmp" && mv "${claude_json_path}.tmp" "$claude_json_path"
echo "Removed lastSessionId from .claude.json"
else
echo "No lastSessionId found in .claude.json - nothing to do"
fi
@@ -0,0 +1,50 @@
run "required_vars" {
command = plan
variables {
agent_id = "foo"
}
}
run "offline_and_use_cached_conflict" {
command = plan
variables {
agent_id = "foo"
use_cached = true
offline = true
}
expect_failures = [
resource.coder_script.code-server
]
}
run "offline_disallows_extensions" {
command = plan
variables {
agent_id = "foo"
offline = true
extensions = ["ms-python.python", "golang.go"]
}
expect_failures = [
resource.coder_script.code-server
]
}
run "url_with_folder_query" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder/project"
port = 13337
}
assert {
condition = resource.coder_app.code-server.url == "http://localhost:13337/?folder=%2Fhome%2Fcoder%2Fproject"
error_message = "coder_app URL must include encoded folder query param"
}
}
+33 -2
View File
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.2.1"
version = "1.3.1"
agent_id = coder_agent.example.id
}
```
@@ -29,8 +29,39 @@ module "cursor" {
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.2.1"
version = "1.3.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
```
### Configure MCP servers for Cursor
Provide a JSON-encoded string via the `mcp` input. When set, the module writes the value to `~/.cursor/mcp.json` using a `coder_script` on workspace start.
The following example configures Cursor to use the GitHub MCP server with authentication facilitated by the [`coder_external_auth`](https://coder.com/docs/admin/external-auth#configure-a-github-oauth-app) resource.
```tf
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.3.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
mcp = jsonencode({
mcpServers = {
"github" : {
"url" : "https://api.githubcopilot.com/mcp/",
"headers" : {
"Authorization" : "Bearer ${data.coder_external_auth.github.access_token}",
},
"type" : "http"
}
}
})
}
data "coder_external_auth" "github" {
id = "github"
}
```
+28 -1
View File
@@ -1,8 +1,13 @@
import { describe, expect, it } from "bun:test";
import { describe, it, expect } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
runContainer,
execContainer,
removeContainer,
findResourceInstance,
readFileContainer,
} from "~test";
describe("cursor", async () => {
@@ -85,4 +90,26 @@ describe("cursor", async () => {
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBe(22);
});
it("writes ~/.cursor/mcp.json when mcp provided", async () => {
const id = await runContainer("alpine");
try {
const mcp = JSON.stringify({ servers: { demo: { url: "http://localhost:1234" } } });
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
mcp,
});
const script = findResourceInstance(state, "coder_script", "cursor_mcp").script;
const resp = await execContainer(id, ["sh", "-c", script]);
if (resp.exitCode !== 0) {
console.log(resp.stdout);
console.log(resp.stderr);
}
expect(resp.exitCode).toBe(0);
const content = await readFileContainer(id, "/root/.cursor/mcp.json");
expect(content).toBe(mcp);
} finally {
await removeContainer(id);
}
});
});
+26
View File
@@ -50,9 +50,20 @@ variable "display_name" {
default = "Cursor Desktop"
}
variable "mcp" {
type = string
description = "JSON-encoded string to configure MCP servers for Cursor. When set, writes ~/.cursor/mcp.json."
default = ""
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
locals {
mcp_b64 = var.mcp != "" ? base64encode(var.mcp) : ""
}
resource "coder_app" "cursor" {
agent_id = var.agent_id
external = true
@@ -75,6 +86,21 @@ resource "coder_app" "cursor" {
])
}
resource "coder_script" "cursor_mcp" {
count = var.mcp != "" ? 1 : 0
agent_id = var.agent_id
display_name = "Cursor MCP"
icon = "/icon/cursor.svg"
run_on_start = true
start_blocks_login = false
script = <<-EOT
#!/bin/sh
set -eu
mkdir -p "$HOME/.cursor"
echo -n "${local.mcp_b64}" | base64 -d > "$HOME/.cursor/mcp.json"
EOT
}
output "cursor_url" {
value = coder_app.cursor.url
description = "Cursor IDE Desktop URL."
+2 -2
View File
@@ -13,7 +13,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener
```tf
module "goose" {
source = "registry.coder.com/coder/goose/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
@@ -79,7 +79,7 @@ resource "coder_agent" "main" {
module "goose" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/goose/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
+1 -1
View File
@@ -139,7 +139,7 @@ EOT
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = var.agent_id
web_app_slug = local.app_slug
+6 -6
View File
@@ -14,7 +14,7 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -39,7 +39,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
@@ -52,7 +52,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
# Show parameter with limited options
@@ -66,7 +66,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -81,7 +81,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/workspace/project"
@@ -107,7 +107,7 @@ module "jetbrains" {
module "jetbrains_pycharm" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.0.2"
version = "1.0.3"
agent_id = coder_agent.example.id
folder = "/workspace/project"
@@ -0,0 +1,131 @@
run "requires_agent_and_folder" {
command = plan
# Setting both required vars should plan
variables {
agent_id = "foo"
folder = "/home/coder"
}
}
run "creates_parameter_when_default_empty_latest" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
major_version = "latest"
}
# When default is empty, a coder_parameter should be created
assert {
condition = can(data.coder_parameter.jetbrains_ides[0].type)
error_message = "Expected data.coder_parameter.jetbrains_ides to exist when default is empty"
}
}
run "no_apps_when_default_empty" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
}
assert {
condition = length(resource.coder_app.jetbrains) == 0
error_message = "Expected no coder_app resources when default is empty"
}
}
run "single_app_when_default_GO" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
}
assert {
condition = length(resource.coder_app.jetbrains) == 1
error_message = "Expected exactly one coder_app when default contains GO"
}
}
run "url_contains_required_params" {
command = apply
variables {
agent_id = "test-agent-123"
folder = "/custom/project/path"
default = ["GO"]
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("jetbrains://gateway/coder", app.url)) > 0])
error_message = "URL must contain jetbrains scheme"
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&folder=/custom/project/path", app.url)) > 0])
error_message = "URL must include folder path"
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_product_code=GO", app.url)) > 0])
error_message = "URL must include product code"
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_build_number=", app.url)) > 0])
error_message = "URL must include build number"
}
}
run "includes_agent_name_when_set" {
command = apply
variables {
agent_id = "test-agent-123"
agent_name = "main-agent"
folder = "/custom/project/path"
default = ["GO"]
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&agent_name=main-agent", app.url)) > 0])
error_message = "URL must include agent_name when provided"
}
}
run "parameter_order_when_default_empty" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
coder_parameter_order = 5
}
assert {
condition = data.coder_parameter.jetbrains_ides[0].order == 5
error_message = "Expected coder_parameter order to be set to 5"
}
}
run "app_order_when_default_not_empty" {
command = plan
variables {
agent_id = "foo"
folder = "/home/coder"
default = ["GO"]
coder_app_order = 10
}
assert {
condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.order == 10])
error_message = "Expected coder_app order to be set to 10"
}
}
@@ -0,0 +1,33 @@
---
display_name: VSCode Desktop Core
description: Building block for modules that need to link to an external VSCode-based IDE
icon: ../../../../.icons/coder.svg
verified: true
tags: [internal, library]
---
# VS Code Desktop Core
> [!CAUTION]
> We do not recommend using this module directly. Instead, please consider using one of our [Desktop IDE modules](https://registry.coder.com/modules?search=tag%3Aide).
The VSCode Desktop Core module is a building block for modules that need to expose access to VSCode-based IDEs. It is intended primarily to be used as a library to create modules for VSCode-based IDEs.
```tf
module "vscode-desktop-core" {
source = "registry.coder.com/coder/vscode-desktop-core/coder"
version = "1.0.0"
agent_id = var.agent_id
coder_app_icon = "/icon/code.svg"
coder_app_slug = "vscode"
coder_app_display_name = "VS Code Desktop"
coder_app_order = var.order
coder_app_group = var.group
folder = var.folder
open_recent = var.open_recent
protocol = "vscode"
}
```
@@ -0,0 +1,100 @@
import { describe, expect, it } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
// hardcoded coder_app name in main.tf
const appName = "vscode-desktop";
const defaultVariables = {
agent_id: "foo",
coder_app_icon: "/icon/code.svg",
coder_app_slug: "vscode",
coder_app_display_name: "VS Code Desktop",
protocol: "vscode",
}
describe("vscode-desktop-core", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, defaultVariables);
it("default output", async () => {
const state = await runTerraformApply(import.meta.dir, defaultVariables);
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === appName,
);
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBeNull();
});
it("adds folder", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
...defaultVariables
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("adds folder and open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
open_recent: "true",
...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("adds folder but not open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
openRecent: "false",
...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("adds open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
open_recent: "true",
...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
);
});
it("expect order to be set", async () => {
const state = await runTerraformApply(import.meta.dir, {
coder_app_order: "22",
...defaultVariables
});
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === appName,
);
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBe(22);
});
});
@@ -0,0 +1,92 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "folder" {
type = string
description = "The folder to open in the IDE."
default = ""
}
variable "open_recent" {
type = bool
description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open."
default = false
}
variable "protocol" {
type = string
description = "The URI protocol for the IDE."
}
variable "coder_app_icon" {
type = string
description = "The icon of the coder_app."
}
variable "coder_app_slug" {
type = string
description = "The slug of the coder_app."
}
variable "coder_app_display_name" {
type = string
description = "The display name of the coder_app."
}
variable "coder_app_order" {
type = number
description = "The order of the coder_app."
default = null
}
variable "coder_app_group" {
type = string
description = "The group of the coder_app."
default = null
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_app" "vscode-desktop" {
agent_id = var.agent_id
external = true
icon = var.coder_app_icon
slug = var.coder_app_slug
display_name = var.coder_app_display_name
order = var.coder_app_order
group = var.coder_app_group
# While the call to "join" is not strictly necessary, it makes the URL more readable.
url = join("", [
"${var.protocol}://coder.coder-remote/open",
"?owner=${data.coder_workspace_owner.me.name}",
"&workspace=${data.coder_workspace.me.name}",
var.folder != "" ? join("", ["&folder=", var.folder]) : "",
var.open_recent ? "&openRecent" : "",
"&url=${data.coder_workspace.me.access_url}",
# NOTE: There is a protocol whitelist for the token replacement, so this will only work with the protocols hardcoded in the front-end.
# (https://github.com/coder/coder/blob/6ba4b5bbc95e2e528d7f5b1e31fffa200ae1a6db/site/src/modules/apps/apps.ts#L18)
"&token=$SESSION_TOKEN",
])
}
output "ide_uri" {
value = coder_app.vscode-desktop.url
description = "IDE URI."
}
+32 -4
View File
@@ -19,7 +19,7 @@ Zed is a high-performance, multiplayer code editor from the creators of Atom and
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -32,7 +32,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -44,7 +44,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
display_name = "Zed Editor"
order = 1
@@ -57,8 +57,36 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.0.1"
version = "1.1.0"
agent_id = coder_agent.example.id
agent_name = coder_agent.example.name
}
```
### Configure Zed settings including MCP servers
Zed stores settings at `~/.config/zed/settings.json` by default. If `XDG_CONFIG_HOME` is set on Linux, settings will be at `$XDG_CONFIG_HOME/zed/settings.json`.
You can declaratively set/merge settings with the `settings` input. Provide a JSON string (e.g., via `jsonencode(...)`). For example, to configure MCP servers:
```tf
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
settings = jsonencode({
context_servers = {
your-mcp-server = {
source = "custom"
command = "some-command"
args = ["arg-1", "arg-2"]
env = {}
}
}
})
}
```
See Zeds settings files documentation: https://zed.dev/docs/configuring-zed#settings-files
+31
View File
@@ -50,7 +50,14 @@ variable "display_name" {
default = "Zed"
}
variable "settings" {
type = string
description = "JSON encoded settings.json"
default = ""
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
locals {
@@ -60,6 +67,30 @@ locals {
hostname = var.agent_name != "" ? "${local.agent_name}.${local.workspace_name}.${local.owner_name}.coder" : "${local.workspace_name}.coder"
}
resource "coder_script" "zed_settings" {
agent_id = var.agent_id
display_name = "Configure Zed settings"
icon = "/icon/zed.svg"
run_on_start = true
script = <<-EOT
set -eu
SETTINGS_JSON='${replace(var.settings, "\"", "\\\"")}'
if [ -z "$${SETTINGS_JSON}" ] || [ "$${SETTINGS_JSON}" = "{}" ]; then
exit 0
fi
CONFIG_HOME="$${XDG_CONFIG_HOME:-$HOME/.config}"
ZED_DIR="$${CONFIG_HOME}/zed"
mkdir -p "$${ZED_DIR}"
SETTINGS_FILE="$${ZED_DIR}/settings.json"
if command -v jq >/dev/null 2>&1 && [ -s "$${SETTINGS_FILE}" ]; then
tmpfile="$(mktemp)"
jq -s '.[0] * .[1]' "$${SETTINGS_FILE}" <(printf '%s\n' "$${SETTINGS_JSON}") > "$${tmpfile}" && mv "$${tmpfile}" "$${SETTINGS_FILE}"
else
printf '%s\n' "$${SETTINGS_JSON}" > "$${SETTINGS_FILE}"
fi
EOT
}
resource "coder_app" "zed" {
agent_id = var.agent_id
display_name = var.display_name
+40
View File
@@ -0,0 +1,40 @@
run "default_output" {
command = apply
variables {
agent_id = "foo"
}
assert {
condition = output.zed_url == "zed://ssh/default.coder"
error_message = "zed_url did not match expected default URL"
}
}
run "adds_folder" {
command = apply
variables {
agent_id = "foo"
folder = "/foo/bar"
}
assert {
condition = output.zed_url == "zed://ssh/default.coder/foo/bar"
error_message = "zed_url did not include provided folder path"
}
}
run "adds_agent_name" {
command = apply
variables {
agent_id = "foo"
agent_name = "myagent"
}
assert {
condition = output.zed_url == "zed://ssh/myagent.default.default.coder"
error_message = "zed_url did not include agent_name in hostname"
}
}
@@ -14,11 +14,26 @@ Provision Devcontainers as [Coder workspaces](https://coder.com/docs/workspaces)
### Infrastructure
Coder must have access to a running Docker socket, and the `coder` user must be a member of the `docker` group:
#### Running Coder inside Docker
```shell
If you installed Coder as a container within Docker, you will have to do the following things:
- Make the the Docker socket available to the container
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
- You can get the GID by running `getent group docker` on the **host** machine
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
#### Running Coder outside of Docker
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
```sh
# Add coder user to Docker group
sudo usermod -aG docker coder
sudo adduser coder docker
# Restart Coder server
sudo systemctl restart coder
+16 -1
View File
@@ -16,7 +16,22 @@ Provision Docker containers as [Coder workspaces](https://coder.com/docs/workspa
### Infrastructure
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
#### Running Coder inside Docker
If you installed Coder as a container within Docker, you will have to do the following things:
- Make the the Docker socket available to the container
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
- You can get the GID by running `getent group docker` on the **host** machine
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
#### Running Coder outside of Docker
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
```sh
# Add coder user to Docker group
@@ -8,6 +8,8 @@ tags: [vm, linux, gcp, devcontainer]
# Remote Development in a Devcontainer on Google Compute Engine
Provision a Devcontainer on Google Compute Engine instances as Coder workspaces
![Architecture Diagram](../../.images/gcp-devcontainer-architecture.svg)
## Prerequisites
@@ -8,6 +8,8 @@ tags: [vm, linux, gcp]
# Remote Development on Google Compute Engine (Linux)
Provision Google Compute Engine instances as Coder workspaces
## Prerequisites
### Authentication
@@ -8,6 +8,8 @@ tags: [vm-container, linux, gcp]
# Remote Development on Google Compute Engine (VM Container)
Provision Google Compute Engine instances as Coder workspaces.
## Prerequisites
### Authentication
@@ -8,6 +8,8 @@ tags: [vm, windows, gcp]
# Remote Development on Google Compute Engine (Windows)
Provision Google Compute Engine instances as Coder workspaces
## Prerequisites
### Authentication
@@ -8,6 +8,8 @@ tags: [kubernetes, containers, docker-in-docker]
# envbox
Provision envbox pods as Coder workspaces
## Introduction
`envbox` is an image that enables creating non-privileged containers capable of running system-level software (e.g. `dockerd`, `systemd`, etc) in Kubernetes.
@@ -1,27 +1,29 @@
---
display_name: "Claude Code AI Agent Template"
description: The goal is to try the experimental ai agent integration with Claude CodeAI agent
description: An experimental AI agent integration with Claude CodeAI agent
icon: "../../../../.icons/claude.svg"
verified: false
tags: ["ai", "docker", "container", "claude", "agent", "tasks"]
---
# ai agent template for a workspace in a container on a Docker host
# AI agent template for a workspace in a container on a Docker host
### Docker image
An experimental AI agent integration with Claude CodeAI agent
## Docker image
1. Based on Coder-managed image `codercom/example-universal:ubuntu`
[Image on DockerHub](https://hub.docker.com/r/codercom/example-universal)
### Apps included
## Apps included
1. A web-based terminal
1. code-server Web IDE
1. A [sample app](https://github.com/gothinkster/realworld) to test the environment
1. [Claude Code AI agent](https://www.anthropic.com/claude-code) to assist with development tasks
### Resources
## Resources
[Coder docs on AI agents and tasks](https://coder.com/docs/ai-coder/tasks)
+508 -113
View File
@@ -2,32 +2,216 @@
# Tag Release Script
# Automatically detects modules that need tagging and creates release tags
# Usage: ./tag_release.sh
# Usage: ./tag_release.sh [OPTIONS]
# Operates on the current checked-out commit
set -euo pipefail
MODULES_TO_TAG=()
AUTO_APPROVE=false
DRY_RUN=false
VERBOSE=false
QUIET=false
OUTPUT_FORMAT="plain"
TARGET_NAMESPACE=""
TARGET_MODULE=""
SKIP_PUSH=false
JSON_OUTPUT='{
"metadata": {},
"summary": {},
"modules": [],
"warnings": [],
"errors": []
}'
readonly EXIT_SUCCESS=0
readonly EXIT_ERROR=1
readonly EXIT_NO_ACTION_NEEDED=2
usage() {
echo "Usage: $0"
echo ""
echo "This script will:"
echo " 1. Scan all modules in the registry"
echo " 2. Check which modules need new release tags"
echo " 3. Extract version information from README files"
echo " 4. Generate a report for confirmation"
echo " 5. Create and push release tags after confirmation"
echo ""
echo "The script operates on the current checked-out commit."
echo "Make sure you have checked out the commit you want to tag before running."
exit 1
cat << EOF
Usage: $0 [OPTIONS]
OPTIONS:
-y, --auto-approve Skip confirmation prompt
-d, --dry-run Preview without creating tags
-v, --verbose Detailed output
-q, --quiet Minimal output
-f, --format=FORMAT Output format: 'plain' or 'json'
-n, --namespace=NAME Target specific namespace
-m, --module=NAME Target specific module
-s, --skip-push Create tags but don't push
-h, --help Show this help
EXAMPLES:
$0 # Interactive mode
$0 -y -q -f json # CI/CD automation
$0 -d -v # Test with verbose output
$0 -m code-server -d # Target specific module
$0 -n coder -m code-server -d # Target module in namespace
Exit codes: 0=success, 1=error, 2=no action needed
EOF
exit 0
}
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
case "$level" in
"ERROR")
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
add_json_error "script_error" "$message"
elif [[ "$QUIET" != "true" ]]; then
echo "$message" >&2
fi
;;
"WARN")
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
add_json_warning "" "$message" "warning"
elif [[ "$QUIET" != "true" ]]; then
echo "⚠️ $message" >&2
fi
;;
"INFO")
if [[ "$QUIET" != "true" && "$OUTPUT_FORMAT" != "json" ]]; then
echo "$message"
fi
;;
"SUCCESS")
if [[ "$QUIET" != "true" && "$OUTPUT_FORMAT" != "json" ]]; then
echo "$message"
fi
;;
"DEBUG")
if [[ "$VERBOSE" == "true" && "$OUTPUT_FORMAT" != "json" ]]; then
echo "🔍 [$timestamp] $message" >&2
fi
;;
esac
}
add_json_error() {
local type="$1"
local message="$2"
local details="${3:-}"
local exit_code="${4:-1}"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg type "$type" --arg msg "$message" --arg details "$details" --argjson code "$exit_code" '.errors += [{"type": $type, "message": $msg, "details": $details, "exit_code": $code}]')
}
add_json_warning() {
local module="$1"
local message="$2"
local type="$3"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg module "$module" --arg msg "$message" --arg type "$type" '.warnings += [{"module": $module, "message": $msg, "type": $type}]')
}
add_json_module() {
local namespace="$1"
local module_name="$2"
local path="$3"
local version="$4"
local tag_name="$5"
local status="$6"
local already_existed="$7"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg ns "$namespace" --arg name "$module_name" --arg path "$path" --arg version "$version" --arg tag "$tag_name" --arg status "$status" --argjson existed "$already_existed" '.modules += [{"namespace": $ns, "module_name": $name, "path": $path, "version": $version, "tag_name": $tag, "status": $status, "already_existed": $existed}]')
}
parse_arguments() {
while [[ $# -gt 0 ]]; do
case $1 in
-y | --auto-approve)
AUTO_APPROVE=true
shift
;;
-d | --dry-run)
DRY_RUN=true
shift
;;
-v | --verbose)
VERBOSE=true
shift
;;
-q | --quiet)
QUIET=true
shift
;;
-f | --format=* | --format)
if [[ "$1" == "-f" || "$1" == "--format" ]]; then
if [[ -z "$2" ]]; then
log "ERROR" "Option $1 requires a value"
exit $EXIT_ERROR
fi
OUTPUT_FORMAT="$2"
shift 2
else
OUTPUT_FORMAT="${1#*=}"
shift
fi
if [[ "$OUTPUT_FORMAT" != "plain" && "$OUTPUT_FORMAT" != "json" ]]; then
log "ERROR" "Invalid format '$OUTPUT_FORMAT'. Must be 'plain' or 'json'"
exit $EXIT_ERROR
fi
;;
-n | --namespace=* | --namespace)
if [[ "$1" == "-n" || "$1" == "--namespace" ]]; then
if [[ -z "$2" ]]; then
log "ERROR" "Option $1 requires a value"
exit $EXIT_ERROR
fi
TARGET_NAMESPACE="$2"
shift 2
else
TARGET_NAMESPACE="${1#*=}"
shift
fi
;;
-m | --module=* | --module)
if [[ "$1" == "-m" || "$1" == "--module" ]]; then
if [[ -z "$2" ]]; then
log "ERROR" "Option $1 requires a value"
exit $EXIT_ERROR
fi
TARGET_MODULE="$2"
shift 2
else
TARGET_MODULE="${1#*=}"
shift
fi
;;
-s | --skip-push)
SKIP_PUSH=true
shift
;;
-h | --help)
usage
;;
*)
log "ERROR" "Unknown option: $1"
echo "Use --help for usage information."
exit $EXIT_ERROR
;;
esac
done
if [[ "$VERBOSE" == "true" && "$QUIET" == "true" ]]; then
echo "❌ --verbose and --quiet cannot be used together" >&2
exit $EXIT_ERROR
fi
}
validate_version() {
local version="$1"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid version format: '$version'. Expected X.Y.Z format." >&2
log "DEBUG" "Invalid version format: '$version'. Expected X.Y.Z format."
return 1
fi
return 0
@@ -38,28 +222,45 @@ extract_version_from_readme() {
local namespace="$2"
local module_name="$3"
[ ! -f "$readme_path" ] && return 1
log "DEBUG" "Extracting version from $readme_path for $namespace/$module_name"
local version_line
version_line=$(grep -E "source\s*=\s*\"registry\.coder\.com/${namespace}/${module_name}" "$readme_path" | head -1 || echo "")
[ ! -f "$readme_path" ] && {
log "DEBUG" "README file not found: $readme_path"
return 1
}
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 version
version=$(extract_version_from_module_block "$readme_path" "$namespace" "$module_name")
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"
if [ -n "$version" ]; then
log "DEBUG" "Found version '$version' from module block for $namespace/$module_name"
echo "$version"
return 0
fi
log "DEBUG" "No version found in module block for $namespace/$module_name in $readme_path"
return 1
}
extract_version_from_module_block() {
local readme_path="$1"
local namespace="$2"
local module_name="$3"
local version
version=$(grep -A 10 "source[[:space:]]*=[[:space:]]*\"registry\.coder\.com/${namespace}/${module_name}/coder" "$readme_path" \
| sed '/^[[:space:]]*}/q' \
| grep -E "version[[:space:]]*=[[:space:]]*\"[^\"]+\"" \
| head -1 \
| sed 's/.*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/')
if [ -n "$version" ]; then
log "DEBUG" "Found version '$version' for $namespace/$module_name"
echo "$version"
return 0
fi
log "DEBUG" "No version found within module block for $namespace/$module_name"
return 1
}
@@ -70,29 +271,56 @@ check_module_needs_tagging() {
local tag_name="release/${namespace}/${module_name}/v${readme_version}"
log "DEBUG" "Checking if tag exists: $tag_name"
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
log "DEBUG" "Tag $tag_name already exists"
return 1
else
log "DEBUG" "Tag $tag_name needs to be created"
return 0
fi
}
should_process_module() {
local namespace="$1"
local module_name="$2"
if [[ -n "$TARGET_NAMESPACE" && "$TARGET_NAMESPACE" != "$namespace" ]]; then
log "DEBUG" "Skipping $namespace/$module_name: namespace filter"
return 1
fi
if [[ -n "$TARGET_MODULE" && "$TARGET_MODULE" != "$module_name" ]]; then
log "DEBUG" "Skipping $namespace/$module_name: module filter"
return 1
fi
return 0
}
detect_modules_needing_tags() {
MODULES_TO_TAG=()
echo "🔍 Scanning all modules for missing release tags..."
echo ""
log "INFO" "🔍 Scanning all modules for missing release tags..."
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
fi
local all_modules
all_modules=$(find registry -mindepth 3 -maxdepth 3 -type d -path "*/modules/*" | sort -u || echo "")
# Find all module directories, excluding hidden directories
# This works on both macOS and Linux
all_modules=$(find registry -mindepth 3 -maxdepth 3 -type d -path "*/modules/*" ! -name ".*" | sort -u || echo "")
[ -z "$all_modules" ] && {
echo "❌ No modules found to check"
return 1
log "ERROR" "No modules found to check"
return $EXIT_ERROR
}
local total_checked=0
local needs_tagging=0
local already_tagged=0
local skipped=0
while IFS= read -r module_path; do
if [ -z "$module_path" ]; then continue; fi
@@ -102,64 +330,133 @@ detect_modules_needing_tags() {
local module_name
module_name=$(echo "$module_path" | cut -d'/' -f4)
if ! should_process_module "$namespace" "$module_name"; then
skipped=$((skipped + 1))
continue
fi
total_checked=$((total_checked + 1))
local readme_path="$module_path/README.md"
local readme_version
if ! readme_version=$(extract_version_from_readme "$readme_path" "$namespace" "$module_name"); then
echo "⚠️ $namespace/$module_name: No version found in README, skipping"
log "WARN" "$namespace/$module_name: No version found in README, skipping"
add_json_warning "$namespace/$module_name" "No version found in README, skipping" "missing_version"
skipped=$((skipped + 1))
continue
fi
if ! validate_version "$readme_version"; then
echo "⚠️ $namespace/$module_name: Invalid version format '$readme_version', skipping"
log "WARN" "$namespace/$module_name: Invalid version format '$readme_version', skipping"
add_json_warning "$namespace/$module_name" "Invalid version format '$readme_version', skipping" "invalid_version"
skipped=$((skipped + 1))
continue
fi
local tag_name="release/$namespace/$module_name/v$readme_version"
if check_module_needs_tagging "$namespace" "$module_name" "$readme_version"; then
echo "📦 $namespace/$module_name: v$readme_version (needs tag)"
log "INFO" "📦 $namespace/$module_name: v$readme_version (needs tag)"
MODULES_TO_TAG+=("$module_path:$namespace:$module_name:$readme_version")
needs_tagging=$((needs_tagging + 1))
local status="needs_tagging"
if [[ "$DRY_RUN" == "true" ]]; then
status="would_be_tagged"
fi
add_json_module "$namespace" "$module_name" "$module_path" "$readme_version" "$tag_name" "$status" false
else
echo "$namespace/$module_name: v$readme_version (already tagged)"
log "SUCCESS" "$namespace/$module_name: v$readme_version (already tagged)"
already_tagged=$((already_tagged + 1))
add_json_module "$namespace" "$module_name" "$module_path" "$readme_version" "$tag_name" "already_tagged" true
fi
done <<< "$all_modules"
echo ""
echo "📊 Summary: $needs_tagging of $total_checked modules need tagging"
echo ""
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson total "$total_checked" --argjson needs "$needs_tagging" \
--argjson tagged "$already_tagged" --argjson skip "$skipped" \
'.summary.total_scanned = $total | .summary.needs_tagging = $needs | .summary.already_tagged = $tagged | .summary.skipped = $skip')
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
log "INFO" "📊 Summary: $needs_tagging of $total_checked modules need tagging"
echo ""
fi
[ $needs_tagging -eq 0 ] && {
echo "🎉 All modules are up to date! No tags needed."
return 0
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
log "SUCCESS" "🎉 All modules are up to date! No tags needed."
fi
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "no_action_needed"')
return $EXIT_NO_ACTION_NEEDED
}
echo "## Tags to be created:"
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
echo "- \`release/$namespace/$module_name/v$version\`"
done
echo ""
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo "## Tags to be created:"
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
echo "- \`release/$namespace/$module_name/v$version\`"
done
echo ""
fi
return 0
return $EXIT_SUCCESS
}
pre_flight_checks() {
log "DEBUG" "Running pre-flight checks..."
if ! git rev-parse --git-dir > /dev/null 2>&1; then
log "ERROR" "Not in a git repository"
return $EXIT_ERROR
fi
if ! git remote get-url origin > /dev/null 2>&1; then
log "ERROR" "No 'origin' remote found"
return $EXIT_ERROR
fi
if [[ "$SKIP_PUSH" != "true" && "$DRY_RUN" != "true" ]]; then
log "DEBUG" "Testing remote connectivity..."
if ! git ls-remote --exit-code origin > /dev/null 2>&1; then
log "ERROR" "Cannot connect to remote repository"
return $EXIT_ERROR
fi
fi
if ! git rev-parse HEAD > /dev/null 2>&1; then
log "ERROR" "Cannot determine current commit"
return $EXIT_ERROR
fi
log "DEBUG" "Pre-flight checks passed"
return $EXIT_SUCCESS
}
create_and_push_tags() {
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
echo "❌ No modules to tag found"
return 1
log "ERROR" "No modules to tag found"
return $EXIT_ERROR
}
local current_commit
current_commit=$(git rev-parse HEAD)
echo "🏷️ Creating release tags for commit: $current_commit"
echo ""
if [[ "$DRY_RUN" == "true" ]]; then
log "INFO" "🏷️ [DRY RUN] Would create release tags for commit: $current_commit"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "dry_run" | .summary.tags_created = 0 | .summary.tags_pushed = 0')
return $EXIT_SUCCESS
fi
log "INFO" "🏷️ Creating release tags for commit: $current_commit"
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
fi
local created_tags=0
local failed_tags=0
local created_tag_names=()
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
@@ -167,35 +464,56 @@ create_and_push_tags() {
local tag_name="release/$namespace/$module_name/v$version"
local tag_message="Release $namespace/$module_name v$version"
echo "Creating tag: $tag_name"
log "DEBUG" "Creating tag: $tag_name"
log "INFO" "Creating tag: $tag_name"
if git tag -a "$tag_name" -m "$tag_message" "$current_commit"; then
echo "Created: $tag_name"
if git tag -a "$tag_name" -m "$tag_message" "$current_commit" 2> /dev/null; then
log "SUCCESS" "Created: $tag_name"
created_tags=$((created_tags + 1))
created_tag_names+=("$tag_name")
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created"')
else
echo "Failed to create: $tag_name"
log "ERROR" "Failed to create: $tag_name"
add_json_error "tag_creation_failed" "Failed to create tag: $tag_name" "git tag -a $tag_name -m '$tag_message' $current_commit"
failed_tags=$((failed_tags + 1))
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_creation_failed"')
fi
done
echo ""
echo "📊 Tag creation summary:"
echo " Created: $created_tags"
echo " Failed: $failed_tags"
echo ""
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
log "INFO" "📊 Tag creation summary:"
log "INFO" " Created: $created_tags"
log "INFO" " Failed: $failed_tags"
echo ""
fi
[ $created_tags -eq 0 ] && {
echo "❌ No tags were created successfully"
return 1
log "ERROR" "No tags were created successfully"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed" | .summary.tags_created = 0 | .summary.tags_pushed = 0')
return $EXIT_ERROR
}
echo "🚀 Pushing tags to origin..."
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson created "$created_tags" '.summary.tags_created = $created')
if [[ "$SKIP_PUSH" == "true" ]]; then
log "INFO" "🚫 Skipping push (--skip-push specified)"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "tags_created_not_pushed" | .summary.tags_pushed = 0')
for tag_name in "${created_tag_names[@]}"; do
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created_not_pushed"')
done
return $EXIT_SUCCESS
fi
log "INFO" "🚀 Pushing tags to origin..."
local tags_to_push=()
for module_info in "${MODULES_TO_TAG[@]}"; do
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
local tag_name="release/$namespace/$module_name/v$version"
for tag_name in "${created_tag_names[@]}"; do
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
tags_to_push+=("$tag_name")
fi
@@ -205,71 +523,148 @@ create_and_push_tags() {
local failed_pushes=0
if [ ${#tags_to_push[@]} -eq 0 ]; then
echo "❌ No valid tags found to push"
log "ERROR" "No valid tags found to push"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed" | .summary.tags_pushed = 0')
else
if git push --atomic origin "${tags_to_push[@]}"; then
echo "Successfully pushed all ${#tags_to_push[@]} tags"
if git push --atomic origin "${tags_to_push[@]}" 2> /dev/null; then
log "SUCCESS" "Successfully pushed all ${#tags_to_push[@]} tags"
pushed_tags=${#tags_to_push[@]}
for tag_name in "${tags_to_push[@]}"; do
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tagged_and_pushed"')
done
else
echo "❌ Failed to push tags"
log "ERROR" "Failed to push tags"
add_json_error "push_failed" "Failed to push tags to remote" "git push --atomic origin ${tags_to_push[*]}"
failed_pushes=${#tags_to_push[@]}
for tag_name in "${tags_to_push[@]}"; do
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created_push_failed"')
done
fi
fi
echo ""
echo "📊 Push summary:"
echo " Pushed: $pushed_tags"
echo " Failed: $failed_pushes"
echo ""
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson pushed "$pushed_tags" '.summary.tags_pushed = $pushed')
if [ $pushed_tags -gt 0 ]; then
echo "🎉 Successfully created and pushed $pushed_tags release tags!"
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
echo ""
log "INFO" "📊 Push summary:"
log "INFO" " Pushed: $pushed_tags"
log "INFO" " Failed: $failed_pushes"
echo ""
echo "📝 Next steps:"
echo " - Tags will be automatically published to registry.coder.com"
echo " - Monitor the registry website for updates"
echo " - Check GitHub releases for any issues"
fi
return 0
if [ "$pushed_tags" -gt 0 ]; then
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
log "SUCCESS" "🎉 Successfully created and pushed $pushed_tags release tags!"
echo ""
log "INFO" "📝 Next steps:"
log "INFO" " - Tags will be automatically published to registry.coder.com"
log "INFO" " - Monitor the registry website for updates"
log "INFO" " - Check GitHub releases for any issues"
fi
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "success"')
return $EXIT_SUCCESS
else
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed"')
return $EXIT_ERROR
fi
}
finalize_json_output() {
local timestamp
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
local current_commit
current_commit=$(git rev-parse HEAD 2> /dev/null || echo "unknown")
local command_line="$0 $*"
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg ts "$timestamp" --arg commit "$current_commit" \
--arg cmd "$command_line" \
'.metadata.timestamp = $ts | .metadata.commit = $commit | .metadata.command = $cmd')
echo "$JSON_OUTPUT"
}
main() {
[ $# -gt 0 ] && usage
parse_arguments "$@"
echo "🚀 Coder Registry Tag Release Script"
echo "Operating on commit: $(git rev-parse HEAD)"
echo ""
if ! git rev-parse --git-dir > /dev/null 2>&1; then
echo "❌ Not in a git repository"
exit 1
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
if ! command -v jq > /dev/null 2>&1; then
echo '{"error": "jq is required for JSON output format but not found"}' >&2
exit $EXIT_ERROR
fi
fi
detect_modules_needing_tags || exit 1
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
log "INFO" "🚀 Coder Registry Tag Release Script"
log "INFO" "Operating on commit: $(git rev-parse HEAD 2> /dev/null || echo 'unknown')"
echo ""
fi
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
echo "✨ No modules need tagging. All done!"
exit 0
}
if ! pre_flight_checks; then
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "preflight_failed"')
finalize_json_output "$@"
fi
exit $EXIT_ERROR
fi
echo ""
echo "❓ Do you want to proceed with creating and pushing these release tags?"
echo " This will create git tags and push them to the remote repository."
echo ""
read -p "Continue? [y/N]: " -r response
local detect_exit_code
detect_modules_needing_tags
detect_exit_code=$?
case "$response" in
[yY] | [yY][eE][sS])
echo ""
create_and_push_tags
case $detect_exit_code in
"$EXIT_NO_ACTION_NEEDED")
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
finalize_json_output "$@"
else
log "SUCCESS" "✨ No modules need tagging. All done!"
fi
exit $EXIT_SUCCESS
;;
*)
echo ""
echo "🚫 Operation cancelled by user"
exit 0
"$EXIT_ERROR")
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "scan_failed"')
finalize_json_output "$@"
fi
exit $EXIT_ERROR
;;
esac
if [[ "$AUTO_APPROVE" != "true" && "$OUTPUT_FORMAT" != "json" && "$DRY_RUN" != "true" ]]; then
echo ""
log "INFO" "❓ Do you want to proceed with creating and pushing these release tags?"
log "INFO" " This will create git tags and push them to the remote repository."
echo ""
read -p "Continue? [y/N]: " -r response
case "$response" in
[yY] | [yY][eE][sS])
echo ""
;;
*)
echo ""
log "INFO" "🚫 Operation cancelled by user"
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "cancelled_by_user"')
finalize_json_output "$@"
fi
exit $EXIT_SUCCESS
;;
esac
fi
local create_exit_code
create_and_push_tags
create_exit_code=$?
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
finalize_json_output "$@"
fi
exit $create_exit_code
}
main "$@"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
# Find all directories that contain any .tftest.hcl files and run terraform test in each
run_dir() {
local dir="$1"
echo "==> Running terraform test in $dir"
(cd "$dir" && terraform init -upgrade -input=false -no-color > /dev/null && terraform test -no-color -verbose)
}
mapfile -t test_dirs < <(find . -type f -name "*.tftest.hcl" -print0 | xargs -0 -I{} dirname {} | sort -u)
if [[ ${#test_dirs[@]} -eq 0 ]]; then
echo "No .tftest.hcl tests found."
exit 0
fi
status=0
for d in "${test_dirs[@]}"; do
if ! run_dir "$d"; then
status=1
fi
done
exit $status