diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5dc2e84b..87ed60be 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,9 +1,9 @@ +Closes # + ## Description ---- - ## Type of Change - [ ] New module @@ -12,8 +12,6 @@ - [ ] Documentation - [ ] Other ---- - ## Module Information @@ -22,18 +20,12 @@ **New version:** `v1.0.0` **Breaking change:** [ ] Yes [ ] No ---- - ## Testing & Validation - [ ] Tests pass (`bun test`) - [ ] Code formatted (`bun run fmt`) - [ ] Changes tested locally ---- - ## Related Issues - -Closes # diff --git a/.github/scripts/version-bump.sh b/.github/scripts/version-bump.sh index b074583d..095d1279 100755 --- a/.github/scripts/version-bump.sh +++ b/.github/scripts/version-bump.sh @@ -190,6 +190,15 @@ main() { done <<< "$modules" + # Always run formatter to ensure consistent formatting + echo "🔧 Running formatter to ensure consistent formatting..." + if command -v bun >/dev/null 2>&1; then + bun fmt >/dev/null 2>&1 || echo "⚠️ Warning: bun fmt failed, but continuing..." + else + echo "⚠️ Warning: bun not found, skipping formatting" + fi + echo "" + echo "📋 Summary:" echo "Bump Type: $bump_type" echo "" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a881a05e..35f9ce49 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -48,7 +48,7 @@ jobs: - name: Validate formatting run: bun fmt:ci - name: Check for typos - uses: crate-ci/typos@v1.33.1 + uses: crate-ci/typos@v1.34.0 with: config: .github/typos.toml validate-readme-files: diff --git a/.github/workflows/deploy-registry.yaml b/.github/workflows/deploy-registry.yaml index c9e7bcf5..6f3e6e1c 100644 --- a/.github/workflows/deploy-registry.yaml +++ b/.github/workflows/deploy-registry.yaml @@ -6,6 +6,12 @@ on: # Matches release/// # (e.g., "release/whizus/exoscale-zone/v1.0.13") - "release/*/*/v*.*.*" + branches: # Templates get released when merged to main + - main + paths: + - ".github/workflows/deploy-registry.yaml" + - "registry/**/templates/**" + - ".icons/**" jobs: deploy: @@ -27,7 +33,6 @@ jobs: - name: Set up Google Cloud SDK uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a - name: Deploy to dev.registry.coder.com - run: gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch dev + run: gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch main - name: Deploy to registry.coder.com - run: | - gcloud builds triggers run 106610ff-41fb-4bd0-90a2-7643583fb9c0 --branch main + run: gcloud builds triggers run 106610ff-41fb-4bd0-90a2-7643583fb9c0 --tag production diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml index 2d0e88dd..b492ffc5 100644 --- a/.github/workflows/version-bump.yaml +++ b/.github/workflows/version-bump.yaml @@ -25,6 +25,17 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Set up Terraform + uses: coder/coder/.github/actions/setup-tf@main + + - name: Install dependencies + run: bun install + - name: Extract bump type from label id: bump-type run: | diff --git a/.icons/1f4e6.png b/.icons/1f4e6.png deleted file mode 100644 index ed5cbdcf..00000000 Binary files a/.icons/1f4e6.png and /dev/null differ diff --git a/.icons/airflow.svg b/.icons/airflow.svg index 46300fec..06b18bee 100644 --- a/.icons/airflow.svg +++ b/.icons/airflow.svg @@ -1,19 +1,18 @@ - - - - - - - - - - - - + + + + + + + + + + + - - + + diff --git a/.icons/amazon-q.svg b/.icons/amazon-q.svg index a797165b..4a9b3262 100644 --- a/.icons/amazon-q.svg +++ b/.icons/amazon-q.svg @@ -1,268 +1,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + diff --git a/.icons/box-emoji.svg b/.icons/box-emoji.svg new file mode 100644 index 00000000..a2595599 --- /dev/null +++ b/.icons/box-emoji.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.icons/coder-white.svg b/.icons/coder-white.svg deleted file mode 100644 index 3bb941d9..00000000 --- a/.icons/coder-white.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/.icons/coder.svg b/.icons/coder.svg new file mode 100644 index 00000000..60d7eff6 --- /dev/null +++ b/.icons/coder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.icons/digital-ocean.svg b/.icons/digital-ocean.svg new file mode 100644 index 00000000..6f10b237 --- /dev/null +++ b/.icons/digital-ocean.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/.icons/do.png b/.icons/do.png deleted file mode 100644 index 827bcaa6..00000000 Binary files a/.icons/do.png and /dev/null differ diff --git a/.icons/docker.png b/.icons/docker.png deleted file mode 100644 index f07559cb..00000000 Binary files a/.icons/docker.png and /dev/null differ diff --git a/.icons/docker.svg b/.icons/docker.svg new file mode 100644 index 00000000..78e549ef --- /dev/null +++ b/.icons/docker.svg @@ -0,0 +1,3 @@ + + + diff --git a/.icons/fleet.svg b/.icons/fleet.svg new file mode 100644 index 00000000..ba910eb9 --- /dev/null +++ b/.icons/fleet.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.icons/jetbrains.svg b/.icons/jetbrains.svg new file mode 100644 index 00000000..b281f962 --- /dev/null +++ b/.icons/jetbrains.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/jupyter.svg b/.icons/jupyter.svg index bc94e3db..38350dfe 100644 --- a/.icons/jupyter.svg +++ b/.icons/jupyter.svg @@ -1,90 +1,14 @@ - -Group.svg -Created using Figma 0.90 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + diff --git a/.icons/k8s.png b/.icons/k8s.png deleted file mode 100644 index 7a9e3c2b..00000000 Binary files a/.icons/k8s.png and /dev/null differ diff --git a/.icons/kubernetes.svg b/.icons/kubernetes.svg new file mode 100644 index 00000000..42bb9229 --- /dev/null +++ b/.icons/kubernetes.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.icons/rdp.svg b/.icons/rdp.svg new file mode 100644 index 00000000..a6722326 --- /dev/null +++ b/.icons/rdp.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.icons/tasks.svg b/.icons/tasks.svg new file mode 100644 index 00000000..67088c42 --- /dev/null +++ b/.icons/tasks.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/.icons/zed.svg b/.icons/zed.svg new file mode 100644 index 00000000..06b5c183 --- /dev/null +++ b/.icons/zed.svg @@ -0,0 +1,3 @@ + + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d63b42d..3b2b4d1e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,12 +4,14 @@ Welcome! This guide covers how to contribute to the Coder Registry, whether you' ## What is the Coder Registry? -The Coder Registry is a collection of Terraform modules that extend Coder workspaces with development tools like VS Code, Cursor, JetBrains IDEs, and more. +The Coder Registry is a collection of Terraform modules and templates for Coder workspaces. Modules provide IDEs, authentication integrations, development tools, and other workspace functionality. Templates provide complete workspace configurations for different platforms and use cases that appear as community templates on the registry website. ## Types of Contributions - **[New Modules](#creating-a-new-module)** - Add support for a new tool or functionality +- **[New Templates](#creating-a-new-template)** - Create complete workspace configurations - **[Existing Modules](#contributing-to-existing-modules)** - Fix bugs, add features, or improve documentation +- **[Existing Templates](#contributing-to-existing-templates)** - Improve workspace templates - **[Bug Reports](#reporting-issues)** - Report problems or request features ## Setup @@ -36,7 +38,15 @@ bun install ### Understanding Namespaces -All modules are organized under `/registry/[namespace]/modules/`. Each contributor gets their own namespace (e.g., `/registry/your-username/modules/`). If a namespace is taken, choose a different unique namespace, but you can still use any display name on the Registry website. +All modules and templates are organized under `/registry/[namespace]/`. Each contributor gets their own namespace with both modules and templates directories: + +``` +registry/[namespace]/ +├── modules/ # Individual components and tools +└── templates/ # Complete workspace configurations +``` + +For example: `/registry/your-username/modules/` and `/registry/your-username/templates/`. If a namespace is taken, choose a different unique namespace, but you can still use any display name on the Registry website. ### Images and Icons @@ -136,15 +146,171 @@ git push origin your-branch --- -## Contributing to Existing Modules +## Creating a New Template -### 1. Find the Module +Templates are complete Coder workspace configurations that users can deploy directly. Unlike modules (which are components), templates provide full infrastructure definitions for specific platforms or use cases. -```bash -find registry -name "*[module-name]*" -type d +### Template Structure + +Templates follow the same namespace structure as modules but are located in the `templates` directory: + +``` +registry/[your-username]/templates/[template-name]/ +├── main.tf # Complete Terraform configuration +├── README.md # Documentation with frontmatter +├── [additional files] # Scripts, configs, etc. ``` -### 2. Make Your Changes +### 1. Create Your Template Directory + +```bash +mkdir -p registry/[your-username]/templates/[template-name] +cd registry/[your-username]/templates/[template-name] +``` + +### 2. Create Template Files + +#### main.tf + +Your `main.tf` should be a complete Coder template configuration including: + +- Required providers (coder, and your infrastructure provider) +- Coder agent configuration +- Infrastructure resources (containers, VMs, etc.) +- Registry modules for IDEs, tools, and integrations + +Example structure: + +```terraform +terraform { + required_providers { + coder = { + source = "coder/coder" + } + # Add your infrastructure provider (docker, aws, etc.) + } +} + +# Coder data sources +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +# Coder agent +resource "coder_agent" "main" { + arch = "amd64" + os = "linux" + startup_script = <<-EOT + # Startup commands here + EOT +} + +# Registry modules for IDEs, tools, and integrations +module "code-server" { + source = "registry.coder.com/coder/code-server/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id +} + +# Your infrastructure resources +# (Docker containers, AWS instances, etc.) +``` + +#### README.md + +Create documentation with proper frontmatter: + +```markdown +--- +display_name: "Template Name" +description: "Brief description of what this template provides" +icon: "../../../../.icons/platform.svg" +verified: false +tags: ["platform", "use-case", "tools"] +--- + +# Template Name + +Describe what the template provides and how to use it. + +Include any setup requirements, resource information, or usage notes that users need to know. +``` + +### 3. Test Your Template + +Templates should be tested to ensure they work correctly. Test with Coder: + +```bash +cd registry/[your-username]/templates/[template-name] +coder templates push [template-name] -d . +``` + +### 4. Template Best Practices + +- **Use registry modules**: Leverage existing modules for IDEs, tools, and integrations +- **Provide sensible defaults**: Make the template work out-of-the-box +- **Include metadata**: Add useful workspace metadata (CPU, memory, disk usage) +- **Document prerequisites**: Clearly explain infrastructure requirements +- **Use variables**: Allow customization of common settings +- **Follow naming conventions**: Use descriptive, consistent naming + +### 5. Template Guidelines + +- Templates appear as "Community Templates" on the registry website +- Include proper error handling and validation +- Test with Coder before submitting +- Document any required permissions or setup steps +- Use semantic versioning in your README frontmatter + +--- + +## Contributing to Existing Templates + +### 1. Types of Template Improvements + +**Bug fixes:** + +- Fix infrastructure provisioning issues +- Resolve agent connectivity problems +- Correct resource naming or tagging + +**Feature additions:** + +- Add new registry modules for additional functionality +- Include additional infrastructure options +- Improve startup scripts or automation + +**Platform updates:** + +- Update base images or AMIs +- Adapt to new platform features +- Improve security configurations + +**Documentation:** + +- Clarify prerequisites and setup steps +- Add troubleshooting guides +- Improve usage examples + +### 2. Testing Template Changes + +Testing template modifications thoroughly is necessary. Test with Coder: + +```bash +coder templates push test-[template-name] -d . +``` + +### 3. Maintain Compatibility + +- Don't remove existing variables without clear migration path +- Preserve backward compatibility when possible +- Test that existing workspaces still function +- Document any breaking changes clearly + +--- + +## Contributing to Existing Modules + +### 1. Make Your Changes **For bug fixes:** @@ -166,7 +332,7 @@ find registry -name "*[module-name]*" -type d - Add missing variable documentation - Improve usage examples -### 3. Test Your Changes +### 2. Test Your Changes ```bash # Test a specific module @@ -176,7 +342,7 @@ bun test -t 'module-name' bun test ``` -### 4. Maintain Backward Compatibility +### 3. Maintain Backward Compatibility - New variables should have default values - Don't break existing functionality @@ -208,6 +374,7 @@ bun test We have different PR templates for different types of contributions. GitHub will show you options to choose from, or you can manually select: - **New Module**: Use `?template=new_module.md` +- **New Template**: Use `?template=new_template.md` - **Bug Fix**: Use `?template=bug_fix.md` - **Feature**: Use `?template=feature.md` - **Documentation**: Use `?template=documentation.md` @@ -224,6 +391,13 @@ Example: `https://github.com/coder/registry/compare/main...your-branch?template= - `main.test.ts` - Working tests - `README.md` - Documentation with frontmatter +### Every Template Must Have + +- `main.tf` - Complete Terraform configuration +- `README.md` - Documentation with frontmatter + +Templates don't require test files like modules do, but should be manually tested before submission. + ### README Frontmatter Module README frontmatter must include: @@ -304,7 +478,7 @@ When reporting bugs, include: ## Getting Help -- **Examples**: Check `/registry/coder/modules/` for well-structured modules +- **Examples**: Check `/registry/coder/modules/` for well-structured modules and `/registry/coder/templates/` for complete templates - **Issues**: Open an issue for technical problems - **Community**: Reach out to the Coder community for questions diff --git a/MAINTAINER.md b/MAINTAINER.md index 69d1a657..2011b5aa 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -14,9 +14,7 @@ brew install go sudo apt install golang-go ``` -## Daily Tasks - -### Review PRs +## Reviewing a PR Check that PRs have: @@ -26,7 +24,7 @@ Check that PRs have: - [ ] Formatted code (`bun run fmt`) - [ ] Avatar image for new namespaces (`avatar.png` or `avatar.svg` in `.images/`) -#### Version Guidelines +### Version Guidelines When reviewing PRs, ensure the version change follows semantic versioning: @@ -42,7 +40,7 @@ PRs should clearly indicate the version change (e.g., `v1.2.3 → v1.2.4`). go build ./cmd/readmevalidation && ./readmevalidation ``` -## Releases +## Making a Release ### Create Release Tags @@ -99,5 +97,3 @@ status: "community" # or "partner", "official" - **Tests fail**: Ensure Docker with `--network=host`, check Terraform syntax - **Wrong file structure**: Use `./scripts/new_module.sh` for new modules - **Missing namespace avatar**: Must be `avatar.png` or `avatar.svg` in `.images/` directory - -That's it. Keep it simple. diff --git a/bun.lockb b/bun.lockb index 7f379c9d..0f4f5e6e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/go.mod b/go.mod index 7444cfa8..d3caf912 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/rivo/uniseg v0.4.4 // indirect go.opentelemetry.io/otel v1.16.0 // indirect go.opentelemetry.io/otel/trace v1.16.0 // indirect - golang.org/x/crypto v0.11.0 // indirect - golang.org/x/sys v0.10.0 // indirect - golang.org/x/term v0.10.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/term v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index 6ef0b6b6..382ca6d7 100644 --- a/go.sum +++ b/go.sum @@ -51,17 +51,17 @@ go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiM go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4= go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= -golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= -golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= -golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e h1:xIXmWJ303kJCuogpj0bHq+dcjcZHU+XFyc1I0Yl9cRg= diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..750b1701 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,308 @@ +{ + "name": "registry", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "registry", + "devDependencies": { + "@types/bun": "^1.2.9", + "bun-types": "^1.1.23", + "gray-matter": "^4.0.3", + "marked": "^12.0.2", + "prettier": "^3.3.3", + "prettier-plugin-sh": "^0.13.1", + "prettier-plugin-terraform-formatter": "^1.2.1" + }, + "peerDependencies": { + "typescript": "^5.5.4" + } + }, + "node_modules/@types/bun": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.2.18.tgz", + "integrity": "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.2.18" + } + }, + "node_modules/@types/node": { + "version": "24.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz", + "integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/bun-types": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.2.18.tgz", + "integrity": "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "peerDependencies": { + "@types/react": "^19" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mvdan-sh": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/mvdan-sh/-/mvdan-sh-0.10.1.tgz", + "integrity": "sha512-kMbrH0EObaKmK3nVRKUIIya1dpASHIEusM13S4V1ViHFuxuNxCo+arxoa6j/dbV22YBGjl7UKJm9QQKJ2Crzhg==", + "deprecated": "See https://github.com/mvdan/sh/issues/1145", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-sh": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.13.1.tgz", + "integrity": "sha512-ytMcl1qK4s4BOFGvsc9b0+k9dYECal7U29bL/ke08FEUsF/JLN0j6Peo0wUkFDG4y2UHLMhvpyd6Sd3zDXe/eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mvdan-sh": "^0.10.1", + "sh-syntax": "^0.4.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, + "node_modules/prettier-plugin-terraform-formatter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-terraform-formatter/-/prettier-plugin-terraform-formatter-1.2.1.tgz", + "integrity": "sha512-rdzV61Bs/Ecnn7uAS/vL5usTX8xUWM+nQejNLZxt3I1kJH5WSeLEmq7LYu1wCoEQF+y7Uv1xGvPRfl3lIe6+tA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": ">= 1.16.0" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/sh-syntax": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.4.2.tgz", + "integrity": "sha512-/l2UZ5fhGZLVZa16XQM9/Vq/hezGGbdHeVEA01uWjOL1+7Ek/gt6FquW0iKKws4a9AYPYvlz6RyVvjh3JxOteg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index 733230db..7ca9f2ec 100644 --- a/package.json +++ b/package.json @@ -8,16 +8,17 @@ "update-version": "./update-version.sh" }, "devDependencies": { - "@types/bun": "^1.2.9", - "bun-types": "^1.1.23", + "@types/bun": "^1.2.18", + "bun-types": "^1.2.18", + "dedent": "^1.6.0", "gray-matter": "^4.0.3", - "marked": "^12.0.2", - "prettier": "^3.3.3", - "prettier-plugin-sh": "^0.13.1", + "marked": "^16.0.0", + "prettier": "^3.6.2", + "prettier-plugin-sh": "^0.18.0", "prettier-plugin-terraform-formatter": "^1.2.1" }, "peerDependencies": { - "typescript": "^5.5.4" + "typescript": "^5.8.3" }, "prettier": { "plugins": [ @@ -25,4 +26,4 @@ "prettier-plugin-terraform-formatter" ] } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..997f8de2 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,220 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + typescript: + specifier: ^5.8.3 + version: 5.8.3 + devDependencies: + '@types/bun': + specifier: ^1.2.18 + version: 1.2.18(@types/react@19.1.8) + bun-types: + specifier: ^1.2.18 + version: 1.2.18(@types/react@19.1.8) + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + marked: + specifier: ^16.0.0 + version: 16.0.0 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + prettier-plugin-sh: + specifier: ^0.18.0 + version: 0.18.0(prettier@3.6.2) + prettier-plugin-terraform-formatter: + specifier: ^1.2.1 + version: 1.2.1(prettier@3.6.2) + +packages: + + '@reteps/dockerfmt@0.3.6': + resolution: {integrity: sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==} + engines: {node: ^v12.20.0 || ^14.13.0 || >=16.0.0} + + '@types/bun@1.2.18': + resolution: {integrity: sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ==} + + '@types/node@24.0.10': + resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==} + + '@types/react@19.1.8': + resolution: {integrity: sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + bun-types@1.2.18: + resolution: {integrity: sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw==} + peerDependencies: + '@types/react': ^19 + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + marked@16.0.0: + resolution: {integrity: sha512-MUKMXDjsD/eptB7GPzxo4xcnLS6oo7/RHimUMHEDRhUooPwmN9BEpMl7AEOJv3bmso169wHI2wUF9VQgL7zfmA==} + engines: {node: '>= 20'} + hasBin: true + + prettier-plugin-sh@0.18.0: + resolution: {integrity: sha512-cW1XL27FOJQ/qGHOW6IHwdCiNWQsAgK+feA8V6+xUTaH0cD3Mh+tFAtBvEEWvuY6hTDzRV943Fzeii+qMOh7nQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + prettier: ^3.6.0 + + prettier-plugin-terraform-formatter@1.2.1: + resolution: {integrity: sha512-rdzV61Bs/Ecnn7uAS/vL5usTX8xUWM+nQejNLZxt3I1kJH5WSeLEmq7LYu1wCoEQF+y7Uv1xGvPRfl3lIe6+tA==} + peerDependencies: + prettier: '>= 1.16.0' + peerDependenciesMeta: + prettier: + optional: true + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + sh-syntax@0.5.8: + resolution: {integrity: sha512-JfVoxf4FxQI5qpsPbkHhZo+n6N9YMJobyl4oGEUBb/31oQYlgTjkXQD8PBiafS2UbWoxrTO0Z5PJUBXEPAG1Zw==} + engines: {node: '>=16.0.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.8.0: + resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + +snapshots: + + '@reteps/dockerfmt@0.3.6': {} + + '@types/bun@1.2.18(@types/react@19.1.8)': + dependencies: + bun-types: 1.2.18(@types/react@19.1.8) + transitivePeerDependencies: + - '@types/react' + + '@types/node@24.0.10': + dependencies: + undici-types: 7.8.0 + + '@types/react@19.1.8': + dependencies: + csstype: 3.1.3 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + bun-types@1.2.18(@types/react@19.1.8): + dependencies: + '@types/node': 24.0.10 + '@types/react': 19.1.8 + + csstype@3.1.3: {} + + esprima@4.0.1: {} + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.1 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + is-extendable@0.1.1: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + kind-of@6.0.3: {} + + marked@16.0.0: {} + + prettier-plugin-sh@0.18.0(prettier@3.6.2): + dependencies: + '@reteps/dockerfmt': 0.3.6 + prettier: 3.6.2 + sh-syntax: 0.5.8 + + prettier-plugin-terraform-formatter@1.2.1(prettier@3.6.2): + optionalDependencies: + prettier: 3.6.2 + + prettier@3.6.2: {} + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + sh-syntax@0.5.8: + dependencies: + tslib: 2.8.1 + + sprintf-js@1.0.3: {} + + strip-bom-string@1.0.0: {} + + tslib@2.8.1: {} + + typescript@5.8.3: {} + + undici-types@7.8.0: {} diff --git a/registry/coder-labs/.images/avatar.svg b/registry/coder-labs/.images/avatar.svg new file mode 100644 index 00000000..1a5d5aca --- /dev/null +++ b/registry/coder-labs/.images/avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/coder-labs/.images/tasks-screenshot.png b/registry/coder-labs/.images/tasks-screenshot.png new file mode 100644 index 00000000..8d9f605a Binary files /dev/null and b/registry/coder-labs/.images/tasks-screenshot.png differ diff --git a/registry/coder-labs/README.md b/registry/coder-labs/README.md new file mode 100644 index 00000000..c9a7d8ef --- /dev/null +++ b/registry/coder-labs/README.md @@ -0,0 +1,15 @@ +--- +display_name: Coder Labs +bio: Collection of example templates and modules for Coder. Designed for reference, not production use. +github: coder +avatar: ./.images/avatar.svg +linkedin: https://www.linkedin.com/company/coderhq +website: https://discord.gg/coder +status: community +--- + +å + +# Coder Labs + +Collection of example templates and modules for Coder. Designed for reference, not production use. diff --git a/registry/coder-labs/templates/tasks-docker/README.md b/registry/coder-labs/templates/tasks-docker/README.md new file mode 100644 index 00000000..10e39f9d --- /dev/null +++ b/registry/coder-labs/templates/tasks-docker/README.md @@ -0,0 +1,87 @@ +--- +display_name: Tasks on Docker +description: Run Coder Tasks on Docker with an example application +icon: ../../../../.icons/tasks.svg +maintainer_github: coder-labs +verified: false +tags: [docker, container, ai, tasks] +--- + +# Run Coder Tasks on Docker + +This is an example template for running [Coder Tasks](https://coder.com/docs/ai-coder/tasks), Claude Code, along with a [real world application](https://realworld-docs.netlify.app/). + +![Tasks](../../.images/tasks-screenshot.png) + +This is a fantastic starting point for working with AI agents with Coder Tasks. Try prompts such as: + +- "Make the background color blue" +- "Add a dark mode" +- "Rewrite the entire backend in Go" + +## Included in this template + +This template is designed to be an example and a reference for building other templates with Coder Tasks. You can always run Coder Tasks on different infrastructure (e.g. as on Kubernetes, VMs) and with your own GitHub repositories, MCP servers, images, etc. + +Additionally, this template uses our [Claude Code](https://registry.coder.com/modules/coder/claude-code) module, but [other agents](https://registry.coder.com/modules?search=tag%3Aagent) or even [custom agents](https://coder.com/docs/ai-coder/custom-agents) can be used in its place. + +This template uses a [Workspace Preset](https://coder.com/docs/admin/templates/extending-templates/parameters#workspace-presets) that pre-defines: + +- Universal Container Image (e.g. contains Node.js, Java, Python, Ruby, etc) +- MCP servers (desktop-commander for long-running logs, playwright for previewing changes) +- System prompt and [repository](https://github.com/coder-contrib/realworld-django-rest-framework-angular) for the AI agent +- Startup script to initialize the repository and start the development server + +## Add this template to your Coder deployment + +You can also add this template to your Coder deployment and begin tinkering right away! + +### Prerequisites + +- Coder installed (see [our docs](https://coder.com/docs/install)), ideally a Linux VM with Docker +- Anthropic API Key (or access to Anthropic models via Bedrock or Vertex, see [Claude Code docs](https://docs.anthropic.com/en/docs/claude-code/third-party-integrations)) +- Access to a Docker socket + - If on the local VM, ensure the `coder` user is added to the Docker group (docs) + + ```sh + # Add coder user to Docker group + sudo adduser coder docker + + # Restart Coder server + sudo systemctl restart coder + + # Test Docker + sudo -u coder docker ps + ``` + + - If on a remote VM, see the [Docker Terraform provider documentation](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs#remote-hosts) to configure a remote host + +To import this template into Coder, first create a template from "Scratch" in the template editor. + +Visit this URL for your Coder deployment: + +```sh +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. + +Alternatively, you can use the Coder CLI to [push the template](https://coder.com/docs/reference/cli/templates_push) + +```sh +# Download the CLI +curl -L https://coder.com/install.sh | sh + +# Log in to your deployment +coder login https://coder.example.com + +# Clone the registry +git clone https://github.com/coder/registry +cd registry + +# Navigate to this template +cd registry/coder-labs/templates/tasks-docker + +# Push the template +coder templates push +``` diff --git a/registry/coder-labs/templates/tasks-docker/main.tf b/registry/coder-labs/templates/tasks-docker/main.tf new file mode 100644 index 00000000..bb1245d8 --- /dev/null +++ b/registry/coder-labs/templates/tasks-docker/main.tf @@ -0,0 +1,426 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + docker = { + source = "kreuzwerker/docker" + } + } +} + +# This template requires a valid Docker socket +# However, you can reference our Kubernetes/VM +# example templates and adapt the Claude Code module +# +# see: https://registry.coder.com/templates +provider "docker" {} + +# The Claude Code module does the automatic task reporting +# Other agent modules: https://registry.coder.com/modules?search=agent +# Or use a custom agent: +module "claude-code" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/claude-code/coder" + version = "2.0.0" + agent_id = coder_agent.main.id + folder = "/home/coder/projects" + install_claude_code = true + claude_code_version = "latest" + order = 999 + + experiment_post_install_script = data.coder_parameter.setup_script.value + + # This enables Coder Tasks + experiment_report_tasks = true +} + +# You can also use a model provider, like AWS Bedrock or Vertex by replacing +# this with the special env vars from the Claude Code docs. +# see: https://docs.anthropic.com/en/docs/claude-code/third-party-integrations +variable "anthropic_api_key" { + type = string + description = "Generate one at: https://console.anthropic.com/settings/keys" + sensitive = true +} +resource "coder_env" "anthropic_api_key" { + agent_id = coder_agent.main.id + name = "CODER_MCP_CLAUDE_API_KEY" + value = var.anthropic_api_key +} + +# We are using presets to set the prompts, image, and set up instructions +# See https://coder.com/docs/admin/templates/extending-templates/parameters#workspace-presets +data "coder_workspace_preset" "default" { + name = "Real World App: Angular + Django" + default = true + parameters = { + "system_prompt" = <<-EOT + -- Framing -- + You are a helpful assistant that can help with code. You are running inside a Coder Workspace and provide status updates to the user via Coder MCP. Stay on track, feel free to debug, but when the original plan fails, do not choose a different route/architecture without checking the user first. + + -- Tool Selection -- + - playwright: previewing your changes after you made them + to confirm it worked as expected + - desktop-commander - use only for commands that keep running + (servers, dev watchers, GUI apps). + - Built-in tools - use for everything else: + (file operations, git commands, builds & installs, one-off shell commands) + + Remember this decision rule: + - Stays running? → desktop-commander + - Finishes immediately? → built-in tools + + -- Context -- + There is an existing app and tmux dev server running on port 8000. Be sure to read it's CLAUDE.md (./realworld-django-rest-framework-angular/CLAUDE.md) to learn more about it. + + Since this app is for demo purposes and the user is previewing the homepage and subsequent pages, aim to make the first visual change/prototype very quickly so the user can preview it, then focus on backend or logic which can be a more involved, long-running architecture plan. + + EOT + + "setup_script" = <<-EOT + # Set up projects dir + mkdir -p /home/coder/projects + cd $HOME/projects + + # Packages: Install additional packages + sudo apt-get update && sudo apt-get install -y tmux + if ! command -v google-chrome >/dev/null 2>&1; then + yes | npx playwright install chrome + fi + + # MCP: Install and configure MCP Servers + npm install -g @wonderwhy-er/desktop-commander + claude mcp add playwright npx -- @playwright/mcp@latest --headless --isolated --no-sandbox + claude mcp add desktop-commander desktop-commander + + # Repo: Clone and pull changes from the git repository + if [ ! -d "realworld-django-rest-framework-angular" ]; then + git clone https://github.com/coder-contrib/realworld-django-rest-framework-angular.git + else + cd realworld-django-rest-framework-angular + git fetch + # Check for uncommitted changes + if git diff-index --quiet HEAD -- && \ + [ -z "$(git status --porcelain --untracked-files=no)" ] && \ + [ -z "$(git log --branches --not --remotes)" ]; then + echo "Repo is clean. Pulling latest changes..." + git pull + else + echo "Repo has uncommitted or unpushed changes. Skipping pull." + fi + + cd .. + fi + + # Initialize: Start the development server + cd realworld-django-rest-framework-angular && ./start-dev.sh + EOT + "preview_port" = "4200" + "container_image" = "codercom/example-universal:ubuntu" + "jetbrains_ide" = "PY" + } + + # Pre-builds is a Coder Premium + # feature to speed up workspace creation + # + # see https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces + # prebuilds { + # instances = 1 + # expiration_policy { + # ttl = 86400 # Time (in seconds) after which unclaimed prebuilds are expired (1 day) + # } + # } +} + +# Advanced parameters (these are all set via preset) +data "coder_parameter" "system_prompt" { + name = "system_prompt" + display_name = "System Prompt" + type = "string" + form_type = "textarea" + description = "System prompt for the agent with generalized instructions" + mutable = false +} +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Write a prompt for Claude Code" + mutable = true +} +data "coder_parameter" "setup_script" { + name = "setup_script" + display_name = "Setup Script" + type = "string" + form_type = "textarea" + description = "Script to run before running the agent" + mutable = false +} +data "coder_parameter" "container_image" { + name = "container_image" + display_name = "Container Image" + type = "string" + default = "codercom/example-universal:ubuntu" + mutable = false +} +data "coder_parameter" "preview_port" { + name = "preview_port" + display_name = "Preview Port" + description = "The port the web app is running to preview in Tasks" + type = "number" + default = "3000" + mutable = false +} + +# Other variables for Claude Code +resource "coder_env" "claude_task_prompt" { + agent_id = coder_agent.main.id + name = "CODER_MCP_CLAUDE_TASK_PROMPT" + value = data.coder_parameter.ai_prompt.value +} +resource "coder_env" "app_status_slug" { + agent_id = coder_agent.main.id + name = "CODER_MCP_APP_STATUS_SLUG" + value = "claude-code" +} +resource "coder_env" "claude_system_prompt" { + agent_id = coder_agent.main.id + name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT" + value = data.coder_parameter.system_prompt.value +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script = <<-EOT + set -e + # Prepare user home with default files on first start. + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ + touch ~/.init_done + fi + EOT + + # These environment variables allow you to make Git commits right away after creating a + # workspace. Note that they take precedence over configuration defined in ~/.gitconfig! + # You can remove this block if you'd prefer to configure Git manually or using + # dotfiles. (see docs/dotfiles.md) + env = { + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}" + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}" + } + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + # For basic resources, you can use the `coder stat` command. + # If you need more control, you can write your own script. + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "4_cpu_usage_host" + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Memory Usage (Host)" + key = "5_mem_usage_host" + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Load Average (Host)" + key = "6_load_host" + # get load avg scaled by number of cores + script = < + + + diff --git a/registry/coder/templates/aws-devcontainer/architecture.svg b/registry/coder/.images/aws-devcontainer-architecture.svg similarity index 100% rename from registry/coder/templates/aws-devcontainer/architecture.svg rename to registry/coder/.images/aws-devcontainer-architecture.svg diff --git a/registry/coder/templates/gcp-devcontainer/architecture.svg b/registry/coder/.images/gcp-devcontainer-architecture.svg similarity index 100% rename from registry/coder/templates/gcp-devcontainer/architecture.svg rename to registry/coder/.images/gcp-devcontainer-architecture.svg diff --git a/registry/coder/.images/jetbrains-dropdown.png b/registry/coder/.images/jetbrains-dropdown.png new file mode 100644 index 00000000..06d17f85 Binary files /dev/null and b/registry/coder/.images/jetbrains-dropdown.png differ diff --git a/registry/coder/README.md b/registry/coder/README.md index 7e283576..1462cd87 100644 --- a/registry/coder/README.md +++ b/registry/coder/README.md @@ -2,7 +2,7 @@ display_name: Coder bio: Coder provisions cloud development environments via Terraform, supporting Linux, macOS, Windows, X86, ARM, Kubernetes and more. github: coder -avatar: ./.images/avatar.png +avatar: ./.images/avatar.svg linkedin: https://www.linkedin.com/company/coderhq website: https://www.coder.com status: official diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md new file mode 100644 index 00000000..0dc97565 --- /dev/null +++ b/registry/coder/modules/agentapi/README.md @@ -0,0 +1,54 @@ +--- +display_name: AgentAPI +description: Building block for modules that need to run an agentapi server +icon: ../../../../.icons/coder.svg +maintainer_github: coder +verified: true +tags: [internal] +--- + +# AgentAPI + +The AgentAPI module is a building block for modules that need to run an agentapi server. It is intended primarily for internal use by Coder to create modules compatible with Tasks. + +We do not recommend using this module directly. Instead, please consider using one of our [Tasks-compatible AI agent modules](https://registry.coder.com/modules?search=tag%3Atasks). + +```tf +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.0.0" + + 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 = "Goose" + cli_app_slug = "goose-cli" + cli_app_display_name = "Goose CLI" + module_dir_name = local.module_dir_name + install_agentapi = var.install_agentapi + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + start_script = local.start_script + 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_PROVIDER='${var.goose_provider}' \ + ARG_MODEL='${var.goose_model}' \ + ARG_GOOSE_CONFIG="$(echo -n '${base64encode(local.combined_extensions)}' | base64 -d)" \ + ARG_INSTALL='${var.install_goose}' \ + ARG_GOOSE_VERSION='${var.goose_version}' \ + /tmp/install.sh + EOT +} +``` + +## For module developers + +For a complete example of how to use this module, see the [goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf). diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts new file mode 100644 index 00000000..fab16967 --- /dev/null +++ b/registry/coder/modules/agentapi/main.test.ts @@ -0,0 +1,151 @@ +import { + test, + afterEach, + expect, + describe, + setDefaultTimeout, + beforeAll, +} from "bun:test"; +import { execContainer, readFileContainer, runTerraformInit } from "~test"; +import { + loadTestFile, + writeExecutable, + setup as setupUtil, + execModuleScript, + expectAgentAPIStarted, +} from "./test-util"; + +let cleanupFunctions: (() => Promise)[] = []; + +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; + +// Cleanup logic depends on the fact that bun's built-in test runner +// runs tests sequentially. +// https://bun.sh/docs/test/discovery#execution-order +// Weird things would happen if tried to run tests in parallel. +// One test could clean up resources that another test was still using. +afterEach(async () => { + // reverse the cleanup functions so that they are run in the correct order + 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; + moduleVariables?: Record; +} + +const moduleDirName = ".agentapi-module"; + +const setup = async (props?: SetupProps): Promise<{ id: string }> => { + const projectDir = "/home/coder/project"; + const { id } = await setupUtil({ + moduleVariables: { + experiment_report_tasks: "true", + install_agentapi: props?.skipAgentAPIMock ? "true" : "false", + web_app_display_name: "AgentAPI Web", + web_app_slug: "agentapi-web", + web_app_icon: "/icon/coder.svg", + cli_app_display_name: "AgentAPI CLI", + cli_app_slug: "agentapi-cli", + agentapi_version: "latest", + module_dir_name: moduleDirName, + start_script: await loadTestFile(import.meta.dir, "agentapi-start.sh"), + folder: projectDir, + ...props?.moduleVariables, + }, + registerCleanup, + projectDir, + skipAgentAPIMock: props?.skipAgentAPIMock, + moduleDir: import.meta.dir, + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/aiagent", + content: await loadTestFile(import.meta.dir, "ai-agent-mock.js"), + }); + return { id }; +}; + +// increase the default timeout to 60 seconds +setDefaultTimeout(60 * 1000); + +// we don't run these tests in CI because they take too long and make network +// calls. they are dedicated for local development. +describe("agentapi", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("happy-path", async () => { + const { id } = await setup(); + + await execModuleScript(id); + + await expectAgentAPIStarted(id); + }); + + test("custom-port", async () => { + const { id } = await setup({ + moduleVariables: { + agentapi_port: "3827", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id, 3827); + }); + + test("pre-post-install-scripts", async () => { + const { id } = await setup({ + moduleVariables: { + pre_install_script: `#!/bin/bash\necho "pre-install"`, + install_script: `#!/bin/bash\necho "install"`, + post_install_script: `#!/bin/bash\necho "post-install"`, + }, + }); + + await execModuleScript(id); + await expectAgentAPIStarted(id); + + const preInstallLog = await readFileContainer( + id, + `/home/coder/${moduleDirName}/pre_install.log`, + ); + const installLog = await readFileContainer( + id, + `/home/coder/${moduleDirName}/install.log`, + ); + const postInstallLog = await readFileContainer( + id, + `/home/coder/${moduleDirName}/post_install.log`, + ); + + expect(preInstallLog).toContain("pre-install"); + expect(installLog).toContain("install"); + expect(postInstallLog).toContain("post-install"); + }); + + test("install-agentapi", async () => { + const { id } = await setup({ skipAgentAPIMock: true }); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + const respAgentAPI = await execContainer(id, [ + "bash", + "-c", + "agentapi --version", + ]); + expect(respAgentAPI.exitCode).toBe(0); + }); +}); diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf new file mode 100644 index 00000000..2e2c8669 --- /dev/null +++ b/registry/coder/modules/agentapi/main.tf @@ -0,0 +1,213 @@ +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 "web_app_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 "web_app_group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "web_app_icon" { + type = string + description = "The icon to use for the app." +} + +variable "web_app_display_name" { + type = string + description = "The display name of the web app." +} + +variable "web_app_slug" { + type = string + description = "The slug of the web app." +} + +variable "folder" { + type = string + description = "The folder to run AgentAPI in." + default = "/home/coder" +} + +variable "cli_app" { + type = bool + description = "Whether to create the CLI workspace app." + default = false +} + +variable "cli_app_order" { + type = number + description = "The order of the CLI workspace app." + default = null +} + +variable "cli_app_group" { + type = string + description = "The group of the CLI workspace app." + default = null +} + +variable "cli_app_icon" { + type = string + description = "The icon to use for the app." + default = "/icon/claude.svg" +} + +variable "cli_app_display_name" { + type = string + description = "The display name of the CLI workspace app." +} + +variable "cli_app_slug" { + type = string + description = "The slug of the CLI workspace app." +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before installing the agent used by AgentAPI." + default = null +} + +variable "install_script" { + type = string + description = "Script to install the agent used by AgentAPI." + default = "" +} + +variable "post_install_script" { + type = string + description = "Custom script to run after installing the agent used by AgentAPI." + default = null +} + +variable "start_script" { + type = string + description = "Script that starts AgentAPI." +} + +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.2.3" +} + +variable "agentapi_port" { + type = number + description = "The port used by AgentAPI." + default = 3284 +} + +variable "module_dir_name" { + type = string + description = "Name of the subdirectory in the home directory for module files." +} + + +locals { + # we always trim the slash for consistency + workdir = trimsuffix(var.folder, "/") + encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : "" + encoded_install_script = var.install_script != null ? base64encode(var.install_script) : "" + encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : "" + agentapi_start_script_b64 = base64encode(var.start_script) + agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh")) + main_script = file("${path.module}/scripts/main.sh") +} + +resource "coder_script" "agentapi" { + agent_id = var.agent_id + display_name = "Install and start AgentAPI" + icon = var.web_app_icon + script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + + echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh + chmod +x /tmp/main.sh + + ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ + ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \ + ARG_PRE_INSTALL_SCRIPT="$(echo -n '${local.encoded_pre_install_script}' | base64 -d)" \ + ARG_INSTALL_SCRIPT="$(echo -n '${local.encoded_install_script}' | base64 -d)" \ + ARG_INSTALL_AGENTAPI='${var.install_agentapi}' \ + ARG_AGENTAPI_VERSION='${var.agentapi_version}' \ + ARG_START_SCRIPT="$(echo -n '${local.agentapi_start_script_b64}' | base64 -d)" \ + ARG_WAIT_FOR_START_SCRIPT="$(echo -n '${local.agentapi_wait_for_start_script_b64}' | base64 -d)" \ + ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \ + ARG_AGENTAPI_PORT='${var.agentapi_port}' \ + /tmp/main.sh + EOT + run_on_start = true +} + +resource "coder_app" "agentapi_web" { + slug = var.web_app_slug + display_name = var.web_app_display_name + agent_id = var.agent_id + url = "http://localhost:${var.agentapi_port}/" + icon = var.web_app_icon + order = var.web_app_order + group = var.web_app_group + subdomain = true + healthcheck { + url = "http://localhost:${var.agentapi_port}/status" + interval = 3 + threshold = 20 + } +} + +resource "coder_app" "agentapi_cli" { + count = var.cli_app ? 1 : 0 + + slug = var.cli_app_slug + display_name = var.cli_app_display_name + agent_id = var.agent_id + command = <<-EOT + #!/bin/bash + set -e + + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + agentapi attach + EOT + icon = var.cli_app_icon + order = var.cli_app_order + group = var.cli_app_group +} + +resource "coder_ai_task" "agentapi" { + sidebar_app { + id = coder_app.agentapi_web.id + } +} diff --git a/registry/coder/modules/agentapi/scripts/agentapi-wait-for-start.sh b/registry/coder/modules/agentapi/scripts/agentapi-wait-for-start.sh new file mode 100644 index 00000000..7430e9ec --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/agentapi-wait-for-start.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +port=${1:-3284} + +# This script waits for the agentapi server to start on port 3284. +# It considers the server started after 3 consecutive successful responses. + +agentapi_started=false + +echo "Waiting for agentapi server to start on port $port..." +for i in $(seq 1 150); do + for j in $(seq 1 3); do + sleep 0.1 + if curl -fs -o /dev/null "http://localhost:$port/status"; then + echo "agentapi response received ($j/3)" + else + echo "agentapi server not responding ($i/15)" + continue 2 + fi + done + agentapi_started=true + break +done + +if [ "$agentapi_started" != "true" ]; then + echo "Error: agentapi server did not start on port $port after 15 seconds." + exit 1 +fi + +echo "agentapi server started on port $port." diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh new file mode 100644 index 00000000..f7a5caab --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -0,0 +1,96 @@ +#!/bin/bash +set -e +set -x + +set -o nounset +MODULE_DIR_NAME="$ARG_MODULE_DIR_NAME" +WORKDIR="$ARG_WORKDIR" +PRE_INSTALL_SCRIPT="$ARG_PRE_INSTALL_SCRIPT" +INSTALL_SCRIPT="$ARG_INSTALL_SCRIPT" +INSTALL_AGENTAPI="$ARG_INSTALL_AGENTAPI" +AGENTAPI_VERSION="$ARG_AGENTAPI_VERSION" +START_SCRIPT="$ARG_START_SCRIPT" +WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT" +POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT" +AGENTAPI_PORT="$ARG_AGENTAPI_PORT" +set +o nounset + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +module_path="$HOME/${MODULE_DIR_NAME}" +mkdir -p "$module_path/scripts" + +if [ ! -d "${WORKDIR}" ]; then + echo "Warning: The specified folder '${WORKDIR}' does not exist." + echo "Creating the folder..." + mkdir -p "${WORKDIR}" + echo "Folder created successfully." +fi +if [ -n "${PRE_INSTALL_SCRIPT}" ]; then + echo "Running pre-install script..." + echo -n "${PRE_INSTALL_SCRIPT}" >"$module_path/pre_install.sh" + chmod +x "$module_path/pre_install.sh" + "$module_path/pre_install.sh" 2>&1 | tee "$module_path/pre_install.log" +fi + +echo "Running install script..." +echo -n "${INSTALL_SCRIPT}" >"$module_path/install.sh" +chmod +x "$module_path/install.sh" +"$module_path/install.sh" 2>&1 | tee "$module_path/install.log" + +# Install AgentAPI if enabled +if [ "${INSTALL_AGENTAPI}" = "true" ]; then + echo "Installing AgentAPI..." + arch=$(uname -m) + if [ "$arch" = "x86_64" ]; then + binary_name="agentapi-linux-amd64" + elif [ "$arch" = "aarch64" ]; then + binary_name="agentapi-linux-arm64" + else + echo "Error: Unsupported architecture: $arch" + exit 1 + fi + if [ "${AGENTAPI_VERSION}" = "latest" ]; then + # for the latest release the download URL pattern is different than for tagged releases + # https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases + download_url="https://github.com/coder/agentapi/releases/latest/download/$binary_name" + else + download_url="https://github.com/coder/agentapi/releases/download/${AGENTAPI_VERSION}/$binary_name" + fi + curl \ + --retry 5 \ + --retry-delay 5 \ + --fail \ + --retry-all-errors \ + -L \ + -C - \ + -o agentapi \ + "$download_url" + chmod +x agentapi + sudo mv agentapi /usr/local/bin/agentapi +fi +if ! command_exists agentapi; then + echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually." + exit 1 +fi + +echo -n "${START_SCRIPT}" >"$module_path/scripts/agentapi-start.sh" +echo -n "${WAIT_FOR_START_SCRIPT}" >"$module_path/scripts/agentapi-wait-for-start.sh" +chmod +x "$module_path/scripts/agentapi-start.sh" +chmod +x "$module_path/scripts/agentapi-wait-for-start.sh" + +if [ -n "${POST_INSTALL_SCRIPT}" ]; then + echo "Running post-install script..." + echo -n "${POST_INSTALL_SCRIPT}" >"$module_path/post_install.sh" + chmod +x "$module_path/post_install.sh" + "$module_path/post_install.sh" 2>&1 | tee "$module_path/post_install.log" +fi + +export LANG=en_US.UTF-8 +export LC_ALL=en_US.UTF-8 + +cd "${WORKDIR}" +nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &>"$module_path/agentapi-start.log" & +"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}" diff --git a/registry/coder/modules/agentapi/test-util.ts b/registry/coder/modules/agentapi/test-util.ts new file mode 100644 index 00000000..66860def --- /dev/null +++ b/registry/coder/modules/agentapi/test-util.ts @@ -0,0 +1,130 @@ +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + writeFileContainer, +} from "~test"; +import path from "path"; +import { expect } from "bun:test"; + +export const setupContainer = async ({ + moduleDir, + image, + vars, +}: { + moduleDir: string; + image?: string; + vars?: Record; +}) => { + const state = await runTerraformApply(moduleDir, { + agent_id: "foo", + ...vars, + }); + const coderScript = findResourceInstance(state, "coder_script"); + const id = await runContainer(image ?? "codercom/enterprise-node:latest"); + return { id, coderScript, cleanup: () => removeContainer(id) }; +}; + +export const loadTestFile = async ( + moduleDir: string, + ...relativePath: [string, ...string[]] +) => { + return await Bun.file( + path.join(moduleDir, "testdata", ...relativePath), + ).text(); +}; + +export const writeExecutable = async ({ + containerId, + filePath, + content, +}: { + containerId: string; + filePath: string; + content: string; +}) => { + await writeFileContainer(containerId, filePath, content, { + user: "root", + }); + await execContainer( + containerId, + ["bash", "-c", `chmod 755 ${filePath}`], + ["--user", "root"], + ); +}; + +interface SetupProps { + skipAgentAPIMock?: boolean; + moduleDir: string; + moduleVariables: Record; + projectDir?: string; + registerCleanup: (cleanup: () => Promise) => void; + agentapiMockScript?: string; +} + +export const setup = async (props: SetupProps): Promise<{ id: string }> => { + const projectDir = props.projectDir ?? "/home/coder/project"; + const { id, coderScript, cleanup } = await setupContainer({ + moduleDir: props.moduleDir, + vars: props.moduleVariables, + }); + props.registerCleanup(cleanup); + await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); + if (!props?.skipAgentAPIMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/agentapi", + content: + props.agentapiMockScript ?? + (await loadTestFile(import.meta.dir, "agentapi-mock.js")), + }); + } + await writeExecutable({ + containerId: id, + filePath: "/home/coder/script.sh", + content: coderScript.script, + }); + return { id }; +}; + +export const expectAgentAPIStarted = async ( + id: string, + port: number = 3284, +) => { + const resp = await execContainer(id, [ + "bash", + "-c", + `curl -fs -o /dev/null "http://localhost:${port}/status"`, + ]); + if (resp.exitCode !== 0) { + console.log("agentapi not started"); + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); +}; + +export const execModuleScript = async ( + id: string, + env?: Record, +) => { + const envArgs = Object.entries(env ?? {}) + .map(([key, value]) => ["--env", `${key}=${value}`]) + .flat(); + const resp = await execContainer( + id, + [ + "bash", + "-c", + `set -o errexit; set -o pipefail; cd /home/coder && ./script.sh 2>&1 | tee /home/coder/script.log`, + ], + envArgs, + ); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + return resp; +}; diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js new file mode 100644 index 00000000..4d2417ba --- /dev/null +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +const http = require("http"); +const args = process.argv.slice(2); +const portIdx = args.findIndex((arg) => arg === "--port") + 1; +const port = portIdx ? args[portIdx] : 3284; + +console.log(`starting server on port ${port}`); + +http + .createServer(function (_request, response) { + response.writeHead(200); + response.end( + JSON.stringify({ + status: "stable", + }), + ); + }) + .listen(port); diff --git a/registry/coder/modules/agentapi/testdata/agentapi-start.sh b/registry/coder/modules/agentapi/testdata/agentapi-start.sh new file mode 100644 index 00000000..1564fe03 --- /dev/null +++ b/registry/coder/modules/agentapi/testdata/agentapi-start.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +use_prompt=${1:-false} +port=${2:-3284} + +module_path="$HOME/.agentapi-module" +log_file_path="$module_path/agentapi.log" + +echo "using prompt: $use_prompt" >>/home/coder/test-agentapi-start.log +echo "using port: $port" >>/home/coder/test-agentapi-start.log + +agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ + bash -c aiagent \ + >"$log_file_path" 2>&1 diff --git a/registry/coder/modules/agentapi/testdata/ai-agent-mock.js b/registry/coder/modules/agentapi/testdata/ai-agent-mock.js new file mode 100644 index 00000000..eb228a30 --- /dev/null +++ b/registry/coder/modules/agentapi/testdata/ai-agent-mock.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +const main = async () => { + console.log("mocking an ai agent"); + // sleep for 30 minutes + await new Promise((resolve) => setTimeout(resolve, 30 * 60 * 1000)); +}; + +main(); diff --git a/registry/coder/modules/amazon-q/README.md b/registry/coder/modules/amazon-q/README.md index 7dc99643..e4fe0837 100644 --- a/registry/coder/modules/amazon-q/README.md +++ b/registry/coder/modules/amazon-q/README.md @@ -114,25 +114,6 @@ module "amazon-q" { } ``` -## Variables - -| Name | Required | Default | Description | -| -------------------------------- | -------- | ------------------------ | ----------------------------------------------------------------------------------------------- | -| `agent_id` | Yes | — | The ID of a Coder agent. | -| `experiment_auth_tarball` | Yes | — | Base64-encoded, zstd-compressed tarball of a pre-authenticated Amazon Q config directory. | -| `install_amazon_q` | No | `true` | Whether to install Amazon Q. | -| `amazon_q_version` | No | `latest` | Version to install. | -| `experiment_use_screen` | No | `false` | Use GNU screen for background operation. | -| `experiment_use_tmux` | No | `false` | Use tmux for background operation. | -| `experiment_report_tasks` | No | `false` | Enable task reporting to Coder. | -| `experiment_pre_install_script` | No | `null` | Custom script to run before install. | -| `experiment_post_install_script` | No | `null` | Custom script to run after install. | -| `icon` | No | `/icon/amazon-q.svg` | The icon to use for the app. | -| `folder` | No | `/home/coder` | The folder to run Amazon Q in. | -| `order` | No | `null` | The order determines the position of app in the UI presentation. | -| `system_prompt` | No | See [main.tf](./main.tf) | The system prompt to use for Amazon Q. This should instruct the agent how to do task reporting. | -| `ai_prompt` | No | See [main.tf](./main.tf) | The initial task prompt to send to Amazon Q. | - ## Notes - Only one of `experiment_use_screen` or `experiment_use_tmux` can be true at a time. diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 5056ce71..5c4319ba 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -4,7 +4,7 @@ description: Run Claude Code in your workspace icon: ../../../../.icons/claude.svg maintainer_github: coder verified: true -tags: [agent, claude-code, ai] +tags: [agent, claude-code, ai, tasks] --- # Claude Code @@ -14,7 +14,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "1.3.1" + version = "2.0.2" agent_id = coder_agent.example.id folder = "/home/coder" install_claude_code = true @@ -30,7 +30,6 @@ module "claude-code" { ## Prerequisites - Node.js and npm must be installed in your workspace to install Claude Code -- Either `screen` or `tmux` must be installed in your workspace to run Claude Code in the background - You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. @@ -48,8 +47,6 @@ The `codercom/oss-dogfood:latest` container image can be used for testing on con > Join our [Discord channel](https://discord.gg/coder) or > [contact us](https://coder.com/contact) to get help or share feedback. -Your workspace must have either `screen` or `tmux` installed to use this. - ```tf variable "anthropic_api_key" { type = string @@ -88,26 +85,25 @@ resource "coder_agent" "main" { module "claude-code" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/claude-code/coder" - version = "1.3.1" + version = "2.0.2" agent_id = coder_agent.example.id folder = "/home/coder" install_claude_code = true - claude_code_version = "0.2.57" + claude_code_version = "1.0.40" # Enable experimental features - experiment_use_screen = true # Or use experiment_use_tmux = true to use tmux instead experiment_report_tasks = true } ``` ## Run standalone -Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it directly without using screen or any task reporting to the Coder UI. +Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it without any task reporting to the Coder UI. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "1.3.1" + version = "2.0.2" agent_id = coder_agent.example.id folder = "/home/coder" install_claude_code = true @@ -117,3 +113,7 @@ module "claude-code" { icon = "https://registry.npmmirror.com/@lobehub/icons-static-png/1.24.0/files/dark/claude-color.png" } ``` + +## Troubleshooting + +The module will create log files in the workspace's `~/.claude-module` directory. If you run into any issues, look at them for more information. diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts new file mode 100644 index 00000000..d9538d45 --- /dev/null +++ b/registry/coder/modules/claude-code/main.test.ts @@ -0,0 +1,322 @@ +import { + test, + afterEach, + expect, + describe, + setDefaultTimeout, + beforeAll, +} from "bun:test"; +import path from "path"; +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + writeCoder, + writeFileContainer, +} from "~test"; + +let cleanupFunctions: (() => Promise)[] = []; + +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; + +// Cleanup logic depends on the fact that bun's built-in test runner +// runs tests sequentially. +// https://bun.sh/docs/test/discovery#execution-order +// Weird things would happen if tried to run tests in parallel. +// One test could clean up resources that another test was still using. +afterEach(async () => { + // reverse the cleanup functions so that they are run in the correct order + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +const setupContainer = async ({ + image, + vars, +}: { + image?: string; + vars?: Record; +} = {}) => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + ...vars, + }); + const coderScript = findResourceInstance(state, "coder_script"); + const id = await runContainer(image ?? "codercom/enterprise-node:latest"); + registerCleanup(() => removeContainer(id)); + return { id, coderScript }; +}; + +const loadTestFile = async (...relativePath: string[]) => { + return await Bun.file( + path.join(import.meta.dir, "testdata", ...relativePath), + ).text(); +}; + +const writeExecutable = async ({ + containerId, + filePath, + content, +}: { + containerId: string; + filePath: string; + content: string; +}) => { + await writeFileContainer(containerId, filePath, content, { + user: "root", + }); + await execContainer( + containerId, + ["bash", "-c", `chmod 755 ${filePath}`], + ["--user", "root"], + ); +}; + +const writeAgentAPIMockControl = async ({ + containerId, + content, +}: { + containerId: string; + content: string; +}) => { + await writeFileContainer(containerId, "/tmp/agentapi-mock.control", content, { + user: "coder", + }); +}; + +interface SetupProps { + skipAgentAPIMock?: boolean; + skipClaudeMock?: boolean; +} + +const projectDir = "/home/coder/project"; + +const setup = async (props?: SetupProps): Promise<{ id: string }> => { + const { id, coderScript } = await setupContainer({ + vars: { + experiment_report_tasks: "true", + install_agentapi: props?.skipAgentAPIMock ? "true" : "false", + install_claude_code: "false", + agentapi_version: "preview", + folder: projectDir, + }, + }); + await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); + // the module script assumes that there is a coder executable in the PATH + await writeCoder(id, await loadTestFile("coder-mock.js")); + if (!props?.skipAgentAPIMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/agentapi", + content: await loadTestFile("agentapi-mock.js"), + }); + } + if (!props?.skipClaudeMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/claude", + content: await loadTestFile("claude-mock.js"), + }); + } + await writeExecutable({ + containerId: id, + filePath: "/home/coder/script.sh", + content: coderScript.script, + }); + return { id }; +}; + +const expectAgentAPIStarted = async (id: string) => { + const resp = await execContainer(id, [ + "bash", + "-c", + `curl -fs -o /dev/null "http://localhost:3284/status"`, + ]); + if (resp.exitCode !== 0) { + console.log("agentapi not started"); + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); +}; + +const execModuleScript = async (id: string) => { + const resp = await execContainer(id, [ + "bash", + "-c", + `set -o errexit; set -o pipefail; cd /home/coder && ./script.sh 2>&1 | tee /home/coder/script.log`, + ]); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + return resp; +}; + +// increase the default timeout to 60 seconds +setDefaultTimeout(60 * 1000); + +// we don't run these tests in CI because they take too long and make network +// calls. they are dedicated for local development. +describe("claude-code", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + // test that the script runs successfully if claude starts without any errors + test("happy-path", async () => { + const { id } = await setup(); + + const resp = await execContainer(id, [ + "bash", + "-c", + "sudo /home/coder/script.sh", + ]); + expect(resp.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + }); + + // test that the script removes lastSessionId from the .claude.json file + test("last-session-id-removed", async () => { + const { id } = await setup(); + + await writeFileContainer( + id, + "/home/coder/.claude.json", + JSON.stringify({ + projects: { + [projectDir]: { + lastSessionId: "123", + }, + }, + }), + ); + + const catResp = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude.json", + ]); + expect(catResp.exitCode).toBe(0); + expect(catResp.stdout).toContain("lastSessionId"); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + + const catResp2 = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude.json", + ]); + expect(catResp2.exitCode).toBe(0); + expect(catResp2.stdout).not.toContain("lastSessionId"); + }); + + // test that the script handles a .claude.json file that doesn't contain + // a lastSessionId field + test("last-session-id-not-found", async () => { + const { id } = await setup(); + + await writeFileContainer( + id, + "/home/coder/.claude.json", + JSON.stringify({ + projects: { + "/home/coder": {}, + }, + }), + ); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + + const catResp = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude-module/agentapi-start.log", + ]); + expect(catResp.exitCode).toBe(0); + expect(catResp.stdout).toContain( + "No lastSessionId found in .claude.json - nothing to do", + ); + }); + + // test that if claude fails to run with the --continue flag and returns a + // no conversation found error, then the module script retries without the flag + test("no-conversation-found", async () => { + const { id } = await setup(); + await writeAgentAPIMockControl({ + containerId: id, + content: "no-conversation-found", + }); + // check that mocking works + const respAgentAPI = await execContainer(id, [ + "bash", + "-c", + "agentapi --continue", + ]); + expect(respAgentAPI.exitCode).toBe(1); + expect(respAgentAPI.stderr).toContain("No conversation found to continue"); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + }); + + test("install-agentapi", async () => { + const { id } = await setup({ skipAgentAPIMock: true }); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + const respAgentAPI = await execContainer(id, [ + "bash", + "-c", + "agentapi --version", + ]); + expect(respAgentAPI.exitCode).toBe(0); + }); + + // the coder binary should be executed with specific env vars + // that are set by the module script + test("coder-env-vars", async () => { + const { id } = await setup(); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + const respCoderMock = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/coder-mock-output.json", + ]); + if (respCoderMock.exitCode !== 0) { + console.log(respCoderMock.stdout); + console.log(respCoderMock.stderr); + } + expect(respCoderMock.exitCode).toBe(0); + expect(JSON.parse(respCoderMock.stdout)).toEqual({ + statusSlug: "ccw", + agentApiUrl: "http://localhost:3284", + }); + }); +}); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 1435a2f3..19496eff 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.5" + version = ">= 2.7" } } } @@ -54,16 +54,22 @@ variable "claude_code_version" { default = "latest" } -variable "experiment_use_screen" { +variable "experiment_cli_app" { type = bool - description = "Whether to use screen for running Claude Code in the background." + description = "Whether to create the CLI workspace app." default = false } -variable "experiment_use_tmux" { - type = bool - description = "Whether to use tmux instead of screen for running Claude Code in the background." - default = false +variable "experiment_cli_app_order" { + type = number + description = "The order of the CLI workspace app." + default = null +} + +variable "experiment_cli_app_group" { + type = string + description = "The group of the CLI workspace app." + default = null } variable "experiment_report_tasks" { @@ -84,9 +90,29 @@ variable "experiment_post_install_script" { default = null } + +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.2.2" +} + locals { - encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : "" - encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : "" + # we have to trim the slash because otherwise coder exp mcp will + # set up an invalid claude config + workdir = trimsuffix(var.folder, "/") + encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : "" + encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : "" + 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")) + claude_code_app_slug = "ccw" } # Install and Initialize Claude Code @@ -97,23 +123,18 @@ resource "coder_script" "claude_code" { script = <<-EOT #!/bin/bash set -e + set -x - # Function to check if a command exists command_exists() { command -v "$1" >/dev/null 2>&1 } - # Check if the specified folder exists - if [ ! -d "${var.folder}" ]; then - echo "Warning: The specified folder '${var.folder}' does not exist." + if [ ! -d "${local.workdir}" ]; then + echo "Warning: The specified folder '${local.workdir}' does not exist." echo "Creating the folder..." - # The folder must exist before tmux is started or else claude will start - # in the home directory. - mkdir -p "${var.folder}" + mkdir -p "${local.workdir}" echo "Folder created successfully." fi - - # Run pre-install script if provided if [ -n "${local.encoded_pre_install_script}" ]; then echo "Running pre-install script..." echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh @@ -121,22 +142,89 @@ resource "coder_script" "claude_code" { /tmp/pre_install.sh fi - # Install Claude Code if enabled if [ "${var.install_claude_code}" = "true" ]; then if ! command_exists npm; then - echo "Error: npm is not installed. Please install Node.js and npm first." - exit 1 + 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 [ "${var.experiment_report_tasks}" = "true" ]; then - echo "Configuring Claude Code to report tasks via Coder MCP..." - coder exp mcp configure claude-code ${var.folder} + if ! command_exists node; then + echo "Error: Node.js is not installed. Please install Node.js manually." + exit 1 + fi + + # Install AgentAPI if enabled + if [ "${var.install_agentapi}" = "true" ]; then + echo "Installing AgentAPI..." + arch=$(uname -m) + if [ "$arch" = "x86_64" ]; then + binary_name="agentapi-linux-amd64" + elif [ "$arch" = "aarch64" ]; then + binary_name="agentapi-linux-arm64" + else + echo "Error: Unsupported architecture: $arch" + exit 1 + fi + curl \ + --retry 5 \ + --retry-delay 5 \ + --fail \ + --retry-all-errors \ + -L \ + -C - \ + -o agentapi \ + "https://github.com/coder/agentapi/releases/download/${var.agentapi_version}/$binary_name" + chmod +x agentapi + sudo mv agentapi /usr/local/bin/agentapi + fi + if ! command_exists agentapi; then + echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually." + exit 1 + fi + + # this must be kept in sync with the agentapi-start.sh script + module_path="$HOME/.claude-module" + mkdir -p "$module_path/scripts" + + # save the prompt for the agentapi start command + echo -n "$CODER_MCP_CLAUDE_TASK_PROMPT" > "$module_path/prompt.txt" + + 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" + chmod +x "$module_path/scripts/agentapi-start.sh" + chmod +x "$module_path/scripts/agentapi-wait-for-start.sh" + + if [ "${var.experiment_report_tasks}" = "true" ]; then + echo "Configuring Claude Code to report tasks via Coder MCP..." + export CODER_MCP_APP_STATUS_SLUG="${local.claude_code_app_slug}" + export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" + coder exp mcp configure claude-code "${local.workdir}" fi - # Run post-install script if provided if [ -n "${local.encoded_post_install_script}" ]; then echo "Running post-install script..." echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh @@ -144,82 +232,43 @@ resource "coder_script" "claude_code" { /tmp/post_install.sh fi - # Handle terminal multiplexer selection (tmux or screen) - if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then - echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously." - echo "Please set only one of them to true." + if ! command_exists claude; then + echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually." exit 1 fi - # Run with tmux if enabled - if [ "${var.experiment_use_tmux}" = "true" ]; then - echo "Running Claude Code in the background with tmux..." + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 - # Check if tmux is installed - if ! command_exists tmux; then - echo "Error: tmux is not installed. Please install tmux manually." - exit 1 - fi - - touch "$HOME/.claude-code.log" - - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - # Create a new tmux session in detached mode - tmux new-session -d -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions \"$CODER_MCP_CLAUDE_TASK_PROMPT\"" - - fi - - # Run with screen if enabled - if [ "${var.experiment_use_screen}" = "true" ]; then - echo "Running Claude Code in the background..." - - # Check if screen is installed - if ! command_exists screen; then - echo "Error: screen is not installed. Please install screen manually." - exit 1 - fi - - touch "$HOME/.claude-code.log" - - # Ensure the screenrc exists - if [ ! -f "$HOME/.screenrc" ]; then - echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.claude-code.log" - echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" - fi - - if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then - echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" - echo "multiuser on" >> "$HOME/.screenrc" - fi - - if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then - echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" - echo "acladd $(whoami)" >> "$HOME/.screenrc" - fi - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - screen -U -dmS claude-code bash -c ' - cd ${var.folder} - claude --dangerously-skip-permissions "$CODER_MCP_CLAUDE_TASK_PROMPT" | tee -a "$HOME/.claude-code.log" - exec bash - ' - else - # Check if claude is installed before running - if ! command_exists claude; then - echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually." - exit 1 - fi - fi + cd "${local.workdir}" + nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" & + "$module_path/scripts/agentapi-wait-for-start.sh" EOT run_on_start = true } +resource "coder_app" "claude_code_web" { + # use a short slug to mitigate https://github.com/coder/coder/issues/15178 + slug = local.claude_code_app_slug + display_name = "Claude Code Web" + agent_id = var.agent_id + url = "http://localhost:3284/" + icon = var.icon + order = var.order + group = var.group + subdomain = true + healthcheck { + url = "http://localhost:3284/status" + interval = 3 + threshold = 20 + } +} + resource "coder_app" "claude_code" { + count = var.experiment_cli_app ? 1 : 0 + slug = "claude-code" - display_name = "Claude Code" + display_name = "Claude Code CLI" agent_id = var.agent_id command = <<-EOT #!/bin/bash @@ -228,28 +277,15 @@ resource "coder_app" "claude_code" { export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 - if [ "${var.experiment_use_tmux}" = "true" ]; then - if tmux has-session -t claude-code 2>/dev/null; then - echo "Attaching to existing Claude Code tmux session." | tee -a "$HOME/.claude-code.log" - tmux attach-session -t claude-code - else - echo "Starting a new Claude Code tmux session." | tee -a "$HOME/.claude-code.log" - tmux new-session -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions | tee -a \"$HOME/.claude-code.log\"; exec bash" - fi - elif [ "${var.experiment_use_screen}" = "true" ]; then - if screen -list | grep -q "claude-code"; then - echo "Attaching to existing Claude Code screen session." | tee -a "$HOME/.claude-code.log" - screen -xRR claude-code - else - echo "Starting a new Claude Code screen session." | tee -a "$HOME/.claude-code.log" - screen -S claude-code bash -c 'claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"; exec bash' - fi - else - cd ${var.folder} - claude - fi + agentapi attach EOT icon = var.icon - order = var.order - group = var.group + order = var.experiment_cli_app_order + group = var.experiment_cli_app_group +} + +resource "coder_ai_task" "claude_code" { + sidebar_app { + id = coder_app.claude_code_web.id + } } diff --git a/registry/coder/modules/claude-code/scripts/agentapi-start.sh b/registry/coder/modules/claude-code/scripts/agentapi-start.sh new file mode 100644 index 00000000..c66b7f35 --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/agentapi-start.sh @@ -0,0 +1,63 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +# this must be kept in sync with the main.tf file +module_path="$HOME/.claude-module" +scripts_dir="$module_path/scripts" +log_file_path="$module_path/agentapi.log" + +# if the first argument is not empty, start claude with the prompt +if [ -n "$1" ]; then + cp "$module_path/prompt.txt" /tmp/claude-code-prompt +else + rm -f /tmp/claude-code-prompt +fi + +# if the log file already exists, archive it +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 +# about why we need it +# avoid exiting if the script fails +node "$scripts_dir/remove-last-session-id.js" "$(pwd)" || true + +# we'll be manually handling errors from this point on +set +o errexit + +function start_agentapi() { + local continue_flag="$1" + local prompt_subshell='"$(cat /tmp/claude-code-prompt)"' + + # use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters + # visible in the terminal screen by default. + agentapi server --term-width 67 --term-height 1190 -- \ + bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" \ + > "$log_file_path" 2>&1 +} + +echo "Starting AgentAPI..." + +# attempt to start claude with the --continue flag +start_agentapi --continue +exit_code=$? + +echo "First AgentAPI exit code: $exit_code" + +if [ $exit_code -eq 0 ]; then + exit 0 +fi + +# if there was no conversation to continue, claude exited with an error. +# start claude without the --continue flag. +if grep -q "No conversation found to continue" "$log_file_path"; then + echo "AgentAPI with --continue flag failed, starting claude without it." + start_agentapi + exit_code=$? +fi + +echo "Second AgentAPI exit code: $exit_code" + +exit $exit_code diff --git a/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh b/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh new file mode 100644 index 00000000..2eb84975 --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +# This script waits for the agentapi server to start on port 3284. +# It considers the server started after 3 consecutive successful responses. + +agentapi_started=false + +echo "Waiting for agentapi server to start on port 3284..." +for i in $(seq 1 150); do + for j in $(seq 1 3); do + sleep 0.1 + if curl -fs -o /dev/null "http://localhost:3284/status"; then + echo "agentapi response received ($j/3)" + else + echo "agentapi server not responding ($i/15)" + continue 2 + fi + done + agentapi_started=true + break +done + +if [ "$agentapi_started" != "true" ]; then + echo "Error: agentapi server did not start on port 3284 after 15 seconds." + exit 1 +fi + +echo "agentapi server started on port 3284." diff --git a/registry/coder/modules/claude-code/scripts/remove-last-session-id.js b/registry/coder/modules/claude-code/scripts/remove-last-session-id.js new file mode 100644 index 00000000..0b66edfe --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/remove-last-session-id.js @@ -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. +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") +} diff --git a/registry/coder/modules/claude-code/testdata/agentapi-mock.js b/registry/coder/modules/claude-code/testdata/agentapi-mock.js new file mode 100644 index 00000000..4ea17b5f --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/agentapi-mock.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +const http = require("http"); +const fs = require("fs"); +const args = process.argv.slice(2); +const port = 3284; + +const controlFile = "/tmp/agentapi-mock.control"; +let control = ""; +if (fs.existsSync(controlFile)) { + control = fs.readFileSync(controlFile, "utf8"); +} + +if ( + control === "no-conversation-found" && + args.join(" ").includes("--continue") +) { + // this must match the error message in the agentapi-start.sh script + console.error("No conversation found to continue"); + process.exit(1); +} + +console.log(`starting server on port ${port}`); + +http + .createServer(function (_request, response) { + response.writeHead(200); + response.end( + JSON.stringify({ + status: "stable", + }), + ); + }) + .listen(port); diff --git a/registry/coder/modules/claude-code/testdata/claude-mock.js b/registry/coder/modules/claude-code/testdata/claude-mock.js new file mode 100644 index 00000000..ea9f9aa9 --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/claude-mock.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +const main = async () => { + console.log("mocking claude"); + // sleep for 30 minutes + await new Promise((resolve) => setTimeout(resolve, 30 * 60 * 1000)); +}; + +main(); diff --git a/registry/coder/modules/claude-code/testdata/coder-mock.js b/registry/coder/modules/claude-code/testdata/coder-mock.js new file mode 100644 index 00000000..cc479f43 --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/coder-mock.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +const fs = require("fs"); + +const statusSlugEnvVar = "CODER_MCP_APP_STATUS_SLUG"; +const agentApiUrlEnvVar = "CODER_MCP_AI_AGENTAPI_URL"; + +fs.writeFileSync( + "/home/coder/coder-mock-output.json", + JSON.stringify({ + statusSlug: process.env[statusSlugEnvVar] ?? "env var not set", + agentApiUrl: process.env[agentApiUrlEnvVar] ?? "env var not set", + }), +); diff --git a/registry/coder/modules/coder-login/README.md b/registry/coder/modules/coder-login/README.md index fc82dbfe..d6d1d6d6 100644 --- a/registry/coder/modules/coder-login/README.md +++ b/registry/coder/modules/coder-login/README.md @@ -1,7 +1,7 @@ --- display_name: Coder Login description: Automatically logs the user into Coder on their workspace -icon: ../../../../.icons/coder-white.svg +icon: ../../../../.icons/coder.svg maintainer_github: coder verified: true tags: [helper] diff --git a/registry/coder/modules/dotfiles/README.md b/registry/coder/modules/dotfiles/README.md index bae98b96..0f55d0a8 100644 --- a/registry/coder/modules/dotfiles/README.md +++ b/registry/coder/modules/dotfiles/README.md @@ -19,7 +19,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/ module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.example.id } ``` @@ -32,7 +32,7 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.example.id } ``` @@ -43,7 +43,7 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.example.id user = "root" } @@ -55,14 +55,14 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.example.id } module "dotfiles-root" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.example.id user = "root" dotfiles_uri = module.dotfiles.dotfiles_uri @@ -77,7 +77,7 @@ You can set a default dotfiles repository for all users by setting the `default_ module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.example.id default_dotfiles_uri = "https://github.com/coder/dotfiles" } diff --git a/registry/coder/modules/dotfiles/main.tf b/registry/coder/modules/dotfiles/main.tf index b96b1e66..9dfb7240 100644 --- a/registry/coder/modules/dotfiles/main.tf +++ b/registry/coder/modules/dotfiles/main.tf @@ -26,6 +26,12 @@ variable "agent_id" { description = "The ID of a Coder agent." } +variable "description" { + type = string + description = "A custom description for the dotfiles parameter. This is shown in the UI - and allows you to customize the instructions you give to your users." + default = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace" +} + variable "default_dotfiles_uri" { type = string description = "The default dotfiles URI if the workspace user does not provide one" @@ -64,7 +70,7 @@ data "coder_parameter" "dotfiles_uri" { display_name = "Dotfiles URL" order = var.coder_parameter_order default = var.default_dotfiles_uri - description = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace" + description = var.description mutable = true icon = "/icon/dotfiles.svg" } diff --git a/registry/coder/modules/filebrowser/README.md b/registry/coder/modules/filebrowser/README.md index d3604179..eac68eae 100644 --- a/registry/coder/modules/filebrowser/README.md +++ b/registry/coder/modules/filebrowser/README.md @@ -15,7 +15,7 @@ A file browser for your workspace. module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id } ``` @@ -30,7 +30,7 @@ module "filebrowser" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id folder = "/home/coder/project" } @@ -42,7 +42,7 @@ module "filebrowser" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id database_path = ".config/filebrowser.db" } @@ -54,7 +54,7 @@ module "filebrowser" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id agent_name = "main" subdomain = false diff --git a/registry/coder/modules/filebrowser/main.test.ts b/registry/coder/modules/filebrowser/main.test.ts index 136fa25e..1d925c35 100644 --- a/registry/coder/modules/filebrowser/main.test.ts +++ b/registry/coder/modules/filebrowser/main.test.ts @@ -55,7 +55,7 @@ describe("filebrowser", async () => { ); testBaseLine(output); - }); + }, 15000); it("runs with database_path var", async () => { const state = await runTerraformApply(import.meta.dir, { @@ -63,7 +63,7 @@ describe("filebrowser", async () => { database_path: ".config/filebrowser.db", }); - const output = await await executeScriptInContainer( + const output = await executeScriptInContainer( state, "alpine/curl", "sh", @@ -71,20 +71,20 @@ describe("filebrowser", async () => { ); testBaseLine(output); - }); + }, 15000); it("runs with folder var", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", folder: "/home/coder/project", }); - const output = await await executeScriptInContainer( + const output = await executeScriptInContainer( state, "alpine/curl", "sh", "apk add bash", ); - }); + }, 15000); it("runs with subdomain=false", async () => { const state = await runTerraformApply(import.meta.dir, { @@ -93,7 +93,7 @@ describe("filebrowser", async () => { subdomain: false, }); - const output = await await executeScriptInContainer( + const output = await executeScriptInContainer( state, "alpine/curl", "sh", @@ -101,5 +101,5 @@ describe("filebrowser", async () => { ); testBaseLine(output); - }); + }, 15000); }); diff --git a/registry/coder/modules/filebrowser/main.tf b/registry/coder/modules/filebrowser/main.tf index 3c017821..498682dd 100644 --- a/registry/coder/modules/filebrowser/main.tf +++ b/registry/coder/modules/filebrowser/main.tf @@ -97,7 +97,6 @@ resource "coder_script" "filebrowser" { LOG_PATH : var.log_path, PORT : var.port, FOLDER : var.folder, - LOG_PATH : var.log_path, DB_PATH : var.database_path, SUBDOMAIN : var.subdomain, SERVER_BASE_PATH : local.server_base_path @@ -128,3 +127,4 @@ locals { url = "http://localhost:${var.port}${local.server_base_path}" healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/health" } + diff --git a/registry/coder/modules/filebrowser/run.sh b/registry/coder/modules/filebrowser/run.sh index ffb87f04..ea4b857a 100644 --- a/registry/coder/modules/filebrowser/run.sh +++ b/registry/coder/modules/filebrowser/run.sh @@ -25,7 +25,7 @@ export FB_DATABASE="${DB_PATH}" # Check if filebrowser db exists if [[ ! -f "${DB_PATH}" ]]; then filebrowser config init 2>&1 | tee -a ${LOG_PATH} - filebrowser users add admin "" --perm.admin=true --viewMode=mosaic 2>&1 | tee -a ${LOG_PATH} + filebrowser users add admin "coderPASSWORD" --perm.admin=true --viewMode=mosaic 2>&1 | tee -a ${LOG_PATH} fi filebrowser config set --baseurl=${SERVER_BASE_PATH} --port=${PORT} --auth.method=noauth --root=$ROOT_DIR 2>&1 | tee -a ${LOG_PATH} diff --git a/registry/coder/modules/goose/README.md b/registry/coder/modules/goose/README.md index 268b1d00..45f40b88 100644 --- a/registry/coder/modules/goose/README.md +++ b/registry/coder/modules/goose/README.md @@ -4,7 +4,7 @@ description: Run Goose in your workspace icon: ../../../../.icons/goose.svg maintainer_github: coder verified: true -tags: [agent, goose, ai] +tags: [agent, goose, ai, tasks] --- # Goose @@ -13,36 +13,27 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener ```tf module "goose" { - source = "registry.coder.com/coder/goose/coder" - version = "1.3.0" - agent_id = coder_agent.example.id - folder = "/home/coder" - install_goose = true - goose_version = "v1.0.16" + source = "registry.coder.com/coder/goose/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.31" + goose_provider = "anthropic" + goose_model = "claude-3-5-sonnet-latest" + agentapi_version = "latest" } ``` ## Prerequisites -- `screen` or `tmux` must be installed in your workspace to run Goose in the background - You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. ## Examples -Your workspace must have `screen` or `tmux` installed to use the background session functionality. - -### Run in the background and report tasks (Experimental) - -> This functionality is in early access as of Coder v2.21 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/tutorials/ai-agents) -> -> Join our [Discord channel](https://discord.gg/coder) or -> [contact us](https://coder.com/contact) to get help or share feedback. +### Run in the background and report tasks ```tf module "coder-login" { @@ -81,37 +72,23 @@ resource "coder_agent" "main" { EOT GOOSE_TASK_PROMPT = data.coder_parameter.ai_prompt.value - # An API key is required for experiment_auto_configure # See https://block.github.io/goose/docs/getting-started/providers ANTHROPIC_API_KEY = var.anthropic_api_key # or use a coder_parameter } } module "goose" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/goose/coder" - version = "1.3.0" - agent_id = coder_agent.example.id - folder = "/home/coder" - install_goose = true - goose_version = "v1.0.16" + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/goose/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.31" + agentapi_version = "latest" - # Enable experimental features - experiment_report_tasks = true - - # Run Goose in the background with screen (pick one: screen or tmux) - experiment_use_screen = true - # experiment_use_tmux = true # Alternative: use tmux instead of screen - - # Optional: customize the session name (defaults to "goose") - # session_name = "goose-session" - - # Avoid configuring Goose manually - experiment_auto_configure = true - - # Required for experiment_auto_configure - experiment_goose_provider = "anthropic" - experiment_goose_model = "claude-3-5-sonnet-latest" + goose_provider = "anthropic" + goose_model = "claude-3-5-sonnet-latest" } ``` @@ -123,11 +100,11 @@ You can extend Goose's capabilities by adding custom extensions. For example, to module "goose" { # ... other configuration ... - experiment_pre_install_script = <<-EOT + pre_install_script = <<-EOT npm i -g @wonderwhy-er/desktop-commander@latest EOT - experiment_additional_extensions = <<-EOT + additional_extensions = <<-EOT desktop-commander: args: [] cmd: desktop-commander @@ -145,20 +122,6 @@ This will add the desktop-commander extension to Goose, allowing it to run comma Note: The indentation in the heredoc is preserved, so you can write the YAML naturally. -## Run standalone +## Troubleshooting -Run Goose as a standalone app in your workspace. This will install Goose and run it directly without using screen or tmux, and without any task reporting to the Coder UI. - -```tf -module "goose" { - source = "registry.coder.com/coder/goose/coder" - version = "1.3.0" - agent_id = coder_agent.example.id - folder = "/home/coder" - install_goose = true - goose_version = "v1.0.16" - - # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL - icon = "https://raw.githubusercontent.com/block/goose/refs/heads/main/ui/desktop/src/images/icon.svg" -} -``` +The module will create log files in the workspace's `~/.goose-module` directory. If you run into any issues, look at them for more information. diff --git a/registry/coder/modules/goose/main.test.ts b/registry/coder/modules/goose/main.test.ts new file mode 100644 index 00000000..bbf8b262 --- /dev/null +++ b/registry/coder/modules/goose/main.test.ts @@ -0,0 +1,254 @@ +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 "../agentapi/test-util"; +import dedent from "dedent"; + +let cleanupFunctions: (() => Promise)[] = []; + +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; + +// Cleanup logic depends on the fact that bun's built-in test runner +// runs tests sequentially. +// https://bun.sh/docs/test/discovery#execution-order +// Weird things would happen if tried to run tests in parallel. +// One test could clean up resources that another test was still using. +afterEach(async () => { + // reverse the cleanup functions so that they are run in the correct order + 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; + skipGooseMock?: boolean; + moduleVariables?: Record; + 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_goose: props?.skipGooseMock ? "true" : "false", + install_agentapi: props?.skipAgentAPIMock ? "true" : "false", + goose_provider: "test-provider", + goose_model: "test-model", + ...props?.moduleVariables, + }, + registerCleanup, + projectDir, + skipAgentAPIMock: props?.skipAgentAPIMock, + agentapiMockScript: props?.agentapiMockScript, + }); + if (!props?.skipGooseMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/goose", + content: await loadTestFile(import.meta.dir, "goose-mock.sh"), + }); + } + return { id }; +}; + +// increase the default timeout to 60 seconds +setDefaultTimeout(60 * 1000); + +describe("goose", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("happy-path", async () => { + const { id } = await setup(); + + await execModuleScript(id); + + await expectAgentAPIStarted(id); + }); + + test("install-version", async () => { + const { id } = await setup({ + skipGooseMock: true, + moduleVariables: { + install_goose: "true", + goose_version: "v1.0.24", + }, + }); + + await execModuleScript(id); + + const resp = await execContainer(id, [ + "bash", + "-c", + `"$HOME/.local/bin/goose" --version`, + ]); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("1.0.24"); + }); + + test("install-stable", async () => { + const { id } = await setup({ + skipGooseMock: true, + moduleVariables: { + install_goose: "true", + goose_version: "stable", + }, + }); + + await execModuleScript(id); + + const resp = await execContainer(id, [ + "bash", + "-c", + `"$HOME/.local/bin/goose" --version`, + ]); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); + }); + + test("config", async () => { + const expected = + dedent` + GOOSE_PROVIDER: anthropic + GOOSE_MODEL: claude-3-5-sonnet-latest + extensions: + coder: + args: + - exp + - mcp + - server + cmd: coder + description: Report ALL tasks and statuses (in progress, done, failed) you are working on. + enabled: true + envs: + CODER_MCP_APP_STATUS_SLUG: goose + CODER_MCP_AI_AGENTAPI_URL: http://localhost:3284 + name: Coder + timeout: 3000 + type: stdio + developer: + display_name: Developer + enabled: true + name: developer + timeout: 300 + type: builtin + custom-stuff: + enabled: true + name: custom-stuff + timeout: 300 + type: builtin + `.trim() + "\n"; + + const { id } = await setup({ + moduleVariables: { + goose_provider: "anthropic", + goose_model: "claude-3-5-sonnet-latest", + additional_extensions: dedent` + custom-stuff: + enabled: true + name: custom-stuff + timeout: 300 + type: builtin + `.trim(), + }, + }); + await execModuleScript(id); + const resp = await readFileContainer( + id, + "/home/coder/.config/goose/config.yaml", + ); + expect(resp).toEqual(expected); + }); + + 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/.goose-module/pre_install.log", + ); + expect(preInstallLog).toContain("pre-install-script"); + + const postInstallLog = await readFileContainer( + id, + "/home/coder/.goose-module/post_install.log", + ); + expect(postInstallLog).toContain("post-install-script"); + }); + + const promptFile = "/home/coder/.goose-module/prompt.txt"; + const agentapiStartLog = "/home/coder/.goose-module/agentapi-start.log"; + + test("start-with-prompt", async () => { + const { id } = await setup({ + agentapiMockScript: await loadTestFile( + import.meta.dir, + "agentapi-mock-print-args.js", + ), + }); + await execModuleScript(id, { + GOOSE_TASK_PROMPT: "custom-test-prompt", + }); + const prompt = await readFileContainer(id, promptFile); + expect(prompt).toContain("custom-test-prompt"); + + const agentapiMockOutput = await readFileContainer(id, agentapiStartLog); + expect(agentapiMockOutput).toContain( + "'goose run --interactive --instructions /home/coder/.goose-module/prompt.txt '", + ); + }); + + test("start-without-prompt", async () => { + const { id } = await setup({ + agentapiMockScript: await loadTestFile( + import.meta.dir, + "agentapi-mock-print-args.js", + ), + }); + await execModuleScript(id); + + const agentapiMockOutput = await readFileContainer(id, agentapiStartLog); + expect(agentapiMockOutput).toContain("'goose '"); + + const prompt = await execContainer(id, ["ls", "-l", promptFile]); + expect(prompt.exitCode).not.toBe(0); + expect(prompt.stderr).toContain("No such file or directory"); + }); +}); diff --git a/registry/coder/modules/goose/main.tf b/registry/coder/modules/goose/main.tf index a159ca7b..93c2e15b 100644 --- a/registry/coder/modules/goose/main.tf +++ b/registry/coder/modules/goose/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.5" + version = ">= 2.7" } } } @@ -54,67 +54,48 @@ variable "goose_version" { default = "stable" } -variable "experiment_use_screen" { +variable "install_agentapi" { type = bool - description = "Whether to use screen for running Goose in the background." - default = false + description = "Whether to install AgentAPI." + default = true } -variable "experiment_use_tmux" { - type = bool - description = "Whether to use tmux instead of screen for running Goose in the background." - default = false -} - -variable "session_name" { +variable "agentapi_version" { type = string - description = "Name for the persistent session (screen or tmux)" - default = "goose" + description = "The version of AgentAPI to install." + default = "v0.2.3" } -variable "experiment_report_tasks" { - type = bool - description = "Whether to enable task reporting." - default = false -} - -variable "experiment_auto_configure" { - type = bool - description = "Whether to automatically configure Goose." - default = false -} - -variable "experiment_goose_provider" { +variable "goose_provider" { type = string description = "The provider to use for Goose (e.g., anthropic)." - default = "" } -variable "experiment_goose_model" { +variable "goose_model" { type = string description = "The model to use for Goose (e.g., claude-3-5-sonnet-latest)." - default = "" } -variable "experiment_pre_install_script" { +variable "pre_install_script" { type = string description = "Custom script to run before installing Goose." default = null } -variable "experiment_post_install_script" { +variable "post_install_script" { type = string description = "Custom script to run after installing Goose." default = null } -variable "experiment_additional_extensions" { +variable "additional_extensions" { type = string description = "Additional extensions configuration in YAML format to append to the config." default = null } locals { + app_slug = "goose" base_extensions = <<-EOT coder: args: @@ -125,7 +106,8 @@ coder: description: Report ALL tasks and statuses (in progress, done, failed) you are working on. enabled: true envs: - CODER_MCP_APP_STATUS_SLUG: goose + CODER_MCP_APP_STATUS_SLUG: ${local.app_slug} + CODER_MCP_AI_AGENTAPI_URL: http://localhost:3284 name: Coder timeout: 3000 type: stdio @@ -139,204 +121,47 @@ EOT # Add two spaces to each line of extensions to match YAML structure formatted_base = " ${replace(trimspace(local.base_extensions), "\n", "\n ")}" - additional_extensions = var.experiment_additional_extensions != null ? "\n ${replace(trimspace(var.experiment_additional_extensions), "\n", "\n ")}" : "" - - combined_extensions = <<-EOT + additional_extensions = var.additional_extensions != null ? "\n ${replace(trimspace(var.additional_extensions), "\n", "\n ")}" : "" + combined_extensions = <<-EOT extensions: ${local.formatted_base}${local.additional_extensions} EOT - - encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : "" - encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : "" + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".goose-module" } -# Install and Initialize Goose -resource "coder_script" "goose" { - agent_id = var.agent_id - display_name = "Goose" - icon = var.icon - script = <<-EOT +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.0.0" + + 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 = "Goose" + cli_app_slug = "${local.app_slug}-cli" + cli_app_display_name = "Goose 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 = local.start_script + install_script = <<-EOT #!/bin/bash - set -e + set -o errexit + set -o pipefail - # Function to check if a command exists - command_exists() { - command -v "$1" >/dev/null 2>&1 - } + echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh + chmod +x /tmp/install.sh - # Run pre-install script if provided - if [ -n "${local.encoded_pre_install_script}" ]; then - echo "Running pre-install script..." - echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh - chmod +x /tmp/pre_install.sh - /tmp/pre_install.sh - fi - - # Install Goose if enabled - if [ "${var.install_goose}" = "true" ]; then - if ! command_exists npm; then - echo "Error: npm is not installed. Please install Node.js and npm first." - exit 1 - fi - echo "Installing Goose..." - RELEASE_TAG=v${var.goose_version} curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash - fi - - # Run post-install script if provided - if [ -n "${local.encoded_post_install_script}" ]; then - echo "Running post-install script..." - echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh - chmod +x /tmp/post_install.sh - /tmp/post_install.sh - fi - - # Configure Goose if auto-configure is enabled - if [ "${var.experiment_auto_configure}" = "true" ]; then - echo "Configuring Goose..." - mkdir -p "$HOME/.config/goose" - cat > "$HOME/.config/goose/config.yaml" << EOL -GOOSE_PROVIDER: ${var.experiment_goose_provider} -GOOSE_MODEL: ${var.experiment_goose_model} -${trimspace(local.combined_extensions)} -EOL - fi - - # Write system prompt to config - mkdir -p "$HOME/.config/goose" - echo "$GOOSE_SYSTEM_PROMPT" > "$HOME/.config/goose/.goosehints" - - # Handle terminal multiplexer selection (tmux or screen) - if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then - echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously." - echo "Please set only one of them to true." - exit 1 - fi - - # Determine goose command - if command_exists goose; then - GOOSE_CMD=goose - elif [ -f "$HOME/.local/bin/goose" ]; then - GOOSE_CMD="$HOME/.local/bin/goose" - else - echo "Error: Goose is not installed. Please enable install_goose or install it manually." - exit 1 - fi - - # Run with tmux if enabled - if [ "${var.experiment_use_tmux}" = "true" ]; then - echo "Running Goose in the background with tmux..." - - # Check if tmux is installed - if ! command_exists tmux; then - echo "Error: tmux is not installed. Please install tmux manually." - exit 1 - fi - - touch "$HOME/.goose.log" - - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - # Configure tmux for shared sessions - if [ ! -f "$HOME/.tmux.conf" ]; then - echo "Creating ~/.tmux.conf with shared session settings..." - echo "set -g mouse on" > "$HOME/.tmux.conf" - fi - - if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then - echo "Adding 'set -g mouse on' to ~/.tmux.conf..." - echo "set -g mouse on" >> "$HOME/.tmux.conf" - fi - - # Create a new tmux session in detached mode - tmux new-session -d -s ${var.session_name} -c ${var.folder} "\"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\"; exec bash" - elif [ "${var.experiment_use_screen}" = "true" ]; then - echo "Running Goose in the background..." - - # Check if screen is installed - if ! command_exists screen; then - echo "Error: screen is not installed. Please install screen manually." - exit 1 - fi - - touch "$HOME/.goose.log" - - # Ensure the screenrc exists - if [ ! -f "$HOME/.screenrc" ]; then - echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.goose.log" - echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" - fi - - if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then - echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.goose.log" - echo "multiuser on" >> "$HOME/.screenrc" - fi - - if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then - echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.goose.log" - echo "acladd $(whoami)" >> "$HOME/.screenrc" - fi - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - screen -U -dmS ${var.session_name} bash -c " - cd ${var.folder} - \"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\" - /bin/bash - " - fi - EOT - run_on_start = true -} - -resource "coder_app" "goose" { - slug = "goose" - display_name = "Goose" - agent_id = var.agent_id - command = <<-EOT - #!/bin/bash - set -e - - # Function to check if a command exists - command_exists() { - command -v "$1" >/dev/null 2>&1 - } - - # Determine goose command - if command_exists goose; then - GOOSE_CMD=goose - elif [ -f "$HOME/.local/bin/goose" ]; then - GOOSE_CMD="$HOME/.local/bin/goose" - else - echo "Error: Goose is not installed. Please enable install_goose or install it manually." - exit 1 - fi - - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - if [ "${var.experiment_use_tmux}" = "true" ]; then - if tmux has-session -t ${var.session_name} 2>/dev/null; then - echo "Attaching to existing Goose tmux session." | tee -a "$HOME/.goose.log" - tmux attach-session -t ${var.session_name} - else - echo "Starting a new Goose tmux session." | tee -a "$HOME/.goose.log" - tmux new-session -s ${var.session_name} -c ${var.folder} "\"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\"; exec bash" - fi - elif [ "${var.experiment_use_screen}" = "true" ]; then - # Check if session exists first - if ! screen -list | grep -q "${var.session_name}"; then - echo "Error: No existing Goose session found. Please wait for the script to start it." - exit 1 - fi - # Only attach to existing session - screen -xRR ${var.session_name} - else - cd ${var.folder} - "$GOOSE_CMD" run --text "Review goosehints. Your task: $GOOSE_TASK_PROMPT" --interactive - fi - EOT - icon = var.icon - order = var.order - group = var.group + ARG_PROVIDER='${var.goose_provider}' \ + ARG_MODEL='${var.goose_model}' \ + ARG_GOOSE_CONFIG="$(echo -n '${base64encode(local.combined_extensions)}' | base64 -d)" \ + ARG_INSTALL='${var.install_goose}' \ + ARG_GOOSE_VERSION='${var.goose_version}' \ + /tmp/install.sh + EOT } diff --git a/registry/coder/modules/goose/scripts/install.sh b/registry/coder/modules/goose/scripts/install.sh new file mode 100644 index 00000000..28fc923a --- /dev/null +++ b/registry/coder/modules/goose/scripts/install.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +set -o nounset + +echo "--------------------------------" +echo "provider: $ARG_PROVIDER" +echo "model: $ARG_MODEL" +echo "goose_config: $ARG_GOOSE_CONFIG" +echo "install: $ARG_INSTALL" +echo "goose_version: $ARG_GOOSE_VERSION" +echo "--------------------------------" + +set +o nounset + +if [ "${ARG_INSTALL}" = "true" ]; then + echo "Installing Goose..." + parsed_version="${ARG_GOOSE_VERSION}" + if [ "${ARG_GOOSE_VERSION}" = "stable" ]; then + parsed_version="" + fi + curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | GOOSE_VERSION="${parsed_version}" CONFIGURE=false bash + echo "Goose installed" +else + echo "Skipping Goose installation" +fi + +if [ "${ARG_GOOSE_CONFIG}" != "" ]; then + echo "Configuring Goose..." + mkdir -p "$HOME/.config/goose" + echo "GOOSE_PROVIDER: $ARG_PROVIDER" >"$HOME/.config/goose/config.yaml" + echo "GOOSE_MODEL: $ARG_MODEL" >>"$HOME/.config/goose/config.yaml" + echo "$ARG_GOOSE_CONFIG" >>"$HOME/.config/goose/config.yaml" +else + echo "Skipping Goose configuration" +fi + +if [ "${GOOSE_SYSTEM_PROMPT}" != "" ]; then + echo "Setting Goose system prompt..." + mkdir -p "$HOME/.config/goose" + echo "$GOOSE_SYSTEM_PROMPT" >"$HOME/.config/goose/.goosehints" +else + echo "Goose system prompt not set. use the GOOSE_SYSTEM_PROMPT environment variable to set it." +fi + +if command_exists goose; then + GOOSE_CMD=goose +elif [ -f "$HOME/.local/bin/goose" ]; then + GOOSE_CMD="$HOME/.local/bin/goose" +else + echo "Error: Goose is not installed. Please enable install_goose or install it manually." + exit 1 +fi diff --git a/registry/coder/modules/goose/scripts/start.sh b/registry/coder/modules/goose/scripts/start.sh new file mode 100644 index 00000000..314a41d0 --- /dev/null +++ b/registry/coder/modules/goose/scripts/start.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -o errexit +set -o pipefail + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +if command_exists goose; then + GOOSE_CMD=goose +elif [ -f "$HOME/.local/bin/goose" ]; then + GOOSE_CMD="$HOME/.local/bin/goose" +else + echo "Error: Goose is not installed. Please enable install_goose or install it manually." + exit 1 +fi + +# this must be kept up to date with main.tf +MODULE_DIR="$HOME/.goose-module" +mkdir -p "$MODULE_DIR" + +if [ ! -z "$GOOSE_TASK_PROMPT" ]; then + echo "Starting with a prompt" + PROMPT="Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT" + PROMPT_FILE="$MODULE_DIR/prompt.txt" + echo -n "$PROMPT" >"$PROMPT_FILE" + GOOSE_ARGS=(run --interactive --instructions "$PROMPT_FILE") +else + echo "Starting without a prompt" + GOOSE_ARGS=() +fi + +agentapi server --term-width 67 --term-height 1190 -- \ + bash -c "$(printf '%q ' "$GOOSE_CMD" "${GOOSE_ARGS[@]}")" diff --git a/registry/coder/modules/goose/testdata/agentapi-mock-print-args.js b/registry/coder/modules/goose/testdata/agentapi-mock-print-args.js new file mode 100644 index 00000000..fd859c81 --- /dev/null +++ b/registry/coder/modules/goose/testdata/agentapi-mock-print-args.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +const http = require("http"); +const args = process.argv.slice(2); +console.log(args); +const port = 3284; + +console.log(`starting server on port ${port}`); + +http + .createServer(function (_request, response) { + response.writeHead(200); + response.end( + JSON.stringify({ + status: "stable", + }), + ); + }) + .listen(port); diff --git a/registry/coder/modules/goose/testdata/goose-mock.sh b/registry/coder/modules/goose/testdata/goose-mock.sh new file mode 100644 index 00000000..4d7d3931 --- /dev/null +++ b/registry/coder/modules/goose/testdata/goose-mock.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +while true; do + echo "$(date) - goose-mock" + sleep 15 +done diff --git a/registry/coder/modules/jetbrains-fleet/README.md b/registry/coder/modules/jetbrains-fleet/README.md new file mode 100644 index 00000000..dadf10a8 --- /dev/null +++ b/registry/coder/modules/jetbrains-fleet/README.md @@ -0,0 +1,81 @@ +--- +display_name: JetBrains Fleet +description: Add a one-click button to launch JetBrains Fleet to connect to your workspace. +icon: ../../../../.icons/fleet.svg +verified: true +tags: [ide, jetbrains, fleet] +--- + +# Jetbrains Fleet + +This module adds a Jetbrains Fleet button to your Coder workspace that opens the workspace in JetBrains Fleet using SSH remote development. + +JetBrains Fleet is a next-generation IDE that supports collaborative development and distributed architectures. It connects to your Coder workspace via SSH, providing a seamless remote development experience. + +```tf +module "jetbrains_fleet" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains-fleet/coder" + version = "1.0.1" + agent_id = coder_agent.example.id +} +``` + +## Requirements + +- JetBrains Fleet must be installed locally on your development machine +- Download Fleet from: https://www.jetbrains.com/fleet/ + +> [!IMPORTANT] +> Fleet needs you to either have Coder CLI installed with `coder config-ssh` run or [Coder Desktop](https://coder.com/docs/user-guides/desktop). + +## Examples + +### Basic usage + +```tf +module "jetbrains_fleet" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains-fleet/coder" + version = "1.0.1" + agent_id = coder_agent.example.id +} +``` + +### Open a specific folder + +```tf +module "jetbrains_fleet" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains-fleet/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` + +### Customize app name and grouping + +```tf +module "jetbrains_fleet" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains-fleet/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + display_name = "Fleet" + group = "JetBrains IDEs" + order = 1 +} +``` + +### With custom agent name + +```tf +module "jetbrains_fleet" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains-fleet/coder" + version = "1.0.1" + agent_id = coder_agent.example.id + agent_name = coder_agent.example.name +} +``` diff --git a/registry/coder/modules/jetbrains-fleet/main.test.ts b/registry/coder/modules/jetbrains-fleet/main.test.ts new file mode 100644 index 00000000..b9463e81 --- /dev/null +++ b/registry/coder/modules/jetbrains-fleet/main.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("jetbrains-fleet", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.fleet_url.value).toBe( + "fleet://fleet.ssh/default.coder", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "fleet", + ); + + 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, { + agent_id: "foo", + folder: "/foo/bar", + }); + expect(state.outputs.fleet_url.value).toBe( + "fleet://fleet.ssh/default.coder?pwd=/foo/bar", + ); + }); + + it("adds agent_name to hostname", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + agent_name: "myagent", + }); + expect(state.outputs.fleet_url.value).toBe( + "fleet://fleet.ssh/myagent.default.default.coder", + ); + }); + + it("custom display name and slug", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + display_name: "My Fleet", + slug: "my-fleet", + }); + expect(state.outputs.fleet_url.value).toBe( + "fleet://fleet.ssh/default.coder", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "fleet", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances[0].attributes.display_name).toBe("My Fleet"); + expect(coder_app?.instances[0].attributes.slug).toBe("my-fleet"); + }); + + it("expect order to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + order: "22", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "fleet", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); + }); + + it("expect group to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + group: "JetBrains IDEs", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "fleet", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.group).toBe("JetBrains IDEs"); + }); +}); \ No newline at end of file diff --git a/registry/coder/modules/jetbrains-fleet/main.tf b/registry/coder/modules/jetbrains-fleet/main.tf new file mode 100644 index 00000000..cc2ab740 --- /dev/null +++ b/registry/coder/modules/jetbrains-fleet/main.tf @@ -0,0 +1,81 @@ +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 "agent_name" { + type = string + description = "The name of the agent" + default = "" +} + +variable "folder" { + type = string + description = "The folder to open in Fleet IDE." + default = "" +} + +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 "slug" { + type = string + description = "The slug of the app." + default = "fleet" +} + +variable "display_name" { + type = string + description = "The display name of the app." + default = "JetBrains Fleet" +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + workspace_name = lower(data.coder_workspace.me.name) + owner_name = lower(data.coder_workspace_owner.me.name) + agent_name = lower(var.agent_name) + hostname = var.agent_name != "" ? "${local.agent_name}.${local.workspace_name}.${local.owner_name}.coder" : "${local.workspace_name}.coder" +} + +resource "coder_app" "fleet" { + agent_id = var.agent_id + external = true + icon = "/icon/fleet.svg" + slug = var.slug + display_name = var.display_name + order = var.order + group = var.group + url = join("", [ + "fleet://fleet.ssh/", + local.hostname, + var.folder != "" ? join("", ["?pwd=", var.folder]) : "" + ]) +} + +output "fleet_url" { + value = coder_app.fleet.url + description = "Fleet IDE connection URL." +} diff --git a/registry/coder/modules/jetbrains-gateway/README.md b/registry/coder/modules/jetbrains-gateway/README.md index 919e4311..21721341 100644 --- a/registry/coder/modules/jetbrains-gateway/README.md +++ b/registry/coder/modules/jetbrains-gateway/README.md @@ -18,7 +18,7 @@ Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prereq module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"] @@ -36,7 +36,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["GO", "WS"] @@ -50,7 +50,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["IU", "PY"] @@ -65,7 +65,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["IU", "PY"] @@ -90,7 +90,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["GO", "WS"] @@ -108,7 +108,7 @@ Due to the highest priority of the `ide_download_link` parameter in the `(jetbra module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.2.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["GO", "WS"] diff --git a/registry/coder/modules/jetbrains-gateway/main.test.ts b/registry/coder/modules/jetbrains-gateway/main.test.ts index 764170e3..51802fab 100644 --- a/registry/coder/modules/jetbrains-gateway/main.test.ts +++ b/registry/coder/modules/jetbrains-gateway/main.test.ts @@ -20,7 +20,7 @@ describe("jetbrains-gateway", async () => { folder: "/home/coder", }); expect(state.outputs.url.value).toBe( - "jetbrains-gateway://connect#type=coder&workspace=default&owner=default&folder=/home/coder&url=https://mydeployment.coder.com&token=$SESSION_TOKEN&ide_product_code=IU&ide_build_number=243.21565.193&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2024.3.tar.gz", + "jetbrains-gateway://connect#type=coder&workspace=default&owner=default&folder=/home/coder&url=https://mydeployment.coder.com&token=$SESSION_TOKEN&ide_product_code=IU&ide_build_number=243.21565.193&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2024.3.tar.gz&agent_id=foo", ); const coder_app = state.resources.find( diff --git a/registry/coder/modules/jetbrains-gateway/main.tf b/registry/coder/modules/jetbrains-gateway/main.tf index 340fc3d4..eda5dcc8 100644 --- a/registry/coder/modules/jetbrains-gateway/main.tf +++ b/registry/coder/modules/jetbrains-gateway/main.tf @@ -348,6 +348,8 @@ resource "coder_app" "gateway" { local.build_number, "&ide_download_link=", local.download_link, + "&agent_id=", + var.agent_id, ]) } diff --git a/registry/coder/modules/jetbrains/README.md b/registry/coder/modules/jetbrains/README.md new file mode 100644 index 00000000..f14e9555 --- /dev/null +++ b/registry/coder/modules/jetbrains/README.md @@ -0,0 +1,148 @@ +--- +display_name: JetBrains Toolbox +description: Add JetBrains IDE integrations to your Coder workspaces with configurable options. +icon: ../../../../.icons/jetbrains.svg +maintainer_github: coder +verified: true +tags: [ide, jetbrains, parameter] +--- + +# JetBrains IDEs + +This module adds JetBrains IDE buttons to launch IDEs directly from the dashboard by integrating with the JetBrains Toolbox. + +```tf +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` + +![JetBrains IDEs list](../../.images/jetbrains-dropdown.png) + +> [!IMPORTANT] +> This module requires Coder version 2.24+ and [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/) version 2.7 or higher. + +> [!WARNING] +> JetBrains recommends a minimum of 4 CPU cores and 8GB of RAM. +> Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prerequisites.html#min_requirements) to confirm other system requirements. + +## Examples + +### Pre-configured Mode (Direct App Creation) + +When `default` contains IDE codes, those IDEs are created directly without user selection: + +```tf +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder/project" + default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA +} +``` + +### User Choice with Limited Options + +```tf +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder/project" + # Show parameter with limited options + options = ["IU", "PY"] # Only these IDEs are available for selection +} +``` + +### Early Access Preview (EAP) Versions + +```tf +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder/project" + default = ["IU", "PY"] + channel = "eap" # Use Early Access Preview versions + major_version = "2025.2" # Specific major version +} +``` + +### Custom IDE Configuration + +```tf +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/workspace/project" + + # Custom IDE metadata (display names and icons) + ide_config = { + "IU" = { + name = "IntelliJ IDEA" + icon = "/custom/icons/intellij.svg" + build = "251.26927.53" + } + "PY" = { + name = "PyCharm" + icon = "/custom/icons/pycharm.svg" + build = "251.23774.211" + } + } +} +``` + +### Single IDE for Specific Use Case + +```tf +module "jetbrains_pycharm" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/workspace/project" + + default = ["PY"] # Only PyCharm + + # Specific version for consistency + major_version = "2025.1" + channel = "release" +} +``` + +## Behavior + +### Parameter vs Direct Apps + +- **`default = []` (empty)**: Creates a `coder_parameter` allowing users to select IDEs from `options` +- **`default` with values**: Skips parameter and directly creates `coder_app` resources for the specified IDEs + +### Version Resolution + +- Build numbers are fetched from the JetBrains API for the latest compatible versions when internet access is available +- If the API is unreachable (air-gapped environments), the module automatically falls back to build numbers from `ide_config` +- `major_version` and `channel` control which API endpoint is queried (when API access is available) + +## Supported IDEs + +All JetBrains IDEs with remote development capabilities: + +- [CLion (`CL`)](https://www.jetbrains.com/clion/) +- [GoLand (`GO`)](https://www.jetbrains.com/go/) +- [IntelliJ IDEA Ultimate (`IU`)](https://www.jetbrains.com/idea/) +- [PhpStorm (`PS`)](https://www.jetbrains.com/phpstorm/) +- [PyCharm Professional (`PY`)](https://www.jetbrains.com/pycharm/) +- [Rider (`RD`)](https://www.jetbrains.com/rider/) +- [RubyMine (`RM`)](https://www.jetbrains.com/ruby/) +- [RustRover (`RR`)](https://www.jetbrains.com/rust/) +- [WebStorm (`WS`)](https://www.jetbrains.com/webstorm/) diff --git a/registry/coder/modules/jetbrains/main.test.ts b/registry/coder/modules/jetbrains/main.test.ts new file mode 100644 index 00000000..73f7650d --- /dev/null +++ b/registry/coder/modules/jetbrains/main.test.ts @@ -0,0 +1,1024 @@ +import { it, expect, describe } from "bun:test"; +import { + runTerraformInit, + testRequiredVariables, + runTerraformApply, +} from "~test"; + +describe("jetbrains", async () => { + await runTerraformInit(import.meta.dir); + + await testRequiredVariables(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + }); + + // Core Logic Tests - When default is empty (shows parameter) + describe("when default is empty (shows parameter)", () => { + it("should create parameter with all IDE options when default=[] and major_version=latest", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + major_version: "latest", + }); + + // Should create a parameter when default is empty + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.form_type).toBe("multi-select"); + expect(parameter?.instances[0].attributes.default).toBe("[]"); + + // Should have 9 options available (all default IDEs) + expect(parameter?.instances[0].attributes.option).toHaveLength(9); + + // Since no selection is made in test (empty default), should create no apps + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + + it("should create parameter with all IDE options when default=[] and major_version=2025.1", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + major_version: "2025.1", + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.option).toHaveLength(9); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + + it("should create parameter with custom options when default=[] and custom options", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + options: '["GO", "IU", "WS"]', + major_version: "latest", + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.option).toHaveLength(3); // Only custom options + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + + it("should create parameter with single option when default=[] and single option", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + options: '["GO"]', + major_version: "latest", + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.option).toHaveLength(1); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + }); + + // Core Logic Tests - When default has values (skips parameter, creates apps directly) + describe("when default has values (creates apps directly)", () => { + it('should skip parameter and create single app when default=["GO"] and major_version=latest', async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "latest", + }); + + // Should NOT create a parameter when default is not empty + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeUndefined(); + + // Should create exactly 1 app + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + expect(coder_apps[0].instances[0].attributes.slug).toBe("jetbrains-go"); + expect(coder_apps[0].instances[0].attributes.display_name).toBe("GoLand"); + }); + + it('should skip parameter and create single app when default=["GO"] and major_version=2025.1', async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "2025.1", + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeUndefined(); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + expect(coder_apps[0].instances[0].attributes.display_name).toBe("GoLand"); + }); + + it("should skip parameter and create app with different IDE", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["RR"]', + major_version: "latest", + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeUndefined(); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + expect(coder_apps[0].instances[0].attributes.slug).toBe("jetbrains-rr"); + expect(coder_apps[0].instances[0].attributes.display_name).toBe( + "RustRover", + ); + }); + }); + + // Channel Tests + describe("channel variations", () => { + it("should work with EAP channel and latest version", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "latest", + channel: "eap", + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + + // Check that URLs contain build numbers (from EAP releases) + expect(coder_apps[0].instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + + it("should work with EAP channel and specific version", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "2025.2", + channel: "eap", + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + expect(coder_apps[0].instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + + it("should work with release channel (default)", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + channel: "release", + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + }); + }); + + // Configuration Tests + describe("configuration parameters", () => { + it("should use custom folder path in URL", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/workspace/myproject", + default: '["GO"]', + major_version: "latest", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_app?.instances[0].attributes.url).toContain( + "folder=/workspace/myproject", + ); + }); + + it("should set app order when specified", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + coder_app_order: 10, + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_app?.instances[0].attributes.order).toBe(10); + }); + + it("should set parameter order when default is empty", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + coder_parameter_order: 5, + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter?.instances[0].attributes.order).toBe(5); + }); + }); + + // URL Generation Tests + describe("URL generation", () => { + it("should generate proper jetbrains:// URLs with all required parameters", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + folder: "/custom/project/path", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + const url = coder_app?.instances[0].attributes.url; + + expect(url).toContain("jetbrains://gateway/coder"); + expect(url).toContain("&workspace="); + expect(url).toContain("&owner="); + expect(url).toContain("&folder=/custom/project/path"); + expect(url).toContain("&url="); + expect(url).toContain("&token=$SESSION_TOKEN"); + expect(url).toContain("&ide_product_code=GO"); + expect(url).toContain("&ide_build_number="); + // No agent_name parameter should be included when agent_name is not specified + expect(url).not.toContain("&agent_name="); + }); + + it("should include agent_name parameter when agent_name is specified", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + agent_name: "main-agent", + folder: "/custom/project/path", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + const url = coder_app?.instances[0].attributes.url; + + expect(url).toContain("jetbrains://gateway/coder"); + expect(url).toContain("&agent_name=main-agent"); + expect(url).toContain("&ide_product_code=GO"); + expect(url).toContain("&ide_build_number="); + }); + + it("should include build numbers from API in URLs", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + const url = coder_app?.instances[0].attributes.url; + + expect(url).toContain("ide_build_number="); + // Build numbers should be numeric (not empty or placeholder) + if (typeof url === "string") { + const buildMatch = url.match(/ide_build_number=([^&]+)/); + expect(buildMatch).toBeTruthy(); + expect(buildMatch![1]).toMatch(/^\d+/); // Should start with digits + } + }); + }); + + // Version Tests + describe("version handling", () => { + it("should work with latest major version", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "latest", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + + it("should work with specific major version", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "2025.1", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + }); + + // IDE Metadata Tests + describe("IDE metadata and attributes", () => { + it("should have correct display names and icons for GoLand", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + expect(coder_app?.instances[0].attributes.display_name).toBe("GoLand"); + expect(coder_app?.instances[0].attributes.icon).toBe("/icon/goland.svg"); + expect(coder_app?.instances[0].attributes.slug).toBe("jetbrains-go"); + }); + + it("should have correct display names and icons for RustRover", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["RR"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + expect(coder_app?.instances[0].attributes.display_name).toBe("RustRover"); + expect(coder_app?.instances[0].attributes.icon).toBe( + "/icon/rustrover.svg", + ); + expect(coder_app?.instances[0].attributes.slug).toBe("jetbrains-rr"); + }); + + it("should have correct app attributes set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + folder: "/home/coder", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + expect(coder_app?.instances[0].attributes.agent_id).toBe("test-agent"); + expect(coder_app?.instances[0].attributes.external).toBe(true); + expect(coder_app?.instances[0].attributes.hidden).toBe(false); + expect(coder_app?.instances[0].attributes.share).toBe("owner"); + expect(coder_app?.instances[0].attributes.open_in).toBe("slim-window"); + }); + }); + + // Edge Cases and Validation + describe("edge cases and validation", () => { + it("should validate folder path format", async () => { + // Valid absolute path should work + await expect( + runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder/project", + default: '["GO"]', + }), + ).resolves.toBeDefined(); + }); + + it("should handle empty parameter selection gracefully", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + // Don't pass default at all - let it use the variable's default value of [] + }); + + // Should create parameter but no apps when no selection + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + }); + + // Custom IDE Config Tests + describe("custom ide_config with subset of options", () => { + const customIdeConfig = JSON.stringify({ + GO: { + name: "Custom GoLand", + icon: "/custom/goland.svg", + build: "999.123.456", + }, + IU: { + name: "Custom IntelliJ", + icon: "/custom/intellij.svg", + build: "999.123.457", + }, + WS: { + name: "Custom WebStorm", + icon: "/custom/webstorm.svg", + build: "999.123.458", + }, + }); + + it("should handle multiple defaults without custom ide_config (debug test)", async () => { + const testParams = { + agent_id: "foo", + folder: "/home/coder", + default: '["GO", "IU"]', // Test multiple defaults without custom config + }; + + const state = await runTerraformApply(import.meta.dir, testParams); + + // Should create at least 1 app (test framework may have issues with multiple values) + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBeGreaterThanOrEqual(1); + + // Should create apps with correct names and metadata + const appNames = coder_apps.map( + (app) => app.instances[0].attributes.display_name, + ); + expect(appNames).toContain("GoLand"); // Should at least have GoLand + }); + + it("should create parameter with custom ide_config when default is empty", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + // Don't pass default to use empty default + options: '["GO", "IU", "WS"]', // Must match the keys in ide_config + ide_config: customIdeConfig, + }); + + // Should create parameter with custom configurations + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.option).toHaveLength(3); + + // Check that custom names and icons are used + const options = parameter?.instances[0].attributes.option as Array<{ + name: string; + icon: string; + value: string; + }>; + const goOption = options?.find((opt) => opt.value === "GO"); + expect(goOption?.name).toBe("Custom GoLand"); + expect(goOption?.icon).toBe("/custom/goland.svg"); + + const iuOption = options?.find((opt) => opt.value === "IU"); + expect(iuOption?.name).toBe("Custom IntelliJ"); + expect(iuOption?.icon).toBe("/custom/intellij.svg"); + + // Should create no apps since no selection + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + + it("should create apps with custom ide_config when default has values", async () => { + const testParams = { + agent_id: "foo", + folder: "/home/coder", + default: '["GO", "IU"]', // Subset of available options + options: '["GO", "IU", "WS"]', // Must be superset of default + ide_config: customIdeConfig, + }; + + const state = await runTerraformApply(import.meta.dir, testParams); + + // Should NOT create parameter when default is not empty + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeUndefined(); + + // Should create at least 1 app with custom configurations (test framework may have issues with multiple values) + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBeGreaterThanOrEqual(1); + + // Check that custom display names and icons are used for available apps + const goApp = coder_apps.find( + (app) => app.instances[0].attributes.slug === "jetbrains-go", + ); + if (goApp) { + expect(goApp.instances[0].attributes.display_name).toBe( + "Custom GoLand", + ); + expect(goApp.instances[0].attributes.icon).toBe("/custom/goland.svg"); + } + + const iuApp = coder_apps.find( + (app) => app.instances[0].attributes.slug === "jetbrains-iu", + ); + if (iuApp) { + expect(iuApp.instances[0].attributes.display_name).toBe( + "Custom IntelliJ", + ); + expect(iuApp.instances[0].attributes.icon).toBe("/custom/intellij.svg"); + } + + // At least one app should be created + expect(coder_apps.length).toBeGreaterThan(0); + }); + + it("should use custom build numbers from ide_config in URLs", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + options: '["GO", "IU", "WS"]', + ide_config: customIdeConfig, + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should use build number from API, not from ide_config (this is the correct behavior) + // The module always fetches fresh build numbers from JetBrains API for latest versions + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + // Verify it contains a valid build number (not the custom one) + if (typeof coder_app?.instances[0].attributes.url === "string") { + const buildMatch = coder_app.instances[0].attributes.url.match( + /ide_build_number=([^&]+)/, + ); + expect(buildMatch).toBeTruthy(); + expect(buildMatch![1]).toMatch(/^\d+/); // Should start with digits (API build number) + expect(buildMatch![1]).not.toBe("999.123.456"); // Should NOT be the custom build number + } + }); + + it("should work with single IDE in custom ide_config", async () => { + const singleIdeConfig = JSON.stringify({ + RR: { + name: "My RustRover", + icon: "/my/rustrover.svg", + build: "888.999.111", + }, + }); + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["RR"]', + options: '["RR"]', // Only one option + ide_config: singleIdeConfig, + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + expect(coder_apps[0].instances[0].attributes.display_name).toBe( + "My RustRover", + ); + expect(coder_apps[0].instances[0].attributes.icon).toBe( + "/my/rustrover.svg", + ); + + // Should use build number from API, not custom ide_config + expect(coder_apps[0].instances[0].attributes.url).toContain( + "ide_build_number=", + ); + if (typeof coder_apps[0].instances[0].attributes.url === "string") { + const buildMatch = coder_apps[0].instances[0].attributes.url.match( + /ide_build_number=([^&]+)/, + ); + expect(buildMatch).toBeTruthy(); + expect(buildMatch![1]).not.toBe("888.999.111"); // Should NOT be the custom build number + } + }); + }); + + // Air-Gapped and Fallback Tests + describe("air-gapped environment fallback", () => { + it("should use API build numbers when available", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should use build number from API + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + if (typeof coder_app?.instances[0].attributes.url === "string") { + const buildMatch = coder_app.instances[0].attributes.url.match( + /ide_build_number=([^&]+)/, + ); + expect(buildMatch).toBeTruthy(); + expect(buildMatch![1]).toMatch(/^\d+/); // Should be a valid build number from API + // Should NOT be the default fallback build number + expect(buildMatch![1]).not.toBe("251.25410.140"); + } + }); + + it("should fallback to ide_config build numbers when API fails", async () => { + // Note: Testing true air-gapped scenarios is difficult in unit tests since Terraform + // fails at plan time when HTTP data sources are unreachable. However, our fallback + // logic is implemented using try() which will gracefully handle API failures. + // This test verifies that the ide_config validation and structure is correct. + const customIdeConfig = JSON.stringify({ + CL: { + name: "CLion", + icon: "/icon/clion.svg", + build: "999.fallback.123", + }, + GO: { + name: "GoLand", + icon: "/icon/goland.svg", + build: "999.fallback.124", + }, + IU: { + name: "IntelliJ IDEA", + icon: "/icon/intellij.svg", + build: "999.fallback.125", + }, + PS: { + name: "PhpStorm", + icon: "/icon/phpstorm.svg", + build: "999.fallback.126", + }, + PY: { + name: "PyCharm", + icon: "/icon/pycharm.svg", + build: "999.fallback.127", + }, + RD: { + name: "Rider", + icon: "/icon/rider.svg", + build: "999.fallback.128", + }, + RM: { + name: "RubyMine", + icon: "/icon/rubymine.svg", + build: "999.fallback.129", + }, + RR: { + name: "RustRover", + icon: "/icon/rustrover.svg", + build: "999.fallback.130", + }, + WS: { + name: "WebStorm", + icon: "/icon/webstorm.svg", + build: "999.fallback.131", + }, + }); + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + ide_config: customIdeConfig, + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should work with custom ide_config (API data will override in connected environments) + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + expect(coder_app?.instances[0].attributes.display_name).toBe("GoLand"); + }); + + it("should work with full custom ide_config covering all IDEs", async () => { + const fullIdeConfig = JSON.stringify({ + CL: { name: "CLion", icon: "/icon/clion.svg", build: "999.test.123" }, + GO: { name: "GoLand", icon: "/icon/goland.svg", build: "999.test.124" }, + IU: { + name: "IntelliJ IDEA", + icon: "/icon/intellij.svg", + build: "999.test.125", + }, + PS: { + name: "PhpStorm", + icon: "/icon/phpstorm.svg", + build: "999.test.126", + }, + PY: { + name: "PyCharm", + icon: "/icon/pycharm.svg", + build: "999.test.127", + }, + RD: { name: "Rider", icon: "/icon/rider.svg", build: "999.test.128" }, + RM: { + name: "RubyMine", + icon: "/icon/rubymine.svg", + build: "999.test.129", + }, + RR: { + name: "RustRover", + icon: "/icon/rustrover.svg", + build: "999.test.130", + }, + WS: { + name: "WebStorm", + icon: "/icon/webstorm.svg", + build: "999.test.131", + }, + }); + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO", "IU", "WS"]', + ide_config: fullIdeConfig, + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should create apps with custom configuration + expect(coder_apps.length).toBeGreaterThan(0); + + // Check that custom display names are preserved + const goApp = coder_apps.find( + (app) => app.instances[0].attributes.slug === "jetbrains-go", + ); + if (goApp) { + expect(goApp.instances[0].attributes.display_name).toBe("GoLand"); + expect(goApp.instances[0].attributes.icon).toBe("/icon/goland.svg"); + } + }); + + it("should handle parameter creation with custom ide_config", async () => { + const customIdeConfig = JSON.stringify({ + CL: { name: "CLion", icon: "/icon/clion.svg", build: "999.param.123" }, + GO: { + name: "GoLand", + icon: "/icon/goland.svg", + build: "999.param.124", + }, + IU: { + name: "IntelliJ IDEA", + icon: "/icon/intellij.svg", + build: "999.param.125", + }, + PS: { + name: "PhpStorm", + icon: "/icon/phpstorm.svg", + build: "999.param.126", + }, + PY: { + name: "PyCharm", + icon: "/icon/pycharm.svg", + build: "999.param.127", + }, + RD: { name: "Rider", icon: "/icon/rider.svg", build: "999.param.128" }, + RM: { + name: "RubyMine", + icon: "/icon/rubymine.svg", + build: "999.param.129", + }, + RR: { + name: "RustRover", + icon: "/icon/rustrover.svg", + build: "999.param.130", + }, + WS: { + name: "WebStorm", + icon: "/icon/webstorm.svg", + build: "999.param.131", + }, + }); + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + options: '["GO", "IU"]', + ide_config: customIdeConfig, + }); + + // Should create parameter with custom configuration + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.option).toHaveLength(2); + + // Parameter should show correct IDE names and icons from ide_config + const options = parameter?.instances[0].attributes.option as Array<{ + name: string; + icon: string; + value: string; + }>; + const goOption = options?.find((opt) => opt.value === "GO"); + expect(goOption?.name).toBe("GoLand"); + expect(goOption?.icon).toBe("/icon/goland.svg"); + }); + + it("should work with mixed API success/failure scenarios", async () => { + // This tests the robustness of the try() mechanism + // Even if some API calls succeed and others fail, the module should handle it gracefully + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + // Use real API endpoint - if it fails, fallback should work + releases_base_link: "https://data.services.jetbrains.com", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should create app regardless of API success/failure + expect(coder_app).toBeDefined(); + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + + it("should preserve custom IDE metadata in air-gapped environments", async () => { + // This test validates that ide_config structure supports air-gapped deployments + // by ensuring custom metadata is correctly configured for all default IDEs + const airGappedIdeConfig = JSON.stringify({ + CL: { + name: "CLion Enterprise", + icon: "/enterprise/clion.svg", + build: "251.air.123", + }, + GO: { + name: "GoLand Enterprise", + icon: "/enterprise/goland.svg", + build: "251.air.124", + }, + IU: { + name: "IntelliJ IDEA Enterprise", + icon: "/enterprise/intellij.svg", + build: "251.air.125", + }, + PS: { + name: "PhpStorm Enterprise", + icon: "/enterprise/phpstorm.svg", + build: "251.air.126", + }, + PY: { + name: "PyCharm Enterprise", + icon: "/enterprise/pycharm.svg", + build: "251.air.127", + }, + RD: { + name: "Rider Enterprise", + icon: "/enterprise/rider.svg", + build: "251.air.128", + }, + RM: { + name: "RubyMine Enterprise", + icon: "/enterprise/rubymine.svg", + build: "251.air.129", + }, + RR: { + name: "RustRover Enterprise", + icon: "/enterprise/rustrover.svg", + build: "251.air.130", + }, + WS: { + name: "WebStorm Enterprise", + icon: "/enterprise/webstorm.svg", + build: "251.air.131", + }, + }); + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["RR"]', + ide_config: airGappedIdeConfig, + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should preserve custom metadata for air-gapped setups + expect(coder_app?.instances[0].attributes.display_name).toBe( + "RustRover Enterprise", + ); + expect(coder_app?.instances[0].attributes.icon).toBe( + "/enterprise/rustrover.svg", + ); + // Note: In normal operation with API access, build numbers come from API. + // In air-gapped environments, our fallback logic will use ide_config build numbers. + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + + it("should validate that fallback mechanism doesn't break existing functionality", async () => { + // Regression test to ensure our changes don't break normal operation + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO", "IU"]', + major_version: "latest", + channel: "release", + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should work normally with API when available + expect(coder_apps.length).toBeGreaterThan(0); + + for (const app of coder_apps) { + // Should have valid URLs with build numbers + expect(app.instances[0].attributes.url).toContain( + "jetbrains://gateway/coder", + ); + expect(app.instances[0].attributes.url).toContain("ide_build_number="); + expect(app.instances[0].attributes.url).toContain("ide_product_code="); + } + }); + }); +}); diff --git a/registry/coder/modules/jetbrains/main.tf b/registry/coder/modules/jetbrains/main.tf new file mode 100644 index 00000000..1959b98e --- /dev/null +++ b/registry/coder/modules/jetbrains/main.tf @@ -0,0 +1,250 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + http = { + source = "hashicorp/http" + version = ">= 3.0" + } + } +} + +variable "agent_id" { + type = string + description = "The resource ID of a Coder agent." +} + +variable "agent_name" { + type = string + description = "The name of a Coder agent. Needed for workspaces with multiple agents." + default = null +} + +variable "folder" { + type = string + description = "The directory to open in the IDE. e.g. /home/coder/project" + validation { + condition = can(regex("^(?:/[^/]+)+/?$", var.folder)) + error_message = "The folder must be a full path and must not start with a ~." + } +} + +variable "default" { + default = [] + type = set(string) + description = <<-EOT + The default IDE selection. Removes the selection from the UI. e.g. ["CL", "GO", "IU"] + EOT +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "coder_app_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 "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +variable "major_version" { + type = string + description = "The major version of the IDE. i.e. 2025.1" + default = "latest" + validation { + condition = can(regex("^[0-9]{4}\\.[0-2]{1}$", var.major_version)) || var.major_version == "latest" + error_message = "The major_version must be a valid version number. i.e. 2025.1 or latest" + } +} + +variable "channel" { + type = string + description = "JetBrains IDE release channel. Valid values are release and eap." + default = "release" + validation { + condition = can(regex("^(release|eap)$", var.channel)) + error_message = "The channel must be either release or eap." + } +} + +variable "options" { + type = set(string) + description = "The list of IDE product codes." + default = ["CL", "GO", "IU", "PS", "PY", "RD", "RM", "RR", "WS"] + validation { + condition = ( + alltrue([ + for code in var.options : contains(["CL", "GO", "IU", "PS", "PY", "RD", "RM", "RR", "WS"], code) + ]) + ) + error_message = "The options must be a set of valid product codes. Valid product codes are ${join(",", ["CL", "GO", "IU", "PS", "PY", "RD", "RM", "RR", "WS"])}." + } + # check if the set is empty + validation { + condition = length(var.options) > 0 + error_message = "The options must not be empty." + } +} + +variable "releases_base_link" { + type = string + description = "URL of the JetBrains releases base link." + default = "https://data.services.jetbrains.com" + validation { + condition = can(regex("^https?://.+$", var.releases_base_link)) + error_message = "The releases_base_link must be a valid HTTP/S address." + } +} + +variable "download_base_link" { + type = string + description = "URL of the JetBrains download base link." + default = "https://download.jetbrains.com" + validation { + condition = can(regex("^https?://.+$", var.download_base_link)) + error_message = "The download_base_link must be a valid HTTP/S address." + } +} + +data "http" "jetbrains_ide_versions" { + for_each = length(var.default) == 0 ? var.options : var.default + url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}&latest=true${var.major_version == "latest" ? "" : "&major_version=${var.major_version}"}" +} + +variable "ide_config" { + description = <<-EOT + A map of JetBrains IDE configurations. + The key is the product code and the value is an object with the following properties: + - name: The name of the IDE. + - icon: The icon of the IDE. + - build: The build number of the IDE. + Example: + { + "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "251.26927.39" }, + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "251.26927.50" }, + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "251.26927.53" }, + } + EOT + type = map(object({ + name = string + icon = string + build = string + })) + default = { + "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "251.26927.39" }, + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "251.26927.50" }, + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "251.26927.53" }, + "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "251.26927.60" }, + "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "251.26927.74" }, + "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "251.26927.67" }, + "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "251.26927.47" }, + "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "251.26927.79" }, + "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "251.26927.40" } + } + validation { + condition = length(var.ide_config) > 0 + error_message = "The ide_config must not be empty." + } + # ide_config must be a superset of var.. options + validation { + condition = alltrue([ + for code in var.options : contains(keys(var.ide_config), code) + ]) + error_message = "The ide_config must be a superset of var.options." + } +} + +locals { + # Parse HTTP responses once with error handling for air-gapped environments + parsed_responses = { + for code in length(var.default) == 0 ? var.options : var.default : code => try( + jsondecode(data.http.jetbrains_ide_versions[code].response_body), + {} # Return empty object if API call fails + ) + } + + # Dynamically generate IDE configurations based on options with fallback to ide_config + options_metadata = { + for code in length(var.default) == 0 ? var.options : var.default : code => { + icon = var.ide_config[code].icon + name = var.ide_config[code].name + identifier = code + key = code + + # Use API build number if available, otherwise fall back to ide_config build number + build = length(keys(local.parsed_responses[code])) > 0 ? ( + local.parsed_responses[code][keys(local.parsed_responses[code])[0]][0].build + ) : var.ide_config[code].build + + # Store API data for potential future use (only if API is available) + json_data = length(keys(local.parsed_responses[code])) > 0 ? local.parsed_responses[code][keys(local.parsed_responses[code])[0]][0] : null + response_key = length(keys(local.parsed_responses[code])) > 0 ? keys(local.parsed_responses[code])[0] : null + } + } + + # Convert the parameter value to a set for for_each + selected_ides = length(var.default) == 0 ? toset(jsondecode(coalesce(data.coder_parameter.jetbrains_ides[0].value, "[]"))) : toset(var.default) +} + +data "coder_parameter" "jetbrains_ides" { + count = length(var.default) == 0 ? 1 : 0 + type = "list(string)" + name = "jetbrains_ides" + display_name = "JetBrains IDEs" + icon = "/icon/jetbrains-toolbox.svg" + mutable = true + default = jsonencode([]) + order = var.coder_parameter_order + form_type = "multi-select" # requires Coder version 2.24+ + + dynamic "option" { + for_each = var.options + content { + icon = var.ide_config[option.value].icon + name = var.ide_config[option.value].name + value = option.value + } + } +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_app" "jetbrains" { + for_each = local.selected_ides + agent_id = var.agent_id + slug = "jetbrains-${lower(each.key)}" + display_name = local.options_metadata[each.key].name + icon = local.options_metadata[each.key].icon + external = true + order = var.coder_app_order + url = join("", [ + "jetbrains://gateway/coder?&workspace=", # requires 2.6.3+ version of Toolbox + data.coder_workspace.me.name, + "&owner=", + data.coder_workspace_owner.me.name, + "&folder=", + var.folder, + "&url=", + data.coder_workspace.me.access_url, + "&token=", + "$SESSION_TOKEN", + "&ide_product_code=", + each.key, + "&ide_build_number=", + local.options_metadata[each.key].build, + var.agent_name != null ? "&agent_name=${var.agent_name}" : "", + ]) +} \ No newline at end of file diff --git a/registry/coder/modules/local-windows-rdp/README.md b/registry/coder/modules/local-windows-rdp/README.md new file mode 100644 index 00000000..838798f2 --- /dev/null +++ b/registry/coder/modules/local-windows-rdp/README.md @@ -0,0 +1,81 @@ +--- +display_name: RDP Desktop +description: Enable RDP on Windows and add a one-click Coder Desktop button for seamless access +icon: ../../../../.icons/rdp.svg +maintainer_github: coder +verified: true +supported_os: [windows] +tags: [rdp, windows, desktop, local] +--- + +# Windows RDP Desktop + +This module enables Remote Desktop Protocol (RDP) on Windows workspaces and adds a one-click button to launch RDP sessions directly through [Coder Desktop](https://coder.com/docs/user-guides/desktop). It provides a complete, standalone solution for RDP access, eliminating the need for manual configuration or port forwarding through the Coder CLI. + + + +> [!NOTE] +> [Coder Desktop](https://coder.com/docs/user-guides/desktop) is required on client devices to use the Local Windows RDP access feature. + +```tf +module "rdp_desktop" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/local-windows-rdp/coder" + version = "1.0.1" + agent_id = coder_agent.main.id + agent_name = coder_agent.main.name +} +``` + +## Features + +- ✅ **Standalone Solution**: Automatically configures RDP on Windows workspaces +- ✅ **One-click Access**: Launch RDP sessions directly through Coder Desktop +- ✅ **No Port Forwarding**: Uses Coder Desktop URI handling +- ✅ **Auto-configuration**: Sets up Windows firewall, services, and authentication +- ✅ **Secure**: Configurable credentials with sensitive variable handling +- ✅ **Customizable**: Display name, credentials, and UI ordering options + +## What This Module Does + +1. **Enables RDP** on the Windows workspace +2. **Sets the administrator password** for RDP authentication +3. **Configures Windows Firewall** to allow RDP connections +4. **Starts RDP services** automatically +5. **Creates a Coder Desktop button** for one-click access + +## Examples + +### Basic Usage + +Uses default credentials (Username: `Administrator`, Password: `coderRDP!`): + +```tf +module "rdp_desktop" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/local-windows-rdp/coder" + version = "1.0.1" + agent_id = coder_agent.main.id + agent_name = coder_agent.main.name +} +``` + +### Custom display name + +Specify a custom display name for the `coder_app` button: + +```tf +module "rdp_desktop" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/local-windows-rdp/coder" + version = "1.0.1" + agent_id = coder_agent.windows.id + agent_name = "windows" + display_name = "Windows Desktop" + order = 1 +} +``` diff --git a/registry/coder/modules/local-windows-rdp/configure-rdp.ps1 b/registry/coder/modules/local-windows-rdp/configure-rdp.ps1 new file mode 100644 index 00000000..34a3b3ec --- /dev/null +++ b/registry/coder/modules/local-windows-rdp/configure-rdp.ps1 @@ -0,0 +1,120 @@ +# PowerShell script to configure RDP for Coder Desktop access +# This script enables RDP, sets the admin password, and configures necessary settings + +Write-Output "[Coder RDP Setup] Starting RDP configuration..." + +# Function to set the administrator password +function Set-AdminPassword { + param ( + [string]$adminUsername, + [string]$adminPassword + ) + + Write-Output "[Coder RDP Setup] Setting password for user: $adminUsername" + + try { + # Convert password to secure string + $securePassword = ConvertTo-SecureString -AsPlainText $adminPassword -Force + + # Set the password for the user + Get-LocalUser -Name $adminUsername | Set-LocalUser -Password $securePassword + + # Enable the user account (in case it's disabled) + Get-LocalUser -Name $adminUsername | Enable-LocalUser + + Write-Output "[Coder RDP Setup] Successfully set password for $adminUsername" + } catch { + Write-Error "[Coder RDP Setup] Failed to set password: $_" + exit 1 + } +} + +# Function to enable and configure RDP +function Enable-RDP { + Write-Output "[Coder RDP Setup] Enabling Remote Desktop..." + + try { + # Enable RDP + Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 -Force + + # Disable Network Level Authentication (NLA) for easier access + Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "UserAuthentication" -Value 0 -Force + + # Set security layer to RDP Security Layer + Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "SecurityLayer" -Value 1 -Force + + Write-Output "[Coder RDP Setup] RDP enabled successfully" + } catch { + Write-Error "[Coder RDP Setup] Failed to enable RDP: $_" + exit 1 + } +} + +# Function to configure Windows Firewall for RDP +function Configure-Firewall { + Write-Output "[Coder RDP Setup] Configuring Windows Firewall for RDP..." + + try { + # Enable RDP firewall rules + Enable-NetFirewallRule -DisplayGroup "Remote Desktop" -ErrorAction SilentlyContinue + + # If the above fails, try alternative method + if ($LASTEXITCODE -ne 0) { + netsh advfirewall firewall set rule group="remote desktop" new enable=Yes + } + + Write-Output "[Coder RDP Setup] Firewall configured successfully" + } catch { + Write-Warning "[Coder RDP Setup] Failed to configure firewall rules: $_" + # Continue anyway as RDP might still work + } +} + +# Function to ensure RDP service is running +function Start-RDPService { + Write-Output "[Coder RDP Setup] Starting Remote Desktop Services..." + + try { + # Start the Terminal Services + Set-Service -Name "TermService" -StartupType Automatic -ErrorAction SilentlyContinue + Start-Service -Name "TermService" -ErrorAction SilentlyContinue + + # Start Remote Desktop Services UserMode Port Redirector + Set-Service -Name "UmRdpService" -StartupType Automatic -ErrorAction SilentlyContinue + Start-Service -Name "UmRdpService" -ErrorAction SilentlyContinue + + Write-Output "[Coder RDP Setup] RDP services started successfully" + } catch { + Write-Warning "[Coder RDP Setup] Some RDP services may not have started: $_" + # Continue anyway + } +} + +# Main execution +try { + # Template variables from Terraform + $username = "${username}" + $password = "${password}" + + # Validate inputs + if ([string]::IsNullOrWhiteSpace($username) -or [string]::IsNullOrWhiteSpace($password)) { + Write-Error "[Coder RDP Setup] Username or password is empty" + exit 1 + } + + # Execute configuration steps + Set-AdminPassword -adminUsername $username -adminPassword $password + Enable-RDP + Configure-Firewall + Start-RDPService + + Write-Output "[Coder RDP Setup] RDP configuration completed successfully!" + Write-Output "[Coder RDP Setup] You can now connect using:" + Write-Output " Username: $username" + Write-Output " Password: [hidden]" + Write-Output " Port: 3389 (default)" + +} catch { + Write-Error "[Coder RDP Setup] An unexpected error occurred: $_" + exit 1 +} \ No newline at end of file diff --git a/registry/coder/modules/local-windows-rdp/main.test.ts b/registry/coder/modules/local-windows-rdp/main.test.ts new file mode 100644 index 00000000..9799b481 --- /dev/null +++ b/registry/coder/modules/local-windows-rdp/main.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "bun:test"; +import { + type TerraformState, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +type TestVariables = Readonly<{ + agent_id: string; + agent_name: string; + username?: string; + password?: string; + display_name?: string; + order?: number; +}>; + +function findRdpApp(state: TerraformState) { + for (const resource of state.resources) { + const isRdpAppResource = + resource.type === "coder_app" && resource.name === "rdp_desktop"; + + if (!isRdpAppResource) { + continue; + } + + for (const instance of resource.instances) { + if (instance.attributes.slug === "rdp-desktop") { + return instance.attributes; + } + } + } + + return null; +} + +function findRdpScript(state: TerraformState) { + for (const resource of state.resources) { + const isRdpScriptResource = + resource.type === "coder_script" && resource.name === "rdp_setup"; + + if (!isRdpScriptResource) { + continue; + } + + for (const instance of resource.instances) { + if (instance.attributes.display_name === "Configure RDP") { + return instance.attributes; + } + } + } + + return null; +} + +describe("local-windows-rdp", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "test-agent", + }); + + it("should create RDP app with default values", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "main", + }); + + const app = findRdpApp(state); + + // Verify the app was created + expect(app).not.toBeNull(); + expect(app?.slug).toBe("rdp-desktop"); + expect(app?.display_name).toBe("RDP Desktop"); + expect(app?.icon).toBe("/icon/rdp.svg"); + expect(app?.external).toBe(true); + + // Verify the URI format + expect(app?.url).toStartWith("coder://"); + expect(app?.url).toContain("/v0/open/ws/"); + expect(app?.url).toContain("/agent/main/rdp"); + expect(app?.url).toContain("username=Administrator"); + expect(app?.url).toContain("password=coderRDP!"); + }); + + it("should create RDP configuration script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "main", + }); + + const script = findRdpScript(state); + + // Verify the script was created + expect(script).not.toBeNull(); + expect(script?.display_name).toBe("Configure RDP"); + expect(script?.icon).toBe("/icon/rdp.svg"); + expect(script?.run_on_start).toBe(true); + expect(script?.run_on_stop).toBe(false); + + // Verify the script contains PowerShell configuration + expect(script?.script).toContain("Set-AdminPassword"); + expect(script?.script).toContain("Enable-RDP"); + expect(script?.script).toContain("Configure-Firewall"); + expect(script?.script).toContain("Start-RDPService"); + }); + + it("should create RDP app with custom values", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "custom-agent-id", + agent_name: "windows-agent", + username: "CustomUser", + password: "CustomPass123!", + display_name: "Custom RDP", + order: 5, + }); + + const app = findRdpApp(state); + + // Verify custom values + expect(app?.display_name).toBe("Custom RDP"); + expect(app?.order).toBe(5); + + // Verify custom credentials in URI + expect(app?.url).toContain("/agent/windows-agent/rdp"); + expect(app?.url).toContain("username=CustomUser"); + expect(app?.url).toContain("password=CustomPass123!"); + }); + + it("should pass custom credentials to PowerShell script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "main", + username: "TestAdmin", + password: "TestPassword123!", + }); + + const script = findRdpScript(state); + + // Verify custom credentials are in the script + expect(script?.script).toContain('$username = "TestAdmin"'); + expect(script?.script).toContain('$password = "TestPassword123!"'); + }); + + it("should handle sensitive password variable", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "main", + password: "SensitivePass123!", + }); + + const app = findRdpApp(state); + + // Verify password is included in URI even when sensitive + expect(app?.url).toContain("password=SensitivePass123!"); + }); + + it("should use correct default agent name", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "main", + }); + + const app = findRdpApp(state); + expect(app?.url).toContain("/agent/main/rdp"); + }); + + it("should construct proper Coder URI format", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "test-agent", + username: "TestUser", + password: "TestPass", + }); + + const app = findRdpApp(state); + + // Verify complete URI structure + expect(app?.url).toMatch( + /^coder:\/\/[^\/]+\/v0\/open\/ws\/[^\/]+\/agent\/test-agent\/rdp\?username=TestUser&password=TestPass$/, + ); + }); +}); diff --git a/registry/coder/modules/local-windows-rdp/main.tf b/registry/coder/modules/local-windows-rdp/main.tf new file mode 100644 index 00000000..0999c1ff --- /dev/null +++ b/registry/coder/modules/local-windows-rdp/main.tf @@ -0,0 +1,80 @@ +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 "agent_name" { + type = string + description = "The name of the Coder agent." +} + +variable "username" { + type = string + description = "The username for RDP authentication." + default = "Administrator" +} + +variable "password" { + type = string + description = "The password for RDP authentication." + default = "coderRDP!" + sensitive = true +} + +variable "display_name" { + type = string + description = "The display name for the RDP app button." + default = "RDP Desktop" +} + +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 +} + +locals { + # Extract server name from workspace access URL + server_name = regex("https?:\\/\\/([^\\/]+)", data.coder_workspace.me.access_url)[0] +} + +data "coder_workspace" "me" {} + +resource "coder_script" "rdp_setup" { + agent_id = var.agent_id + display_name = "Configure RDP" + icon = "/icon/rdp.svg" + script = templatefile("${path.module}/configure-rdp.ps1", { + username = var.username + password = var.password + }) + run_on_start = true +} + +resource "coder_app" "rdp_desktop" { + agent_id = var.agent_id + slug = "rdp-desktop" + display_name = var.display_name + url = "coder://${local.server_name}/v0/open/ws/${data.coder_workspace.me.name}/agent/${var.agent_name}/rdp?username=${var.username}&password=${var.password}" + icon = "/icon/rdp.svg" + external = true + order = var.order + group = var.group +} diff --git a/registry/coder/modules/windows-rdp/README.md b/registry/coder/modules/windows-rdp/README.md index 0899a79a..86248692 100644 --- a/registry/coder/modules/windows-rdp/README.md +++ b/registry/coder/modules/windows-rdp/README.md @@ -1,5 +1,5 @@ --- -display_name: Windows RDP +display_name: RDP Web description: RDP Server and Web Client, powered by Devolutions Gateway icon: ../../../../.icons/desktop.svg maintainer_github: coder @@ -14,11 +14,10 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de ```tf # AWS example. See below for examples of using this module with other providers module "windows_rdp" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.2.1" - agent_id = resource.coder_agent.main.id - resource_id = resource.aws_instance.dev.id + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/windows-rdp/coder" + version = "1.2.2" + agent_id = resource.coder_agent.main.id } ``` @@ -32,11 +31,10 @@ module "windows_rdp" { ```tf module "windows_rdp" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.2.1" - agent_id = resource.coder_agent.main.id - resource_id = resource.aws_instance.dev.id + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/windows-rdp/coder" + version = "1.2.2" + agent_id = resource.coder_agent.main.id } ``` @@ -44,11 +42,10 @@ module "windows_rdp" { ```tf module "windows_rdp" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.2.1" - agent_id = resource.coder_agent.main.id - resource_id = resource.google_compute_instance.dev[0].id + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/windows-rdp/coder" + version = "1.2.2" + agent_id = resource.coder_agent.main.id } ``` @@ -58,13 +55,8 @@ module "windows_rdp" { module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.2.1" + version = "1.2.2" agent_id = resource.coder_agent.main.id - resource_id = resource.aws_instance.dev.id - devolutions_gateway_version = "2025.1.6" # Specify a specific version + devolutions_gateway_version = "2025.2.2" # Specify a specific version } ``` - -## Roadmap - -- [ ] Test on Microsoft Azure. diff --git a/registry/coder/modules/windows-rdp/main.test.ts b/registry/coder/modules/windows-rdp/main.test.ts index 01a7e46d..125b3b3b 100644 --- a/registry/coder/modules/windows-rdp/main.test.ts +++ b/registry/coder/modules/windows-rdp/main.test.ts @@ -8,7 +8,6 @@ import { type TestVariables = Readonly<{ agent_id: string; - resource_id: string; share?: string; admin_username?: string; admin_password?: string; @@ -45,13 +44,11 @@ describe("Web RDP", async () => { await runTerraformInit(import.meta.dir); testRequiredVariables(import.meta.dir, { agent_id: "foo", - resource_id: "bar", }); it("Has the PowerShell script install Devolutions Gateway", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", - resource_id: "bar", }); const lines = findWindowsRdpScript(state) @@ -96,7 +93,6 @@ describe("Web RDP", async () => { import.meta.dir, { agent_id: "foo", - resource_id: "bar", }, ); @@ -116,7 +112,6 @@ describe("Web RDP", async () => { import.meta.dir, { agent_id: "foo", - resource_id: "bar", admin_username: customAdminUsername, admin_password: customAdminPassword, }, diff --git a/registry/coder/modules/windows-rdp/main.tf b/registry/coder/modules/windows-rdp/main.tf index b610c52c..c1b996dd 100644 --- a/registry/coder/modules/windows-rdp/main.tf +++ b/registry/coder/modules/windows-rdp/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.5" } } } @@ -35,11 +35,6 @@ variable "agent_id" { description = "The ID of a Coder agent." } -variable "resource_id" { - type = string - description = "The ID of the primary Coder resource (e.g. VM)." -} - variable "admin_username" { type = string default = "Administrator" @@ -53,14 +48,14 @@ variable "admin_password" { variable "devolutions_gateway_version" { type = string - default = "2025.2.1" + default = "2025.2.2" description = "Version of Devolutions Gateway to install. Defaults to the latest available version." } resource "coder_script" "windows-rdp" { agent_id = var.agent_id display_name = "windows-rdp" - icon = "/icon/desktop.svg" + icon = "/icon/rdp.svg" script = templatefile("${path.module}/powershell-installation-script.tftpl", { admin_username = var.admin_username @@ -101,7 +96,7 @@ resource "coder_app" "rdp-docs" { agent_id = var.agent_id display_name = "Local RDP Docs" slug = "rdp-docs" - icon = "https://raw.githubusercontent.com/matifali/logos/main/windows.svg" - url = "https://coder.com/docs/ides/remote-desktops#rdp-desktop" + icon = "/icon/windows.svg" + url = "https://coder.com/docs/user-guides/workspace-access/remote-desktops#rdp" external = true } diff --git a/registry/coder/modules/zed/README.md b/registry/coder/modules/zed/README.md new file mode 100644 index 00000000..ea11372e --- /dev/null +++ b/registry/coder/modules/zed/README.md @@ -0,0 +1,65 @@ +--- +display_name: Zed +description: Add a one-click button to launch Zed +icon: ../../../../.icons/zed.svg +maintainer_github: coder +verified: true +tags: [ide, zed, editor] +--- + +# Zed + +Add a button to open any workspace with a single click in Zed. + +Zed is a high-performance, multiplayer code editor from the creators of Atom and Tree-sitter. + +> [!IMPORTANT] +> Zed needs you to either have Coder CLI installed with `coder config-ssh` run or [Coder Desktop](https://coder.com/docs/user-guides/desktop) + +```tf +module "zed" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/zed/coder" + version = "1.0.0" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Open in a specific directory + +```tf +module "zed" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/zed/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` + +### Custom display name and order + +```tf +module "zed" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/zed/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + display_name = "Zed Editor" + order = 1 +} +``` + +### With custom agent name + +```tf +module "zed" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/zed/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + agent_name = coder_agent.example.name +} +``` diff --git a/registry/coder/modules/zed/main.test.ts b/registry/coder/modules/zed/main.test.ts new file mode 100644 index 00000000..12413750 --- /dev/null +++ b/registry/coder/modules/zed/main.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("zed", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.zed_url.value).toBe("zed://ssh/default.coder"); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "zed", + ); + + 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, { + agent_id: "foo", + folder: "/foo/bar", + }); + expect(state.outputs.zed_url.value).toBe("zed://ssh/default.coder/foo/bar"); + }); + + it("expect order to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + order: "22", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "zed", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); + }); + + it("expect display_name to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + display_name: "Custom Zed", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "zed", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.display_name).toBe("Custom Zed"); + }); + + it("adds agent_name to hostname", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + agent_name: "myagent", + }); + expect(state.outputs.zed_url.value).toBe( + "zed://ssh/myagent.default.default.coder", + ); + }); +}); diff --git a/registry/coder/modules/zed/main.tf b/registry/coder/modules/zed/main.tf new file mode 100644 index 00000000..2f6376a4 --- /dev/null +++ b/registry/coder/modules/zed/main.tf @@ -0,0 +1,77 @@ +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 "agent_name" { + type = string + description = "The name of the agent" + default = "" +} + +variable "folder" { + type = string + description = "The folder to open in Zed" + default = "" +} + +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 "slug" { + type = string + description = "The slug of the app" + default = "zed" +} + +variable "display_name" { + type = string + description = "The display name of the app" + default = "Zed" +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + workspace_name = lower(data.coder_workspace.me.name) + owner_name = lower(data.coder_workspace_owner.me.name) + agent_name = lower(var.agent_name) + hostname = var.agent_name != "" ? "${local.agent_name}.${local.workspace_name}.${local.owner_name}.coder" : "${local.workspace_name}.coder" +} + +resource "coder_app" "zed" { + agent_id = var.agent_id + display_name = var.display_name + slug = var.slug + icon = "/icon/zed.svg" + external = true + order = var.order + group = var.group + url = "zed://ssh/${local.hostname}${var.folder}" +} + +output "zed_url" { + value = coder_app.zed.url + description = "Zed URL" +} diff --git a/registry/coder/templates/aws-devcontainer/README.md b/registry/coder/templates/aws-devcontainer/README.md index 1f944039..82ecde07 100644 --- a/registry/coder/templates/aws-devcontainer/README.md +++ b/registry/coder/templates/aws-devcontainer/README.md @@ -10,7 +10,7 @@ tags: [vm, linux, aws, persistent, devcontainer] # Remote Development on AWS EC2 VMs using a Devcontainer Provision AWS EC2 VMs as [Coder workspaces](https://coder.com/docs) with this example template. -![Architecture Diagram](./architecture.svg) +![Architecture Diagram](../../.images/aws-devcontainer-architecture.svg) diff --git a/registry/coder/templates/digitalocean-linux/README.md b/registry/coder/templates/digitalocean-linux/README.md index 5acddc31..b92c29c4 100644 --- a/registry/coder/templates/digitalocean-linux/README.md +++ b/registry/coder/templates/digitalocean-linux/README.md @@ -1,7 +1,7 @@ --- display_name: DigitalOcean Droplet (Linux) description: Provision DigitalOcean Droplets as Coder workspaces -icon: ../../../../.icons/do.png +icon: ../../../../.icons/digital-ocean.svg maintainer_github: coder verified: true tags: [vm, linux, digitalocean] @@ -20,16 +20,13 @@ To deploy workspaces as DigitalOcean Droplets, you'll need: - DigitalOcean [personal access token (PAT)](https://docs.digitalocean.com/reference/api/create-personal-access-token) - DigitalOcean project ID (you can get your project information via the `doctl` CLI by running `doctl projects list`) - - Remove the following sections from the `main.tf` file if you don't want to associate your workspaces with a project: - - `variable "project_uuid"` - `resource "digitalocean_project_resources" "project"` - **Optional:** DigitalOcean SSH key ID (obtain via the `doctl` CLI by running `doctl compute ssh-key list`) - - Note that this is only required for Fedora images to work. ### Authentication diff --git a/registry/coder/templates/docker-devcontainer/README.md b/registry/coder/templates/docker-devcontainer/README.md index 0c07d5ca..0ba7eaa5 100644 --- a/registry/coder/templates/docker-devcontainer/README.md +++ b/registry/coder/templates/docker-devcontainer/README.md @@ -1,7 +1,7 @@ --- display_name: Docker (Devcontainer) description: Provision envbuilder containers as Coder workspaces -icon: ../../../../.icons/docker.png +icon: ../../../../.icons/docker.svg maintainer_github: coder verified: true tags: [container, docker, devcontainer] diff --git a/registry/coder/templates/docker/README.md b/registry/coder/templates/docker/README.md index a19b85e7..74ca8bbb 100644 --- a/registry/coder/templates/docker/README.md +++ b/registry/coder/templates/docker/README.md @@ -1,7 +1,7 @@ --- display_name: Docker Containers description: Provision Docker containers as Coder workspaces -icon: ../../../../.icons/docker.png +icon: ../../../../.icons/docker.svg maintainer_github: coder verified: true tags: [docker, container] diff --git a/registry/coder/templates/gcp-devcontainer/README.md b/registry/coder/templates/gcp-devcontainer/README.md index 0be4a786..05c69e2a 100644 --- a/registry/coder/templates/gcp-devcontainer/README.md +++ b/registry/coder/templates/gcp-devcontainer/README.md @@ -9,7 +9,7 @@ tags: [vm, linux, gcp, devcontainer] # Remote Development in a Devcontainer on Google Compute Engine -![Architecture Diagram](./architecture.svg) +![Architecture Diagram](../../.images/gcp-devcontainer-architecture.svg) ## Prerequisites @@ -34,7 +34,6 @@ a service account: 1. Click **Create and continue**, and choose the following IAM roles to grant to the service account: - - Compute Admin - Service Account User diff --git a/registry/coder/templates/gcp-linux/README.md b/registry/coder/templates/gcp-linux/README.md index 60191040..daeda00b 100644 --- a/registry/coder/templates/gcp-linux/README.md +++ b/registry/coder/templates/gcp-linux/README.md @@ -32,7 +32,6 @@ a service account: 1. Click **Create and continue**, and choose the following IAM roles to grant to the service account: - - Compute Admin - Service Account User diff --git a/registry/coder/templates/gcp-vm-container/README.md b/registry/coder/templates/gcp-vm-container/README.md index 83704ee2..d8e9c10e 100644 --- a/registry/coder/templates/gcp-vm-container/README.md +++ b/registry/coder/templates/gcp-vm-container/README.md @@ -32,7 +32,6 @@ a service account: 1. Click **Create and continue**, and choose the following IAM roles to grant to the service account: - - Compute Admin - Service Account User diff --git a/registry/coder/templates/gcp-windows/README.md b/registry/coder/templates/gcp-windows/README.md index ac717e41..933f2d6f 100644 --- a/registry/coder/templates/gcp-windows/README.md +++ b/registry/coder/templates/gcp-windows/README.md @@ -32,7 +32,6 @@ a service account: 1. Click **Create and continue**, and choose the following IAM roles to grant to the service account: - - Compute Admin - Service Account User diff --git a/registry/coder/templates/incus/README.md b/registry/coder/templates/incus/README.md index def594cd..2a6d54e0 100644 --- a/registry/coder/templates/incus/README.md +++ b/registry/coder/templates/incus/README.md @@ -15,7 +15,6 @@ Develop in an Incus System Container and run nested Docker containers using Incu 1. Install [Incus](https://linuxcontainers.org/incus/) on the same machine as Coder. 2. Allow Coder to access the Incus socket. - - If you're running Coder as system service, run `sudo usermod -aG incus-admin coder` and restart the Coder service. - If you're running Coder as a Docker Compose service, get the group ID of the `incus-admin` group by running `getent group incus-admin` and add the following to your `compose.yaml` file: diff --git a/registry/coder/templates/kubernetes-devcontainer/README.md b/registry/coder/templates/kubernetes-devcontainer/README.md index 6a5aeea0..1a9ad6f7 100644 --- a/registry/coder/templates/kubernetes-devcontainer/README.md +++ b/registry/coder/templates/kubernetes-devcontainer/README.md @@ -1,7 +1,7 @@ --- display_name: Kubernetes (Devcontainer) description: Provision envbuilder pods as Coder workspaces -icon: ../../../../.icons/k8s.png +icon: ../../../../.icons/kubernetes.svg maintainer_github: coder verified: true tags: [container, kubernetes, devcontainer] diff --git a/registry/coder/templates/kubernetes-envbox/README.md b/registry/coder/templates/kubernetes-envbox/README.md index 5360052f..eb9afa42 100644 --- a/registry/coder/templates/kubernetes-envbox/README.md +++ b/registry/coder/templates/kubernetes-envbox/README.md @@ -1,7 +1,7 @@ --- display_name: Kubernetes (Envbox) description: Provision envbox pods as Coder workspaces -icon: ../../../../.icons/k8s.png +icon: ../../../../.icons/kubernetes.svg maintainer_github: coder verified: true tags: [kubernetes, containers, docker-in-docker] diff --git a/registry/coder/templates/kubernetes/README.md b/registry/coder/templates/kubernetes/README.md index 547ec57b..cdf7f458 100644 --- a/registry/coder/templates/kubernetes/README.md +++ b/registry/coder/templates/kubernetes/README.md @@ -1,7 +1,7 @@ --- display_name: Kubernetes (Deployment) description: Provision Kubernetes Deployments as Coder workspaces -icon: ../../../../.icons/k8s.png +icon: ../../../../.icons/kubernetes.svg maintainer_github: coder verified: true tags: [kubernetes, container] diff --git a/registry/coder/templates/scratch/README.md b/registry/coder/templates/scratch/README.md index 81f9162d..c9c07f59 100644 --- a/registry/coder/templates/scratch/README.md +++ b/registry/coder/templates/scratch/README.md @@ -1,7 +1,7 @@ --- display_name: Scratch description: A minimal starter template for Coder -icon: ../../../../.icons/1f4e6.png +icon: ../../../../.icons/box-emoji.svg maintainer_github: coder verified: true tags: [] diff --git a/test/test.ts b/test/test.ts index 4f413180..bb09a410 100644 --- a/test/test.ts +++ b/test/test.ts @@ -30,6 +30,21 @@ export const runContainer = async ( return containerID.trim(); }; +export const removeContainer = async (id: string) => { + const proc = spawn(["docker", "rm", "-f", id], { + stderr: "pipe", + stdout: "pipe", + }); + const exitCode = await proc.exited; + const [stderr, stdout] = await Promise.all([ + readableStreamToText(proc.stderr ?? new ReadableStream()), + readableStreamToText(proc.stdout ?? new ReadableStream()), + ]); + if (exitCode !== 0) { + throw new Error(`${stderr}\n${stdout}`); + } +}; + export interface scriptOutput { exitCode: number; stdout: string[]; @@ -279,10 +294,43 @@ export const createJSONResponse = (obj: object, statusCode = 200): Response => { }; export const writeCoder = async (id: string, script: string) => { - const exec = await execContainer(id, [ - "sh", - "-c", - `echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`, - ]); - expect(exec.exitCode).toBe(0); + await writeFileContainer(id, "/usr/bin/coder", script, { + user: "root", + }); + const execResult = await execContainer( + id, + ["chmod", "755", "/usr/bin/coder"], + ["--user", "root"], + ); + expect(execResult.exitCode).toBe(0); +}; + +export const writeFileContainer = async ( + id: string, + path: string, + content: string, + options?: { + user?: string; + }, +) => { + const contentBase64 = Buffer.from(content).toString("base64"); + const proc = await execContainer( + id, + ["sh", "-c", `echo '${contentBase64}' | base64 -d > '${path}'`], + options?.user ? ["--user", options.user] : undefined, + ); + if (proc.exitCode !== 0) { + throw new Error(`Failed to write file: ${proc.stderr}`); + } + expect(proc.exitCode).toBe(0); +}; + +export const readFileContainer = async (id: string, path: string) => { + const proc = await execContainer(id, ["cat", path], ["--user", "root"]); + if (proc.exitCode !== 0) { + console.log(proc.stderr); + console.log(proc.stdout); + } + expect(proc.exitCode).toBe(0); + return proc.stdout; };