mirror of
https://github.com/coder/registry.git
synced 2026-06-05 05:58:14 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51ec6e3212 | |||
| 843b1f1e5a | |||
| 583918bfef | |||
| a1786a09ea | |||
| a35986d7df | |||
| e34320cb0b | |||
| ca7bc42946 | |||
| a599302774 | |||
| ff09c415e8 | |||
| 90873e8009 | |||
| 2168360195 | |||
| da5a2ba6a8 | |||
| 63cad25954 | |||
| cd759bd9a1 | |||
| 54a7bb0001 | |||
| 50f4d5388b | |||
| 36943d1dfb | |||
| e7d705bf98 | |||
| 898219b16b | |||
| fc071e0930 | |||
| d516aff908 | |||
| ccdca6daf5 | |||
| ce039f64df | |||
| 8acda84dd7 | |||
| 76c1299968 | |||
| 60372ff797 | |||
| f28bcdb713 | |||
| cb553209a5 | |||
| 5d0504aef9 | |||
| c1c0dec90f | |||
| 59b67c2c98 | |||
| 7abe422e0a | |||
| db8217e4e5 | |||
| f75afeb0c8 | |||
| 182e5548e2 | |||
| d057a820c1 | |||
| b4e9545c35 | |||
| 50ac3b31f6 | |||
| 056937a758 | |||
| af8b4f02fd | |||
| 2de6a57a3f | |||
| 60fec19d7d | |||
| 44354b202d | |||
| 80acbd7e3a | |||
| 80f429faf1 | |||
| e516446d03 | |||
| f0045397d4 | |||
| 6af8508bc0 | |||
| d212de47ed | |||
| 54b9bf3038 | |||
| cb990bbee0 | |||
| 213aabb3b0 | |||
| 2937286712 | |||
| 8d556a8ab7 |
@@ -1,5 +1,3 @@
|
||||
Closes #
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Briefly describe what this PR does and why -->
|
||||
@@ -7,6 +5,7 @@ Closes #
|
||||
## Type of Change
|
||||
|
||||
- [ ] New module
|
||||
- [ ] New template
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature/enhancement
|
||||
- [ ] Documentation
|
||||
@@ -20,10 +19,16 @@ Closes #
|
||||
**New version:** `v1.0.0`
|
||||
**Breaking change:** [ ] Yes [ ] No
|
||||
|
||||
## Template Information
|
||||
|
||||
<!-- Delete this section if not applicable -->
|
||||
|
||||
**Path:** `registry/[namespace]/templates/[template-name]`
|
||||
|
||||
## Testing & Validation
|
||||
|
||||
- [ ] Tests pass (`bun test`)
|
||||
- [ ] Code formatted (`bun run fmt`)
|
||||
- [ ] Code formatted (`bun fmt`)
|
||||
- [ ] Changes tested locally
|
||||
|
||||
## Related Issues
|
||||
|
||||
@@ -5,6 +5,7 @@ Hashi = "Hashi"
|
||||
HashiCorp = "HashiCorp"
|
||||
mavrickrishi = "mavrickrishi" # Username
|
||||
mavrick = "mavrick" # Username
|
||||
inh = "inh" # Option in setpriv command
|
||||
|
||||
[files]
|
||||
extend-exclude = ["registry/coder/templates/aws-devcontainer/architecture.svg"] #False positive
|
||||
@@ -13,6 +13,26 @@ jobs:
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
- name: Detect changed files
|
||||
uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
list-files: shell
|
||||
filters: |
|
||||
shared:
|
||||
- 'test/**'
|
||||
- 'package.json'
|
||||
- 'bun.lock'
|
||||
- 'bunfig.toml'
|
||||
- 'tsconfig.json'
|
||||
- '.github/workflows/ci.yaml'
|
||||
- 'scripts/ts_test_auto.sh'
|
||||
- 'scripts/terraform_test_all.sh'
|
||||
- 'scripts/terraform_validate.sh'
|
||||
modules:
|
||||
- 'registry/**/modules/**'
|
||||
all:
|
||||
- '**'
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@main
|
||||
- name: Set up Bun
|
||||
@@ -27,8 +47,22 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Run TypeScript tests
|
||||
run: bun test
|
||||
env:
|
||||
ALL_CHANGED_FILES: ${{ steps.filter.outputs.all_files }}
|
||||
SHARED_CHANGED: ${{ steps.filter.outputs.shared }}
|
||||
MODULE_CHANGED_FILES: ${{ steps.filter.outputs.modules_files }}
|
||||
run: bun tstest
|
||||
- name: Run Terraform tests
|
||||
env:
|
||||
ALL_CHANGED_FILES: ${{ steps.filter.outputs.all_files }}
|
||||
SHARED_CHANGED: ${{ steps.filter.outputs.shared }}
|
||||
MODULE_CHANGED_FILES: ${{ steps.filter.outputs.modules_files }}
|
||||
run: bun tftest
|
||||
- name: Run Terraform Validate
|
||||
env:
|
||||
ALL_CHANGED_FILES: ${{ steps.filter.outputs.all_files }}
|
||||
SHARED_CHANGED: ${{ steps.filter.outputs.shared }}
|
||||
MODULE_CHANGED_FILES: ${{ steps.filter.outputs.modules_files }}
|
||||
run: bun terraform-validate
|
||||
validate-style:
|
||||
name: Check for typos and unformatted code
|
||||
@@ -48,7 +82,7 @@ jobs:
|
||||
- name: Validate formatting
|
||||
run: bun fmt:ci
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.36.2
|
||||
uses: crate-ci/typos@v1.38.1
|
||||
with:
|
||||
config: .github/typos.toml
|
||||
validate-readme-files:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512pt" height="512pt" version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m500.48 262.2-48.18 73.984c-0.73438 1.1367-2 1.8242-3.3555 1.8242-1.3516 0-2.6172-0.6875-3.3516-1.8242l-48.129-73.984c-0.78125-1.2227-0.83594-2.7773-0.14453-4.0547 0.69141-1.2734 2.0195-2.0742 3.4727-2.0898h24.781c-0.007813-29.523-7.7188-58.531-22.375-84.156-14.652-25.629-35.742-46.988-61.184-61.969-2.3711-1.3633-3.8633-3.8594-3.9453-6.5938-0.085937-2.7305 1.2539-5.3125 3.5352-6.8203l27.035-17.613c3.4766-2.3633 8.043-2.3633 11.52 0 28.473 19.934 51.723 46.441 67.773 77.27 16.051 30.828 24.434 65.074 24.438 99.832h24.781c1.4688 0 2.8203 0.80859 3.5156 2.1055 0.69531 1.293 0.62109 2.8633-0.1875 4.0898zm-85.043 79.359c-1.5078-2.2812-4.0898-3.6211-6.8203-3.5391-2.7344 0.085937-5.2305 1.5781-6.5938 3.9492-14.965 25.434-36.305 46.523-61.914 61.188-25.609 14.664-54.602 22.391-84.109 22.422v-24.781c-0.011719-1.4531-0.8125-2.7812-2.0898-3.4727-1.2773-0.69141-2.832-0.63672-4.0547 0.14453l-74.035 47.977c-1.1367 0.73438-1.8242 1.9961-1.8242 3.3516s0.6875 2.6172 1.8242 3.3555l73.984 48.18c1.2227 0.78125 2.7773 0.83594 4.0547 0.14453 1.2734-0.69141 2.0742-2.0234 2.0898-3.4727v-24.68c34.734-0.015624 68.957-8.3984 99.766-24.441 30.812-16.039 57.301-39.27 77.23-67.719 2.3672-3.4766 2.3672-8.043 0-11.52zm-245.45 60.52c-25.434-14.977-46.516-36.328-61.172-61.945-14.652-25.617-22.371-54.617-22.387-84.129h24.781c1.4531-0.011719 2.7812-0.8125 3.4727-2.0898 0.69141-1.2773 0.63672-2.832-0.14453-4.0547l-47.977-74.035c-0.73438-1.1367-1.9961-1.8242-3.3516-1.8242s-2.6172 0.6875-3.3555 1.8242l-48.332 73.984c-0.80859 1.2266-0.88281 2.7969-0.1875 4.0898 0.69531 1.2969 2.0469 2.1055 3.5156 2.1055h24.781c0.015625 34.734 8.3984 68.957 24.438 99.766 16.043 30.812 39.273 57.301 67.723 77.234 3.4766 2.3633 8.043 2.3633 11.52 0l27.086-17.664c2.2109-1.5195 3.4961-4.0625 3.4141-6.7422-0.082032-2.6836-1.5234-5.1406-3.8242-6.5195zm92.16-390.5c-1.2227-0.78125-2.7773-0.83594-4.0547-0.14453-1.2773 0.69141-2.0781 2.0195-2.0898 3.4727v24.73c-34.734 0.015625-68.957 8.3984-99.766 24.438-30.812 16.043-57.301 39.273-77.234 67.723-2.3633 3.4766-2.3633 8.043 0 11.52l17.664 27.086c1.5078 2.2812 4.0898 3.6211 6.8242 3.5352 2.7305-0.082032 5.2266-1.5742 6.5898-3.9453 14.965-25.41 36.289-46.48 61.879-61.133 25.59-14.652 54.555-22.383 84.043-22.426v24.781c0.011719 1.4531 0.8125 2.7812 2.0898 3.4727 1.2773 0.69141 2.832 0.63672 4.0547-0.14453l74.035-47.977c1.1367-0.73438 1.8242-1.9961 1.8242-3.3516s-0.6875-2.6172-1.8242-3.3555zm-6.1445 210.23c-9.0703 0-17.77 3.6055-24.184 10.02-6.4141 6.4141-10.02 15.113-10.02 24.184s3.6055 17.77 10.02 24.184c6.4141 6.4141 15.113 10.02 24.184 10.02s17.77-3.6055 24.184-10.02c6.4141-6.4141 10.02-15.113 10.02-24.184s-3.6055-17.77-10.02-24.184c-6.4141-6.4141-15.113-10.02-24.184-10.02zm90.727-26.828-10.344 14.953c4.0039 6.9414 7.0859 14.375 9.1641 22.117l17.973 2.9688c6.543 1.1445 11.316 6.8242 11.316 13.465v15.055c0 6.6406-4.7734 12.32-11.316 13.465l-17.766 3.125v-0.003907c-2.1562 7.6992-5.3086 15.082-9.3711 21.965l10.238 14.797h0.003906c3.8047 5.4375 3.1562 12.82-1.5352 17.512l-10.648 10.648h-0.003906c-4.6914 4.6953-12.074 5.3438-17.508 1.5391l-14.797-10.238v-0.003907c-6.9453 4.0039-14.379 7.0859-22.121 9.1641l-3.0195 18.023c-1.1445 6.543-6.8242 11.316-13.465 11.316h-15.055c-6.6406 0-12.32-4.7734-13.465-11.316l-3.125-17.766h0.003907c-7.7031-2.1758-15.086-5.3398-21.965-9.4219l-14.797 10.238v0.003907c-5.4375 3.8047-12.82 3.1562-17.512-1.5391l-10.648-10.648c-4.6953-4.6914-5.3438-12.074-1.5391-17.512l10.238-14.797h0.003907c-4.0039-6.9414-7.0859-14.375-9.1641-22.117l-18.023-2.9688c-6.543-1.1445-11.316-6.8242-11.316-13.465v-15.055c0-6.6406 4.7734-12.32 11.316-13.465l17.766-3.125v0.003907c2.1562-7.6992 5.3086-15.082 9.3711-21.965l-10.238-14.797h-0.003906c-3.8047-5.4375-3.1562-12.82 1.5352-17.512l10.648-10.648h0.003906c4.6914-4.6953 12.074-5.3438 17.508-1.5391l14.797 10.238v0.003907c6.9453-4.0039 14.379-7.0859 22.121-9.1641l3.0195-18.023c1.1445-6.543 6.8242-11.316 13.465-11.316h15.055c6.6406 0 12.32 4.7734 13.465 11.316l3.125 17.766h-0.003907c7.6992 2.1562 15.082 5.3086 21.965 9.3711l14.797-10.238v-0.003906c5.4375-3.8047 12.82-3.1562 17.512 1.5352l10.648 10.648v0.003906c4.6875 4.6367 5.3984 11.957 1.6914 17.406zm-36.047 61.031c0-14.504-5.7578-28.41-16.016-38.664-10.254-10.258-24.16-16.016-38.664-16.016s-28.41 5.7578-38.664 16.016c-10.258 10.254-16.016 24.16-16.016 38.664s5.7578 28.41 16.016 38.664c10.254 10.258 24.16 16.016 38.664 16.016 14.5-0.011719 28.398-5.7773 38.652-16.027 10.25-10.254 16.016-24.152 16.027-38.652z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" fill="#FFF"><path d="M7.05 40q-1.2 0-2.1-.925-.9-.925-.9-2.075V11q0-1.15.9-2.075Q5.85 8 7.05 8h14l3 3h17q1.15 0 2.075.925.925.925.925 2.075v23q0 1.15-.925 2.075Q42.2 40 41.05 40Zm0-29v26h34V14H22.8l-3-3H7.05Zm0 0v26Z"/></svg>
|
||||
|
After Width: | Height: | Size: 289 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 47.0195C39.45 49.6394 71.06 81.3272 73.54 120.815H119.61C117.05 55.8589 64.93 3.64245 0 0.942627V47.0195Z" fill="#0DC09D"/>
|
||||
<path d="M73.8 131.324C71.18 170.771 39.49 202.379 0 204.859V250.926C64.96 248.366 117.18 196.249 119.88 131.324H73.8Z" fill="#0DC09D"/>
|
||||
<path d="M176.201 120.545C178.821 81.0972 210.511 49.4894 250.001 47.0095V0.942627C185.041 3.50245 132.821 55.619 130.121 120.545H176.201Z" fill="#0DC09D"/>
|
||||
<path d="M250.001 204.849C210.551 202.229 178.941 170.542 176.461 131.054H130.391C132.951 196.01 185.071 248.226 250.001 250.926V204.849Z" fill="#0DC09D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 693 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg height="32" viewBox="0 0 375 375" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect fill="#0071ff" height="375.001088" rx="58.59392" stroke-width=".91553" width="375.001088" x=".0009759" y="-.0066962"/>
|
||||
<path d="m150.428 322.264c-29.063-6.202-53.897-22.439-73.115-47.804-19.507-25.746-27.838-55.355-25.723-91.414 6.655-62.013 47.667-106.753 99.687-120.411 4.509-.989 8.353-3.462 12.55-1.322 3.22 1.64 6.028 4.467 7.206 7.251 1.25 2.955 1.877 21.54.99 29.331-1.076 9.46-3.877 12.418-14.566 15.388-29.723 10.195-48.105 34.07-53.697 61.017-4.8 29.668 2.951 59.729 21.528 78.727 8.966 8.993 17.92 14.24 30.869 18.086 8.646 2.57 13.393 5.758 15.036 10.102 1.085 2.867 1.63 22.984.779 28.772-1.33 9.046-1.702 9.796-5.792 11.667-5.029 2.3-7.404 2.392-15.752.61zm50.708.29c-3.092-1.402-5.673-4.83-6.73-8.94-.134-9.408-2.366-25.754 1.02-33.373 1.88-4.128 4.65-5.999 12.433-8.396 21.267-6.551 37.593-19.88 46.806-38.213 11.11-22.108 11.877-55.183 1.808-77.975-9.154-20.723-25.7-35.217-48.555-42.534-8.872-2.84-12.004-5.065-12.968-9.21-1.002-4.31-1.435-19.87-.785-28.218.682-8.766 1.249-9.99 6.162-13.318 3.701-2.505 5.482-2.446 17.223.575 36.718 10.077 65.97 33.597 83.026 66.68 18.495 37.034 19.191 86.11 1.742 122.655-17.233 36.09-50.591 62.511-88.622 70.194-8.172 1.65-9.07 1.656-12.56.073z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
+19
-8
@@ -124,18 +124,23 @@ This script generates:
|
||||
- Accurate description and usage examples
|
||||
- Correct icon path (usually `../../../../.icons/your-icon.svg`)
|
||||
- Proper tags that describe your module
|
||||
3. **Create at least one `.tftest.hcl`** to test your module with `terraform test`
|
||||
3. **Create tests for your module:**
|
||||
- **Terraform tests**: Create a `*.tftest.hcl` file and test with `terraform test`
|
||||
- **TypeScript tests**: Create `main.test.ts` file if your module runs scripts or has business logic that Terraform tests can't cover
|
||||
4. **Add any scripts** or additional files your module needs
|
||||
|
||||
### 4. Test and Submit
|
||||
|
||||
```bash
|
||||
# Test your module (from the module directory)
|
||||
# Test your module
|
||||
cd registry/[namespace]/modules/[module-name]
|
||||
|
||||
# Required: Test Terraform functionality
|
||||
terraform init -upgrade
|
||||
terraform test -verbose
|
||||
|
||||
# Or run all tests in the repo
|
||||
./scripts/terraform_test_all.sh
|
||||
# Optional: Test TypeScript files if you have main.test.ts
|
||||
bun test main.test.ts
|
||||
|
||||
# Format code
|
||||
bun run fmt
|
||||
@@ -343,8 +348,8 @@ coder templates push test-[template-name] -d .
|
||||
terraform init -upgrade
|
||||
terraform test -verbose
|
||||
|
||||
# Test all modules
|
||||
./scripts/terraform_test_all.sh
|
||||
# Optional: If you have TypeScript tests
|
||||
bun test main.test.ts
|
||||
```
|
||||
|
||||
### 3. Maintain Backward Compatibility
|
||||
@@ -393,7 +398,9 @@ Example: `https://github.com/coder/registry/compare/main...your-branch?template=
|
||||
### Every Module Must Have
|
||||
|
||||
- `main.tf` - Terraform code
|
||||
- One or more `.tftest.hcl` files - Working tests with `terraform test`
|
||||
- **Tests**:
|
||||
- `*.tftest.hcl` files with `terraform test` (to test terraform specific logic)
|
||||
- `main.test.ts` file with `bun test` (to test business logic, i.e., `coder_script` to install a package.)
|
||||
- `README.md` - Documentation with frontmatter
|
||||
|
||||
### Every Template Must Have
|
||||
@@ -493,6 +500,10 @@ When reporting bugs, include:
|
||||
2. **No tests** or broken tests
|
||||
3. **Hardcoded values** instead of variables
|
||||
4. **Breaking changes** without defaults
|
||||
5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`) before submitting
|
||||
5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`, and `bun test main.test.ts` if applicable) before submitting
|
||||
|
||||
## For Maintainers
|
||||
|
||||
Guidelines for reviewing PRs, managing releases, and maintaining the registry. [See the maintainer guide for detailed information.](./MAINTAINER.md)
|
||||
|
||||
Happy contributing! 🚀
|
||||
|
||||
+3
-1
@@ -23,6 +23,7 @@ Check that PRs have:
|
||||
- [ ] Working tests (`terraform test`)
|
||||
- [ ] Formatted code (`bun run fmt`)
|
||||
- [ ] Avatar image for new namespaces (`avatar.png` or `avatar.svg` in `.images/`)
|
||||
- [ ] Version label: `version:patch`, `version:minor`, or `version:major`
|
||||
|
||||
### Version Guidelines
|
||||
|
||||
@@ -32,7 +33,8 @@ When reviewing PRs, ensure the version change follows semantic versioning:
|
||||
- **Minor** (1.2.3 → 1.3.0): New features, adding inputs
|
||||
- **Major** (1.2.3 → 2.0.0): Breaking changes (removing inputs, changing types)
|
||||
|
||||
PRs should clearly indicate the version change (e.g., `v1.2.3 → v1.2.4`).
|
||||
PRs should clearly indicate the intended version change (e.g., `v1.2.3 → v1.2.4`) and include the appropriate label: `version:patch`, `version:minor`, or `version:major`.
|
||||
The “Version Bump” CI uses this label to validate required updates (README version refs, etc.).
|
||||
|
||||
### Validate READMEs
|
||||
|
||||
|
||||
@@ -48,3 +48,7 @@ Simply include that snippet inside your Coder template, defining any data depend
|
||||
## Contributing
|
||||
|
||||
We are always accepting new contributions. [Please see our contributing guide for more information.](./CONTRIBUTING.md)
|
||||
|
||||
## For Maintainers
|
||||
|
||||
Guidelines for maintainers reviewing PRs and managing releases. [See the maintainer guide for more information.](./MAINTAINER.md)
|
||||
|
||||
@@ -15,7 +15,7 @@ run "app_url_uses_port" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.MODULE_NAME.url == "http://localhost:19999"
|
||||
error_message = "Expected MODULE_NAME app URL to include configured port"
|
||||
condition = resource.coder_app.module_name.url == "http://localhost:19999"
|
||||
error_message = "Expected module-name app URL to include configured port"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -35,13 +35,13 @@ variable "agent_id" {
|
||||
|
||||
variable "log_path" {
|
||||
type = string
|
||||
description = "The path to log MODULE_NAME to."
|
||||
default = "/tmp/MODULE_NAME.log"
|
||||
description = "The path to the module log file."
|
||||
default = "/tmp/module_name.log"
|
||||
}
|
||||
|
||||
variable "port" {
|
||||
type = number
|
||||
description = "The port to run MODULE_NAME on."
|
||||
description = "The port to run the application on."
|
||||
default = 19999
|
||||
}
|
||||
|
||||
@@ -59,9 +59,9 @@ variable "order" {
|
||||
# Add other variables here
|
||||
|
||||
|
||||
resource "coder_script" "MODULE_NAME" {
|
||||
resource "coder_script" "module_name" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "MODULE_NAME"
|
||||
display_name = "Module Name"
|
||||
icon = local.icon_url
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
LOG_PATH : var.log_path,
|
||||
@@ -70,10 +70,10 @@ resource "coder_script" "MODULE_NAME" {
|
||||
run_on_stop = false
|
||||
}
|
||||
|
||||
resource "coder_app" "MODULE_NAME" {
|
||||
resource "coder_app" "module_name" {
|
||||
agent_id = var.agent_id
|
||||
slug = "MODULE_NAME"
|
||||
display_name = "MODULE_NAME"
|
||||
slug = "module-name"
|
||||
display_name = "Module Name"
|
||||
url = "http://localhost:${var.port}"
|
||||
icon = local.icon_url
|
||||
subdomain = false
|
||||
@@ -88,10 +88,10 @@ resource "coder_app" "MODULE_NAME" {
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "MODULE_NAME" {
|
||||
type = "list(string)"
|
||||
name = "MODULE_NAME"
|
||||
display_name = "MODULE_NAME"
|
||||
data "coder_parameter" "module_name" {
|
||||
type = "string"
|
||||
name = "module_name"
|
||||
display_name = "Module Name"
|
||||
icon = local.icon_url
|
||||
mutable = var.mutable
|
||||
default = local.options["Option 1"]["value"]
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"fmt": "bun x prettier --write . && terraform fmt -recursive -diff",
|
||||
"fmt:ci": "bun x prettier --check . && terraform fmt -check -recursive -diff",
|
||||
"terraform-validate": "./scripts/terraform_validate.sh",
|
||||
"test": "./scripts/terraform_test_all.sh",
|
||||
"tftest": "./scripts/terraform_test_all.sh",
|
||||
"tstest": "./scripts/ts_test_auto.sh",
|
||||
"update-version": "./update-version.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,14 @@
|
||||
---
|
||||
display_name: "Benraouane Soufiane"
|
||||
bio: "Full stack developer creating awesome things."
|
||||
avatar: "./.images/avatar.png"
|
||||
github: "benraouanesoufiane"
|
||||
linkedin: "https://www.linkedin.com/in/benraouane-soufiane" # Optional
|
||||
website: "https://benraouanesoufiane.com" # Optional
|
||||
support_email: "hello@benraouanesoufiane.com" # Optional
|
||||
status: "community"
|
||||
---
|
||||
|
||||
# Benraouane Soufiane
|
||||
|
||||
Full stack developer creating awesome things.
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
display_name: RustDesk
|
||||
description: Run RustDesk in your workspace with virtual display
|
||||
icon: ../../../../.icons/rustdesk.svg
|
||||
verified: false
|
||||
tags: [rustdesk, rdp, vm]
|
||||
---
|
||||
|
||||
# RustDesk
|
||||
|
||||
Launches RustDesk within your workspace with a virtual display to provide remote desktop access. The module outputs the RustDesk ID and password needed to connect from external RustDesk clients.
|
||||
|
||||
```tf
|
||||
module "rustdesk" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/BenraouaneSoufiane/rustdesk/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Automatically sets up virtual display (Xvfb)
|
||||
- Downloads and configures RustDesk
|
||||
- Outputs RustDesk ID and password for easy connection
|
||||
- Provides external app link to RustDesk web client for browser-based access
|
||||
- Starts virtual display (Xvfb) with customizable resolution
|
||||
- Customizable screen resolution and RustDesk version
|
||||
|
||||
## Requirements
|
||||
|
||||
- Coder v2.5 or higher
|
||||
- Linux workspace with `apt`, `dnf`, or `yum` package manager
|
||||
|
||||
## Examples
|
||||
|
||||
### Custom configuration with specific version
|
||||
|
||||
```tf
|
||||
module "rustdesk" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/BenraouaneSoufiane/rustdesk/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
rustdesk_password = "mycustompass"
|
||||
xvfb_resolution = "1920x1080x24"
|
||||
rustdesk_version = "1.4.1"
|
||||
}
|
||||
```
|
||||
|
||||
### Docker container configuration
|
||||
|
||||
It requires coder' server to be run as root, when using with Docker, add the following to your `docker_container` resource:
|
||||
|
||||
```tf
|
||||
resource "docker_container" "workspace" {
|
||||
|
||||
# ... other configuration ...
|
||||
|
||||
user = "root"
|
||||
privileged = true
|
||||
network_mode = "host"
|
||||
|
||||
ports {
|
||||
internal = 21115
|
||||
external = 21115
|
||||
}
|
||||
ports {
|
||||
internal = 21116
|
||||
external = 21116
|
||||
}
|
||||
ports {
|
||||
internal = 21118
|
||||
external = 21118
|
||||
}
|
||||
ports {
|
||||
internal = 21119
|
||||
external = 21119
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "log_path" {
|
||||
type = string
|
||||
description = "The path to log rustdesk to."
|
||||
default = "/tmp/rustdesk.log"
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
description = "Attach RustDesk setup to this agent"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "order" {
|
||||
description = "Run order among scripts/apps"
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
# Optional knobs passed as env (you can expose these as variables too)
|
||||
variable "rustdesk_password" {
|
||||
description = "If empty, the script will generate one"
|
||||
type = string
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "xvfb_resolution" {
|
||||
description = "Xvfb screen size/depth"
|
||||
type = string
|
||||
default = "1024x768x16"
|
||||
}
|
||||
|
||||
variable "rustdesk_version" {
|
||||
description = "RustDesk version to install (use 'latest' for most recent release)"
|
||||
type = string
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
resource "coder_script" "rustdesk" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "RustDesk"
|
||||
run_on_start = true
|
||||
|
||||
# Prepend env as bash exports, then append the script file literally.
|
||||
script = <<-EOT
|
||||
# --- module-provided env knobs ---
|
||||
export RUSTDESK_PASSWORD="${var.rustdesk_password}"
|
||||
export XVFB_RESOLUTION="${var.xvfb_resolution}"
|
||||
export RUSTDESK_VERSION="${var.rustdesk_version}"
|
||||
# ---------------------------------
|
||||
|
||||
${file("${path.module}/run.sh")}
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_app" "rustdesk" {
|
||||
agent_id = var.agent_id
|
||||
slug = "rustdesk"
|
||||
display_name = "Rustdesk"
|
||||
url = "https://rustdesk.com/web"
|
||||
icon = "/icon/rustdesk.svg"
|
||||
order = var.order
|
||||
external = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
RESET='\033[0m'
|
||||
|
||||
printf "${BOLD}🖥️ Installing RustDesk Remote Desktop\n${RESET}"
|
||||
|
||||
# ---- configurable knobs (env overrides) ----
|
||||
RUSTDESK_VERSION="${RUSTDESK_VERSION:-latest}"
|
||||
LOG_PATH="${LOG_PATH:-/tmp/rustdesk.log}"
|
||||
|
||||
# ---- fetch latest version if needed ----
|
||||
if [ "$RUSTDESK_VERSION" = "latest" ]; then
|
||||
printf "🔍 Fetching latest RustDesk version...\n"
|
||||
RUSTDESK_VERSION=$(curl -s https://api.github.com/repos/rustdesk/rustdesk/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || echo "1.4.1")
|
||||
printf "📌 Fetched RustDesk version: ${RUSTDESK_VERSION}\n"
|
||||
else
|
||||
printf "📌 Using specified RustDesk version: ${RUSTDESK_VERSION}\n"
|
||||
fi
|
||||
XVFB_RESOLUTION="${XVFB_RESOLUTION:-1024x768x16}"
|
||||
RUSTDESK_PASSWORD="${RUSTDESK_PASSWORD:-}"
|
||||
|
||||
# ---- detect package manager & arch ----
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64 | amd64) PKG_ARCH="x86_64" ;;
|
||||
aarch64 | arm64) PKG_ARCH="aarch64" ;;
|
||||
*)
|
||||
echo "❌ Unsupported arch: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if command -v apt-get > /dev/null 2>&1; then
|
||||
PKG_SYS="deb"
|
||||
PKG_NAME="rustdesk-${RUSTDESK_VERSION}-${PKG_ARCH}.deb"
|
||||
INSTALL_DEPS='apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y wget libva2 libva-drm2 libva-x11-2 libgstreamer-plugins-base1.0-0 gstreamer1.0-pipewire xfce4 xfce4-goodies xvfb x11-xserver-utils dbus-x11 libegl1 libgl1 libglx0 libglu1-mesa mesa-utils libxrandr2 libxss1 libgtk-3-0t64 libgbm1 libdrm2 libxcomposite1 libxdamage1 libxfixes3'
|
||||
INSTALL_CMD="apt-get install -y ./${PKG_NAME}"
|
||||
CLEAN_CMD="rm -f \"${PKG_NAME}\""
|
||||
elif command -v dnf > /dev/null 2>&1; then
|
||||
PKG_SYS="rpm"
|
||||
PKG_NAME="rustdesk-${RUSTDESK_VERSION}-${PKG_ARCH}.rpm"
|
||||
INSTALL_DEPS='dnf install -y wget libva libva-intel-driver gstreamer1-plugins-base pipewire xfce4-session xfce4-panel xorg-x11-server-Xvfb xorg-x11-xauth dbus-x11 mesa-libEGL mesa-libGL mesa-libGLU mesa-dri-drivers libXrandr libXScrnSaver gtk3 mesa-libgbm libdrm libXcomposite libXdamage libXfixes'
|
||||
INSTALL_CMD="dnf install -y ./${PKG_NAME}"
|
||||
CLEAN_CMD="rm -f \"${PKG_NAME}\""
|
||||
elif command -v yum > /dev/null 2>&1; then
|
||||
PKG_SYS="rpm"
|
||||
PKG_NAME="rustdesk-${RUSTDESK_VERSION}-${PKG_ARCH}.rpm"
|
||||
INSTALL_DEPS='yum install -y wget libva libva-intel-driver gstreamer1-plugins-base pipewire xfce4-session xfce4-panel xorg-x11-server-Xvfb xorg-x11-xauth dbus-x11 mesa-libEGL mesa-libGL mesa-libGLU mesa-dri-drivers libXrandr libXScrnSaver gtk3 mesa-libgbm libdrm libXcomposite libXdamage libXfixes'
|
||||
INSTALL_CMD="yum install -y ./${PKG_NAME}"
|
||||
CLEAN_CMD="rm -f \"${PKG_NAME}\""
|
||||
else
|
||||
echo "❌ Unsupported distro: need apt, dnf, or yum."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- install rustdesk if missing ----
|
||||
if ! command -v rustdesk > /dev/null 2>&1; then
|
||||
printf "📦 Installing dependencies...\n"
|
||||
sudo bash -c "$INSTALL_DEPS" 2>&1 | tee -a "${LOG_PATH}"
|
||||
|
||||
printf "⬇️ Downloading RustDesk ${RUSTDESK_VERSION} (${PKG_SYS}, ${PKG_ARCH})...\n"
|
||||
URL="https://github.com/rustdesk/rustdesk/releases/download/${RUSTDESK_VERSION}/${PKG_NAME}"
|
||||
wget -q "$URL" 2>&1 | tee -a "${LOG_PATH}"
|
||||
|
||||
printf "🔧 Installing RustDesk...\n"
|
||||
sudo bash -c "$INSTALL_CMD" 2>&1 | tee -a "${LOG_PATH}"
|
||||
|
||||
printf "🧹 Cleaning up...\n"
|
||||
bash -c "$CLEAN_CMD" 2>&1 | tee -a "${LOG_PATH}"
|
||||
else
|
||||
printf "✅ RustDesk already installed\n"
|
||||
fi
|
||||
|
||||
# ---- start virtual display ----
|
||||
echo "Starting Xvfb with resolution ${XVFB_RESOLUTION}…"
|
||||
Xvfb :99 -screen 0 "${XVFB_RESOLUTION}" >> "${LOG_PATH}" 2>&1 &
|
||||
export DISPLAY=:99
|
||||
|
||||
# Wait for X to be ready
|
||||
for i in {1..10}; do
|
||||
if xdpyinfo -display :99 > /dev/null 2>&1; then
|
||||
echo "X display is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ---- create (or accept) password and start rustdesk ----
|
||||
if [[ -z "${RUSTDESK_PASSWORD}" ]]; then
|
||||
RUSTDESK_PASSWORD="$(tr -dc 'a-zA-Z0-9@' < /dev/urandom | head -c 10)@97"
|
||||
fi
|
||||
|
||||
echo "Starting XFCE desktop environment..."
|
||||
xfce4-session >> "${LOG_PATH}" 2>&1 &
|
||||
|
||||
echo "Waiting for xfce4-session to initialize..."
|
||||
sleep 5
|
||||
|
||||
printf "🔐 Setting RustDesk password and starting service...\n"
|
||||
rustdesk >> "${LOG_PATH}" 2>&1 &
|
||||
sleep 2
|
||||
|
||||
rustdesk --password "${RUSTDESK_PASSWORD}" >> "${LOG_PATH}" 2>&1 &
|
||||
sleep 3
|
||||
|
||||
RID="$(rustdesk --get-id 2> /dev/null || echo 'ID_PENDING')"
|
||||
|
||||
printf "🥳 RustDesk setup complete!\n\n"
|
||||
printf "${BOLD}📋 Connection Details:${RESET}\n"
|
||||
printf " RustDesk ID: ${RID}\n"
|
||||
printf " RustDesk Password: ${RUSTDESK_PASSWORD}\n"
|
||||
printf " Display: ${DISPLAY} (${XVFB_RESOLUTION})\n"
|
||||
printf "\n📝 Logs available at: ${LOG_PATH}\n\n"
|
||||
|
||||
echo "Setup script completed successfully. All services running in background."
|
||||
exit 0
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
display_name: Archive
|
||||
description: Create automated and user-invocable scripts that archive and extract selected files/directories with optional compression (gzip or zstd).
|
||||
icon: ../../../../.icons/folder.svg
|
||||
verified: false
|
||||
tags: [backup, archive, tar, helper]
|
||||
---
|
||||
|
||||
# Archive
|
||||
|
||||
This module installs small, robust scripts in your workspace to create and extract tar archives from a list of files and directories. It supports optional compression (gzip or zstd). The create command prints only the resulting archive path to stdout; operational logs go to stderr. An optional stop hook can also create an archive automatically when the workspace stops, and an optional start hook can wait for an archive on-disk and extract it on start.
|
||||
|
||||
```tf
|
||||
module "archive" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/archive/coder"
|
||||
version = "0.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
|
||||
paths = ["./projects", "./code"]
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Installs two commands into the workspace `$PATH`: `coder-archive-create` and `coder-archive-extract`.
|
||||
- Creates a single `.tar`, `.tar.gz`, or `.tar.zst` containing selected paths (depends on `tar`).
|
||||
- Optional compression: `gzip`, `zstd` (depends on `gzip` or `zstd`).
|
||||
- Stores defaults so commands can be run without arguments (supports overriding via CLI flags).
|
||||
- Logs and status messages go to stderr, the create command prints only the final archive path to stdout.
|
||||
- Optional:
|
||||
- `create_on_stop` to create an archive automatically when the workspace stops.
|
||||
- `extract_on_start` to wait for an archive to appear and extract it on start.
|
||||
|
||||
> [!WARNING]
|
||||
> The `create_on_stop` feature uses the `coder_script` `run_on_stop` which may not work as expected on certain templates without additional provider configuration. The agent may be terminated before the script completes. See [coder/coder#6174](https://github.com/coder/coder/issues/6174) for provider-specific workarounds and [coder/coder#6175](https://github.com/coder/coder/issues/6175) for tracking a fix.
|
||||
|
||||
## Usage
|
||||
|
||||
Basic example:
|
||||
|
||||
```tf
|
||||
module "archive" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/archive/coder"
|
||||
version = "0.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
|
||||
# Paths to include in the archive (files or directories).
|
||||
directory = "~"
|
||||
paths = [
|
||||
"./projects",
|
||||
"./code",
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Customize compression and output:
|
||||
|
||||
```tf
|
||||
module "archive" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/archive/coder"
|
||||
version = "0.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
|
||||
directory = "/"
|
||||
paths = ["/etc", "/home"]
|
||||
compression = "zstd" # "gzip" | "zstd" | "none"
|
||||
output_dir = "/tmp/backup" # defaults to /tmp
|
||||
archive_name = "my-backup" # base name (extension is inferred from compression)
|
||||
}
|
||||
```
|
||||
|
||||
Enable auto-archive on stop:
|
||||
|
||||
```tf
|
||||
module "archive" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/archive/coder"
|
||||
version = "0.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
|
||||
# Creates /tmp/coder-archive.tar.gz of the users home directory (defaults).
|
||||
create_on_stop = true
|
||||
}
|
||||
```
|
||||
|
||||
Extract on start:
|
||||
|
||||
```tf
|
||||
module "archive" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/archive/coder"
|
||||
version = "0.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
|
||||
# Where to look for the archive file to extract:
|
||||
output_dir = "/tmp"
|
||||
archive_name = "my-archive"
|
||||
compression = "gzip"
|
||||
|
||||
# Waits up to 5 minutes for /tmp/my-archive.tar.gz to be present, note that
|
||||
# using a long timeout will delay every workspace start by this much until the
|
||||
# archive is present.
|
||||
extract_on_start = true
|
||||
extract_wait_timeout_seconds = 300
|
||||
}
|
||||
```
|
||||
|
||||
## Command usage
|
||||
|
||||
The installer writes the following files:
|
||||
|
||||
- `$CODER_SCRIPT_DATA_DIR/archive-lib.sh`
|
||||
- `$CODER_SCRIPT_BIN_DIR/coder-archive-create`
|
||||
- `$CODER_SCRIPT_BIN_DIR/coder-archive-extract`
|
||||
|
||||
Create usage:
|
||||
|
||||
```console
|
||||
coder-archive-create [OPTIONS] [PATHS...]
|
||||
-c, --compression <gzip|zstd|none> Compression algorithm (default from module)
|
||||
-C, --directory <DIRECTORY> Change to directory for archiving (default from module)
|
||||
-f, --file <ARCHIVE> Output archive file (default from module)
|
||||
-h, --help Show help
|
||||
```
|
||||
|
||||
Extract usage:
|
||||
|
||||
```console
|
||||
coder-archive-extract [OPTIONS]
|
||||
-c, --compression <gzip|zstd|none> Compression algorithm (default from module)
|
||||
-C, --directory <DIRECTORY> Extract into directory (default from module)
|
||||
-f, --file <ARCHIVE> Archive file to extract (default from module)
|
||||
-h, --help Show help
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- Use Terraform defaults:
|
||||
|
||||
```
|
||||
coder-archive-create
|
||||
```
|
||||
|
||||
- Override compression and output file at runtime:
|
||||
|
||||
```
|
||||
coder-archive-create --compression zstd --file /tmp/backups/archive.tar.zst
|
||||
```
|
||||
|
||||
- Add extra paths on the fly (in addition to the Terraform defaults):
|
||||
|
||||
```
|
||||
coder-archive-create /etc/hosts
|
||||
```
|
||||
|
||||
- Extract an archive into a directory:
|
||||
|
||||
```
|
||||
coder-archive-extract --file /tmp/backups/archive.tar.gz --directory /tmp/restore
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
mock_provider "coder" {}
|
||||
|
||||
run "apply_defaults" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "agent-123"
|
||||
paths = ["~/project", "/etc/hosts"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.archive_path == "/tmp/coder-archive.tar.gz"
|
||||
error_message = "archive_path should be empty when archive_name is not set"
|
||||
}
|
||||
}
|
||||
|
||||
run "apply_with_name" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "agent-123"
|
||||
paths = ["/etc/hosts"]
|
||||
archive_name = "nightly"
|
||||
output_dir = "/tmp/backups"
|
||||
compression = "zstd"
|
||||
create_archive_on_stop = true
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.archive_path == "/tmp/backups/nightly.tar.zst"
|
||||
error_message = "archive_path should be computed from archive_name + output_dir + extension"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { describe, expect, it, beforeAll } from "bun:test";
|
||||
import {
|
||||
execContainer,
|
||||
findResourceInstance,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
type TerraformState,
|
||||
} from "~test";
|
||||
|
||||
const USE_XTRACE =
|
||||
process.env.ARCHIVE_TEST_XTRACE === "1" || process.env.XTRACE === "1";
|
||||
|
||||
const IMAGE = "alpine";
|
||||
const BIN_DIR = "/tmp/coder-script-data/bin";
|
||||
const DATA_DIR = "/tmp/coder-script-data";
|
||||
|
||||
type ExecResult = {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
const ensureRunOk = (label: string, res: ExecResult) => {
|
||||
if (res.exitCode !== 0) {
|
||||
console.error(
|
||||
`[${label}] non-zero exit code: ${res.exitCode}\n--- stdout ---\n${res.stdout.trim()}\n--- stderr ---\n${res.stderr.trim()}\n--------------`,
|
||||
);
|
||||
}
|
||||
expect(res.exitCode).toBe(0);
|
||||
};
|
||||
|
||||
const sh = async (id: string, cmd: string): Promise<ExecResult> => {
|
||||
const res = await execContainer(id, ["sh", "-c", cmd]);
|
||||
return res;
|
||||
};
|
||||
|
||||
const bashRun = async (id: string, cmd: string): Promise<ExecResult> => {
|
||||
const injected = USE_XTRACE ? `/bin/bash -x ${cmd}` : cmd;
|
||||
return sh(id, injected);
|
||||
};
|
||||
|
||||
const prepareContainer = async (image = IMAGE) => {
|
||||
const id = await runContainer(image);
|
||||
// Prepare script dirs and deps.
|
||||
ensureRunOk(
|
||||
"mkdirs",
|
||||
await sh(id, `mkdir -p ${BIN_DIR} ${DATA_DIR} /tmp/backup`),
|
||||
);
|
||||
|
||||
// Install tools used by tests.
|
||||
ensureRunOk(
|
||||
"apk add",
|
||||
await sh(id, "apk add --no-cache bash tar gzip zstd coreutils"),
|
||||
);
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
const installArchive = async (
|
||||
state: TerraformState,
|
||||
opts?: { env?: string[] },
|
||||
) => {
|
||||
const instance = findResourceInstance(state, "coder_script");
|
||||
const id = await prepareContainer();
|
||||
// Run installer script with correct env for CODER_SCRIPT paths.
|
||||
const args = ["bash"];
|
||||
if (USE_XTRACE) args.push("-x");
|
||||
args.push("-c", instance.script);
|
||||
|
||||
const resp = await execContainer(id, args, [
|
||||
"--env",
|
||||
`CODER_SCRIPT_BIN_DIR=${BIN_DIR}`,
|
||||
"--env",
|
||||
`CODER_SCRIPT_DATA_DIR=${DATA_DIR}`,
|
||||
...(opts?.env ?? []),
|
||||
]);
|
||||
|
||||
return {
|
||||
id,
|
||||
install: {
|
||||
exitCode: resp.exitCode,
|
||||
stdout: resp.stdout.trim(),
|
||||
stderr: resp.stderr.trim(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const fileExists = async (id: string, path: string) => {
|
||||
const res = await sh(id, `test -f ${path} && echo yes || echo no`);
|
||||
return res.stdout.trim() === "yes";
|
||||
};
|
||||
|
||||
const isExecutable = async (id: string, path: string) => {
|
||||
const res = await sh(id, `test -x ${path} && echo yes || echo no`);
|
||||
return res.stdout.trim() === "yes";
|
||||
};
|
||||
|
||||
const listTar = async (id: string, path: string) => {
|
||||
// Try to autodetect compression flags from extension.
|
||||
let cmd = "";
|
||||
if (path.endsWith(".tar.gz")) {
|
||||
cmd = `tar -tzf ${path}`;
|
||||
} else if (path.endsWith(".tar.zst")) {
|
||||
// validate with zstd and ask tar to list via --zstd.
|
||||
cmd = `zstd -t -q ${path} && tar --zstd -tf ${path}`;
|
||||
} else {
|
||||
cmd = `tar -tf ${path}`;
|
||||
}
|
||||
return sh(id, cmd);
|
||||
};
|
||||
|
||||
describe("archive", () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
// Ensure required variables are enforced.
|
||||
testRequiredVariables(import.meta.dir, {
|
||||
agent_id: "agent-123",
|
||||
});
|
||||
|
||||
it("installs wrapper scripts to BIN_DIR and library to DATA_DIR", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "agent-123",
|
||||
});
|
||||
|
||||
// The Terraform output should reflect defaults from main.tf.
|
||||
expect(state.outputs.archive_path.value).toEqual(
|
||||
"/tmp/coder-archive.tar.gz",
|
||||
);
|
||||
|
||||
const { id, install } = await installArchive(state);
|
||||
ensureRunOk("install", install);
|
||||
|
||||
expect(install.stdout).toContain(
|
||||
`Installed archive library to: ${DATA_DIR}/archive-lib.sh`,
|
||||
);
|
||||
expect(install.stdout).toContain(
|
||||
`Installed create script to: ${BIN_DIR}/coder-archive-create`,
|
||||
);
|
||||
expect(install.stdout).toContain(
|
||||
`Installed extract script to: ${BIN_DIR}/coder-archive-extract`,
|
||||
);
|
||||
expect(await isExecutable(id, `${BIN_DIR}/coder-archive-create`)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await isExecutable(id, `${BIN_DIR}/coder-archive-extract`)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses sane defaults: creates gzip archive at the default path and logs to stderr", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "agent-123",
|
||||
// Keep defaults: compression=gzip, output_dir=/tmp, archive_name=coder-archive.
|
||||
});
|
||||
|
||||
const { id } = await installArchive(state);
|
||||
|
||||
const createTestdata = await bashRun(
|
||||
id,
|
||||
`mkdir ~/gzip; touch ~/gzip/defaults.txt`,
|
||||
);
|
||||
ensureRunOk("create testdata", createTestdata);
|
||||
|
||||
const run = await bashRun(id, `${BIN_DIR}/coder-archive-create`);
|
||||
ensureRunOk("archive-create default run", run);
|
||||
|
||||
// Only the archive path should print to stdout.
|
||||
expect(run.stdout.trim()).toEqual("/tmp/coder-archive.tar.gz");
|
||||
expect(await fileExists(id, "/tmp/coder-archive.tar.gz")).toBe(true);
|
||||
|
||||
// Some useful diagnostics should be on stderr.
|
||||
expect(run.stderr).toContain("Creating archive:");
|
||||
expect(run.stderr).toContain("Compression: gzip");
|
||||
|
||||
const list = await listTar(id, "/tmp/coder-archive.tar.gz");
|
||||
ensureRunOk("list default archive", list);
|
||||
expect(list.stdout).toContain("gzip/defaults.txt");
|
||||
}, 20000);
|
||||
|
||||
it("creates a gzip archive with explicit -f and includes extra CLI paths", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "agent-123",
|
||||
// Provide a simple default path so we can assert contents.
|
||||
paths: `["~/gzip"]`,
|
||||
compression: "gzip",
|
||||
});
|
||||
|
||||
const { id } = await installArchive(state);
|
||||
|
||||
const createTestdata = await bashRun(
|
||||
id,
|
||||
`mkdir ~/gzip; touch ~/gzip/test.txt; touch ~/gziptest.txt`,
|
||||
);
|
||||
ensureRunOk("create testdata", createTestdata);
|
||||
|
||||
const out = "/tmp/backup/test-archive.tar.gz";
|
||||
const run = await bashRun(
|
||||
id,
|
||||
`${BIN_DIR}/coder-archive-create -f ${out} ~/gziptest.txt`,
|
||||
);
|
||||
ensureRunOk("archive-create gzip explicit -f", run);
|
||||
|
||||
expect(run.stdout.trim()).toEqual(out);
|
||||
expect(await fileExists(id, out)).toBe(true);
|
||||
|
||||
const list = await sh(id, `tar -tzf ${out}`);
|
||||
ensureRunOk("tar -tzf contents (gzip)", list);
|
||||
expect(list.stdout).toContain("gzip/test.txt");
|
||||
expect(list.stdout).toContain("gziptest.txt");
|
||||
}, 20000);
|
||||
|
||||
it("creates a zstd-compressed archive when requested via CLI override", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "agent-123",
|
||||
paths: `["/etc/hostname"]`,
|
||||
// Module default is gzip, override at runtime to zstd.
|
||||
});
|
||||
|
||||
const { id } = await installArchive(state);
|
||||
|
||||
const out = "/tmp/backup/zstd-archive.tar.zst";
|
||||
const run = await bashRun(
|
||||
id,
|
||||
`${BIN_DIR}/coder-archive-create --compression zstd -f ${out}`,
|
||||
);
|
||||
ensureRunOk("archive-create zstd", run);
|
||||
|
||||
expect(run.stdout.trim()).toEqual(out);
|
||||
|
||||
// Check integrity via zstd and that tar can list it.
|
||||
ensureRunOk("zstd -t", await sh(id, `test -f ${out} && zstd -t -q ${out}`));
|
||||
ensureRunOk("tar --zstd -tf", await sh(id, `tar --zstd -tf ${out}`));
|
||||
}, 30000);
|
||||
|
||||
it("creates an uncompressed tar when compression=none", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "agent-123",
|
||||
// Keep module defaults but override at runtime.
|
||||
});
|
||||
|
||||
const { id } = await installArchive(state);
|
||||
|
||||
const out = "/tmp/backup/raw-archive.tar";
|
||||
const run = await bashRun(
|
||||
id,
|
||||
`${BIN_DIR}/coder-archive-create --compression none -f ${out}`,
|
||||
);
|
||||
ensureRunOk("archive-create none", run);
|
||||
|
||||
expect(run.stdout.trim()).toEqual(out);
|
||||
ensureRunOk("tar -tf (none)", await sh(id, `tar -tf ${out} >/dev/null`));
|
||||
}, 20000);
|
||||
|
||||
it("applies exclude patterns from Terraform", async () => {
|
||||
// Include a file, but also exclude it via Terraform defaults to ensure
|
||||
// exclusion flows through.
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "agent-123",
|
||||
paths: `["/etc/hostname"]`,
|
||||
exclude_patterns: `["/etc/hostname"]`,
|
||||
});
|
||||
|
||||
const { id } = await installArchive(state);
|
||||
|
||||
const out = "/tmp/backup/excluded.tar.gz";
|
||||
const run = await bashRun(id, `${BIN_DIR}/coder-archive-create -f ${out}`);
|
||||
ensureRunOk("archive-create with exclude_patterns", run);
|
||||
|
||||
const list = await sh(id, `tar -tzf ${out}`);
|
||||
ensureRunOk("tar -tzf contents (exclude)", list);
|
||||
expect(list.stdout).not.toContain("etc/hostname"); // Excluded by Terraform default.
|
||||
}, 20000);
|
||||
|
||||
it("adds a run_on_stop script when enabled", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "agent-123",
|
||||
create_on_stop: true,
|
||||
});
|
||||
|
||||
const coderScripts = state.resources.filter(
|
||||
(r) => r.type === "coder_script",
|
||||
);
|
||||
// Installer (run_on_start) + run_on_stop.
|
||||
expect(coderScripts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("extracts a previously created archive into a target directory", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "agent-123",
|
||||
paths: `["/etc/hostname"]`,
|
||||
compression: "gzip",
|
||||
});
|
||||
|
||||
const { id } = await installArchive(state);
|
||||
|
||||
// Create archive.
|
||||
const out = "/tmp/backup/extract-test.tar.gz";
|
||||
const created = await bashRun(
|
||||
id,
|
||||
`${BIN_DIR}/coder-archive-create -f ${out} /etc/hosts`,
|
||||
);
|
||||
ensureRunOk("create for extract", created);
|
||||
|
||||
// Extract archive.
|
||||
const extractDir = "/tmp/extract";
|
||||
const extract = await bashRun(
|
||||
id,
|
||||
`${BIN_DIR}/coder-archive-extract -f ${out} -C ${extractDir}`,
|
||||
);
|
||||
ensureRunOk("archive-extract", extract);
|
||||
|
||||
// Verify a known file exists after extraction.
|
||||
const exists = await sh(
|
||||
id,
|
||||
`test -f ${extractDir}/etc/hosts && echo ok || echo no`,
|
||||
);
|
||||
expect(exists.stdout.trim()).toEqual("ok");
|
||||
}, 20000);
|
||||
|
||||
it("honors Terraform defaults without CLI args (compression, name, output_dir)", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "agent-123",
|
||||
compression: "zstd",
|
||||
archive_name: "my-default",
|
||||
output_dir: "/tmp/defout",
|
||||
});
|
||||
|
||||
const { id } = await installArchive(state);
|
||||
|
||||
const run = await bashRun(id, `${BIN_DIR}/coder-archive-create`);
|
||||
ensureRunOk("archive-create terraform defaults", run);
|
||||
expect(run.stdout.trim()).toEqual("/tmp/defout/my-default.tar.zst");
|
||||
expect(run.stderr).toContain("Creating archive:");
|
||||
expect(run.stderr).toContain("Compression: zstd");
|
||||
ensureRunOk(
|
||||
"zstd -t",
|
||||
await sh(id, "zstd -t -q /tmp/defout/my-default.tar.zst"),
|
||||
);
|
||||
ensureRunOk(
|
||||
"tar --zstd -tf",
|
||||
await sh(id, "tar --zstd -tf /tmp/defout/my-default.tar.zst"),
|
||||
);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 0.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
description = "The ID of a Coder agent."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "paths" {
|
||||
description = "List of files/directories to include in the archive. Defaults to the current directory."
|
||||
type = list(string)
|
||||
default = ["."]
|
||||
}
|
||||
|
||||
variable "exclude_patterns" {
|
||||
description = "Exclude patterns for the archive."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "compression" {
|
||||
description = "Compression algorithm for the archive. Supported: gzip, zstd, none."
|
||||
type = string
|
||||
default = "gzip"
|
||||
validation {
|
||||
condition = contains(["gzip", "zstd", "none"], var.compression)
|
||||
error_message = "compression must be one of: gzip, zstd, none."
|
||||
}
|
||||
}
|
||||
|
||||
variable "archive_name" {
|
||||
description = "Optional archive base name without extension. If empty, defaults to \"coder-archive\"."
|
||||
type = string
|
||||
default = "coder-archive"
|
||||
}
|
||||
|
||||
variable "output_dir" {
|
||||
description = "Optional output directory where the archive will be written. Defaults to \"/tmp\"."
|
||||
type = string
|
||||
default = "/tmp"
|
||||
}
|
||||
|
||||
variable "directory" {
|
||||
description = "Change current directory to this path before creating or extracting the archive. Defaults to the user's home directory."
|
||||
type = string
|
||||
default = "~"
|
||||
}
|
||||
|
||||
variable "create_on_stop" {
|
||||
description = "If true, also create a run_on_stop script that creates the archive automatically on workspace stop."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "extract_on_start" {
|
||||
description = "If true, the installer will wait for an archive and extract it on start."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "extract_wait_timeout_seconds" {
|
||||
description = "Timeout (seconds) to wait for an archive when extract_on_start is true."
|
||||
type = number
|
||||
default = 5
|
||||
}
|
||||
|
||||
# Provide a stable script filename and sensible defaults.
|
||||
locals {
|
||||
extension = var.compression == "gzip" ? ".tar.gz" : var.compression == "zstd" ? ".tar.zst" : ".tar"
|
||||
|
||||
# Ensure ~ is expanded because it cannot be expanded inside quotes in a
|
||||
# templated shell script.
|
||||
paths = [for v in var.paths : replace(v, "/^~(\\/|$)/", "$$HOME$1")]
|
||||
exclude_patterns = [for v in var.exclude_patterns : replace(v, "/^~(\\/|$)/", "$$HOME$1")]
|
||||
directory = replace(var.directory, "/^~(\\/|$)/", "$$HOME$1")
|
||||
output_dir = replace(var.output_dir, "/^~(\\/|$)/", "$$HOME$1")
|
||||
|
||||
archive_path = "${local.output_dir}/${var.archive_name}${local.extension}"
|
||||
}
|
||||
|
||||
output "archive_path" {
|
||||
description = "Full path to the archive file that will be created, extracted, or both."
|
||||
value = local.archive_path
|
||||
}
|
||||
|
||||
# This script installs the user-facing archive script into $CODER_SCRIPT_BIN_DIR.
|
||||
# The installed script can be run manually by the user to create an archive.
|
||||
resource "coder_script" "archive_start_script" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Archive"
|
||||
icon = "/icon/folder.svg"
|
||||
run_on_start = true
|
||||
start_blocks_login = var.extract_on_start
|
||||
|
||||
# Render the user-facing archive script with Terraform defaults, then write it to $CODER_SCRIPT_BIN_DIR
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
TF_LIB_B64 = base64encode(file("${path.module}/scripts/archive-lib.sh")),
|
||||
TF_PATHS = join(" ", formatlist("%q", local.paths)),
|
||||
TF_EXCLUDE_PATTERNS = join(" ", formatlist("%q", local.exclude_patterns)),
|
||||
TF_COMPRESSION = var.compression,
|
||||
TF_ARCHIVE_PATH = local.archive_path,
|
||||
TF_DIRECTORY = local.directory,
|
||||
TF_EXTRACT_ON_START = var.extract_on_start,
|
||||
TF_EXTRACT_WAIT_TIMEOUT = var.extract_wait_timeout_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
# Optionally, also register a run_on_stop script that creates the archive automatically
|
||||
# when the workspace stops. It simply invokes the installed archive script.
|
||||
resource "coder_script" "archive_stop_script" {
|
||||
count = var.create_on_stop ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
display_name = "Archive"
|
||||
icon = "/icon/folder.svg"
|
||||
run_on_stop = true
|
||||
start_blocks_login = false
|
||||
|
||||
# Call the installed script. It will log to stderr and print the archive path to stdout.
|
||||
# We redirect stdout to stderr to avoid surfacing the path in system logs if undesired.
|
||||
# Remove the redirection if you want the path to appear in stdout on stop as well.
|
||||
script = <<-EOT
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
"$CODER_SCRIPT_BIN_DIR/coder-archive-create"
|
||||
EOT
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
LIB_B64="${TF_LIB_B64}"
|
||||
EXTRACT_ON_START="${TF_EXTRACT_ON_START}"
|
||||
EXTRACT_WAIT_TIMEOUT="${TF_EXTRACT_WAIT_TIMEOUT}"
|
||||
|
||||
# Set script defaults from Terraform.
|
||||
DEFAULT_PATHS=(${TF_PATHS})
|
||||
DEFAULT_EXCLUDE_PATTERNS=(${TF_EXCLUDE_PATTERNS})
|
||||
DEFAULT_COMPRESSION="${TF_COMPRESSION}"
|
||||
DEFAULT_ARCHIVE_PATH="${TF_ARCHIVE_PATH}"
|
||||
DEFAULT_DIRECTORY="${TF_DIRECTORY}"
|
||||
|
||||
# 1) Decode the library into $CODER_SCRIPT_DATA_DIR/archive-lib.sh (static, sourceable).
|
||||
LIB_PATH="$CODER_SCRIPT_DATA_DIR/archive-lib.sh"
|
||||
lib_tmp="$(mktemp -t coder-module-archive.XXXXXX))"
|
||||
trap 'rm -f "$lib_tmp" 2>/dev/null || true' EXIT
|
||||
|
||||
# Decode the base64 content safely.
|
||||
if ! printf '%s' "$LIB_B64" | base64 -d > "$lib_tmp"; then
|
||||
echo "ERROR: Failed to decode archive library from base64." >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 0644 "$lib_tmp"
|
||||
mv "$lib_tmp" "$LIB_PATH"
|
||||
|
||||
# 2) Generate the wrapper scripts (create and extract).
|
||||
create_wrapper() {
|
||||
tmp="$(mktemp -t coder-module-archive.XXXXXX)"
|
||||
trap 'rm -f "$tmp" 2>/dev/null || true' EXIT
|
||||
cat > "$tmp" << EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
. "$LIB_PATH"
|
||||
|
||||
# Set defaults from Terraform (through installer).
|
||||
$(
|
||||
declare -p \
|
||||
DEFAULT_PATHS \
|
||||
DEFAULT_EXCLUDE_PATTERNS \
|
||||
DEFAULT_COMPRESSION \
|
||||
DEFAULT_ARCHIVE_PATH \
|
||||
DEFAULT_DIRECTORY
|
||||
)
|
||||
|
||||
$1 "\$@"
|
||||
EOF
|
||||
chmod 0755 "$tmp"
|
||||
mv "$tmp" "$2"
|
||||
}
|
||||
|
||||
CREATE_WRAPPER_PATH="$CODER_SCRIPT_BIN_DIR/coder-archive-create"
|
||||
EXTRACT_WRAPPER_PATH="$CODER_SCRIPT_BIN_DIR/coder-archive-extract"
|
||||
create_wrapper archive_create "$CREATE_WRAPPER_PATH"
|
||||
create_wrapper archive_extract "$EXTRACT_WRAPPER_PATH"
|
||||
|
||||
echo "Installed archive library to: $LIB_PATH"
|
||||
echo "Installed create script to: $CREATE_WRAPPER_PATH"
|
||||
echo "Installed extract script to: $EXTRACT_WRAPPER_PATH"
|
||||
|
||||
# 3) Optionally wait for and extract an archive on start.
|
||||
if [[ $EXTRACT_ON_START = true ]]; then
|
||||
. "$LIB_PATH"
|
||||
|
||||
archive_wait_and_extract "$EXTRACT_WAIT_TIMEOUT" quiet || {
|
||||
exit_code=$?
|
||||
if [[ $exit_code -eq 2 ]]; then
|
||||
echo "WARNING: Archive not found in backup path (this is expected with new workspaces)."
|
||||
else
|
||||
exit $exit_code
|
||||
fi
|
||||
}
|
||||
fi
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
log() {
|
||||
printf '%s\n' "$@" >&2
|
||||
}
|
||||
warn() {
|
||||
printf 'WARNING: %s\n' "$1" >&2
|
||||
}
|
||||
error() {
|
||||
printf 'ERROR: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
load_defaults() {
|
||||
DEFAULT_PATHS=("${DEFAULT_PATHS[@]:-.}")
|
||||
DEFAULT_EXCLUDE_PATTERNS=("${DEFAULT_EXCLUDE_PATTERNS[@]:-}")
|
||||
DEFAULT_COMPRESSION="${DEFAULT_COMPRESSION:-gzip}"
|
||||
DEFAULT_ARCHIVE_PATH="${DEFAULT_ARCHIVE_PATH:-/tmp/coder-archive.tar.gz}"
|
||||
DEFAULT_DIRECTORY="${DEFAULT_DIRECTORY:-$HOME}"
|
||||
}
|
||||
|
||||
ensure_tools() {
|
||||
command -v tar > /dev/null 2>&1 || error "tar is required"
|
||||
case "$1" in
|
||||
gzip)
|
||||
command -v gzip > /dev/null 2>&1 || error "gzip is required for gzip compression"
|
||||
;;
|
||||
zstd)
|
||||
command -v zstd > /dev/null 2>&1 || error "zstd is required for zstd compression"
|
||||
;;
|
||||
none) ;;
|
||||
*)
|
||||
error "Unsupported compression algorithm: $1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
usage_archive_create() {
|
||||
load_defaults
|
||||
|
||||
cat >&2 << USAGE
|
||||
Usage: coder-archive-create [OPTIONS] [[PATHS] ...]
|
||||
Options:
|
||||
-c, --compression <gzip|zstd|none> Compression algorithm (default "${DEFAULT_COMPRESSION}")
|
||||
-C, --directory <DIRECTORY> Change to directory (default "${DEFAULT_DIRECTORY}")
|
||||
-f, --file <ARCHIVE> Output archive file (default "${DEFAULT_ARCHIVE_PATH}")
|
||||
-h, --help Show this help
|
||||
USAGE
|
||||
}
|
||||
|
||||
archive_create() {
|
||||
load_defaults
|
||||
|
||||
local compression="${DEFAULT_COMPRESSION}"
|
||||
local directory="${DEFAULT_DIRECTORY}"
|
||||
local file="${DEFAULT_ARCHIVE_PATH}"
|
||||
local paths=("${DEFAULT_PATHS[@]}")
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-c | --compression)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
usage_archive_create
|
||||
error "Missing value for $1"
|
||||
fi
|
||||
compression="$2"
|
||||
shift 2
|
||||
;;
|
||||
-C | --directory)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
usage_archive_create
|
||||
error "Missing value for $1"
|
||||
fi
|
||||
directory="$2"
|
||||
shift 2
|
||||
;;
|
||||
-f | --file)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
usage_archive_create
|
||||
error "Missing value for $1"
|
||||
fi
|
||||
file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
usage_archive_create
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
while [[ $# -gt 0 ]]; do
|
||||
paths+=("$1")
|
||||
shift
|
||||
done
|
||||
;;
|
||||
-*)
|
||||
usage_archive_create
|
||||
error "Unknown option: $1"
|
||||
;;
|
||||
*)
|
||||
paths+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ensure_tools "$compression"
|
||||
|
||||
local -a tar_opts=(-c -f "$file" -C "$directory")
|
||||
case "$compression" in
|
||||
gzip)
|
||||
tar_opts+=(-z)
|
||||
;;
|
||||
zstd)
|
||||
tar_opts+=(--zstd)
|
||||
;;
|
||||
none) ;;
|
||||
*)
|
||||
error "Unsupported compression algorithm: $compression"
|
||||
;;
|
||||
esac
|
||||
|
||||
for path in "${DEFAULT_EXCLUDE_PATTERNS[@]}"; do
|
||||
if [[ -n $path ]]; then
|
||||
tar_opts+=(--exclude "$path")
|
||||
fi
|
||||
done
|
||||
|
||||
# Ensure destination directory exists.
|
||||
dest="$(dirname "$file")"
|
||||
mkdir -p "$dest" 2> /dev/null || error "Failed to create output dir: $dest"
|
||||
|
||||
log "Creating archive:"
|
||||
log " Compression: $compression"
|
||||
log " Directory: $directory"
|
||||
log " Archive: $file"
|
||||
log " Paths: ${paths[*]}"
|
||||
log " Exclude: ${DEFAULT_EXCLUDE_PATTERNS[*]}"
|
||||
|
||||
umask 077
|
||||
tar "${tar_opts[@]}" "${paths[@]}"
|
||||
|
||||
printf '%s\n' "$file"
|
||||
}
|
||||
|
||||
usage_archive_extract() {
|
||||
load_defaults
|
||||
|
||||
cat >&2 << USAGE
|
||||
Usage: coder-archive-extract [OPTIONS]
|
||||
Options:
|
||||
-c, --compression <gzip|zstd|none> Compression algorithm (default "${DEFAULT_COMPRESSION}")
|
||||
-C, --directory <DIRECTORY> Change to directory (default "${DEFAULT_DIRECTORY}")
|
||||
-f, --file <ARCHIVE> Output archive file (default "${DEFAULT_ARCHIVE_PATH}")
|
||||
-h, --help Show this help
|
||||
USAGE
|
||||
}
|
||||
|
||||
archive_extract() {
|
||||
load_defaults
|
||||
|
||||
local compression="${DEFAULT_COMPRESSION}"
|
||||
local directory="${DEFAULT_DIRECTORY}"
|
||||
local file="${DEFAULT_ARCHIVE_PATH}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-c | --compression)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
usage_archive_extract
|
||||
error "Missing value for $1"
|
||||
fi
|
||||
compression="$2"
|
||||
shift 2
|
||||
;;
|
||||
-C | --directory)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
usage_archive_extract
|
||||
error "Missing value for $1"
|
||||
fi
|
||||
directory="$2"
|
||||
shift 2
|
||||
;;
|
||||
-f | --file)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
usage_archive_extract
|
||||
error "Missing value for $1"
|
||||
fi
|
||||
file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
usage_archive_extract
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
done
|
||||
;;
|
||||
-*)
|
||||
usage_archive_extract
|
||||
error "Unknown option: $1"
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ensure_tools "$compression"
|
||||
|
||||
local -a tar_opts=(-x -f "$file" -C "$directory")
|
||||
case "$compression" in
|
||||
gzip)
|
||||
tar_opts+=(-z)
|
||||
;;
|
||||
zstd)
|
||||
tar_opts+=(--zstd)
|
||||
;;
|
||||
none) ;;
|
||||
*)
|
||||
error "Unsupported compression algorithm: $compression"
|
||||
;;
|
||||
esac
|
||||
|
||||
for path in "${DEFAULT_EXCLUDE_PATTERNS[@]}"; do
|
||||
if [[ -n $path ]]; then
|
||||
tar_opts+=(--exclude "$path")
|
||||
fi
|
||||
done
|
||||
|
||||
# Ensure destination directory exists.
|
||||
mkdir -p "$directory" || error "Failed to create directory: $directory"
|
||||
|
||||
log "Extracting archive:"
|
||||
log " Compression: $compression"
|
||||
log " Directory: $directory"
|
||||
log " Archive: $file"
|
||||
log " Exclude: ${DEFAULT_EXCLUDE_PATTERNS[*]}"
|
||||
|
||||
umask 077
|
||||
tar "${tar_opts[@]}" "${paths[@]}"
|
||||
|
||||
printf 'Extracted %s into %s\n' "$file" "$directory"
|
||||
}
|
||||
|
||||
archive_wait_and_extract() {
|
||||
load_defaults
|
||||
|
||||
local timeout="${1:-300}"
|
||||
local quiet="${2:-}"
|
||||
local file="${DEFAULT_ARCHIVE_PATH}"
|
||||
|
||||
local start now
|
||||
start=$(date +%s)
|
||||
while true; do
|
||||
if [[ -f "$file" ]]; then
|
||||
archive_extract -f "$file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ((timeout <= 0)); then
|
||||
break
|
||||
fi
|
||||
now=$(date +%s)
|
||||
if ((now - start >= timeout)); then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [[ -z $quiet ]]; then
|
||||
printf 'ERROR: Timed out waiting for archive: %s\n' "$file" >&2
|
||||
fi
|
||||
return 2
|
||||
}
|
||||
@@ -13,7 +13,7 @@ Run Auggie CLI in your workspace to access Augment's AI coding assistant with ad
|
||||
```tf
|
||||
module "auggie" {
|
||||
source = "registry.coder.com/coder-labs/auggie/coder"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -47,7 +47,7 @@ module "coder-login" {
|
||||
|
||||
module "auggie" {
|
||||
source = "registry.coder.com/coder-labs/auggie/coder"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
|
||||
@@ -103,7 +103,7 @@ EOF
|
||||
```tf
|
||||
module "auggie" {
|
||||
source = "registry.coder.com/coder-labs/auggie/coder"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.6.0"
|
||||
default = "v0.10.0"
|
||||
validation {
|
||||
condition = can(regex("^v[0-9]+\\.[0-9]+\\.[0-9]+", var.agentapi_version))
|
||||
error_message = "agentapi_version must be a valid semantic version starting with 'v', like 'v0.3.3'."
|
||||
@@ -174,13 +174,15 @@ locals {
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".auggie-module"
|
||||
folder = trimsuffix(var.folder, "/")
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.folder
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
|
||||
@@ -13,10 +13,10 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
version = "3.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = var.openai_api_key
|
||||
folder = "/home/coder/project"
|
||||
workdir = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -33,10 +33,11 @@ module "codex" {
|
||||
module "codex" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
version = "3.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
folder = "/home/coder/project"
|
||||
workdir = "/home/coder/project"
|
||||
report_tasks = false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -60,11 +61,11 @@ module "coder-login" {
|
||||
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
version = "3.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
folder = "/home/coder/project"
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
# Custom configuration for full auto mode
|
||||
base_config_toml = <<-EOT
|
||||
@@ -75,7 +76,7 @@ module "codex" {
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified folder. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module _only_ in trusted environments and be aware of the security implications.
|
||||
> This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified workdir. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module _only_ in trusted environments and be aware of the security implications.
|
||||
|
||||
## How it Works
|
||||
|
||||
@@ -106,7 +107,7 @@ For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_se
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
version = "3.0.0"
|
||||
# ... other variables ...
|
||||
|
||||
# Override default configuration
|
||||
@@ -137,7 +138,7 @@ module "codex" {
|
||||
> [!IMPORTANT]
|
||||
> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set, and **you create a `coder_parameter` named `"AI Prompt"` and pass its value to the codex module's `ai_prompt` variable**. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker).
|
||||
> The module automatically configures Codex with your API key and model preferences.
|
||||
> folder is a required variable for the module to function correctly.
|
||||
> workdir is a required variable for the module to function correctly.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
install_codex: props?.skipCodexMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
codex_model: "gpt-4-turbo",
|
||||
folder: "/home/coder",
|
||||
workdir: "/home/coder",
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
@@ -166,12 +166,12 @@ describe("codex", async () => {
|
||||
expect(postInstallLog).toContain("post-install-script");
|
||||
});
|
||||
|
||||
test("folder-variable", async () => {
|
||||
const folder = "/tmp/codex-test-folder";
|
||||
test("workdir-variable", async () => {
|
||||
const workdir = "/tmp/codex-test-workdir";
|
||||
const { id } = await setup({
|
||||
skipCodexMock: false,
|
||||
moduleVariables: {
|
||||
folder,
|
||||
workdir,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
@@ -179,7 +179,7 @@ describe("codex", async () => {
|
||||
id,
|
||||
"/home/coder/.codex-module/install.log",
|
||||
);
|
||||
expect(resp).toContain(folder);
|
||||
expect(resp).toContain(workdir);
|
||||
});
|
||||
|
||||
test("additional-mcp-servers", async () => {
|
||||
|
||||
@@ -36,11 +36,41 @@ variable "icon" {
|
||||
default = "/icon/openai.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
variable "workdir" {
|
||||
type = string
|
||||
description = "The folder to run Codex in."
|
||||
}
|
||||
|
||||
variable "report_tasks" {
|
||||
type = bool
|
||||
description = "Whether to enable task reporting to Coder UI via AgentAPI"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "subdomain" {
|
||||
type = bool
|
||||
description = "Whether to use a subdomain for AgentAPI."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "cli_app" {
|
||||
type = bool
|
||||
description = "Whether to create a CLI app for Codex"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "web_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the web app"
|
||||
default = "Codex"
|
||||
}
|
||||
|
||||
variable "cli_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the CLI app"
|
||||
default = "Codex CLI"
|
||||
}
|
||||
|
||||
variable "install_codex" {
|
||||
type = bool
|
||||
description = "Whether to install Codex."
|
||||
@@ -80,7 +110,7 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.5.0"
|
||||
default = "v0.10.0"
|
||||
}
|
||||
|
||||
variable "codex_model" {
|
||||
@@ -120,6 +150,7 @@ resource "coder_env" "openai_api_key" {
|
||||
}
|
||||
|
||||
locals {
|
||||
workdir = trimsuffix(var.workdir, "/")
|
||||
app_slug = "codex"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
@@ -128,18 +159,21 @@ locals {
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.workdir
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Codex"
|
||||
cli_app_slug = "${local.app_slug}-cli"
|
||||
cli_app_display_name = "Codex CLI"
|
||||
web_app_display_name = var.web_app_display_name
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_subdomain = var.subdomain
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
@@ -151,8 +185,9 @@ module "agentapi" {
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
ARG_CODEX_MODEL='${var.codex_model}' \
|
||||
ARG_CODEX_START_DIRECTORY='${var.folder}' \
|
||||
ARG_CODEX_START_DIRECTORY='${var.workdir}' \
|
||||
ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
@@ -164,12 +199,14 @@ module "agentapi" {
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
ARG_INSTALL='${var.install_codex}' \
|
||||
ARG_CODEX_VERSION='${var.codex_version}' \
|
||||
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
|
||||
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
ARG_CODEX_START_DIRECTORY='${var.folder}' \
|
||||
ARG_CODEX_START_DIRECTORY='${var.workdir}' \
|
||||
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
|
||||
@@ -22,6 +22,8 @@ printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
|
||||
printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")"
|
||||
printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")"
|
||||
printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")"
|
||||
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
|
||||
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
|
||||
echo "======================================"
|
||||
|
||||
set +o nounset
|
||||
@@ -100,13 +102,20 @@ EOF
|
||||
append_mcp_servers_section() {
|
||||
local config_path="$1"
|
||||
|
||||
if [ "${ARG_REPORT_TASKS}" == "false" ]; then
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG=""
|
||||
CODER_MCP_AI_AGENTAPI_URL=""
|
||||
else
|
||||
CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
fi
|
||||
|
||||
cat << EOF >> "$config_path"
|
||||
|
||||
# MCP Servers Configuration
|
||||
[mcp_servers.Coder]
|
||||
command = "coder"
|
||||
args = ["exp", "mcp", "server"]
|
||||
env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "http://localhost:3284", "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}" }
|
||||
env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "${CODER_MCP_AI_AGENTAPI_URL}" , "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}" }
|
||||
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
|
||||
type = "stdio"
|
||||
|
||||
@@ -159,7 +168,21 @@ function add_instruction_prompt_if_exists() {
|
||||
fi
|
||||
}
|
||||
|
||||
function add_auth_json() {
|
||||
AUTH_JSON_PATH="$HOME/.codex/auth.json"
|
||||
mkdir -p "$(dirname "$AUTH_JSON_PATH")"
|
||||
AUTH_JSON=$(
|
||||
cat << EOF
|
||||
{
|
||||
"OPENAI_API_KEY": "${ARG_OPENAI_API_KEY}"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
echo "$AUTH_JSON" > "$AUTH_JSON_PATH"
|
||||
}
|
||||
|
||||
install_codex
|
||||
codex --version
|
||||
populate_config_toml
|
||||
add_instruction_prompt_if_exists
|
||||
add_auth_json
|
||||
|
||||
@@ -22,6 +22,7 @@ printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided"
|
||||
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
|
||||
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
|
||||
printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")"
|
||||
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
|
||||
echo "======================================"
|
||||
set +o nounset
|
||||
CODEX_ARGS=()
|
||||
@@ -57,7 +58,11 @@ fi
|
||||
|
||||
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
|
||||
printf "Running the task prompt %s\n" "$ARG_CODEX_TASK_PROMPT"
|
||||
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
|
||||
if [ "${ARG_REPORT_TASKS}" == "true" ]; then
|
||||
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
|
||||
else
|
||||
PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
|
||||
fi
|
||||
CODEX_ARGS+=("$PROMPT")
|
||||
else
|
||||
printf "No task prompt given.\n"
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
display_name: Copilot CLI
|
||||
description: GitHub Copilot CLI agent for AI-powered terminal assistance
|
||||
icon: ../../../../.icons/github.svg
|
||||
verified: false
|
||||
tags: [agent, copilot, ai, github, tasks]
|
||||
---
|
||||
|
||||
# Copilot
|
||||
|
||||
Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-copilot-cli) in your workspace for AI-powered coding assistance directly from the terminal. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for task reporting in the Coder UI.
|
||||
|
||||
```tf
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
}
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This example assumes you have [Coder external authentication](https://coder.com/docs/admin/external-auth) configured with `id = "github"`. If not, you can provide a direct token using the `github_token` variable or provide the correct external authentication id for GitHub by setting `external_auth_id = "my-github"`.
|
||||
|
||||
> [!NOTE]
|
||||
> By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js v22+** and **npm v10+**
|
||||
- **[Active Copilot subscription](https://docs.github.com/en/copilot/about-github-copilot/subscription-plans-for-github-copilot)** (GitHub Copilot Pro, Pro+, Business, or Enterprise)
|
||||
- **GitHub authentication** via one of:
|
||||
- [Coder external authentication](https://coder.com/docs/admin/external-auth) (recommended)
|
||||
- Direct token via `github_token` variable
|
||||
- Interactive login in Copilot
|
||||
|
||||
## Examples
|
||||
|
||||
### Usage with Tasks
|
||||
|
||||
For development environments where you want Copilot to have full access to tools and automatically resume sessions:
|
||||
|
||||
```tf
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Initial task prompt for Copilot."
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
copilot_model = "claude-sonnet-4.5"
|
||||
allow_all_tools = true
|
||||
resume_session = true
|
||||
|
||||
trusted_directories = ["/home/coder/projects", "/tmp"]
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
Customize tool permissions, MCP servers, and Copilot settings:
|
||||
|
||||
```tf
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
|
||||
# Version pinning (defaults to "latest", use specific version if desired)
|
||||
copilot_version = "0.0.334"
|
||||
|
||||
# Tool permissions
|
||||
allow_tools = ["shell(git)", "shell(npm)", "write"]
|
||||
trusted_directories = ["/home/coder/projects", "/tmp"]
|
||||
|
||||
# Custom Copilot configuration
|
||||
copilot_config = jsonencode({
|
||||
banner = "never"
|
||||
theme = "dark"
|
||||
})
|
||||
|
||||
# MCP server configuration
|
||||
mcp_config = jsonencode({
|
||||
mcpServers = {
|
||||
filesystem = {
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/coder/projects"]
|
||||
description = "Provides file system access to the workspace"
|
||||
name = "Filesystem"
|
||||
timeout = 3000
|
||||
type = "local"
|
||||
tools = ["*"]
|
||||
trust = true
|
||||
}
|
||||
playwright = {
|
||||
command = "npx"
|
||||
args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated"]
|
||||
description = "Browser automation for testing and previewing changes"
|
||||
name = "Playwright"
|
||||
timeout = 5000
|
||||
type = "local"
|
||||
tools = ["*"]
|
||||
trust = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Pre-install Node.js if needed
|
||||
pre_install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> GitHub Copilot CLI does not automatically install MCP servers. You have two options:
|
||||
>
|
||||
> - Use `npx -y` in the MCP config (shown above) to auto-install on each run
|
||||
> - Pre-install MCP servers in `pre_install_script` for faster startup (e.g., `npm install -g @modelcontextprotocol/server-filesystem`)
|
||||
|
||||
### Direct Token Authentication
|
||||
|
||||
Use this example when you want to provide a GitHub Personal Access Token instead of using Coder external auth:
|
||||
|
||||
```tf
|
||||
variable "github_token" {
|
||||
type = string
|
||||
description = "GitHub Personal Access Token"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
github_token = var.github_token
|
||||
}
|
||||
```
|
||||
|
||||
### Standalone Mode
|
||||
|
||||
Run Copilot as a command-line tool without task reporting or web interface. This installs and configures Copilot, making it available as a CLI app in the Coder agent bar that you can launch to interact with Copilot directly from your terminal. Set `report_tasks = false` to disable integration with Coder Tasks.
|
||||
|
||||
```tf
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
report_tasks = false
|
||||
cli_app = true
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
The module supports multiple authentication methods (in priority order):
|
||||
|
||||
1. **[Coder External Auth](https://coder.com/docs/admin/external-auth) (Recommended)** - Automatic if GitHub external auth is configured in Coder
|
||||
2. **Direct Token** - Pass `github_token` variable (OAuth or Personal Access Token)
|
||||
3. **Interactive** - Copilot prompts for login via `/login` command if no auth found
|
||||
|
||||
> [!NOTE]
|
||||
> OAuth tokens work best with Copilot. Personal Access Tokens may have limited functionality.
|
||||
|
||||
## Session Resumption
|
||||
|
||||
By default, the module resumes the latest Copilot session when the workspace restarts. Set `resume_session = false` to always start fresh sessions.
|
||||
|
||||
> [!NOTE]
|
||||
> Session resumption requires persistent storage for the home directory or workspace volume. Without persistent storage, sessions will not resume across workspace restarts.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter any issues, check the log files in the `~/.copilot-module` directory within your workspace for detailed information.
|
||||
|
||||
```bash
|
||||
# Installation logs
|
||||
cat ~/.copilot-module/install.log
|
||||
|
||||
# Startup logs
|
||||
cat ~/.copilot-module/agentapi-start.log
|
||||
|
||||
# Pre/post install script logs
|
||||
cat ~/.copilot-module/pre_install.log
|
||||
cat ~/.copilot-module/post_install.log
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To use tasks with Copilot, you must have an active GitHub Copilot subscription.
|
||||
> The `workdir` variable is required and specifies the directory where Copilot will run.
|
||||
|
||||
## References
|
||||
|
||||
- [GitHub Copilot CLI Documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli)
|
||||
- [Installing GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
@@ -0,0 +1,236 @@
|
||||
run "defaults_are_correct" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.copilot_model == "claude-sonnet-4.5"
|
||||
error_message = "Default model should be 'claude-sonnet-4.5'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.report_tasks == true
|
||||
error_message = "Task reporting should be enabled by default"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.resume_session == true
|
||||
error_message = "Session resumption should be enabled by default"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.allow_all_tools == false
|
||||
error_message = "allow_all_tools should be disabled by default"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.mcp_app_status_slug.name == "CODER_MCP_APP_STATUS_SLUG"
|
||||
error_message = "Status slug env var should be created"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.mcp_app_status_slug.value == "copilot"
|
||||
error_message = "Status slug value should be 'copilot'"
|
||||
}
|
||||
}
|
||||
|
||||
run "github_token_creates_env_var" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
github_token = "test_github_token_abc123"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_env.github_token) == 1
|
||||
error_message = "github_token env var should be created when token is provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.github_token[0].name == "GITHUB_TOKEN"
|
||||
error_message = "github_token env var name should be 'GITHUB_TOKEN'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.github_token[0].value == "test_github_token_abc123"
|
||||
error_message = "github_token env var value should match input"
|
||||
}
|
||||
}
|
||||
|
||||
run "github_token_not_created_when_empty" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
github_token = ""
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_env.github_token) == 0
|
||||
error_message = "github_token env var should not be created when empty"
|
||||
}
|
||||
}
|
||||
|
||||
run "copilot_model_env_var_for_non_default" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
copilot_model = "claude-sonnet-4"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_env.copilot_model) == 1
|
||||
error_message = "copilot_model env var should be created for non-default model"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.copilot_model[0].name == "COPILOT_MODEL"
|
||||
error_message = "copilot_model env var name should be 'COPILOT_MODEL'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.copilot_model[0].value == "claude-sonnet-4"
|
||||
error_message = "copilot_model env var value should match input"
|
||||
}
|
||||
}
|
||||
|
||||
run "copilot_model_not_created_for_default" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
copilot_model = "claude-sonnet-4.5"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_env.copilot_model) == 0
|
||||
error_message = "copilot_model env var should not be created for default model"
|
||||
}
|
||||
}
|
||||
|
||||
run "model_validation_accepts_valid_models" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
copilot_model = "gpt-5"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model)
|
||||
error_message = "Model should be one of the valid options"
|
||||
}
|
||||
}
|
||||
|
||||
run "copilot_config_merges_with_trusted_directories" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder/project"
|
||||
trusted_directories = ["/workspace", "/data"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(local.final_copilot_config) > 0
|
||||
error_message = "final_copilot_config should be computed"
|
||||
}
|
||||
|
||||
# Verify workdir is trimmed of trailing slash
|
||||
assert {
|
||||
condition = local.workdir == "/home/coder/project"
|
||||
error_message = "workdir should be trimmed of trailing slash"
|
||||
}
|
||||
}
|
||||
|
||||
run "custom_copilot_config_overrides_default" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
copilot_config = jsonencode({
|
||||
banner = "always"
|
||||
theme = "dark"
|
||||
})
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.copilot_config != ""
|
||||
error_message = "Custom copilot config should be set"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = jsondecode(local.final_copilot_config).banner == "always"
|
||||
error_message = "Custom banner setting should be applied"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = jsondecode(local.final_copilot_config).theme == "dark"
|
||||
error_message = "Custom theme setting should be applied"
|
||||
}
|
||||
}
|
||||
|
||||
run "trusted_directories_merged_with_custom_config" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder/project"
|
||||
copilot_config = jsonencode({
|
||||
banner = "always"
|
||||
theme = "dark"
|
||||
trusted_folders = ["/custom"]
|
||||
})
|
||||
trusted_directories = ["/workspace", "/data"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/custom")
|
||||
error_message = "Custom trusted folder should be included"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/home/coder/project")
|
||||
error_message = "Workdir should be included in trusted folders"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/workspace")
|
||||
error_message = "trusted_directories should be merged into config"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/data")
|
||||
error_message = "All trusted_directories should be merged into config"
|
||||
}
|
||||
}
|
||||
|
||||
run "app_slug_is_consistent" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.app_slug == "copilot"
|
||||
error_message = "app_slug should be 'copilot'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.module_dir_name == ".copilot-module"
|
||||
error_message = "module_dir_name should be '.copilot-module'"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
findResourceInstance,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
} from "~test";
|
||||
|
||||
describe("copilot", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
testRequiredVariables(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
workdir: "/home/coder",
|
||||
});
|
||||
|
||||
it("creates mcp_app_status_slug env var", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
workdir: "/home/coder",
|
||||
});
|
||||
|
||||
const statusSlugEnv = findResourceInstance(
|
||||
state,
|
||||
"coder_env",
|
||||
"mcp_app_status_slug",
|
||||
);
|
||||
expect(statusSlugEnv).toBeDefined();
|
||||
expect(statusSlugEnv.name).toBe("CODER_MCP_APP_STATUS_SLUG");
|
||||
expect(statusSlugEnv.value).toBe("copilot");
|
||||
});
|
||||
|
||||
it("creates github_token env var with correct value", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
workdir: "/home/coder",
|
||||
github_token: "test_token_12345",
|
||||
});
|
||||
|
||||
const githubTokenEnv = findResourceInstance(
|
||||
state,
|
||||
"coder_env",
|
||||
"github_token",
|
||||
);
|
||||
expect(githubTokenEnv).toBeDefined();
|
||||
expect(githubTokenEnv.name).toBe("GITHUB_TOKEN");
|
||||
expect(githubTokenEnv.value).toBe("test_token_12345");
|
||||
});
|
||||
|
||||
it("does not create github_token env var when empty", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
workdir: "/home/coder",
|
||||
github_token: "",
|
||||
});
|
||||
|
||||
const githubTokenEnvs = state.resources.filter(
|
||||
(r) => r.type === "coder_env" && r.name === "github_token",
|
||||
);
|
||||
expect(githubTokenEnvs.length).toBe(0);
|
||||
});
|
||||
|
||||
it("creates copilot_model env var for non-default models", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
workdir: "/home/coder",
|
||||
copilot_model: "claude-sonnet-4",
|
||||
});
|
||||
|
||||
const modelEnv = findResourceInstance(state, "coder_env", "copilot_model");
|
||||
expect(modelEnv).toBeDefined();
|
||||
expect(modelEnv.name).toBe("COPILOT_MODEL");
|
||||
expect(modelEnv.value).toBe("claude-sonnet-4");
|
||||
});
|
||||
|
||||
it("does not create copilot_model env var for default model", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
workdir: "/home/coder",
|
||||
copilot_model: "claude-sonnet-4.5",
|
||||
});
|
||||
|
||||
const modelEnvs = state.resources.filter(
|
||||
(r) => r.type === "coder_env" && r.name === "copilot_model",
|
||||
);
|
||||
expect(modelEnvs.length).toBe(0);
|
||||
});
|
||||
|
||||
it("creates coder_script resources via agentapi module", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
workdir: "/home/coder",
|
||||
});
|
||||
|
||||
// The agentapi module should create coder_script resources for install and start
|
||||
const scripts = state.resources.filter((r) => r.type === "coder_script");
|
||||
expect(scripts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("validates copilot_model accepts valid values", async () => {
|
||||
// Test valid models don't throw errors
|
||||
await expect(
|
||||
runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
workdir: "/home/coder",
|
||||
copilot_model: "gpt-5",
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
await expect(
|
||||
runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
workdir: "/home/coder",
|
||||
copilot_model: "claude-sonnet-4.5",
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("merges trusted_directories with custom copilot_config", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
workdir: "/home/coder/project",
|
||||
trusted_directories: JSON.stringify(["/workspace", "/data"]),
|
||||
copilot_config: JSON.stringify({
|
||||
banner: "always",
|
||||
theme: "dark",
|
||||
trusted_folders: ["/custom"],
|
||||
}),
|
||||
});
|
||||
|
||||
// Verify that the state was created successfully with the merged config
|
||||
// The actual merging logic is tested in the .tftest.hcl file
|
||||
expect(state).toBeDefined();
|
||||
expect(state.resources).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
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."
|
||||
}
|
||||
|
||||
variable "workdir" {
|
||||
type = string
|
||||
description = "The folder to run Copilot in."
|
||||
}
|
||||
|
||||
variable "external_auth_id" {
|
||||
type = string
|
||||
description = "ID of the GitHub external auth provider configured in Coder."
|
||||
default = "github"
|
||||
}
|
||||
|
||||
variable "github_token" {
|
||||
type = string
|
||||
description = "GitHub OAuth token or Personal Access Token. If provided, this will be used instead of auto-detecting authentication."
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "copilot_model" {
|
||||
type = string
|
||||
description = "Model to use. Supported values: claude-sonnet-4, claude-sonnet-4.5 (default), gpt-5."
|
||||
default = "claude-sonnet-4.5"
|
||||
validation {
|
||||
condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model)
|
||||
error_message = "copilot_model must be one of: claude-sonnet-4, claude-sonnet-4.5, gpt-5."
|
||||
}
|
||||
}
|
||||
|
||||
variable "copilot_config" {
|
||||
type = string
|
||||
description = "Custom Copilot configuration as JSON string. Leave empty to use default configuration with banner disabled, theme set to auto, and workdir as trusted folder."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "Initial task prompt for programmatic mode."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "system_prompt" {
|
||||
type = string
|
||||
description = "The system prompt to use for the Copilot server. Task reporting instructions are automatically added when report_tasks is enabled."
|
||||
default = "You are a helpful coding assistant that helps developers write, debug, and understand code. Provide clear explanations, follow best practices, and help solve coding problems efficiently."
|
||||
}
|
||||
|
||||
variable "trusted_directories" {
|
||||
type = list(string)
|
||||
description = "Additional directories to trust for Copilot operations."
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "allow_all_tools" {
|
||||
type = bool
|
||||
description = "Allow all tools without prompting (equivalent to --allow-all-tools)."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "allow_tools" {
|
||||
type = list(string)
|
||||
description = "Specific tools to allow: shell(command), write, or MCP_SERVER_NAME."
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "deny_tools" {
|
||||
type = list(string)
|
||||
description = "Specific tools to deny: shell(command), write, or MCP_SERVER_NAME."
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "mcp_config" {
|
||||
type = string
|
||||
description = "Custom MCP server configuration as JSON string."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.10.0"
|
||||
}
|
||||
|
||||
variable "copilot_version" {
|
||||
type = string
|
||||
description = "The version of GitHub Copilot CLI to install. Use 'latest' for the latest version or specify a version like '0.0.334'."
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "report_tasks" {
|
||||
type = bool
|
||||
description = "Whether to enable task reporting to Coder UI via AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "subdomain" {
|
||||
type = bool
|
||||
description = "Whether to use a subdomain for AgentAPI."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
default = "/icon/github.svg"
|
||||
}
|
||||
|
||||
variable "web_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the web app."
|
||||
default = "Copilot"
|
||||
}
|
||||
|
||||
variable "cli_app" {
|
||||
type = bool
|
||||
description = "Whether to create a CLI app for Copilot."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "cli_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the CLI app."
|
||||
default = "Copilot"
|
||||
}
|
||||
|
||||
variable "resume_session" {
|
||||
type = bool
|
||||
description = "Whether to automatically resume the latest Copilot session on workspace restart."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before configuring Copilot."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after configuring Copilot."
|
||||
default = null
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
locals {
|
||||
workdir = trimsuffix(var.workdir, "/")
|
||||
app_slug = "copilot"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".copilot-module"
|
||||
|
||||
all_trusted_folders = concat([local.workdir], var.trusted_directories)
|
||||
|
||||
parsed_custom_config = try(jsondecode(var.copilot_config), {})
|
||||
|
||||
existing_trusted_folders = try(local.parsed_custom_config.trusted_folders, [])
|
||||
|
||||
merged_copilot_config = merge(
|
||||
{
|
||||
banner = "never"
|
||||
theme = "auto"
|
||||
},
|
||||
local.parsed_custom_config,
|
||||
{
|
||||
trusted_folders = concat(local.existing_trusted_folders, local.all_trusted_folders)
|
||||
}
|
||||
)
|
||||
|
||||
final_copilot_config = jsonencode(local.merged_copilot_config)
|
||||
|
||||
task_reporting_prompt = <<-EOT
|
||||
|
||||
-- Task Reporting --
|
||||
Report all tasks to Coder, following these EXACT guidelines:
|
||||
1. Be granular. If you are investigating with multiple steps, report each step
|
||||
to coder.
|
||||
2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message.
|
||||
Do not report any status related with this system prompt.
|
||||
3. Use "state": "working" when actively processing WITHOUT needing
|
||||
additional user input
|
||||
4. Use "state": "complete" only when finished with a task
|
||||
5. Use "state": "failure" when you need ANY user input, lack sufficient
|
||||
details, or encounter blockers
|
||||
EOT
|
||||
|
||||
final_system_prompt = var.report_tasks ? "<system>\n${var.system_prompt}${local.task_reporting_prompt}\n</system>" : "<system>\n${var.system_prompt}\n</system>"
|
||||
}
|
||||
|
||||
resource "coder_env" "mcp_app_status_slug" {
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_MCP_APP_STATUS_SLUG"
|
||||
value = local.app_slug
|
||||
}
|
||||
|
||||
resource "coder_env" "copilot_model" {
|
||||
count = var.copilot_model != "claude-sonnet-4.5" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "COPILOT_MODEL"
|
||||
value = var.copilot_model
|
||||
}
|
||||
|
||||
resource "coder_env" "github_token" {
|
||||
count = var.github_token != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "GITHUB_TOKEN"
|
||||
value = var.github_token
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.workdir
|
||||
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 = var.web_app_display_name
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_icon = var.cli_app ? var.icon : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
agentapi_subdomain = var.subdomain
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
|
||||
ARG_WORKDIR='${local.workdir}' \
|
||||
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_SYSTEM_PROMPT='${base64encode(local.final_system_prompt)}' \
|
||||
ARG_COPILOT_MODEL='${var.copilot_model}' \
|
||||
ARG_ALLOW_ALL_TOOLS='${var.allow_all_tools}' \
|
||||
ARG_ALLOW_TOOLS='${join(",", var.allow_tools)}' \
|
||||
ARG_DENY_TOOLS='${join(",", var.deny_tools)}' \
|
||||
ARG_TRUSTED_DIRECTORIES='${join(",", var.trusted_directories)}' \
|
||||
ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \
|
||||
ARG_RESUME_SESSION='${var.resume_session}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
|
||||
ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
ARG_WORKDIR='${local.workdir}' \
|
||||
ARG_MCP_CONFIG='${var.mcp_config != "" ? base64encode(var.mcp_config) : ""}' \
|
||||
ARG_COPILOT_CONFIG='${base64encode(local.final_copilot_config)}' \
|
||||
ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \
|
||||
ARG_COPILOT_VERSION='${var.copilot_version}' \
|
||||
ARG_COPILOT_MODEL='${var.copilot_model}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
|
||||
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
|
||||
ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-}
|
||||
ARG_MCP_CONFIG=$(echo -n "${ARG_MCP_CONFIG:-}" | base64 -d 2> /dev/null || echo "")
|
||||
ARG_COPILOT_CONFIG=$(echo -n "${ARG_COPILOT_CONFIG:-}" | base64 -d 2> /dev/null || echo "")
|
||||
ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github}
|
||||
ARG_COPILOT_VERSION=${ARG_COPILOT_VERSION:-0.0.334}
|
||||
ARG_COPILOT_MODEL=${ARG_COPILOT_MODEL:-claude-sonnet-4.5}
|
||||
|
||||
validate_prerequisites() {
|
||||
if ! command_exists node; then
|
||||
echo "ERROR: Node.js not found. Copilot requires Node.js v22+."
|
||||
echo "Install with: curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command_exists npm; then
|
||||
echo "ERROR: npm not found. Copilot requires npm v10+."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node_version=$(node --version | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$node_version" -lt 22 ]; then
|
||||
echo "WARNING: Node.js v$node_version detected. Copilot requires v22+."
|
||||
fi
|
||||
}
|
||||
|
||||
install_copilot() {
|
||||
if ! command_exists copilot; then
|
||||
echo "Installing GitHub Copilot CLI (version: ${ARG_COPILOT_VERSION})..."
|
||||
if [ "$ARG_COPILOT_VERSION" = "latest" ]; then
|
||||
npm install -g @github/copilot
|
||||
else
|
||||
npm install -g "@github/copilot@${ARG_COPILOT_VERSION}"
|
||||
fi
|
||||
|
||||
if ! command_exists copilot; then
|
||||
echo "ERROR: Failed to install Copilot"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "GitHub Copilot CLI installed successfully"
|
||||
else
|
||||
echo "GitHub Copilot CLI already installed"
|
||||
fi
|
||||
}
|
||||
|
||||
check_github_authentication() {
|
||||
echo "Checking GitHub authentication..."
|
||||
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
echo "✓ GitHub token provided via module configuration"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command_exists coder; then
|
||||
if coder external-auth access-token "${ARG_EXTERNAL_AUTH_ID:-github}" > /dev/null 2>&1; then
|
||||
echo "✓ GitHub OAuth authentication via Coder external auth"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command_exists gh && gh auth status > /dev/null 2>&1; then
|
||||
echo "✓ GitHub OAuth authentication via GitHub CLI"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "⚠ No GitHub authentication detected"
|
||||
echo " Copilot will prompt for authentication when started"
|
||||
echo " For seamless experience, configure GitHub external auth in Coder or run 'gh auth login'"
|
||||
return 0
|
||||
}
|
||||
|
||||
setup_copilot_configurations() {
|
||||
mkdir -p "$ARG_WORKDIR"
|
||||
|
||||
local module_path="$HOME/.copilot-module"
|
||||
mkdir -p "$module_path"
|
||||
|
||||
setup_copilot_config
|
||||
|
||||
echo "$ARG_WORKDIR" > "$module_path/trusted_directories"
|
||||
}
|
||||
|
||||
setup_copilot_config() {
|
||||
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
local copilot_config_dir="$XDG_CONFIG_HOME/.copilot"
|
||||
local copilot_config_file="$copilot_config_dir/config.json"
|
||||
local mcp_config_file="$copilot_config_dir/mcp-config.json"
|
||||
|
||||
mkdir -p "$copilot_config_dir"
|
||||
|
||||
if [ -n "$ARG_COPILOT_CONFIG" ]; then
|
||||
echo "Setting up Copilot configuration..."
|
||||
|
||||
if command_exists jq; then
|
||||
echo "$ARG_COPILOT_CONFIG" | jq 'del(.mcpServers)' > "$copilot_config_file"
|
||||
else
|
||||
echo "$ARG_COPILOT_CONFIG" > "$copilot_config_file"
|
||||
fi
|
||||
|
||||
echo "Setting up MCP server configuration..."
|
||||
setup_mcp_config "$mcp_config_file"
|
||||
else
|
||||
echo "ERROR: No Copilot configuration provided"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
setup_mcp_config() {
|
||||
local mcp_config_file="$1"
|
||||
|
||||
echo '{"mcpServers": {}}' > "$mcp_config_file"
|
||||
|
||||
if [ "$ARG_REPORT_TASKS" = "true" ] && [ -n "$ARG_MCP_APP_STATUS_SLUG" ]; then
|
||||
echo "Adding Coder MCP server for task reporting..."
|
||||
setup_coder_mcp_server "$mcp_config_file"
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_MCP_CONFIG" ]; then
|
||||
echo "Adding custom MCP servers..."
|
||||
add_custom_mcp_servers "$mcp_config_file"
|
||||
fi
|
||||
|
||||
echo "MCP configuration completed: $mcp_config_file"
|
||||
}
|
||||
|
||||
setup_coder_mcp_server() {
|
||||
local mcp_config_file="$1"
|
||||
|
||||
local coder_mcp_wrapper_script
|
||||
coder_mcp_wrapper_script=$(
|
||||
cat << EOF
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
export CODER_MCP_APP_STATUS_SLUG="${ARG_MCP_APP_STATUS_SLUG}"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
export CODER_AGENT_URL="${CODER_AGENT_URL}"
|
||||
export CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN}"
|
||||
|
||||
exec coder exp mcp server
|
||||
EOF
|
||||
)
|
||||
echo "$coder_mcp_wrapper_script" > "/tmp/coder-mcp-server.sh"
|
||||
chmod +x /tmp/coder-mcp-server.sh
|
||||
|
||||
local coder_mcp_config
|
||||
coder_mcp_config=$(
|
||||
cat << EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"coder": {
|
||||
"command": "/tmp/coder-mcp-server.sh",
|
||||
"args": [],
|
||||
"description": "CRITICAL: Call this tool immediately after receiving ANY user message to report task status. Rules: (1) Call FIRST before doing work - report what you will do with state='working'. (2) Be granular - report each step separately. (3) State 'working' = actively processing without needing user input. (4) State 'complete' = task 100% finished. (5) State 'failure' = need user input, missing info, or blocked. Example: User says 'fix the bug' -> call with state='working', description='Investigating authentication bug'. When done -> call with state='complete', description='Fixed token validation'. You MUST report on every interaction.",
|
||||
"name": "Coder",
|
||||
"timeout": 3000,
|
||||
"type": "local",
|
||||
"tools": ["*"],
|
||||
"trust": true
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "$coder_mcp_config" > "$mcp_config_file"
|
||||
}
|
||||
|
||||
add_custom_mcp_servers() {
|
||||
local mcp_config_file="$1"
|
||||
|
||||
if command_exists jq; then
|
||||
local custom_servers
|
||||
custom_servers=$(echo "$ARG_MCP_CONFIG" | jq '.mcpServers // {}')
|
||||
|
||||
local updated_config
|
||||
updated_config=$(jq --argjson custom "$custom_servers" '.mcpServers += $custom' "$mcp_config_file")
|
||||
echo "$updated_config" > "$mcp_config_file"
|
||||
elif command_exists node; then
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const existing = JSON.parse(fs.readFileSync('$mcp_config_file', 'utf8'));
|
||||
const input = JSON.parse(\`$ARG_MCP_CONFIG\`);
|
||||
const custom = input.mcpServers || {};
|
||||
existing.mcpServers = {...existing.mcpServers, ...custom};
|
||||
fs.writeFileSync('$mcp_config_file', JSON.stringify(existing, null, 2));
|
||||
"
|
||||
else
|
||||
echo "WARNING: jq and node not available, cannot merge custom MCP servers"
|
||||
fi
|
||||
}
|
||||
|
||||
configure_copilot_model() {
|
||||
if [ -n "$ARG_COPILOT_MODEL" ] && [ "$ARG_COPILOT_MODEL" != "claude-sonnet-4.5" ]; then
|
||||
echo "Setting Copilot model to: $ARG_COPILOT_MODEL"
|
||||
copilot config model "$ARG_COPILOT_MODEL" || {
|
||||
echo "WARNING: Failed to set model via copilot config, will use environment variable fallback"
|
||||
export COPILOT_MODEL="$ARG_COPILOT_MODEL"
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
configure_coder_integration() {
|
||||
if [ "$ARG_REPORT_TASKS" = "true" ] && [ -n "$ARG_MCP_APP_STATUS_SLUG" ]; then
|
||||
echo "Configuring Copilot task reporting..."
|
||||
export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
echo "✓ Coder MCP server configured for task reporting"
|
||||
else
|
||||
echo "Task reporting disabled or no app status slug provided."
|
||||
export CODER_MCP_APP_STATUS_SLUG=""
|
||||
export CODER_MCP_AI_AGENTAPI_URL=""
|
||||
fi
|
||||
}
|
||||
|
||||
validate_prerequisites
|
||||
install_copilot
|
||||
check_github_authentication
|
||||
setup_copilot_configurations
|
||||
configure_copilot_model
|
||||
configure_coder_integration
|
||||
|
||||
echo "Copilot module setup completed."
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
source "$HOME"/.bashrc
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
|
||||
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d 2> /dev/null || echo "")
|
||||
ARG_SYSTEM_PROMPT=$(echo -n "${ARG_SYSTEM_PROMPT:-}" | base64 -d 2> /dev/null || echo "")
|
||||
ARG_COPILOT_MODEL=${ARG_COPILOT_MODEL:-}
|
||||
ARG_ALLOW_ALL_TOOLS=${ARG_ALLOW_ALL_TOOLS:-false}
|
||||
ARG_ALLOW_TOOLS=${ARG_ALLOW_TOOLS:-}
|
||||
ARG_DENY_TOOLS=${ARG_DENY_TOOLS:-}
|
||||
ARG_TRUSTED_DIRECTORIES=${ARG_TRUSTED_DIRECTORIES:-}
|
||||
ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github}
|
||||
ARG_RESUME_SESSION=${ARG_RESUME_SESSION:-true}
|
||||
|
||||
validate_copilot_installation() {
|
||||
if ! command_exists copilot; then
|
||||
echo "ERROR: Copilot not installed. Run: npm install -g @github/copilot"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
build_initial_prompt() {
|
||||
local initial_prompt=""
|
||||
|
||||
if [ -n "$ARG_AI_PROMPT" ]; then
|
||||
if [ -n "$ARG_SYSTEM_PROMPT" ]; then
|
||||
initial_prompt="$ARG_SYSTEM_PROMPT
|
||||
|
||||
$ARG_AI_PROMPT"
|
||||
else
|
||||
initial_prompt="$ARG_AI_PROMPT"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$initial_prompt"
|
||||
}
|
||||
|
||||
build_copilot_args() {
|
||||
COPILOT_ARGS=()
|
||||
|
||||
if [ "$ARG_ALLOW_ALL_TOOLS" = "true" ]; then
|
||||
COPILOT_ARGS+=(--allow-all-tools)
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_ALLOW_TOOLS" ]; then
|
||||
IFS=',' read -ra ALLOW_ARRAY <<< "$ARG_ALLOW_TOOLS"
|
||||
for tool in "${ALLOW_ARRAY[@]}"; do
|
||||
if [ -n "$tool" ]; then
|
||||
COPILOT_ARGS+=(--allow-tool "$tool")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_DENY_TOOLS" ]; then
|
||||
IFS=',' read -ra DENY_ARRAY <<< "$ARG_DENY_TOOLS"
|
||||
for tool in "${DENY_ARRAY[@]}"; do
|
||||
if [ -n "$tool" ]; then
|
||||
COPILOT_ARGS+=(--deny-tool "$tool")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
check_existing_session() {
|
||||
if [ "$ARG_RESUME_SESSION" = "true" ]; then
|
||||
if copilot --help > /dev/null 2>&1; then
|
||||
local session_dir="$HOME/.copilot/history-session-state"
|
||||
if [ -d "$session_dir" ] && [ -n "$(ls "$session_dir"/session_*_*.json 2> /dev/null)" ]; then
|
||||
echo "Found existing Copilot session. Will continue latest session." >&2
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
setup_github_authentication() {
|
||||
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
echo "Setting up GitHub authentication..."
|
||||
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
export GH_TOKEN="$GITHUB_TOKEN"
|
||||
echo "✓ Using GitHub token from module configuration"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command_exists coder; then
|
||||
local github_token
|
||||
if github_token=$(coder external-auth access-token "${ARG_EXTERNAL_AUTH_ID:-github}" 2> /dev/null); then
|
||||
if [ -n "$github_token" ] && [ "$github_token" != "null" ]; then
|
||||
export GITHUB_TOKEN="$github_token"
|
||||
export GH_TOKEN="$github_token"
|
||||
echo "✓ Using Coder external auth OAuth token"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if command_exists gh && gh auth status > /dev/null 2>&1; then
|
||||
echo "✓ Using GitHub CLI OAuth authentication"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "⚠ No GitHub authentication available"
|
||||
echo " Copilot will prompt for login during first use"
|
||||
echo " Use the '/login' command in Copilot to authenticate"
|
||||
return 0
|
||||
}
|
||||
|
||||
start_agentapi() {
|
||||
echo "Starting in directory: $ARG_WORKDIR"
|
||||
cd "$ARG_WORKDIR"
|
||||
|
||||
build_copilot_args
|
||||
|
||||
if check_existing_session; then
|
||||
echo "Continuing latest Copilot session..."
|
||||
if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then
|
||||
echo "Copilot arguments: ${COPILOT_ARGS[*]}"
|
||||
agentapi server --type copilot --term-width 120 --term-height 40 -- copilot --continue "${COPILOT_ARGS[@]}"
|
||||
else
|
||||
agentapi server --type copilot --term-width 120 --term-height 40 -- copilot --continue
|
||||
fi
|
||||
else
|
||||
echo "Starting new Copilot session..."
|
||||
local initial_prompt
|
||||
initial_prompt=$(build_initial_prompt)
|
||||
|
||||
if [ -n "$initial_prompt" ]; then
|
||||
echo "Using initial prompt with system context"
|
||||
if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then
|
||||
echo "Copilot arguments: ${COPILOT_ARGS[*]}"
|
||||
agentapi server -I="$initial_prompt" --type copilot --term-width 120 --term-height 40 -- copilot "${COPILOT_ARGS[@]}"
|
||||
else
|
||||
agentapi server -I="$initial_prompt" --type copilot --term-width 120 --term-height 40 -- copilot
|
||||
fi
|
||||
else
|
||||
if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then
|
||||
echo "Copilot arguments: ${COPILOT_ARGS[*]}"
|
||||
agentapi server --type copilot --term-width 120 --term-height 40 -- copilot "${COPILOT_ARGS[@]}"
|
||||
else
|
||||
agentapi server --type copilot --term-width 120 --term-height 40 -- copilot
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
setup_github_authentication
|
||||
validate_copilot_installation
|
||||
start_agentapi
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "GitHub Copilot CLI v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while true; do
|
||||
echo "$(date) - Copilot mock running..."
|
||||
sleep 15
|
||||
done
|
||||
@@ -13,7 +13,7 @@ Run the Cursor Agent CLI in your workspace for interactive coding assistance and
|
||||
```tf
|
||||
module "cursor_cli" {
|
||||
source = "registry.coder.com/coder-labs/cursor-cli/coder"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -42,7 +42,7 @@ module "coder-login" {
|
||||
|
||||
module "cursor_cli" {
|
||||
source = "registry.coder.com/coder-labs/cursor-cli/coder"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.5.0"
|
||||
default = "v0.10.0"
|
||||
}
|
||||
|
||||
variable "force" {
|
||||
@@ -113,6 +113,7 @@ locals {
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".cursor-cli-module"
|
||||
folder = trimsuffix(var.folder, "/")
|
||||
}
|
||||
|
||||
# Expose status slug and API key to the agent environment
|
||||
@@ -131,9 +132,10 @@ resource "coder_env" "cursor_api_key" {
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.folder
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
|
||||
@@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace
|
||||
```tf
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -46,7 +46,7 @@ variable "gemini_api_key" {
|
||||
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
folder = "/home/coder/project"
|
||||
@@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" {
|
||||
module "gemini" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
gemini_model = "gemini-2.5-flash"
|
||||
@@ -118,7 +118,7 @@ For enterprise users who prefer Google's Vertex AI platform:
|
||||
```tf
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
folder = "/home/coder/project"
|
||||
|
||||
@@ -81,7 +81,7 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.2.3"
|
||||
default = "v0.10.0"
|
||||
}
|
||||
|
||||
variable "gemini_model" {
|
||||
@@ -172,13 +172,15 @@ EOT
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".gemini-module"
|
||||
folder = trimsuffix(var.folder, "/")
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.folder
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
display_name: Nextflow
|
||||
description: A module that adds Nextflow to your Coder template.
|
||||
icon: ../../../../.icons/nextflow.svg
|
||||
verified: true
|
||||
tags: [nextflow, workflow, hpc, bioinformatics]
|
||||
---
|
||||
|
||||
# Nextflow
|
||||
|
||||
A module that adds Nextflow to your Coder template.
|
||||
|
||||

|
||||
|
||||
```tf
|
||||
module "nextflow" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/nextflow/coder"
|
||||
version = "0.9.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,106 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Add required variables for your modules and remove any unneeded variables
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
variable "nextflow_version" {
|
||||
type = string
|
||||
description = "Nextflow version"
|
||||
default = "25.04.7"
|
||||
}
|
||||
|
||||
variable "project_path" {
|
||||
type = string
|
||||
description = "The path to Nextflow project, it will be mounted in the container."
|
||||
}
|
||||
|
||||
variable "http_server_port" {
|
||||
type = number
|
||||
description = "The port to run HTTP server on."
|
||||
default = 9876
|
||||
}
|
||||
|
||||
variable "http_server_reports_dir" {
|
||||
type = string
|
||||
description = "Subdirectory for HTTP server reports, relative to the project path."
|
||||
default = "reports"
|
||||
}
|
||||
|
||||
variable "http_server_log_path" {
|
||||
type = string
|
||||
description = "HTTP server logs"
|
||||
default = "/tmp/nextflow_reports.log"
|
||||
}
|
||||
|
||||
variable "stub_run" {
|
||||
type = bool
|
||||
description = "Execute a stub run?"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "stub_run_command" {
|
||||
type = string
|
||||
description = "Nextflow command to be executed in the stub run."
|
||||
default = "run rnaseq-nf -with-report reports/report.html -with-trace reports/trace.txt -with-timeline reports/timeline.html -with-dag reports/flowchart.png"
|
||||
}
|
||||
|
||||
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 "share" {
|
||||
type = string
|
||||
default = "owner"
|
||||
validation {
|
||||
condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
|
||||
error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
|
||||
}
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
resource "coder_script" "nextflow" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "nextflow"
|
||||
icon = "/icon/nextflow.svg"
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
NEXTFLOW_VERSION : var.nextflow_version,
|
||||
PROJECT_PATH : var.project_path,
|
||||
HTTP_SERVER_PORT : var.http_server_port,
|
||||
HTTP_SERVER_REPORTS_DIR : var.http_server_reports_dir,
|
||||
HTTP_SERVER_LOG_PATH : var.http_server_log_path,
|
||||
STUB_RUN : var.stub_run,
|
||||
STUB_RUN_COMMAND : var.stub_run_command,
|
||||
})
|
||||
run_on_start = true
|
||||
}
|
||||
|
||||
resource "coder_app" "nextflow" {
|
||||
agent_id = var.agent_id
|
||||
slug = "nextflow-reports"
|
||||
display_name = "Nextflow Reports"
|
||||
url = "http://localhost:${var.http_server_port}"
|
||||
icon = "/icon/nextflow.svg"
|
||||
subdomain = true
|
||||
share = var.share
|
||||
order = var.order
|
||||
group = var.group
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
set -eu
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
RESET='\033[0m'
|
||||
|
||||
printf "$${BOLD}Starting Nextflow...$${RESET}\n"
|
||||
|
||||
if ! command -v nextflow > /dev/null 2>&1; then
|
||||
# Update system dependencies
|
||||
sudo apt update
|
||||
sudo apt install openjdk-21-jdk graphviz salmon fastqc multiqc -y
|
||||
|
||||
# Install nextflow
|
||||
export NXF_VER=${NEXTFLOW_VERSION}
|
||||
curl -s https://get.nextflow.io | bash
|
||||
sudo mv nextflow /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/nextflow
|
||||
|
||||
# Verify installation
|
||||
tmp_verify=$(mktemp -d coder-nextflow-XXXXXX)
|
||||
nextflow run hello \
|
||||
-with-report "$${tmp_verify}/report.html" \
|
||||
-with-trace "$${tmp_verify}/trace.txt" \
|
||||
-with-timeline "$${tmp_verify}/timeline.html" \
|
||||
-with-dag "$${tmp_verify}/flowchart.png"
|
||||
rm -r "$${tmp_verify}"
|
||||
else
|
||||
echo "Nextflow is already installed\n\n"
|
||||
fi
|
||||
|
||||
if [ ! -z ${PROJECT_PATH} ]; then
|
||||
# Project is located at PROJECT_PATH
|
||||
echo "Change directory: ${PROJECT_PATH}"
|
||||
cd ${PROJECT_PATH}
|
||||
fi
|
||||
|
||||
# Start a web server to preview reports
|
||||
mkdir -p ${HTTP_SERVER_REPORTS_DIR}
|
||||
echo "Starting HTTP server in background, check logs: ${HTTP_SERVER_LOG_PATH}"
|
||||
python3 -m http.server --directory ${HTTP_SERVER_REPORTS_DIR} ${HTTP_SERVER_PORT} > "${HTTP_SERVER_LOG_PATH}" 2>&1 &
|
||||
|
||||
# Stub run?
|
||||
if [ "${STUB_RUN}" = "true" ]; then
|
||||
nextflow ${STUB_RUN_COMMAND} -stub-run
|
||||
fi
|
||||
|
||||
printf "\n$${BOLD}Nextflow ${NEXTFLOW_VERSION} is ready. HTTP server is listening on port ${HTTP_SERVER_PORT}$${RESET}\n"
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
display_name: Amp CLI
|
||||
display_name: Amp
|
||||
icon: ../../../../.icons/sourcegraph-amp.svg
|
||||
description: Sourcegraph's AI coding agent with deep codebase understanding and intelligent code search capabilities
|
||||
verified: true
|
||||
@@ -13,7 +13,7 @@ Run [Amp CLI](https://ampcode.com/) in your workspace to access Sourcegraph's AI
|
||||
```tf
|
||||
module "amp-cli" {
|
||||
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
|
||||
version = "1.0.3"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key
|
||||
install_sourcegraph_amp = true
|
||||
@@ -23,8 +23,10 @@ module "amp-cli" {
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Include the [Coder Login](https://registry.coder.com/modules/coder-login/coder) module in your template
|
||||
- Node.js and npm are automatically installed (via NVM) if not already available
|
||||
- **Default (official installer)**: No prerequisites - the official installer includes its own runtime (Bun)
|
||||
- **npm installation (`install_via_npm = true`)**: Requires Node.js and npm to be installed before Amp installation
|
||||
- Required for Alpine Linux or other musl-based systems
|
||||
- Ensure Node.js and npm are available in your workspace image or via earlier provisioning steps
|
||||
|
||||
## Usage Example
|
||||
|
||||
@@ -35,52 +37,55 @@ data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
default = ""
|
||||
mutable = true
|
||||
|
||||
}
|
||||
|
||||
# Set system prompt for Amp CLI via environment variables
|
||||
resource "coder_agent" "main" {
|
||||
# ...
|
||||
env = {
|
||||
SOURCEGRAPH_AMP_SYSTEM_PROMPT = <<-EOT
|
||||
You are an Amp assistant that helps developers debug and write code efficiently.
|
||||
|
||||
Always log task status to Coder.
|
||||
EOT
|
||||
SOURCEGRAPH_AMP_TASK_PROMPT = data.coder_parameter.ai_prompt.value
|
||||
}
|
||||
}
|
||||
|
||||
variable "sourcegraph_amp_api_key" {
|
||||
variable "amp_api_key" {
|
||||
type = string
|
||||
description = "Sourcegraph Amp API key. Get one at https://ampcode.com/settings"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
module "amp-cli" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.example.id
|
||||
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key # recommended for authenticated usage
|
||||
install_sourcegraph_amp = true
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
|
||||
amp_version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
amp_api_key = var.amp_api_key # recommended for tasks usage
|
||||
workdir = "/home/coder/project"
|
||||
instruction_prompt = <<-EOT
|
||||
# Instructions
|
||||
- Start every response with `amp > `
|
||||
EOT
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
base_amp_config = jsonencode({
|
||||
"amp.anthropic.thinking.enabled" = true
|
||||
"amp.todos.enabled" = true
|
||||
"amp.tools.stopTimeout" = 600
|
||||
"amp.git.commit.ampThread.enabled" = true
|
||||
"amp.git.commit.coauthor.enabled" = true
|
||||
"amp.terminal.commands.nodeSpawn.loadProfile" = "daily"
|
||||
"amp.permissions" = [
|
||||
{ "tool" : "mcp__coder__*", "action" : "allow" },
|
||||
{ "tool" : "Bash", "action" : "allow", "context" : "thread" },
|
||||
{ "tool" : "Bash", "matches" : { "cmd" : ["rm -rf /*", "rm -rf ~/*"] }, "action" : "reject", "context" : "subagent" },
|
||||
{ "tool" : "edit_file", "action" : "allow" },
|
||||
{ "tool" : "write_file", "action" : "allow" },
|
||||
{ "tool" : "read_file", "action" : "allow" },
|
||||
{ "tool" : "Grep", "action" : "allow" }
|
||||
]
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## How it Works
|
||||
|
||||
- **Install**: Installs Sourcegraph Amp CLI using npm (installs Node.js via NVM if required)
|
||||
- **Start**: Launches Amp CLI in the specified directory, wrapped with AgentAPI to enable tasks and AI interactions
|
||||
- **Environment Variables**: Sets `SOURCEGRAPH_AMP_API_KEY` and `SOURCEGRAPH_AMP_START_DIRECTORY` for the CLI execution
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `amp` is not found, ensure `install_sourcegraph_amp = true` and your API key is valid
|
||||
- Logs are written under `/home/coder/.sourcegraph-amp-module/` (`install.log`, `agentapi-start.log`) for debugging
|
||||
- If `amp` is not found, ensure `install_amp = true` and your API key is valid
|
||||
- Logs are written under `/home/coder/.amp-module/` (`install.log`, `agentapi-start.log`) for debugging
|
||||
- If AgentAPI fails to start, verify that your container has network access and executable permissions for the scripts
|
||||
|
||||
> [!IMPORTANT]
|
||||
> For using **Coder Tasks** with Amp CLI, make sure to pass the `AI Prompt` parameter and set `sourcegraph_amp_api_key`.
|
||||
> To use tasks with Amp CLI, create a `coder_parameter` named `"AI Prompt"` and pass its value to the amp-cli module's `ai_prompt` variable. The `folder` variable is required for the module to function correctly.
|
||||
> For using **Coder Tasks** with Amp CLI, make sure to set `amp_api_key`.
|
||||
> This ensures task reporting and status updates work seamlessly.
|
||||
|
||||
## References
|
||||
|
||||
@@ -43,9 +43,9 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_sourcegraph_amp: props?.skipAmpMock ? "true" : "false",
|
||||
workdir: "/home/coder",
|
||||
install_amp: props?.skipAmpMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
sourcegraph_amp_model: "test-model",
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
@@ -68,45 +68,94 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
|
||||
describe("sourcegraph-amp", async () => {
|
||||
describe("amp", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
// test("happy-path", async () => {
|
||||
// const { id } = await setup();
|
||||
// await execModuleScript(id);
|
||||
// await expectAgentAPIStarted(id);
|
||||
// });
|
||||
//
|
||||
// test("api-key", async () => {
|
||||
// const apiKey = "test-api-key-123";
|
||||
// const { id } = await setup({
|
||||
// moduleVariables: {
|
||||
// amp_api_key: apiKey,
|
||||
// },
|
||||
// });
|
||||
// await execModuleScript(id);
|
||||
// const resp = await readFileContainer(
|
||||
// id,
|
||||
// "/home/coder/.amp-module/agentapi-start.log",
|
||||
// );
|
||||
// expect(resp).toContain("amp_api_key provided !");
|
||||
// });
|
||||
//
|
||||
test("install-latest-version", async () => {
|
||||
const { id } = await setup({
|
||||
skipAmpMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
moduleVariables: {
|
||||
amp_version: "",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("api-key", async () => {
|
||||
const apiKey = "test-api-key-123";
|
||||
test("install-specific-version", async () => {
|
||||
const { id } = await setup({
|
||||
skipAmpMock: true,
|
||||
moduleVariables: {
|
||||
sourcegraph_amp_api_key: apiKey,
|
||||
amp_version: "0.0.1755964909-g31e083",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/agentapi-start.log",
|
||||
"/home/coder/.amp-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("sourcegraph_amp_api_key provided !");
|
||||
expect(resp).toContain("0.0.1755964909-g31e08");
|
||||
});
|
||||
|
||||
test("custom-folder", async () => {
|
||||
const folder = "/tmp/sourcegraph-amp-test";
|
||||
test("install-via-npm", async () => {
|
||||
const { id } = await setup({
|
||||
skipAmpMock: true,
|
||||
moduleVariables: {
|
||||
install_via_npm: "true",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.amp-module/install.log",
|
||||
);
|
||||
expect(installLog).toContain("Installing Amp via npm");
|
||||
|
||||
const startLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.amp-module/agentapi-start.log",
|
||||
);
|
||||
expect(startLog).toContain("AMP version:");
|
||||
});
|
||||
|
||||
test("custom-workdir", async () => {
|
||||
const workdir = "/tmp/amp-test";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
folder,
|
||||
workdir,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/install.log",
|
||||
"/home/coder/.amp-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain(folder);
|
||||
expect(resp).toContain(workdir);
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
@@ -119,39 +168,104 @@ describe("sourcegraph-amp", async () => {
|
||||
await execModuleScript(id);
|
||||
const preLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/pre_install.log",
|
||||
"/home/coder/.amp-module/pre_install.log",
|
||||
);
|
||||
expect(preLog).toContain("pre-install-script");
|
||||
const postLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/post_install.log",
|
||||
"/home/coder/.amp-module/post_install.log",
|
||||
);
|
||||
expect(postLog).toContain("post-install-script");
|
||||
});
|
||||
|
||||
test("system-prompt", async () => {
|
||||
const prompt = "this is a system prompt for AMP";
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id, {
|
||||
SOURCEGRAPH_AMP_SYSTEM_PROMPT: prompt,
|
||||
test("instruction-prompt", async () => {
|
||||
const prompt = "this is a instruction prompt for AMP";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
instruction_prompt: prompt,
|
||||
},
|
||||
});
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/SYSTEM_PROMPT.md",
|
||||
);
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.config/AGENTS.md");
|
||||
expect(resp).toContain(prompt);
|
||||
});
|
||||
|
||||
test("task-prompt", async () => {
|
||||
test("ai-prompt", async () => {
|
||||
const prompt = "this is a task prompt for AMP";
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id, {
|
||||
SOURCEGRAPH_AMP_TASK_PROMPT: prompt,
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
ai_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/agentapi-start.log",
|
||||
"/home/coder/.amp-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain(`sourcegraph amp task prompt provided : ${prompt}`);
|
||||
expect(resp).toContain(`amp task prompt provided : ${prompt}`);
|
||||
});
|
||||
|
||||
test("custom-base-config", async () => {
|
||||
const customConfig = JSON.stringify({
|
||||
"amp.anthropic.thinking.enabled": false,
|
||||
"amp.todos.enabled": false,
|
||||
"amp.tools.stopTimeout": 900,
|
||||
"amp.git.commit.ampThread.enabled": true,
|
||||
});
|
||||
const customMcp = JSON.stringify({
|
||||
"test-server": {
|
||||
command: "/usr/bin/test-mcp",
|
||||
args: ["--test-arg"],
|
||||
type: "stdio",
|
||||
},
|
||||
});
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
base_amp_config: customConfig,
|
||||
mcp: customMcp,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id, {
|
||||
CODER_AGENT_TOKEN: "test-token",
|
||||
CODER_AGENT_URL: "http://test-url:3000",
|
||||
});
|
||||
const settingsContent = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.config/amp/settings.json",
|
||||
);
|
||||
const settings = JSON.parse(settingsContent);
|
||||
|
||||
expect(settings["amp.anthropic.thinking.enabled"]).toBe(false);
|
||||
expect(settings["amp.todos.enabled"]).toBe(false);
|
||||
expect(settings["amp.tools.stopTimeout"]).toBe(900);
|
||||
expect(settings["amp.git.commit.ampThread.enabled"]).toBe(true);
|
||||
expect(settings["amp.mcpServers"]).toBeDefined();
|
||||
expect(settings["amp.mcpServers"].coder).toBeDefined();
|
||||
expect(settings["amp.mcpServers"]["test-server"]).toBeDefined();
|
||||
expect(settings["amp.mcpServers"]["test-server"].command).toBe(
|
||||
"/usr/bin/test-mcp",
|
||||
);
|
||||
expect(settings["amp.mcpServers"]["test-server"].args).toEqual([
|
||||
"--test-arg",
|
||||
]);
|
||||
});
|
||||
|
||||
test("default-base-config", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id, {
|
||||
CODER_AGENT_TOKEN: "test-token",
|
||||
CODER_AGENT_URL: "http://test-url:3000",
|
||||
});
|
||||
const settingsContent = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.config/amp/settings.json",
|
||||
);
|
||||
const settings = JSON.parse(settingsContent);
|
||||
|
||||
expect(settings["amp.anthropic.thinking.enabled"]).toBe(true);
|
||||
expect(settings["amp.todos.enabled"]).toBe(true);
|
||||
expect(settings["amp.mcpServers"]).toBeDefined();
|
||||
expect(settings["amp.mcpServers"].coder).toBeDefined();
|
||||
expect(settings["amp.mcpServers"].coder.command).toBe("coder");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,28 +36,9 @@ variable "icon" {
|
||||
default = "/icon/sourcegraph-amp.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
variable "workdir" {
|
||||
type = string
|
||||
description = "The folder to run sourcegraph_amp in."
|
||||
default = "/home/coder"
|
||||
}
|
||||
|
||||
variable "install_sourcegraph_amp" {
|
||||
type = bool
|
||||
description = "Whether to install sourcegraph-amp."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "sourcegraph_amp_api_key" {
|
||||
type = string
|
||||
description = "sourcegraph-amp API Key"
|
||||
default = ""
|
||||
}
|
||||
|
||||
resource "coder_env" "sourcegraph_amp_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "SOURCEGRAPH_AMP_API_KEY"
|
||||
value = var.sourcegraph_amp_api_key
|
||||
description = "The folder to run AMP CLI in."
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
@@ -69,21 +50,87 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.3.0"
|
||||
default = "v0.10.0"
|
||||
}
|
||||
|
||||
variable "cli_app" {
|
||||
type = bool
|
||||
description = "Whether to create a CLI app for Claude Code"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "web_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the web app"
|
||||
default = "Amp"
|
||||
}
|
||||
|
||||
variable "cli_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the CLI app"
|
||||
default = "Amp CLI"
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing sourcegraph_amp"
|
||||
description = "Custom script to run before installing amp cli"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing sourcegraph_amp."
|
||||
description = "Custom script to run after installing amp cli."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "report_tasks" {
|
||||
type = bool
|
||||
description = "Whether to enable task reporting to Coder UI"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "install_amp" {
|
||||
type = bool
|
||||
description = "Whether to install amp cli."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "install_via_npm" {
|
||||
type = bool
|
||||
description = "Install Amp via npm instead of the official installer."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "amp_api_key" {
|
||||
type = string
|
||||
description = "amp cli API Key"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "amp_version" {
|
||||
type = string
|
||||
description = "The version of amp cli to install."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "Task prompt for the Amp CLI"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "instruction_prompt" {
|
||||
type = string
|
||||
description = "Instruction prompt for the Amp CLI. https://ampcode.com/manual#AGENTS.md"
|
||||
default = ""
|
||||
}
|
||||
|
||||
resource "coder_env" "amp_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "AMP_API_KEY"
|
||||
value = var.amp_api_key
|
||||
}
|
||||
|
||||
variable "base_amp_config" {
|
||||
type = string
|
||||
description = <<-EOT
|
||||
@@ -102,22 +149,25 @@ variable "base_amp_config" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "additional_mcp_servers" {
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "Additional MCP servers configuration in JSON format to append to amp.mcpServers."
|
||||
default = null
|
||||
}
|
||||
|
||||
data "external" "env" {
|
||||
program = ["sh", "-c", "echo '{\"CODER_AGENT_TOKEN\":\"'$CODER_AGENT_TOKEN'\",\"CODER_AGENT_URL\":\"'$CODER_AGENT_URL'\"}'"]
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "amp"
|
||||
|
||||
default_base_config = {
|
||||
default_base_config = jsonencode({
|
||||
"amp.anthropic.thinking.enabled" = true
|
||||
"amp.todos.enabled" = true
|
||||
}
|
||||
})
|
||||
|
||||
# Use provided config or default, then extract base settings (excluding mcpServers)
|
||||
user_config = var.base_amp_config != "" ? jsondecode(var.base_amp_config) : local.default_base_config
|
||||
user_config = jsondecode(var.base_amp_config != "" ? var.base_amp_config : local.default_base_config)
|
||||
base_amp_settings = { for k, v in local.user_config : k => v if k != "amp.mcpServers" }
|
||||
|
||||
coder_mcp = {
|
||||
@@ -125,14 +175,16 @@ locals {
|
||||
"command" = "coder"
|
||||
"args" = ["exp", "mcp", "server"]
|
||||
"env" = {
|
||||
"CODER_MCP_APP_STATUS_SLUG" = local.app_slug
|
||||
"CODER_MCP_AI_AGENTAPI_URL" = "http://localhost:3284"
|
||||
"CODER_MCP_APP_STATUS_SLUG" = var.report_tasks == true ? local.app_slug : ""
|
||||
"CODER_MCP_AI_AGENTAPI_URL" = var.report_tasks == true ? "http://localhost:3284" : ""
|
||||
"CODER_AGENT_TOKEN" = data.external.env.result.CODER_AGENT_TOKEN
|
||||
"CODER_AGENT_URL" = data.external.env.result.CODER_AGENT_URL
|
||||
}
|
||||
"type" = "stdio"
|
||||
}
|
||||
}
|
||||
|
||||
additional_mcp = var.additional_mcp_servers != null ? jsondecode(var.additional_mcp_servers) : {}
|
||||
additional_mcp = var.mcp != null ? jsondecode(var.mcp) : {}
|
||||
|
||||
merged_mcp_servers = merge(
|
||||
lookup(local.user_config, "amp.mcpServers", {}),
|
||||
@@ -146,21 +198,24 @@ locals {
|
||||
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".sourcegraph-amp-module"
|
||||
module_dir_name = ".amp-module"
|
||||
workdir = trimsuffix(var.workdir, "/")
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.workdir
|
||||
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 = "Sourcegraph Amp"
|
||||
cli_app_slug = "${local.app_slug}-cli"
|
||||
cli_app_display_name = "Sourcegraph Amp CLI"
|
||||
web_app_display_name = var.web_app_display_name
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
@@ -173,8 +228,10 @@ module "agentapi" {
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
SOURCEGRAPH_AMP_API_KEY='${var.sourcegraph_amp_api_key}' \
|
||||
SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \
|
||||
ARG_AMP_API_KEY='${var.amp_api_key}' \
|
||||
ARG_AMP_START_DIRECTORY='${var.workdir}' \
|
||||
ARG_AMP_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
@@ -185,9 +242,11 @@ module "agentapi" {
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_INSTALL_SOURCEGRAPH_AMP='${var.install_sourcegraph_amp}' \
|
||||
SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \
|
||||
ARG_AMP_CONFIG="$(echo -n '${base64encode(jsonencode(local.final_config))}' | base64 -d)" \
|
||||
ARG_INSTALL_AMP='${var.install_amp}' \
|
||||
ARG_INSTALL_VIA_NPM='${var.install_via_npm}' \
|
||||
ARG_AMP_CONFIG="${base64encode(jsonencode(local.final_config))}" \
|
||||
ARG_AMP_VERSION='${var.amp_version}' \
|
||||
ARG_AMP_INSTRUCTION_PROMPT='${base64encode(var.instruction_prompt)}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
|
||||
@@ -1,77 +1,119 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
# ANSI colors
|
||||
BOLD='\033[1m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
ARG_INSTALL_AMP=${ARG_INSTALL_AMP:-true}
|
||||
ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false}
|
||||
ARG_AMP_VERSION=${ARG_AMP_VERSION:-}
|
||||
ARG_AMP_INSTRUCTION_PROMPT=$(echo -n "${ARG_AMP_INSTRUCTION_PROMPT:-}" | base64 -d)
|
||||
ARG_AMP_CONFIG=$(echo -n "${ARG_AMP_CONFIG:-}" | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "Install flag: $ARG_INSTALL_SOURCEGRAPH_AMP"
|
||||
echo "Workspace: $SOURCEGRAPH_AMP_START_DIRECTORY"
|
||||
printf "Install flag: %s\n" "$ARG_INSTALL_AMP"
|
||||
printf "Install via npm: %s\n" "$ARG_INSTALL_VIA_NPM"
|
||||
printf "Amp Version: %s\n" "$ARG_AMP_VERSION"
|
||||
printf "AMP Config: %s\n" "$ARG_AMP_CONFIG"
|
||||
printf "Instruction Prompt: %s\n" "$ARG_AMP_INSTRUCTION_PROMPT"
|
||||
echo "--------------------------------"
|
||||
|
||||
# Helper function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
function install_node() {
|
||||
if ! command_exists npm; then
|
||||
printf "npm not found, checking for Node.js installation...\n"
|
||||
if ! command_exists node; then
|
||||
printf "Node.js not found, installing Node.js via NVM...\n"
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
if [ ! -d "$NVM_DIR" ]; then
|
||||
mkdir -p "$NVM_DIR"
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
else
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
install_amp_npm() {
|
||||
printf "%s${YELLOW}Installing Amp via npm${NC}\n" "${BOLD}"
|
||||
|
||||
# Temporarily disable nounset (-u) for nvm to avoid PROVIDED_VERSION error
|
||||
set +u
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
nvm alias default node
|
||||
set -u
|
||||
|
||||
printf "Node.js installed: %s\n" "$(node --version)"
|
||||
printf "npm installed: %s\n" "$(npm --version)"
|
||||
else
|
||||
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
|
||||
exit 1
|
||||
fi
|
||||
# Load nvm if available
|
||||
# shellcheck source=/dev/null
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME/.nvm/nvm.sh"
|
||||
fi
|
||||
}
|
||||
|
||||
function install_sourcegraph_amp() {
|
||||
if [ "${ARG_INSTALL_SOURCEGRAPH_AMP}" = "true" ]; then
|
||||
install_node
|
||||
|
||||
# If nvm is not used, set up user npm global directory
|
||||
if ! command_exists nvm; then
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
|
||||
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "%s Installing Sourcegraph AMP CLI...\n" "${BOLD}"
|
||||
npm install -g @sourcegraph/amp@0.0.1754179307-gba1f97
|
||||
printf "%s Successfully installed Sourcegraph AMP CLI. Version: %s\n" "${BOLD}" "$(amp --version)"
|
||||
if ! command_exists node || ! command_exists npm; then
|
||||
printf "${YELLOW}Warning: Node.js/npm not found. Skipping Amp installation.${NC}\n"
|
||||
printf "To install Amp via npm, please install Node.js and npm first.\n"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function setup_system_prompt() {
|
||||
if [ -n "${SOURCEGRAPH_AMP_SYSTEM_PROMPT:-}" ]; then
|
||||
echo "Setting Sourcegraph AMP system prompt..."
|
||||
mkdir -p "$HOME/.sourcegraph-amp-module"
|
||||
echo "$SOURCEGRAPH_AMP_SYSTEM_PROMPT" > "$HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md"
|
||||
echo "System prompt saved to $HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md"
|
||||
printf "Node.js version: %s\n" "$(node --version)"
|
||||
printf "npm version: %s\n" "$(npm --version)"
|
||||
|
||||
NPM_GLOBAL_PREFIX="${HOME}/.npm-global"
|
||||
if [ ! -d "$NPM_GLOBAL_PREFIX" ]; then
|
||||
mkdir -p "$NPM_GLOBAL_PREFIX"
|
||||
fi
|
||||
|
||||
npm config set prefix "$NPM_GLOBAL_PREFIX"
|
||||
export PATH="$NPM_GLOBAL_PREFIX/bin:$PATH"
|
||||
|
||||
if [ -n "$ARG_AMP_VERSION" ]; then
|
||||
npm install -g "@sourcegraph/amp@$ARG_AMP_VERSION"
|
||||
else
|
||||
echo "No system prompt provided for Sourcegraph AMP."
|
||||
npm install -g "@sourcegraph/amp"
|
||||
fi
|
||||
|
||||
if ! grep -q 'export PATH="$HOME/.npm-global/bin:$PATH"' "$HOME/.bashrc"; then
|
||||
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$HOME/.bashrc"
|
||||
fi
|
||||
}
|
||||
|
||||
install_amp_official() {
|
||||
printf "%s Installing Amp using official installer\n" "${BOLD}"
|
||||
|
||||
if [ -n "$ARG_AMP_VERSION" ]; then
|
||||
export AMP_VERSION="$ARG_AMP_VERSION"
|
||||
printf "Installing Amp version: %s\n" "$AMP_VERSION"
|
||||
fi
|
||||
|
||||
if curl -fsSL https://ampcode.com/install.sh | bash; then
|
||||
export PATH="$HOME/.local/bin:$HOME/.amp/bin:$PATH"
|
||||
|
||||
if ! grep -q 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc"; then
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.bashrc"
|
||||
fi
|
||||
else
|
||||
printf "${YELLOW}Warning: Official installer failed. Installation skipped.${NC}\n"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function install_amp() {
|
||||
if [ "${ARG_INSTALL_AMP}" = "true" ]; then
|
||||
if [ "${ARG_INSTALL_VIA_NPM}" = "true" ]; then
|
||||
install_amp_npm || {
|
||||
printf "${YELLOW}Amp installation via npm failed.${NC}\n"
|
||||
return 0
|
||||
}
|
||||
else
|
||||
install_amp_official || {
|
||||
printf "${YELLOW}Amp installation via official installer failed.${NC}\n"
|
||||
return 0
|
||||
}
|
||||
fi
|
||||
|
||||
if command_exists amp; then
|
||||
printf "%s${GREEN}Successfully installed Sourcegraph Amp CLI. Version: %s${NC}\n" "${BOLD}" "$(amp --version)"
|
||||
fi
|
||||
else
|
||||
printf "Skipping Sourcegraph Amp CLI installation (install_amp=false)\n"
|
||||
fi
|
||||
}
|
||||
|
||||
function setup_instruction_prompt() {
|
||||
if [ -n "${ARG_AMP_INSTRUCTION_PROMPT:-}" ]; then
|
||||
echo "Setting AMP instruction prompt..."
|
||||
mkdir -p "$HOME/.config"
|
||||
echo "$ARG_AMP_INSTRUCTION_PROMPT" > "$HOME/.config/AGENTS.md"
|
||||
echo "Instruction prompt saved to $HOME/.config/AGENTS.md"
|
||||
else
|
||||
echo "No instruction prompt provided for Sourcegraph AMP."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -86,11 +128,17 @@ function configure_amp_settings() {
|
||||
fi
|
||||
|
||||
echo "Writing AMP configuration to $SETTINGS_PATH"
|
||||
printf '%s\n' "$ARG_AMP_CONFIG" > "$SETTINGS_PATH"
|
||||
UPDATED_CONFIG=$(echo "$ARG_AMP_CONFIG" | jq --arg token "$CODER_AGENT_TOKEN" --arg url "$CODER_AGENT_URL" \
|
||||
".[\"amp.mcpServers\"].coder.env += {
|
||||
\"CODER_AGENT_TOKEN\": \"$CODER_AGENT_TOKEN\",
|
||||
\"CODER_AGENT_URL\": \"$CODER_AGENT_URL\"
|
||||
}")
|
||||
printf "UPDATED_CONFIG: %s\n" "$UPDATED_CONFIG"
|
||||
printf '%s\n' "$UPDATED_CONFIG" > "$SETTINGS_PATH"
|
||||
|
||||
echo "AMP configuration complete"
|
||||
}
|
||||
|
||||
install_sourcegraph_amp
|
||||
setup_system_prompt
|
||||
install_amp
|
||||
setup_instruction_prompt
|
||||
configure_amp_settings
|
||||
|
||||
@@ -6,11 +6,11 @@ set -euo pipefail
|
||||
source "$HOME/.bashrc"
|
||||
# shellcheck source=/dev/null
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME"/.nvm/nvm.sh
|
||||
else
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
source "$HOME/.nvm/nvm.sh"
|
||||
fi
|
||||
|
||||
export PATH="$HOME/.local/bin:$HOME/.amp/bin:$HOME/.npm-global/bin:$PATH"
|
||||
|
||||
function ensure_command() {
|
||||
command -v "$1" &> /dev/null || {
|
||||
echo "Error: '$1' not found." >&2
|
||||
@@ -18,10 +18,21 @@ function ensure_command() {
|
||||
}
|
||||
}
|
||||
|
||||
ARG_AMP_START_DIRECTORY=${ARG_AMP_START_DIRECTORY:-"$HOME"}
|
||||
ARG_AMP_API_KEY=${ARG_AMP_API_KEY:-}
|
||||
ARG_AMP_TASK_PROMPT=$(echo -n "${ARG_AMP_TASK_PROMPT:-}" | base64 -d)
|
||||
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
|
||||
|
||||
echo "--------------------------------"
|
||||
printf "Workspace: %s\n" "$ARG_AMP_START_DIRECTORY"
|
||||
printf "Task Prompt: %s\n" "$ARG_AMP_TASK_PROMPT"
|
||||
printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS"
|
||||
echo "--------------------------------"
|
||||
|
||||
ensure_command amp
|
||||
echo "AMP version: $(amp --version)"
|
||||
|
||||
dir="$SOURCEGRAPH_AMP_START_DIRECTORY"
|
||||
dir="$ARG_AMP_START_DIRECTORY"
|
||||
if [[ -d "$dir" ]]; then
|
||||
echo "Using existing directory: $dir"
|
||||
else
|
||||
@@ -30,20 +41,23 @@ else
|
||||
fi
|
||||
cd "$dir"
|
||||
|
||||
if [ -n "$SOURCEGRAPH_AMP_API_KEY" ]; then
|
||||
printf "sourcegraph_amp_api_key provided !\n"
|
||||
export AMP_API_KEY=$SOURCEGRAPH_AMP_API_KEY
|
||||
if [ -n "$ARG_AMP_API_KEY" ]; then
|
||||
printf "amp_api_key provided !\n"
|
||||
export AMP_API_KEY=$ARG_AMP_API_KEY
|
||||
else
|
||||
printf "sourcegraph_amp_api_key not provided\n"
|
||||
printf "amp_api_key not provided\n"
|
||||
fi
|
||||
|
||||
if [ -n "${SOURCEGRAPH_AMP_TASK_PROMPT:-}" ]; then
|
||||
printf "sourcegraph amp task prompt provided : $SOURCEGRAPH_AMP_TASK_PROMPT"
|
||||
PROMPT="Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $SOURCEGRAPH_AMP_TASK_PROMPT"
|
||||
|
||||
if [ -n "$ARG_AMP_TASK_PROMPT" ]; then
|
||||
if [ "$ARG_REPORT_TASKS" == "true" ]; then
|
||||
printf "amp task prompt provided : %s" "$ARG_AMP_TASK_PROMPT\n"
|
||||
PROMPT="Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_AMP_TASK_PROMPT"
|
||||
else
|
||||
PROMPT="$ARG_AMP_TASK_PROMPT"
|
||||
fi
|
||||
# Pipe the prompt into amp, which will be run inside agentapi
|
||||
agentapi server --term-width=67 --term-height=1190 -- bash -c "echo \"$PROMPT\" | amp"
|
||||
agentapi server --type amp --term-width=67 --term-height=1190 -- bash -c "echo \"$PROMPT\" | amp"
|
||||
else
|
||||
printf "No task prompt given.\n"
|
||||
agentapi server --term-width=67 --term-height=1190 -- amp
|
||||
agentapi server --type amp --term-width=67 --term-height=1190 -- amp
|
||||
fi
|
||||
|
||||
@@ -22,33 +22,16 @@ provider "docker" {}
|
||||
module "claude-code" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.0.0"
|
||||
version = "3.0.0"
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
folder = "/home/coder/projects"
|
||||
install_claude_code = true
|
||||
claude_code_version = "latest"
|
||||
workdir = "/home/coder/projects"
|
||||
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
|
||||
agent_name = "main"
|
||||
name = "CODER_MCP_CLAUDE_API_KEY"
|
||||
value = var.anthropic_api_key
|
||||
claude_api_key = ""
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
system_prompt = data.coder_parameter.system_prompt.value
|
||||
model = "sonnet"
|
||||
permission_mode = "plan"
|
||||
post_install_script = data.coder_parameter.setup_script.value
|
||||
}
|
||||
|
||||
# We are using presets to set the prompts, image, and set up instructions
|
||||
@@ -174,26 +157,6 @@ data "coder_parameter" "preview_port" {
|
||||
mutable = false
|
||||
}
|
||||
|
||||
# Other variables for Claude Code
|
||||
resource "coder_env" "claude_task_prompt" {
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
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
|
||||
agent_name = "main"
|
||||
name = "CODER_MCP_APP_STATUS_SLUG"
|
||||
value = "ccw"
|
||||
}
|
||||
resource "coder_env" "claude_system_prompt" {
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
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" {}
|
||||
@@ -301,38 +264,27 @@ module "code-server" {
|
||||
# This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production.
|
||||
version = "~> 1.0"
|
||||
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
order = 1
|
||||
}
|
||||
|
||||
module "vscode" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-desktop/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
agent_id = coder_agent.main.id
|
||||
order = 1
|
||||
}
|
||||
|
||||
module "windsurf" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/windsurf/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/windsurf/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/modules/coder/jetbrains/coder"
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "~> 1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
@@ -368,7 +320,6 @@ resource "docker_volume" "home_volume" {
|
||||
|
||||
resource "coder_app" "preview" {
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
slug = "preview"
|
||||
display_name = "Preview your app"
|
||||
icon = "${data.coder_workspace.me.access_url}/emojis/1f50e.png"
|
||||
|
||||
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
|
||||
```tf
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
|
||||
@@ -117,7 +117,7 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.3.3"
|
||||
default = "v0.10.0"
|
||||
}
|
||||
|
||||
variable "agentapi_port" {
|
||||
|
||||
@@ -13,7 +13,7 @@ Run [Amazon Q](https://aws.amazon.com/q/) in your workspace to access Amazon's A
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
|
||||
@@ -102,7 +102,7 @@ data "coder_parameter" "ai_prompt" {
|
||||
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
auth_tarball = var.amazon_q_auth_tarball
|
||||
@@ -228,7 +228,7 @@ If no custom `agent_config` is provided, the default agent name "agent" is used.
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
auth_tarball = var.amazon_q_auth_tarball
|
||||
@@ -258,7 +258,7 @@ This example will:
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
auth_tarball = var.amazon_q_auth_tarball
|
||||
@@ -279,7 +279,7 @@ module "amazon-q" {
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
auth_tarball = var.amazon_q_auth_tarball
|
||||
@@ -305,7 +305,7 @@ module "amazon-q" {
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
auth_tarball = var.amazon_q_auth_tarball
|
||||
@@ -319,7 +319,7 @@ module "amazon-q" {
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
auth_tarball = var.amazon_q_auth_tarball
|
||||
@@ -340,14 +340,14 @@ module "amazon-q" {
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
auth_tarball = var.amazon_q_auth_tarball
|
||||
|
||||
# AgentAPI configuration for environments without wildcard access url. https://coder.com/docs/admin/setup#wildcard-access-url
|
||||
agentapi_chat_based_path = true
|
||||
agentapi_version = "v0.6.1"
|
||||
agentapi_version = "v0.10.0"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -358,7 +358,7 @@ For environments without direct internet access, you can host Amazon Q installat
|
||||
```tf
|
||||
module "amazon-q" {
|
||||
source = "registry.coder.com/coder/amazon-q/coder"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
auth_tarball = var.amazon_q_auth_tarball
|
||||
|
||||
@@ -88,7 +88,7 @@ variable "post_install_script" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.6.1"
|
||||
default = "v0.10.0"
|
||||
}
|
||||
|
||||
variable "workdir" {
|
||||
@@ -96,8 +96,6 @@ variable "workdir" {
|
||||
description = "The folder to run Amazon Q in."
|
||||
}
|
||||
|
||||
# ---------------------------------------------
|
||||
|
||||
variable "install_amazon_q" {
|
||||
type = bool
|
||||
description = "Whether to install Amazon Q."
|
||||
@@ -190,6 +188,7 @@ resource "coder_env" "auth_tarball" {
|
||||
|
||||
locals {
|
||||
app_slug = "amazonq"
|
||||
workdir = trimsuffix(var.workdir, "/")
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".amazonq-module"
|
||||
@@ -215,9 +214,10 @@ locals {
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = local.workdir
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
|
||||
@@ -94,6 +94,13 @@ function install_amazon_q() {
|
||||
function extract_auth_tarball() {
|
||||
if [ -n "$ARG_AUTH_TARBALL" ]; then
|
||||
echo "Extracting auth tarball..."
|
||||
|
||||
if ! command_exists zstd; then
|
||||
echo "Error: zstd is required to extract the authentication tarball but is not installed."
|
||||
echo "Please install zstd using the pre_install_script parameter."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREV_DIR="$PWD"
|
||||
echo "$ARG_AUTH_TARBALL" | base64 -d > /tmp/auth.tar.zst
|
||||
rm -rf ~/.local/share/amazon-q
|
||||
|
||||
@@ -1,117 +1,318 @@
|
||||
---
|
||||
display_name: Claude Code
|
||||
description: Run Claude Code in your workspace
|
||||
description: Run the Claude Code agent in your workspace.
|
||||
icon: ../../../../.icons/claude.svg
|
||||
verified: true
|
||||
tags: [agent, claude-code, ai, tasks]
|
||||
tags: [agent, claude-code, ai, tasks, anthropic]
|
||||
---
|
||||
|
||||
# Claude Code
|
||||
|
||||
Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks.
|
||||
Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for task reporting in the Coder UI.
|
||||
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
claude_code_version = "latest"
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "3.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
}
|
||||
```
|
||||
|
||||
> **Security Notice**: This module uses the [`--dangerously-skip-permissions`](https://docs.anthropic.com/en/docs/claude-code/cli-usage#cli-flags) flag when running Claude Code. This flag
|
||||
> bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While
|
||||
> this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as
|
||||
> the user running it. Use this module _only_ in trusted environments and be aware of the security implications.
|
||||
> [!WARNING]
|
||||
> **Security Notice**: This module uses the `--dangerously-skip-permissions` flag when running Claude Code tasks. This flag bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as the user running it. Use this module _only_ in trusted environments and be aware of the security implications.
|
||||
|
||||
> [!NOTE]
|
||||
> By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
|
||||
- An **Anthropic API key** or a _Claude Session Token_ is required for tasks.
|
||||
- You can get the API key from the [Anthropic Console](https://console.anthropic.com/dashboard).
|
||||
- You can get the Session Token using the `claude setup-token` command. This is a long-lived authentication token (requires Claude subscription)
|
||||
|
||||
The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces.
|
||||
### Session Resumption Behavior
|
||||
|
||||
By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false`
|
||||
|
||||
## Examples
|
||||
|
||||
### Run in the background and report tasks (Experimental)
|
||||
### Usage with Agent Boundaries
|
||||
|
||||
> 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.
|
||||
This example shows how to configure the Claude Code module to run the agent behind a process-level boundary that restricts its network access.
|
||||
|
||||
```tf
|
||||
variable "anthropic_api_key" {
|
||||
type = string
|
||||
description = "The Anthropic API key"
|
||||
sensitive = true
|
||||
module "claude-code" {
|
||||
source = "dev.registry.coder.com/coder/claude-code/coder"
|
||||
enable_boundary = true
|
||||
boundary_version = "main"
|
||||
boundary_log_dir = "/tmp/boundary_logs"
|
||||
boundary_log_level = "WARN"
|
||||
boundary_additional_allowed_urls = ["GET *google.com"]
|
||||
boundary_proxy_port = "8087"
|
||||
version = "3.2.1"
|
||||
}
|
||||
```
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "1.0.15"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
### Usage with Tasks and Advanced Configuration
|
||||
|
||||
This example shows how to configure the Claude Code module with an AI prompt, API key shared by all users of the template, and other custom settings.
|
||||
|
||||
```tf
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Write a prompt for Claude Code"
|
||||
description = "Initial task prompt for Claude Code."
|
||||
mutable = true
|
||||
}
|
||||
|
||||
# Set the prompt and system prompt for Claude Code via environment variables
|
||||
resource "coder_agent" "main" {
|
||||
# ...
|
||||
env = {
|
||||
CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter
|
||||
CODER_MCP_CLAUDE_TASK_PROMPT = data.coder_parameter.ai_prompt.value
|
||||
CODER_MCP_APP_STATUS_SLUG = "claude-code"
|
||||
CODER_MCP_CLAUDE_SYSTEM_PROMPT = <<-EOT
|
||||
You are a helpful assistant that can help with code.
|
||||
EOT
|
||||
}
|
||||
}
|
||||
|
||||
module "claude-code" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
claude_code_version = "1.0.40"
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "3.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
# Enable experimental features
|
||||
experiment_report_tasks = true
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
# OR
|
||||
claude_code_oauth_token = "xxxxx-xxxx-xxxx"
|
||||
|
||||
claude_code_version = "1.0.82" # Pin to a specific version
|
||||
agentapi_version = "v0.10.0"
|
||||
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
model = "sonnet"
|
||||
|
||||
permission_mode = "plan"
|
||||
|
||||
mcp = <<-EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-custom-tool": {
|
||||
"command": "my-tool-server"
|
||||
"args": ["--port", "8080"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
```
|
||||
|
||||
## Run standalone
|
||||
### Standalone Mode
|
||||
|
||||
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.
|
||||
Run and configure Claude Code as a standalone CLI in your workspace.
|
||||
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.2.0"
|
||||
version = "3.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
workdir = "/home/coder"
|
||||
install_claude_code = true
|
||||
claude_code_version = "latest"
|
||||
|
||||
# Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL
|
||||
icon = "https://registry.npmmirror.com/@lobehub/icons-static-png/1.24.0/files/dark/claude-color.png"
|
||||
report_tasks = false
|
||||
cli_app = true
|
||||
}
|
||||
```
|
||||
|
||||
### Usage with Claude Code Subscription
|
||||
|
||||
```tf
|
||||
|
||||
variable "claude_code_oauth_token" {
|
||||
type = string
|
||||
description = "Generate one using `claude setup-token` command"
|
||||
sensitive = true
|
||||
value = "xxxx-xxx-xxxx"
|
||||
}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "3.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
}
|
||||
```
|
||||
|
||||
### Usage with AWS Bedrock
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
AWS account with Bedrock access, Claude models enabled in Bedrock console, appropriate IAM permissions.
|
||||
|
||||
Configure Claude Code to use AWS Bedrock for accessing Claude models through your AWS infrastructure.
|
||||
|
||||
```tf
|
||||
resource "coder_env" "bedrock_use" {
|
||||
agent_id = coder_agent.example.id
|
||||
name = "CLAUDE_CODE_USE_BEDROCK"
|
||||
value = "1"
|
||||
}
|
||||
|
||||
resource "coder_env" "aws_region" {
|
||||
agent_id = coder_agent.example.id
|
||||
name = "AWS_REGION"
|
||||
value = "us-east-1" # Choose your preferred region
|
||||
}
|
||||
|
||||
# Option 1: Using AWS credentials
|
||||
|
||||
variable "aws_access_key_id" {
|
||||
type = string
|
||||
description = "Your AWS access key ID. Create this in the AWS IAM console under 'Security credentials'."
|
||||
sensitive = true
|
||||
value = "xxxx-xxx-xxxx"
|
||||
}
|
||||
|
||||
variable "aws_secret_access_key" {
|
||||
type = string
|
||||
description = "Your AWS secret access key. This is shown once when you create an access key in the AWS IAM console."
|
||||
sensitive = true
|
||||
value = "xxxx-xxx-xxxx"
|
||||
}
|
||||
|
||||
resource "coder_env" "aws_access_key_id" {
|
||||
agent_id = coder_agent.example.id
|
||||
name = "AWS_ACCESS_KEY_ID"
|
||||
value = var.aws_access_key_id
|
||||
}
|
||||
|
||||
resource "coder_env" "aws_secret_access_key" {
|
||||
agent_id = coder_agent.example.id
|
||||
name = "AWS_SECRET_ACCESS_KEY"
|
||||
value = var.aws_secret_access_key
|
||||
}
|
||||
|
||||
# Option 2: Using Bedrock API key (simpler)
|
||||
|
||||
variable "aws_bearer_token_bedrock" {
|
||||
type = string
|
||||
description = "Your AWS Bedrock bearer token. This provides access to Bedrock without needing separate access key and secret key."
|
||||
sensitive = true
|
||||
value = "xxxx-xxx-xxxx"
|
||||
}
|
||||
|
||||
resource "coder_env" "bedrock_api_key" {
|
||||
agent_id = coder_agent.example.id
|
||||
name = "AWS_BEARER_TOKEN_BEDROCK"
|
||||
value = var.aws_bearer_token_bedrock
|
||||
}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "3.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> For additional Bedrock configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Bedrock documentation](https://docs.claude.com/en/docs/claude-code/amazon-bedrock).
|
||||
|
||||
### Usage with Google Vertex AI
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, appropriate IAM permissions (Vertex AI User role).
|
||||
|
||||
Configure Claude Code to use Google Vertex AI for accessing Claude models through Google Cloud Platform.
|
||||
|
||||
```tf
|
||||
variable "vertex_sa_json" {
|
||||
type = string
|
||||
description = "The complete JSON content of your Google Cloud service account key file. Create a service account in the GCP Console under 'IAM & Admin > Service Accounts', then create and download a JSON key. Copy the entire JSON content into this variable."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
resource "coder_env" "vertex_use" {
|
||||
agent_id = coder_agent.example.id
|
||||
name = "CLAUDE_CODE_USE_VERTEX"
|
||||
value = "1"
|
||||
}
|
||||
|
||||
resource "coder_env" "vertex_project_id" {
|
||||
agent_id = coder_agent.example.id
|
||||
name = "ANTHROPIC_VERTEX_PROJECT_ID"
|
||||
value = "your-gcp-project-id"
|
||||
}
|
||||
|
||||
resource "coder_env" "cloud_ml_region" {
|
||||
agent_id = coder_agent.example.id
|
||||
name = "CLOUD_ML_REGION"
|
||||
value = "global"
|
||||
}
|
||||
|
||||
resource "coder_env" "vertex_sa_json" {
|
||||
agent_id = coder_agent.example.id
|
||||
name = "VERTEX_SA_JSON"
|
||||
value = var.vertex_sa_json
|
||||
}
|
||||
|
||||
resource "coder_env" "google_application_credentials" {
|
||||
agent_id = coder_agent.example.id
|
||||
name = "GOOGLE_APPLICATION_CREDENTIALS"
|
||||
value = "/tmp/gcp-sa.json"
|
||||
}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "3.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
|
||||
pre_install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
# Write the service account JSON to a file
|
||||
echo "$VERTEX_SA_JSON" > /tmp/gcp-sa.json
|
||||
|
||||
# Install prerequisite packages
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y apt-transport-https ca-certificates gnupg curl
|
||||
|
||||
# Add Google Cloud public key
|
||||
curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg
|
||||
|
||||
# Add Google Cloud SDK repo to apt sources
|
||||
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list
|
||||
|
||||
# Update and install the Google Cloud SDK
|
||||
sudo apt-get update && sudo apt-get install -y google-cloud-cli
|
||||
|
||||
# Authenticate gcloud with the service account
|
||||
gcloud auth activate-service-account --key-file=/tmp/gcp-sa.json
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> For additional Vertex AI configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Vertex AI documentation](https://docs.claude.com/en/docs/claude-code/google-vertex-ai).
|
||||
|
||||
## 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.
|
||||
If you encounter any issues, check the log files in the `~/.claude-module` directory within your workspace for detailed information.
|
||||
|
||||
```bash
|
||||
# Installation logs
|
||||
cat ~/.claude-module/install.log
|
||||
|
||||
# Startup logs
|
||||
cat ~/.claude-module/agentapi-start.log
|
||||
|
||||
# Pre/post install script logs
|
||||
cat ~/.claude-module/pre_install.log
|
||||
cat ~/.claude-module/post_install.log
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To use tasks with Claude Code, you must provide an `anthropic_api_key` or `claude_code_oauth_token`.
|
||||
> The `workdir` variable is required and specifies the directory where Claude Code will run.
|
||||
|
||||
## References
|
||||
|
||||
- [Claude Code Documentation](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
|
||||
@@ -1,37 +1,26 @@
|
||||
import {
|
||||
test,
|
||||
afterEach,
|
||||
expect,
|
||||
describe,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import path from "path";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
execContainer,
|
||||
findResourceInstance,
|
||||
readFileContainer,
|
||||
removeContainer,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
writeCoder,
|
||||
writeFileContainer,
|
||||
} from "~test";
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../agentapi/test-util";
|
||||
import dedent from "dedent";
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
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) {
|
||||
@@ -43,298 +32,294 @@ afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
const setupContainer = async ({
|
||||
image,
|
||||
vars,
|
||||
}: {
|
||||
image?: string;
|
||||
vars?: Record<string, string>;
|
||||
} = {}) => {
|
||||
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;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const projectDir = "/home/coder/project";
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const { id, coderScript } = await setupContainer({
|
||||
vars: {
|
||||
experiment_report_tasks: "true",
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_claude_code: props?.skipClaudeMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
install_claude_code: "false",
|
||||
agentapi_version: "preview",
|
||||
folder: projectDir,
|
||||
workdir: projectDir,
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
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"),
|
||||
content: await loadTestFile(import.meta.dir, "claude-mock.sh"),
|
||||
});
|
||||
}
|
||||
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();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("install-claude-code-version", async () => {
|
||||
const version_to_install = "1.0.40";
|
||||
const { id } = await setup({
|
||||
skipClaudeMock: true,
|
||||
moduleVariables: {
|
||||
install_claude_code: "true",
|
||||
claude_code_version: version_to_install,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/install.log",
|
||||
]);
|
||||
expect(resp.stdout).toContain(version_to_install);
|
||||
});
|
||||
|
||||
test("check-latest-claude-code-version-works", async () => {
|
||||
const { id } = await setup({
|
||||
skipClaudeMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
moduleVariables: {
|
||||
install_claude_code: "true",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("claude-api-key", async () => {
|
||||
const apiKey = "test-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
claude_api_key: apiKey,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const envCheck = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
'env | grep CLAUDE_API_KEY || echo "CLAUDE_API_KEY not found"',
|
||||
]);
|
||||
expect(envCheck.stdout).toContain("CLAUDE_API_KEY");
|
||||
});
|
||||
|
||||
test("claude-mcp-config", async () => {
|
||||
const mcpConfig = JSON.stringify({
|
||||
mcpServers: {
|
||||
test: {
|
||||
command: "test-cmd",
|
||||
type: "stdio",
|
||||
},
|
||||
},
|
||||
});
|
||||
const { id } = await setup({
|
||||
skipClaudeMock: true,
|
||||
moduleVariables: {
|
||||
mcp: mcpConfig,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(id, "/home/coder/.claude.json");
|
||||
expect(resp).toContain("test-cmd");
|
||||
});
|
||||
|
||||
test("claude-task-prompt", async () => {
|
||||
const prompt = "This is a task prompt for Claude.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
ai_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"sudo /home/coder/script.sh",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
expect(resp.stdout).toContain(prompt);
|
||||
});
|
||||
|
||||
// test that the script removes lastSessionId from the .claude.json file
|
||||
test("last-session-id-removed", async () => {
|
||||
const { id } = await setup();
|
||||
test("claude-permission-mode", async () => {
|
||||
const mode = "plan";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
permission_mode: mode,
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
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, [
|
||||
const startLog = 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",
|
||||
);
|
||||
expect(startLog.stdout).toContain(`--permission-mode ${mode}`);
|
||||
});
|
||||
|
||||
// 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",
|
||||
test("claude-model", async () => {
|
||||
const model = "opus";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
model: model,
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
// check that mocking works
|
||||
const respAgentAPI = await execContainer(id, [
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"agentapi --continue",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
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);
|
||||
expect(startLog.stdout).toContain(`--model ${model}`);
|
||||
});
|
||||
|
||||
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",
|
||||
test("claude-continue-resume-existing-session", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
continue: "true",
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
|
||||
// Create a mock session file with the predefined task session ID
|
||||
const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
|
||||
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
|
||||
await execContainer(id, ["mkdir", "-p", sessionDir]);
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`touch ${sessionDir}/session-${taskSessionId}.jsonl`,
|
||||
]);
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain("--resume");
|
||||
expect(startLog.stdout).toContain(taskSessionId);
|
||||
expect(startLog.stdout).toContain("Resuming existing task session");
|
||||
});
|
||||
|
||||
// verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable
|
||||
// set in main.tf
|
||||
test("agentapi-allowed-hosts", async () => {
|
||||
const { id } = await setup();
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'claude-pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'claude-post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const respModuleScript = await execModuleScript(id);
|
||||
expect(respModuleScript.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
|
||||
const agentApiStartLog = await readFileContainer(
|
||||
const preInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
"/home/coder/.claude-module/pre_install.log",
|
||||
);
|
||||
expect(preInstallLog).toContain("claude-pre-install-script");
|
||||
|
||||
const postInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude-module/post_install.log",
|
||||
);
|
||||
expect(postInstallLog).toContain("claude-post-install-script");
|
||||
});
|
||||
|
||||
test("workdir-variable", async () => {
|
||||
const workdir = "/home/coder/claude-test-folder";
|
||||
const { id } = await setup({
|
||||
skipClaudeMock: false,
|
||||
moduleVariables: {
|
||||
workdir,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain(workdir);
|
||||
});
|
||||
|
||||
test("coder-mcp-config-created", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
install_claude_code: "false",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.claude-module/install.log",
|
||||
);
|
||||
expect(installLog).toContain(
|
||||
"Configuring Claude Code to report tasks via Coder MCP",
|
||||
);
|
||||
});
|
||||
|
||||
test("dangerously-skip-permissions", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
dangerously_skip_permissions: "true",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`--dangerously-skip-permissions`);
|
||||
});
|
||||
|
||||
test("subdomain-false", async () => {
|
||||
const { id } = await setup({
|
||||
skipAgentAPIMock: true,
|
||||
moduleVariables: {
|
||||
subdomain: "false",
|
||||
post_install_script: dedent`
|
||||
#!/bin/bash
|
||||
env | grep AGENTAPI_CHAT_BASE_PATH || echo "AGENTAPI_CHAT_BASE_PATH not found"
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
||||
await execModuleScript(id);
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/post_install.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(
|
||||
"ARG_AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/ccw/chat",
|
||||
);
|
||||
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,12 +36,72 @@ variable "icon" {
|
||||
default = "/icon/claude.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
variable "workdir" {
|
||||
type = string
|
||||
description = "The folder to run Claude Code in."
|
||||
default = "/home/coder"
|
||||
}
|
||||
|
||||
variable "report_tasks" {
|
||||
type = bool
|
||||
description = "Whether to enable task reporting to Coder UI via AgentAPI"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "cli_app" {
|
||||
type = bool
|
||||
description = "Whether to create a CLI app for Claude Code"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "web_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the web app"
|
||||
default = "Claude Code"
|
||||
}
|
||||
|
||||
variable "cli_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the CLI app"
|
||||
default = "Claude Code CLI"
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing Claude Code."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing Claude Code."
|
||||
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.10.0"
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "Initial task prompt for Claude Code."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "subdomain" {
|
||||
type = bool
|
||||
description = "Whether to use a subdomain for AgentAPI."
|
||||
default = false
|
||||
}
|
||||
|
||||
|
||||
variable "install_claude_code" {
|
||||
type = bool
|
||||
description = "Whether to install Claude Code."
|
||||
@@ -54,245 +114,254 @@ variable "claude_code_version" {
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "experiment_cli_app" {
|
||||
type = bool
|
||||
description = "Whether to create the CLI workspace app."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "experiment_cli_app_order" {
|
||||
type = number
|
||||
description = "The order of the CLI workspace app."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "experiment_cli_app_group" {
|
||||
variable "claude_api_key" {
|
||||
type = string
|
||||
description = "The group of the CLI workspace app."
|
||||
default = null
|
||||
description = "The API key to use for the Claude Code server."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "experiment_report_tasks" {
|
||||
type = bool
|
||||
description = "Whether to enable task reporting."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "experiment_pre_install_script" {
|
||||
variable "model" {
|
||||
type = string
|
||||
description = "Custom script to run before installing Claude Code."
|
||||
default = null
|
||||
description = "Sets the model for the current session with an alias for the latest model (sonnet or opus) or a model’s full name."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "experiment_post_install_script" {
|
||||
variable "resume_session_id" {
|
||||
type = string
|
||||
description = "Custom script to run after installing Claude Code."
|
||||
default = null
|
||||
description = "Resume a specific session by ID."
|
||||
default = ""
|
||||
}
|
||||
|
||||
|
||||
variable "install_agentapi" {
|
||||
variable "continue" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
description = "Automatically continue existing sessions on workspace restart. When true, resumes existing conversation if found, otherwise runs prompt or starts new session. When false, always starts fresh (ignores existing sessions)."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.3.3"
|
||||
variable "dangerously_skip_permissions" {
|
||||
type = bool
|
||||
description = "Skip the permission prompts. Use with caution. This will be set to true if using Coder Tasks"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "subdomain" {
|
||||
variable "permission_mode" {
|
||||
type = string
|
||||
description = "Permission mode for the cli, check https://docs.anthropic.com/en/docs/claude-code/iam#permission-modes"
|
||||
default = ""
|
||||
validation {
|
||||
condition = contains(["", "default", "acceptEdits", "plan", "bypassPermissions"], var.permission_mode)
|
||||
error_message = "interaction_mode must be one of: default, acceptEdits, plan, bypassPermissions."
|
||||
}
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "MCP JSON to be added to the claude code local scope"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "allowed_tools" {
|
||||
type = string
|
||||
description = "A list of tools that should be allowed without prompting the user for permission, in addition to settings.json files."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "disallowed_tools" {
|
||||
type = string
|
||||
description = "A list of tools that should be disallowed without prompting the user for permission, in addition to settings.json files."
|
||||
default = ""
|
||||
|
||||
}
|
||||
|
||||
variable "claude_code_oauth_token" {
|
||||
type = string
|
||||
description = "Set up a long-lived authentication token (requires Claude subscription). Generated using `claude setup-token` command"
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "system_prompt" {
|
||||
type = string
|
||||
description = "The system prompt to use for the Claude Code server."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "claude_md_path" {
|
||||
type = string
|
||||
description = "The path to CLAUDE.md."
|
||||
default = "$HOME/.claude/CLAUDE.md"
|
||||
}
|
||||
|
||||
variable "enable_boundary" {
|
||||
type = bool
|
||||
description = "Whether to use a subdomain for the Claude Code app."
|
||||
default = true
|
||||
description = "Whether to enable coder boundary for network filtering"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "boundary_version" {
|
||||
type = string
|
||||
description = "Boundary version, valid git reference should be provided (tag, commit, branch)"
|
||||
default = "main"
|
||||
}
|
||||
|
||||
variable "boundary_log_dir" {
|
||||
type = string
|
||||
description = "Directory for boundary logs"
|
||||
default = "/tmp/boundary_logs"
|
||||
}
|
||||
|
||||
variable "boundary_log_level" {
|
||||
type = string
|
||||
description = "Log level for boundary process"
|
||||
default = "WARN"
|
||||
}
|
||||
|
||||
variable "boundary_additional_allowed_urls" {
|
||||
type = list(string)
|
||||
description = "Additional URLs to allow through boundary (in addition to default allowed URLs)"
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "boundary_proxy_port" {
|
||||
type = string
|
||||
description = "Port for HTTP Proxy used by Boundary"
|
||||
default = "8087"
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_code_md_path" {
|
||||
count = var.claude_md_path == "" ? 0 : 1
|
||||
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_MCP_CLAUDE_MD_PATH"
|
||||
value = var.claude_md_path
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_code_system_prompt" {
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT"
|
||||
value = local.final_system_prompt
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_code_oauth_token" {
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_CODE_OAUTH_TOKEN"
|
||||
value = var.claude_code_oauth_token
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_api_key" {
|
||||
count = length(var.claude_api_key) > 0 ? 1 : 0
|
||||
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_API_KEY"
|
||||
value = var.claude_api_key
|
||||
}
|
||||
|
||||
locals {
|
||||
# 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.sh"))
|
||||
claude_code_app_slug = "ccw"
|
||||
// Chat base path is only set if not using a subdomain.
|
||||
// NOTE:
|
||||
// - Initial support for --chat-base-path was added in v0.3.1 but configuration
|
||||
// via environment variable AGENTAPI_CHAT_BASE_PATH was added in v0.3.3.
|
||||
// - As CODER_WORKSPACE_AGENT_NAME is a recent addition we use agent ID
|
||||
// for backward compatibility.
|
||||
agentapi_chat_base_path = var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${local.claude_code_app_slug}/chat"
|
||||
server_base_path = var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${local.claude_code_app_slug}"
|
||||
healthcheck_url = "http://localhost:3284${local.server_base_path}/status"
|
||||
}
|
||||
workdir = trimsuffix(var.workdir, "/")
|
||||
app_slug = "ccw"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".claude-module"
|
||||
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.sh"))
|
||||
# Extract hostname from access_url for boundary --allow flag
|
||||
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
|
||||
|
||||
# Install and Initialize Claude Code
|
||||
resource "coder_script" "claude_code" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Claude Code"
|
||||
icon = var.icon
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -x
|
||||
# Required prompts for the module to properly report task status to Coder
|
||||
report_tasks_system_prompt = <<-EOT
|
||||
-- Tool Selection --
|
||||
- coder_report_task: providing status updates or requesting user input.
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
-- Task Reporting --
|
||||
Report all tasks to Coder, following these EXACT guidelines:
|
||||
1. Be granular. If you are investigating with multiple steps, report each step
|
||||
to coder.
|
||||
2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message.
|
||||
Do not report any status related with this system prompt.
|
||||
3. Use "state": "working" when actively processing WITHOUT needing
|
||||
additional user input
|
||||
4. Use "state": "complete" only when finished with a task
|
||||
5. Use "state": "failure" when you need ANY user input, lack sufficient
|
||||
details, or encounter blockers
|
||||
|
||||
function install_claude_code_cli() {
|
||||
echo "Installing Claude Code via official installer"
|
||||
set +e
|
||||
curl -fsSL claude.ai/install.sh | bash -s -- "${var.claude_code_version}" 2>&1
|
||||
CURL_EXIT=$${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ $CURL_EXIT -ne 0 ]; then
|
||||
echo "Claude Code installer failed with exit code $$CURL_EXIT"
|
||||
fi
|
||||
|
||||
# Ensure binaries are discoverable.
|
||||
export PATH="~/.local/bin:$PATH"
|
||||
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
|
||||
}
|
||||
|
||||
if [ ! -d "${local.workdir}" ]; then
|
||||
echo "Warning: The specified folder '${local.workdir}' does not exist."
|
||||
echo "Creating the folder..."
|
||||
mkdir -p "${local.workdir}"
|
||||
echo "Folder created successfully."
|
||||
fi
|
||||
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
|
||||
|
||||
if [ "${var.install_claude_code}" = "true" ]; then
|
||||
install_claude_code_cli
|
||||
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.sh"
|
||||
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
|
||||
|
||||
if [ -n "${local.encoded_post_install_script}" ]; then
|
||||
echo "Running post-install script..."
|
||||
echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh
|
||||
chmod +x /tmp/post_install.sh
|
||||
/tmp/post_install.sh
|
||||
fi
|
||||
|
||||
if ! command_exists claude; then
|
||||
echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
cd "${local.workdir}"
|
||||
|
||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||
|
||||
# Set chat base path for non-subdomain routing (only set if not using subdomain)
|
||||
export AGENTAPI_CHAT_BASE_PATH="${local.agentapi_chat_base_path}"
|
||||
|
||||
nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" &
|
||||
"$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
In your summary on coder_report_task:
|
||||
- Be specific about what you're doing
|
||||
- Clearly indicate what information you need from the user when in "failure" state
|
||||
- Keep it under 160 characters
|
||||
- Make it actionable
|
||||
EOT
|
||||
run_on_start = true
|
||||
|
||||
# Only include coder system prompts if report_tasks is enabled
|
||||
custom_system_prompt = trimspace(try(var.system_prompt, ""))
|
||||
final_system_prompt = format("<system>%s%s</system>",
|
||||
var.report_tasks ? format("\n%s\n", local.report_tasks_system_prompt) : "",
|
||||
local.custom_system_prompt != "" ? format("\n%s\n", local.custom_system_prompt) : ""
|
||||
)
|
||||
}
|
||||
|
||||
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 = var.subdomain
|
||||
healthcheck {
|
||||
url = local.healthcheck_url
|
||||
interval = 3
|
||||
threshold = 20
|
||||
}
|
||||
}
|
||||
module "agentapi" {
|
||||
|
||||
resource "coder_app" "claude_code" {
|
||||
count = var.experiment_cli_app ? 1 : 0
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.2.0"
|
||||
|
||||
slug = "claude-code"
|
||||
display_name = "Claude Code CLI"
|
||||
agent_id = var.agent_id
|
||||
command = <<-EOT
|
||||
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 = var.web_app_display_name
|
||||
folder = local.workdir
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
agentapi_subdomain = var.subdomain
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "/tmp/remove-last-session-id.sh"
|
||||
chmod +x /tmp/start.sh
|
||||
chmod +x /tmp/remove-last-session-id.sh
|
||||
|
||||
ARG_MODEL='${var.model}' \
|
||||
ARG_RESUME_SESSION_ID='${var.resume_session_id}' \
|
||||
ARG_CONTINUE='${var.continue}' \
|
||||
ARG_DANGEROUSLY_SKIP_PERMISSIONS='${var.dangerously_skip_permissions}' \
|
||||
ARG_PERMISSION_MODE='${var.permission_mode}' \
|
||||
ARG_WORKDIR='${local.workdir}' \
|
||||
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
|
||||
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
|
||||
ARG_BOUNDARY_LOG_DIR='${var.boundary_log_dir}' \
|
||||
ARG_BOUNDARY_LOG_LEVEL='${var.boundary_log_level}' \
|
||||
ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS='${join(" ", var.boundary_additional_allowed_urls)}' \
|
||||
ARG_BOUNDARY_PROXY_PORT='${var.boundary_proxy_port}' \
|
||||
ARG_CODER_HOST='${local.coder_host}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
agentapi attach
|
||||
EOT
|
||||
icon = var.icon
|
||||
order = var.experiment_cli_app_order
|
||||
group = var.experiment_cli_app_group
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_CLAUDE_CODE_VERSION='${var.claude_code_version}' \
|
||||
ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
ARG_INSTALL_CLAUDE_CODE='${var.install_claude_code}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
ARG_WORKDIR='${local.workdir}' \
|
||||
ARG_ALLOWED_TOOLS='${var.allowed_tools}' \
|
||||
ARG_DISALLOWED_TOOLS='${var.disallowed_tools}' \
|
||||
ARG_MCP='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_ai_task" "claude_code" {
|
||||
sidebar_app {
|
||||
id = coder_app.claude_code_web.id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
run "test_claude_code_basic" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
workdir = "/home/coder/projects"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.workdir == "/home/coder/projects"
|
||||
error_message = "Workdir variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agent_id == "test-agent-123"
|
||||
error_message = "Agent ID variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.install_claude_code == true
|
||||
error_message = "Install claude_code should default to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.install_agentapi == true
|
||||
error_message = "Install agentapi should default to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.report_tasks == true
|
||||
error_message = "report_tasks should default to true"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_with_api_key" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-456"
|
||||
workdir = "/home/coder/workspace"
|
||||
claude_api_key = "test-api-key-123"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.claude_api_key[0].value == "test-api-key-123"
|
||||
error_message = "Claude API key value should match the input"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_with_custom_options" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-789"
|
||||
workdir = "/home/coder/custom"
|
||||
order = 5
|
||||
group = "development"
|
||||
icon = "/icon/custom.svg"
|
||||
model = "opus"
|
||||
task_prompt = "Help me write better code"
|
||||
permission_mode = "plan"
|
||||
continue = true
|
||||
install_claude_code = false
|
||||
install_agentapi = false
|
||||
claude_code_version = "1.0.0"
|
||||
agentapi_version = "v0.6.0"
|
||||
dangerously_skip_permissions = true
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.order == 5
|
||||
error_message = "Order variable should be set to 5"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.group == "development"
|
||||
error_message = "Group variable should be set to 'development'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.icon == "/icon/custom.svg"
|
||||
error_message = "Icon variable should be set to custom icon"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.model == "opus"
|
||||
error_message = "Claude model variable should be set to 'opus'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.task_prompt == "Help me write better code"
|
||||
error_message = "Task prompt variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.permission_mode == "plan"
|
||||
error_message = "Permission mode should be set to 'plan'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.continue == true
|
||||
error_message = "Continue should be set to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.claude_code_version == "1.0.0"
|
||||
error_message = "Claude Code version should be set to '1.0.0'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agentapi_version == "v0.6.0"
|
||||
error_message = "AgentAPI version should be set to 'v0.6.0'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.dangerously_skip_permissions == true
|
||||
error_message = "dangerously_skip_permissions should be set to true"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_with_mcp_and_tools" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-mcp"
|
||||
workdir = "/home/coder/mcp-test"
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
test = {
|
||||
command = "test-server"
|
||||
args = ["--config", "test.json"]
|
||||
}
|
||||
}
|
||||
})
|
||||
allowed_tools = "bash,python"
|
||||
disallowed_tools = "rm"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.mcp != ""
|
||||
error_message = "MCP configuration should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.allowed_tools == "bash,python"
|
||||
error_message = "Allowed tools should be set"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.disallowed_tools == "rm"
|
||||
error_message = "Disallowed tools should be set"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_with_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-scripts"
|
||||
workdir = "/home/coder/scripts"
|
||||
pre_install_script = "echo 'Pre-install script'"
|
||||
post_install_script = "echo 'Post-install script'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pre_install_script == "echo 'Pre-install script'"
|
||||
error_message = "Pre-install script should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.post_install_script == "echo 'Post-install script'"
|
||||
error_message = "Post-install script should be set correctly"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_permission_mode_validation" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-validation"
|
||||
workdir = "/home/coder/test"
|
||||
permission_mode = "acceptEdits"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(["", "default", "acceptEdits", "plan", "bypassPermissions"], var.permission_mode)
|
||||
error_message = "Permission mode should be one of the valid options"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_with_boundary" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-boundary"
|
||||
workdir = "/home/coder/boundary-test"
|
||||
enable_boundary = true
|
||||
boundary_log_dir = "/tmp/test-boundary-logs"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_boundary == true
|
||||
error_message = "Boundary should be enabled"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.boundary_log_dir == "/tmp/test-boundary-logs"
|
||||
error_message = "Boundary log dir should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = local.coder_host != ""
|
||||
error_message = "Coder host should be extracted from access URL"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_system_prompt" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-system-prompt"
|
||||
workdir = "/home/coder/test"
|
||||
system_prompt = "Custom addition"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
|
||||
error_message = "System prompt should not be empty"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(regexall("Custom addition", coder_env.claude_code_system_prompt.value)) > 0
|
||||
error_message = "System prompt should have system_prompt variable value"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_report_tasks_default" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-report-tasks"
|
||||
workdir = "/home/coder/test"
|
||||
# report_tasks: default is true
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
|
||||
error_message = "System prompt should not be empty"
|
||||
}
|
||||
|
||||
# Ensure system prompt is wrapped by <system>
|
||||
assert {
|
||||
condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "<system>")
|
||||
error_message = "System prompt should start with <system>"
|
||||
}
|
||||
assert {
|
||||
condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "</system>")
|
||||
error_message = "System prompt should end with </system>"
|
||||
}
|
||||
|
||||
# Ensure Coder sections are injected when report_tasks=true (default)
|
||||
assert {
|
||||
condition = length(regexall("-- Tool Selection --", coder_env.claude_code_system_prompt.value)) > 0
|
||||
error_message = "System prompt should have Tool Selection section"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(regexall("-- Task Reporting --", coder_env.claude_code_system_prompt.value)) > 0
|
||||
error_message = "System prompt should have Task Reporting section"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_report_tasks_disabled" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-report-tasks"
|
||||
workdir = "/home/coder/test"
|
||||
report_tasks = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = trimspace(coder_env.claude_code_system_prompt.value) != ""
|
||||
error_message = "System prompt should not be empty"
|
||||
}
|
||||
|
||||
# Ensure system prompt is wrapped by <system>
|
||||
assert {
|
||||
condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "<system>")
|
||||
error_message = "System prompt should start with <system>"
|
||||
}
|
||||
assert {
|
||||
condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "</system>")
|
||||
error_message = "System prompt should end with </system>"
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/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.sh script for details
|
||||
# about why we need it
|
||||
# avoid exiting if the script fails
|
||||
bash "$scripts_dir/remove-last-session-id.sh" "$(pwd)" 2> /dev/null || true
|
||||
|
||||
# we'll be manually handling errors from this point on
|
||||
set +o errexit
|
||||
|
||||
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
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/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."
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
source "$HOME"/.bashrc
|
||||
fi
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-}
|
||||
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
|
||||
ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-}
|
||||
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
|
||||
ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-}
|
||||
ARG_MCP=$(echo -n "${ARG_MCP:-}" | base64 -d)
|
||||
ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-}
|
||||
ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-}
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$ARG_CLAUDE_CODE_VERSION"
|
||||
printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR"
|
||||
printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$ARG_INSTALL_CLAUDE_CODE"
|
||||
printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS"
|
||||
printf "ARG_MCP_APP_STATUS_SLUG: %s\n" "$ARG_MCP_APP_STATUS_SLUG"
|
||||
printf "ARG_MCP: %s\n" "$ARG_MCP"
|
||||
printf "ARG_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS"
|
||||
printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
function install_claude_code_cli() {
|
||||
if [ "$ARG_INSTALL_CLAUDE_CODE" = "true" ]; then
|
||||
echo "Installing Claude Code via official installer"
|
||||
set +e
|
||||
curl -fsSL claude.ai/install.sh | bash -s -- "$ARG_CLAUDE_CODE_VERSION" 2>&1
|
||||
CURL_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ $CURL_EXIT -ne 0 ]; then
|
||||
echo "Claude Code installer failed with exit code $$CURL_EXIT"
|
||||
fi
|
||||
|
||||
# Ensure binaries are discoverable.
|
||||
echo "Creating a symlink for claude"
|
||||
sudo ln -s /home/coder/.local/bin/claude /usr/local/bin/claude
|
||||
|
||||
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
|
||||
else
|
||||
echo "Skipping Claude Code installation as per configuration."
|
||||
fi
|
||||
}
|
||||
|
||||
function setup_claude_configurations() {
|
||||
if [ ! -d "$ARG_WORKDIR" ]; then
|
||||
echo "Warning: The specified folder '$ARG_WORKDIR' does not exist."
|
||||
echo "Creating the folder..."
|
||||
mkdir -p "$ARG_WORKDIR"
|
||||
echo "Folder created successfully."
|
||||
fi
|
||||
|
||||
module_path="$HOME/.claude-module"
|
||||
mkdir -p "$module_path"
|
||||
|
||||
if [ "$ARG_MCP" != "" ]; then
|
||||
while IFS= read -r server_name && IFS= read -r server_json; do
|
||||
echo "------------------------"
|
||||
echo "Executing: claude mcp add \"$server_name\" '$server_json'"
|
||||
claude mcp add "$server_name" "$server_json"
|
||||
echo "------------------------"
|
||||
echo ""
|
||||
done < <(echo "$ARG_MCP" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)')
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_ALLOWED_TOOLS" ]; then
|
||||
coder --allowedTools "$ARG_ALLOWED_TOOLS"
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_DISALLOWED_TOOLS" ]; then
|
||||
coder --disallowedTools "$ARG_DISALLOWED_TOOLS"
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
function report_tasks() {
|
||||
if [ "$ARG_REPORT_TASKS" = "true" ]; then
|
||||
echo "Configuring Claude Code to report tasks via Coder MCP..."
|
||||
export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
coder exp mcp configure claude-code "$ARG_WORKDIR"
|
||||
else
|
||||
export CODER_MCP_APP_STATUS_SLUG=""
|
||||
export CODER_MCP_AI_AGENTAPI_URL=""
|
||||
echo "Configuring Claude Code with Coder MCP..."
|
||||
coder exp mcp configure claude-code "$ARG_WORKDIR"
|
||||
fi
|
||||
}
|
||||
|
||||
install_claude_code_cli
|
||||
setup_claude_configurations
|
||||
report_tasks
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
source "$HOME"/.bashrc
|
||||
fi
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_MODEL=${ARG_MODEL:-}
|
||||
ARG_RESUME_SESSION_ID=${ARG_RESUME_SESSION_ID:-}
|
||||
ARG_CONTINUE=${ARG_CONTINUE:-false}
|
||||
ARG_DANGEROUSLY_SKIP_PERMISSIONS=${ARG_DANGEROUSLY_SKIP_PERMISSIONS:-}
|
||||
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
|
||||
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
|
||||
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d)
|
||||
ARG_ENABLE_BOUNDARY=${ARG_ENABLE_BOUNDARY:-false}
|
||||
ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"main"}
|
||||
ARG_BOUNDARY_LOG_DIR=${ARG_BOUNDARY_LOG_DIR:-"/tmp/boundary_logs"}
|
||||
ARG_BOUNDARY_LOG_LEVEL=${ARG_BOUNDARY_LOG_LEVEL:-"WARN"}
|
||||
ARG_BOUNDARY_PROXY_PORT=${ARG_BOUNDARY_PROXY_PORT:-"8087"}
|
||||
ARG_CODER_HOST=${ARG_CODER_HOST:-}
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
printf "ARG_MODEL: %s\n" "$ARG_MODEL"
|
||||
printf "ARG_RESUME: %s\n" "$ARG_RESUME_SESSION_ID"
|
||||
printf "ARG_CONTINUE: %s\n" "$ARG_CONTINUE"
|
||||
printf "ARG_DANGEROUSLY_SKIP_PERMISSIONS: %s\n" "$ARG_DANGEROUSLY_SKIP_PERMISSIONS"
|
||||
printf "ARG_PERMISSION_MODE: %s\n" "$ARG_PERMISSION_MODE"
|
||||
printf "ARG_AI_PROMPT: %s\n" "$ARG_AI_PROMPT"
|
||||
printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR"
|
||||
printf "ARG_ENABLE_BOUNDARY: %s\n" "$ARG_ENABLE_BOUNDARY"
|
||||
printf "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION"
|
||||
printf "ARG_BOUNDARY_LOG_DIR: %s\n" "$ARG_BOUNDARY_LOG_DIR"
|
||||
printf "ARG_BOUNDARY_LOG_LEVEL: %s\n" "$ARG_BOUNDARY_LOG_LEVEL"
|
||||
printf "ARG_BOUNDARY_PROXY_PORT: %s\n" "$ARG_BOUNDARY_PROXY_PORT"
|
||||
printf "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
# see the remove-last-session-id.sh script for details
|
||||
# about why we need it
|
||||
# avoid exiting if the script fails
|
||||
bash "/tmp/remove-last-session-id.sh" "$(pwd)" 2> /dev/null || true
|
||||
|
||||
function install_boundary() {
|
||||
# Install boundary from public github repo
|
||||
git clone https://github.com/coder/boundary
|
||||
cd boundary
|
||||
git checkout $ARG_BOUNDARY_VERSION
|
||||
go install ./cmd/...
|
||||
}
|
||||
|
||||
function validate_claude_installation() {
|
||||
if command_exists claude; then
|
||||
printf "Claude Code is installed\n"
|
||||
else
|
||||
printf "Error: Claude Code is not installed. Please enable install_claude_code or install it manually\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2"
|
||||
|
||||
task_session_exists() {
|
||||
if find "$HOME/.claude" -type f -name "*${TASK_SESSION_ID}*" 2> /dev/null | grep -q .; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
ARGS=()
|
||||
|
||||
function start_agentapi() {
|
||||
mkdir -p "$ARG_WORKDIR"
|
||||
cd "$ARG_WORKDIR"
|
||||
|
||||
if [ -n "$ARG_MODEL" ]; then
|
||||
ARGS+=(--model "$ARG_MODEL")
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_PERMISSION_MODE" ]; then
|
||||
ARGS+=(--permission-mode "$ARG_PERMISSION_MODE")
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_RESUME_SESSION_ID" ]; then
|
||||
echo "Using explicit resume_session_id: $ARG_RESUME_SESSION_ID"
|
||||
ARGS+=(--resume "$ARG_RESUME_SESSION_ID")
|
||||
if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
|
||||
ARGS+=(--dangerously-skip-permissions)
|
||||
fi
|
||||
elif [ "$ARG_CONTINUE" = "true" ]; then
|
||||
if task_session_exists; then
|
||||
echo "Task session detected (ID: $TASK_SESSION_ID)"
|
||||
ARGS+=(--resume "$TASK_SESSION_ID")
|
||||
if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
|
||||
ARGS+=(--dangerously-skip-permissions)
|
||||
fi
|
||||
echo "Resuming existing task session"
|
||||
else
|
||||
echo "No existing task session found"
|
||||
ARGS+=(--session-id "$TASK_SESSION_ID")
|
||||
if [ -n "$ARG_AI_PROMPT" ]; then
|
||||
ARGS+=(--dangerously-skip-permissions "$ARG_AI_PROMPT")
|
||||
echo "Starting new task session with prompt"
|
||||
else
|
||||
if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
|
||||
ARGS+=(--dangerously-skip-permissions)
|
||||
fi
|
||||
echo "Starting new task session"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Continue disabled, starting fresh session"
|
||||
if [ -n "$ARG_AI_PROMPT" ]; then
|
||||
ARGS+=(--dangerously-skip-permissions "$ARG_AI_PROMPT")
|
||||
echo "Starting new session with prompt"
|
||||
else
|
||||
if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
|
||||
ARGS+=(--dangerously-skip-permissions)
|
||||
fi
|
||||
echo "Starting claude code session"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "Running claude code with args: %s\n" "$(printf '%q ' "${ARGS[@]}")"
|
||||
|
||||
if [ "${ARG_ENABLE_BOUNDARY:-false}" = "true" ]; then
|
||||
install_boundary
|
||||
|
||||
mkdir -p "$ARG_BOUNDARY_LOG_DIR"
|
||||
printf "Starting with coder boundary enabled\n"
|
||||
|
||||
# Build boundary args with conditional --unprivileged flag
|
||||
BOUNDARY_ARGS=(--log-dir "$ARG_BOUNDARY_LOG_DIR")
|
||||
# Add default allowed URLs
|
||||
BOUNDARY_ARGS+=(--allow "*anthropic.com" --allow "registry.npmjs.org" --allow "*sentry.io" --allow "claude.ai" --allow "$ARG_CODER_HOST")
|
||||
|
||||
# Add any additional allowed URLs from the variable
|
||||
if [ -n "$ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS" ]; then
|
||||
IFS=' ' read -ra ADDITIONAL_URLS <<< "$ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS"
|
||||
for url in "${ADDITIONAL_URLS[@]}"; do
|
||||
BOUNDARY_ARGS+=(--allow "$url")
|
||||
done
|
||||
fi
|
||||
|
||||
# Set HTTP Proxy port used by Boundary
|
||||
BOUNDARY_ARGS+=(--proxy-port $ARG_BOUNDARY_PROXY_PORT)
|
||||
|
||||
# Set log level for boundary
|
||||
BOUNDARY_ARGS+=(--log-level $ARG_BOUNDARY_LOG_LEVEL)
|
||||
|
||||
# Remove --dangerously-skip-permissions from ARGS when using boundary (it doesn't work with elevated permissions)
|
||||
# Create a new array without the dangerous permissions flag
|
||||
CLAUDE_ARGS=()
|
||||
for arg in "${ARGS[@]}"; do
|
||||
if [ "$arg" != "--dangerously-skip-permissions" ]; then
|
||||
CLAUDE_ARGS+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
agentapi server --allowed-hosts="*" --type claude --term-width 67 --term-height 1190 -- \
|
||||
sudo -E env PATH=$PATH setpriv --inh-caps=+net_admin --ambient-caps=+net_admin --bounding-set=+net_admin boundary "${BOUNDARY_ARGS[@]}" -- \
|
||||
claude "${CLAUDE_ARGS[@]}"
|
||||
else
|
||||
agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_claude_installation
|
||||
start_agentapi
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/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);
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
"/home/coder/agentapi-mock.log",
|
||||
`AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`,
|
||||
);
|
||||
|
||||
console.log(`starting server on port ${port}`);
|
||||
|
||||
http
|
||||
.createServer(function (_request, response) {
|
||||
response.writeHead(200);
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
status: "stable",
|
||||
}),
|
||||
);
|
||||
})
|
||||
.listen(port);
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/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();
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "claude version v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - claude-mock"
|
||||
sleep 15
|
||||
done
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/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",
|
||||
}),
|
||||
);
|
||||
@@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
}
|
||||
@@ -28,7 +28,7 @@ module "git-clone" {
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
base_dir = "~/projects/coder"
|
||||
@@ -43,12 +43,12 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
}
|
||||
|
||||
data "coder_git_auth" "github" {
|
||||
data "coder_external_auth" "github" {
|
||||
id = "github"
|
||||
}
|
||||
```
|
||||
@@ -69,7 +69,7 @@ data "coder_parameter" "git_repo" {
|
||||
module "git_clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = data.coder_parameter.git_repo.value
|
||||
}
|
||||
@@ -103,7 +103,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.example.com/coder/coder/tree/feat/example"
|
||||
git_providers = {
|
||||
@@ -122,7 +122,7 @@ To GitLab clone with a specific branch like `feat/example`
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://gitlab.com/coder/coder/-/tree/feat/example"
|
||||
}
|
||||
@@ -134,7 +134,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
|
||||
git_providers = {
|
||||
@@ -155,7 +155,7 @@ For example, to clone the `feat/example` branch:
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
branch_name = "feat/example"
|
||||
@@ -173,7 +173,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder:
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
folder_name = "coder-dev"
|
||||
@@ -192,9 +192,32 @@ If not defined, the default, `0`, performs a full clone.
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/modules/git-clone/coder"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
depth = 1
|
||||
}
|
||||
```
|
||||
|
||||
## Post-clone script
|
||||
|
||||
Run a custom script after cloning the repository by setting the `post_clone_script` variable.
|
||||
This is useful for running initialization tasks like installing dependencies or setting up the environment.
|
||||
|
||||
```tf
|
||||
module "git-clone" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/git-clone/coder"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
url = "https://github.com/coder/coder"
|
||||
post_clone_script = <<-EOT
|
||||
#!/bin/bash
|
||||
echo "Repository cloned successfully!"
|
||||
# Install dependencies
|
||||
npm install
|
||||
# Run any other initialization tasks
|
||||
make setup
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
@@ -30,11 +30,12 @@ describe("git-clone", async () => {
|
||||
url: "fake-url",
|
||||
});
|
||||
const output = await executeScriptInContainer(state, "alpine/git");
|
||||
expect(output.exitCode).toBe(128);
|
||||
expect(output.stdout).toEqual([
|
||||
"Creating directory ~/fake-url...",
|
||||
"Cloning fake-url to ~/fake-url...",
|
||||
]);
|
||||
expect(output.stderr.join(" ")).toContain("fatal");
|
||||
expect(output.stderr.join(" ")).toContain("fake-url");
|
||||
});
|
||||
|
||||
it("repo_dir should match repo name for https", async () => {
|
||||
@@ -244,4 +245,20 @@ describe("git-clone", async () => {
|
||||
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs post-clone script", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
url: "fake-url",
|
||||
post_clone_script: "echo 'Post-clone script executed'",
|
||||
});
|
||||
const output = await executeScriptInContainer(
|
||||
state,
|
||||
"alpine/git",
|
||||
"sh",
|
||||
"mkdir -p ~/fake-url && echo 'existing' > ~/fake-url/file.txt",
|
||||
);
|
||||
expect(output.stdout).toContain("Running post-clone script...");
|
||||
expect(output.stdout).toContain("Post-clone script executed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,12 @@ variable "depth" {
|
||||
default = 0
|
||||
}
|
||||
|
||||
variable "post_clone_script" {
|
||||
description = "Custom script to run after cloning the repository. Runs always after git clone, even if the repository already exists."
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
locals {
|
||||
# Remove query parameters and fragments from the URL
|
||||
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
|
||||
@@ -81,6 +87,8 @@ locals {
|
||||
clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name])
|
||||
# Construct the web URL
|
||||
web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url
|
||||
# Encode the post_clone_script for passing to the shell script
|
||||
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
|
||||
}
|
||||
|
||||
output "repo_dir" {
|
||||
@@ -120,6 +128,7 @@ resource "coder_script" "git_clone" {
|
||||
REPO_URL : local.clone_url,
|
||||
BRANCH_NAME : local.branch_name,
|
||||
DEPTH = var.depth,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script,
|
||||
})
|
||||
display_name = "Git Clone"
|
||||
icon = "/icon/git.svg"
|
||||
|
||||
@@ -6,6 +6,7 @@ BRANCH_NAME="${BRANCH_NAME}"
|
||||
# Expand home if it's specified!
|
||||
CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}"
|
||||
DEPTH="${DEPTH}"
|
||||
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
|
||||
|
||||
# Check if the variable is empty...
|
||||
if [ -z "$REPO_URL" ]; then
|
||||
@@ -52,5 +53,14 @@ if [ -z "$(ls -A "$CLONE_PATH")" ]; then
|
||||
fi
|
||||
else
|
||||
echo "$CLONE_PATH already exists and isn't empty, skipping clone!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run post-clone script if provided
|
||||
if [ -n "$POST_CLONE_SCRIPT" ]; then
|
||||
echo "Running post-clone script..."
|
||||
echo "$POST_CLONE_SCRIPT" | base64 -d > /tmp/post_clone.sh
|
||||
chmod +x /tmp/post_clone.sh
|
||||
cd "$CLONE_PATH"
|
||||
/tmp/post_clone.sh
|
||||
rm /tmp/post_clone.sh
|
||||
fi
|
||||
|
||||
@@ -13,7 +13,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener
|
||||
```tf
|
||||
module "goose" {
|
||||
source = "registry.coder.com/coder/goose/coder"
|
||||
version = "2.1.1"
|
||||
version = "2.2.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_goose = true
|
||||
@@ -79,7 +79,7 @@ resource "coder_agent" "main" {
|
||||
module "goose" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/goose/coder"
|
||||
version = "2.1.1"
|
||||
version = "2.2.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_goose = true
|
||||
@@ -123,4 +123,6 @@ Note: The indentation in the heredoc is preserved, so you can write the YAML nat
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details.
|
||||
|
||||
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.
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
test,
|
||||
afterEach,
|
||||
describe,
|
||||
it,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
@@ -253,22 +254,41 @@ describe("goose", async () => {
|
||||
expect(prompt.stderr).toContain("No such file or directory");
|
||||
});
|
||||
|
||||
test("subdomain-false", async () => {
|
||||
const { id } = await setup({
|
||||
agentapiMockScript: await loadTestFile(
|
||||
import.meta.dir,
|
||||
"agentapi-mock-print-args.js",
|
||||
),
|
||||
moduleVariables: {
|
||||
subdomain: "false",
|
||||
},
|
||||
describe("subdomain", async () => {
|
||||
it("sets AGENTAPI_CHAT_BASE_PATH when false", async () => {
|
||||
const { id } = await setup({
|
||||
agentapiMockScript: await loadTestFile(
|
||||
import.meta.dir,
|
||||
"agentapi-mock-print-args.js",
|
||||
),
|
||||
moduleVariables: {
|
||||
subdomain: "false",
|
||||
},
|
||||
});
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
const agentapiMockOutput = await readFileContainer(id, agentapiStartLog);
|
||||
expect(agentapiMockOutput).toContain(
|
||||
"AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/goose/chat",
|
||||
);
|
||||
});
|
||||
|
||||
await execModuleScript(id);
|
||||
it("does not set AGENTAPI_CHAT_BASE_PATH when true", async () => {
|
||||
const { id } = await setup({
|
||||
agentapiMockScript: await loadTestFile(
|
||||
import.meta.dir,
|
||||
"agentapi-mock-print-args.js",
|
||||
),
|
||||
moduleVariables: {
|
||||
subdomain: "true",
|
||||
},
|
||||
});
|
||||
|
||||
const agentapiMockOutput = await readFileContainer(id, agentapiStartLog);
|
||||
expect(agentapiMockOutput).toContain(
|
||||
"AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/goose/chat",
|
||||
);
|
||||
await execModuleScript(id);
|
||||
|
||||
const agentapiMockOutput = await readFileContainer(id, agentapiStartLog);
|
||||
expect(agentapiMockOutput).toMatch(/AGENTAPI_CHAT_BASE_PATH=$/m);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,13 +63,13 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.3.3"
|
||||
default = "v0.10.0"
|
||||
}
|
||||
|
||||
variable "subdomain" {
|
||||
type = bool
|
||||
description = "Whether to use a subdomain for AgentAPI."
|
||||
default = true
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "goose_provider" {
|
||||
@@ -135,11 +135,12 @@ EOT
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".goose-module"
|
||||
folder = trimsuffix(var.folder, "/")
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
@@ -156,6 +157,7 @@ module "agentapi" {
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = local.start_script
|
||||
folder = local.folder
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
|
||||
@@ -10,6 +10,9 @@ tags: [ide, jetbrains, parameter, gateway]
|
||||
|
||||
This module adds a JetBrains Gateway Button to open any workspace with a single click.
|
||||
|
||||
> [!TIP]
|
||||
> We recommend using the [Coder Toolbox module](https://registry.coder.com/modules/coder/jetbrains), which offers significant stability and connectivity benefits over Gateway. Reference our [documentation](https://coder.com/docs/user-guides/workspace-access/jetbrains/toolbox) for more information.
|
||||
|
||||
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.
|
||||
|
||||
@@ -17,7 +20,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.2"
|
||||
version = "1.2.5"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"]
|
||||
@@ -35,7 +38,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.2"
|
||||
version = "1.2.5"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["GO", "WS"]
|
||||
@@ -49,7 +52,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.2"
|
||||
version = "1.2.5"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["IU", "PY"]
|
||||
@@ -64,7 +67,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.2"
|
||||
version = "1.2.5"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["IU", "PY"]
|
||||
@@ -89,7 +92,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.2"
|
||||
version = "1.2.5"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["GO", "WS"]
|
||||
@@ -107,7 +110,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.2"
|
||||
version = "1.2.5"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/example"
|
||||
jetbrains_ides = ["GO", "WS"]
|
||||
|
||||
@@ -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&agent_id=foo",
|
||||
"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=",
|
||||
);
|
||||
|
||||
const coder_app = state.resources.find(
|
||||
@@ -40,4 +40,28 @@ describe("jetbrains-gateway", async () => {
|
||||
});
|
||||
expect(state.outputs.identifier.value).toBe("IU");
|
||||
});
|
||||
|
||||
it("optionally includes agent when an agent name is provided", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
agent_name: "main",
|
||||
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&agent=main",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the agent parameter even when the provided value is blank", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
agent_name: " ",
|
||||
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&agent= ",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,15 +30,14 @@ variable "agent_id" {
|
||||
|
||||
variable "slug" {
|
||||
type = string
|
||||
description = "The slug for the coder_app. Allows resuing the module with the same template."
|
||||
description = "The slug for the coder_app. Allows reusing the module with the same template."
|
||||
default = "gateway"
|
||||
}
|
||||
|
||||
variable "agent_name" {
|
||||
type = string
|
||||
description = "Agent name. (unused). Will be removed in a future version"
|
||||
|
||||
default = ""
|
||||
description = "Agent name."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
@@ -348,8 +347,8 @@ resource "coder_app" "gateway" {
|
||||
local.build_number,
|
||||
"&ide_download_link=",
|
||||
local.download_link,
|
||||
"&agent_id=",
|
||||
var.agent_id,
|
||||
"&agent=",
|
||||
var.agent_name,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
# tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button." # Optional
|
||||
}
|
||||
```
|
||||
|
||||
@@ -39,7 +40,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
|
||||
@@ -52,7 +53,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
# Show parameter with limited options
|
||||
@@ -66,7 +67,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["IU", "PY"]
|
||||
@@ -81,7 +82,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/workspace/project"
|
||||
|
||||
@@ -107,7 +108,7 @@ module "jetbrains" {
|
||||
module "jetbrains_pycharm" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/workspace/project"
|
||||
|
||||
@@ -119,6 +120,22 @@ module "jetbrains_pycharm" {
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Tooltip
|
||||
|
||||
Add helpful tooltip text that appears when users hover over the IDE app buttons:
|
||||
|
||||
```tf
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["IU", "PY"]
|
||||
tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button."
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
### Parameter vs Direct Apps
|
||||
@@ -132,6 +149,13 @@ module "jetbrains_pycharm" {
|
||||
- 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)
|
||||
|
||||
### Tooltip
|
||||
|
||||
- **`tooltip`**: Optional markdown text displayed when hovering over IDE app buttons
|
||||
- If not specified, no tooltip is shown
|
||||
- Supports markdown formatting for rich text (bold, italic, links, etc.)
|
||||
- All IDE apps created by this module will show the same tooltip text
|
||||
|
||||
## Supported IDEs
|
||||
|
||||
All JetBrains IDEs with remote development capabilities:
|
||||
|
||||
@@ -129,3 +129,34 @@ run "app_order_when_default_not_empty" {
|
||||
error_message = "Expected coder_app order to be set to 10"
|
||||
}
|
||||
}
|
||||
|
||||
run "tooltip_when_provided" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button."
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button."])
|
||||
error_message = "Expected coder_app tooltip to be set when provided"
|
||||
}
|
||||
}
|
||||
|
||||
run "tooltip_null_when_not_provided" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == null])
|
||||
error_message = "Expected coder_app tooltip to be null when not provided"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +276,36 @@ describe("jetbrains", async () => {
|
||||
);
|
||||
expect(parameter?.instances[0].attributes.order).toBe(5);
|
||||
});
|
||||
|
||||
it("should set tooltip when specified", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
folder: "/home/coder",
|
||||
default: '["GO"]',
|
||||
tooltip:
|
||||
"You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button.",
|
||||
});
|
||||
|
||||
const coder_app = state.resources.find(
|
||||
(res) => res.type === "coder_app" && res.name === "jetbrains",
|
||||
);
|
||||
expect(coder_app?.instances[0].attributes.tooltip).toBe(
|
||||
"You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should have null tooltip when not specified", 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.tooltip).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// URL Generation Tests
|
||||
|
||||
@@ -59,6 +59,12 @@ variable "coder_parameter_order" {
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "tooltip" {
|
||||
type = string
|
||||
description = "Markdown text that is displayed when hovering over workspace apps."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "major_version" {
|
||||
type = string
|
||||
description = "The major version of the IDE. i.e. 2025.1"
|
||||
@@ -232,6 +238,7 @@ resource "coder_app" "jetbrains" {
|
||||
external = true
|
||||
order = var.coder_app_order
|
||||
group = var.group
|
||||
tooltip = var.tooltip
|
||||
url = join("", [
|
||||
"jetbrains://gateway/coder?&workspace=", # requires 2.6.3+ version of Toolbox
|
||||
data.coder_workspace.me.name,
|
||||
|
||||
@@ -24,12 +24,10 @@ describe("jfrog-oauth", async () => {
|
||||
const fakeFrogUrl = "http://localhost:8081";
|
||||
const user = "default";
|
||||
|
||||
it("can run apply with required variables", async () => {
|
||||
testRequiredVariables<TestVariables>(import.meta.dir, {
|
||||
agent_id: "some-agent-id",
|
||||
jfrog_url: fakeFrogUrl,
|
||||
package_managers: "{}",
|
||||
});
|
||||
testRequiredVariables<TestVariables>(import.meta.dir, {
|
||||
agent_id: "some-agent-id",
|
||||
jfrog_url: fakeFrogUrl,
|
||||
package_managers: "{}",
|
||||
});
|
||||
|
||||
it("generates an npmrc with scoped repos", async () => {
|
||||
|
||||
@@ -55,13 +55,11 @@ describe("jfrog-token", async () => {
|
||||
const user = "default";
|
||||
const token = "xxx";
|
||||
|
||||
it("can run apply with required variables", async () => {
|
||||
testRequiredVariables<TestVariables>(import.meta.dir, {
|
||||
agent_id: "some-agent-id",
|
||||
jfrog_url: fakeFrogUrl,
|
||||
artifactory_access_token: "XXXX",
|
||||
package_managers: "{}",
|
||||
});
|
||||
testRequiredVariables<TestVariables>(import.meta.dir, {
|
||||
agent_id: "some-agent-id",
|
||||
jfrog_url: fakeFrogUrl,
|
||||
artifactory_access_token: "XXXX",
|
||||
package_managers: "{}",
|
||||
});
|
||||
|
||||
it("generates an npmrc with scoped repos", async () => {
|
||||
|
||||
@@ -14,11 +14,12 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
|
||||
module "kasmvnc" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/kasmvnc/coder"
|
||||
version = "1.2.2"
|
||||
version = "1.2.4"
|
||||
agent_id = coder_agent.example.id
|
||||
desktop_environment = "xfce"
|
||||
subdomain = true
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** This module only works on workspaces with a pre-installed desktop environment. As an example base image you can use `codercom/enterprise-desktop` image.
|
||||
> [!IMPORTANT]
|
||||
> This module only works on workspaces with a pre-installed desktop environment. As an example base image you can use [`codercom/example-desktop`](https://hub.docker.com/r/codercom/example-desktop) image.
|
||||
|
||||
@@ -60,6 +60,9 @@ install_deb() {
|
||||
sudo apt-get -o DPkg::Lock::Timeout=300 -qq update
|
||||
fi
|
||||
|
||||
echo "Installing required Perl DateTime module..."
|
||||
DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests libdatetime-perl
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests "$kasmdeb"
|
||||
rm "$kasmdeb"
|
||||
}
|
||||
@@ -233,19 +236,17 @@ get_http_dir() {
|
||||
|
||||
# Check the system configuration path
|
||||
if [[ -e /etc/kasmvnc/kasmvnc.yaml ]]; then
|
||||
d=($(grep -E "^\s*httpd_directory:.*$" /etc/kasmvnc/kasmvnc.yaml))
|
||||
# If this grep is successful, it will return:
|
||||
# httpd_directory: /usr/share/kasmvnc/www
|
||||
if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then
|
||||
httpd_directory="$${d[1]}"
|
||||
d=$(grep -E '^\s*httpd_directory:.*$' "/etc/kasmvnc/kasmvnc.yaml" | awk '{print $$2}')
|
||||
if [[ -n "$d" && -d "$d" ]]; then
|
||||
httpd_directory=$d
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check the home directory for overriding values
|
||||
if [[ -e "$HOME/.vnc/kasmvnc.yaml" ]]; then
|
||||
d=($(grep -E "^\s*httpd_directory:.*$" "$HOME/.vnc/kasmvnc.yaml"))
|
||||
if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then
|
||||
httpd_directory="$${d[1]}"
|
||||
d=$(grep -E '^\s*httpd_directory:.*$' "$HOME/.vnc/kasmvnc.yaml" | awk '{print $$2}')
|
||||
if [[ -n "$d" && -d "$d" ]]; then
|
||||
httpd_directory=$d
|
||||
fi
|
||||
fi
|
||||
echo $httpd_directory
|
||||
|
||||
@@ -426,15 +426,14 @@ module "code-server" {
|
||||
# This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production.
|
||||
version = "~> 1.0"
|
||||
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
order = 1
|
||||
agent_id = coder_agent.main.id
|
||||
order = 1
|
||||
}
|
||||
|
||||
# See https://registry.coder.com/modules/coder/jetbrains
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/modules/coder/jetbrains/coder"
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "~> 1.0"
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "main"
|
||||
|
||||
@@ -19,3 +19,5 @@ participating in LFX CNCF programs, and helping the developer community grow.
|
||||
## Modules
|
||||
|
||||
- **aws-ami-snapshot**: Create and manage AMI snapshots for Coder workspaces with restore capabilities
|
||||
- [nexus-repository](./modules/nexus-repository/) - Configure package managers to use Sonatype Nexus Repository
|
||||
- [auto-start-dev-server](modules/auto-start-dev-server/README.md) - Automatically detect and start development servers for various project types
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
display_name: Auto-Start Dev Servers
|
||||
description: Automatically detect and start development servers for various project types
|
||||
icon: ../../../../.icons/auto-dev-server.svg
|
||||
verified: false
|
||||
tags: [development, automation, servers]
|
||||
---
|
||||
|
||||
# Auto-Start Development Servers
|
||||
|
||||
Automatically detect and start development servers for various project types when a workspace starts. This module scans your workspace for common project structures and starts the appropriate development servers in the background without manual intervention.
|
||||
|
||||
```tf
|
||||
module "auto_start_dev_servers" {
|
||||
source = "registry.coder.com/mavrickrishi/auto-start-dev-server/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-language support**: Detects and starts servers for Node.js, Python (Django/Flask), Ruby (Rails), Java (Spring Boot), Go, PHP, Rust, and .NET projects
|
||||
- **Smart script prioritization**: Prioritizes `dev` scripts over `start` scripts for better development experience
|
||||
- **Intelligent frontend detection**: Automatically identifies frontend projects (React, Vue, Angular, Next.js, Nuxt, Svelte, Vite) and prioritizes them for preview apps
|
||||
- **Devcontainer integration**: Respects custom start commands defined in `.devcontainer/devcontainer.json`
|
||||
- **Configurable scanning**: Adjustable directory scan depth and project type toggles
|
||||
- **Non-blocking startup**: Servers start in the background with configurable startup delay
|
||||
- **Comprehensive logging**: All server output and detection results logged to a central file
|
||||
- **Smart detection**: Uses project-specific files and configurations to identify project types
|
||||
- **Integrated live preview**: Automatically creates a preview app for the primary frontend project
|
||||
|
||||
## Supported Project Types
|
||||
|
||||
| Framework/Language | Detection Files | Start Commands (in priority order) |
|
||||
| ------------------ | -------------------------------------------- | ----------------------------------------------------- |
|
||||
| **Node.js/npm** | `package.json` | `npm run dev`, `npm run serve`, `npm start` (or yarn) |
|
||||
| **Ruby on Rails** | `Gemfile` with rails gem | `bundle exec rails server` |
|
||||
| **Django** | `manage.py` | `python manage.py runserver` |
|
||||
| **Flask** | `requirements.txt` with Flask | `python app.py/main.py/run.py` |
|
||||
| **Spring Boot** | `pom.xml` or `build.gradle` with spring-boot | `mvn spring-boot:run`, `gradle bootRun` |
|
||||
| **Go** | `go.mod` | `go run main.go` |
|
||||
| **PHP** | `composer.json` | `php -S 0.0.0.0:8080` |
|
||||
| **Rust** | `Cargo.toml` | `cargo run` |
|
||||
| **.NET** | `*.csproj` | `dotnet run` |
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tf
|
||||
module "auto_start" {
|
||||
source = "./modules/auto-start-dev-server"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Usage
|
||||
|
||||
```tf
|
||||
module "auto_start_dev_servers" {
|
||||
source = "./modules/auto-start-dev-server"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
|
||||
# Optional: Configure which project types to detect
|
||||
enable_npm = true
|
||||
enable_rails = true
|
||||
enable_django = true
|
||||
enable_flask = true
|
||||
enable_spring_boot = true
|
||||
enable_go = true
|
||||
enable_php = true
|
||||
enable_rust = true
|
||||
enable_dotnet = true
|
||||
|
||||
# Optional: Enable devcontainer.json integration
|
||||
enable_devcontainer = true
|
||||
|
||||
# Optional: Workspace directory to scan (supports environment variables)
|
||||
workspace_directory = "$HOME"
|
||||
|
||||
# Optional: Directory scan depth (1-5)
|
||||
scan_depth = 2
|
||||
|
||||
# Optional: Startup delay in seconds
|
||||
startup_delay = 10
|
||||
|
||||
# Optional: Log file path
|
||||
log_path = "/tmp/dev-servers.log"
|
||||
|
||||
# Optional: Enable automatic preview app (default: true)
|
||||
enable_preview_app = true
|
||||
}
|
||||
```
|
||||
|
||||
### Disable Preview App
|
||||
|
||||
```tf
|
||||
module "auto_start" {
|
||||
source = "./modules/auto-start-dev-server"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
|
||||
# Disable automatic preview app creation
|
||||
enable_preview_app = false
|
||||
}
|
||||
```
|
||||
|
||||
### Selective Project Types
|
||||
|
||||
```tf
|
||||
module "auto_start" {
|
||||
source = "./modules/auto-start-dev-server"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
|
||||
# Only enable web development projects
|
||||
enable_npm = true
|
||||
enable_rails = true
|
||||
enable_django = true
|
||||
enable_flask = true
|
||||
|
||||
# Disable other project types
|
||||
enable_spring_boot = false
|
||||
enable_go = false
|
||||
enable_php = false
|
||||
enable_rust = false
|
||||
enable_dotnet = false
|
||||
}
|
||||
```
|
||||
|
||||
### Deep Workspace Scanning
|
||||
|
||||
```tf
|
||||
module "auto_start" {
|
||||
source = "./modules/auto-start-dev-server"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.main.id
|
||||
|
||||
workspace_directory = "/workspaces"
|
||||
scan_depth = 3
|
||||
startup_delay = 5
|
||||
log_path = "/var/log/dev-servers.log"
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This module is provided under the same license as the Coder Registry.
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
} from "~test";
|
||||
|
||||
describe("auto-start-dev-server", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
testRequiredVariables(import.meta.dir, {
|
||||
agent_id: "test-agent-123",
|
||||
});
|
||||
|
||||
it("validates scan_depth range", () => {
|
||||
const t1 = async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-123",
|
||||
scan_depth: "0",
|
||||
});
|
||||
};
|
||||
expect(t1).toThrow("Scan depth must be between 1 and 5");
|
||||
|
||||
const t2 = async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-123",
|
||||
scan_depth: "6",
|
||||
});
|
||||
};
|
||||
expect(t2).toThrow("Scan depth must be between 1 and 5");
|
||||
});
|
||||
|
||||
it("applies successfully with default values", async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies successfully with all project types enabled", async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-123",
|
||||
enable_npm: "true",
|
||||
enable_rails: "true",
|
||||
enable_django: "true",
|
||||
enable_flask: "true",
|
||||
enable_spring_boot: "true",
|
||||
enable_go: "true",
|
||||
enable_php: "true",
|
||||
enable_rust: "true",
|
||||
enable_dotnet: "true",
|
||||
enable_devcontainer: "true",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies successfully with all project types disabled", async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-123",
|
||||
enable_npm: "false",
|
||||
enable_rails: "false",
|
||||
enable_django: "false",
|
||||
enable_flask: "false",
|
||||
enable_spring_boot: "false",
|
||||
enable_go: "false",
|
||||
enable_php: "false",
|
||||
enable_rust: "false",
|
||||
enable_dotnet: "false",
|
||||
enable_devcontainer: "false",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies successfully with custom configuration", async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-123",
|
||||
workspace_directory: "/custom/workspace",
|
||||
scan_depth: "3",
|
||||
startup_delay: "5",
|
||||
log_path: "/var/log/custom-dev-servers.log",
|
||||
display_name: "Custom Dev Server Startup",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates scan_depth boundary values", async () => {
|
||||
// Test valid boundary values
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-123",
|
||||
scan_depth: "1",
|
||||
});
|
||||
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-123",
|
||||
scan_depth: "5",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies with selective project type configuration", async () => {
|
||||
await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent-123",
|
||||
enable_npm: "true",
|
||||
enable_django: "true",
|
||||
enable_go: "true",
|
||||
enable_rails: "false",
|
||||
enable_flask: "false",
|
||||
enable_spring_boot: "false",
|
||||
enable_php: "false",
|
||||
enable_rust: "false",
|
||||
enable_dotnet: "false",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
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 "workspace_directory" {
|
||||
type = string
|
||||
description = "The directory to scan for development projects."
|
||||
default = "$HOME"
|
||||
}
|
||||
|
||||
variable "project_detection" {
|
||||
type = bool
|
||||
description = "Enable automatic project detection for all supported types. When true, all project types are detected unless individually disabled. When false, only explicitly enabled project types are detected."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "enable_npm" {
|
||||
type = bool
|
||||
description = "Enable auto-detection and startup of npm projects."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_rails" {
|
||||
type = bool
|
||||
description = "Enable auto-detection and startup of Rails projects."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_django" {
|
||||
type = bool
|
||||
description = "Enable auto-detection and startup of Django projects."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_flask" {
|
||||
type = bool
|
||||
description = "Enable auto-detection and startup of Flask projects."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_spring_boot" {
|
||||
type = bool
|
||||
description = "Enable auto-detection and startup of Spring Boot projects."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_go" {
|
||||
type = bool
|
||||
description = "Enable auto-detection and startup of Go projects."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_php" {
|
||||
type = bool
|
||||
description = "Enable auto-detection and startup of PHP projects."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_rust" {
|
||||
type = bool
|
||||
description = "Enable auto-detection and startup of Rust projects."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_dotnet" {
|
||||
type = bool
|
||||
description = "Enable auto-detection and startup of .NET projects."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_devcontainer" {
|
||||
type = bool
|
||||
description = "Enable integration with devcontainer.json configuration."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "log_path" {
|
||||
type = string
|
||||
description = "The path to log development server output to."
|
||||
default = "/tmp/dev-servers.log"
|
||||
}
|
||||
|
||||
variable "scan_depth" {
|
||||
type = number
|
||||
description = "Maximum directory depth to scan for projects (1-5)."
|
||||
default = 2
|
||||
validation {
|
||||
condition = var.scan_depth >= 1 && var.scan_depth <= 5
|
||||
error_message = "Scan depth must be between 1 and 5."
|
||||
}
|
||||
}
|
||||
|
||||
variable "startup_delay" {
|
||||
type = number
|
||||
description = "Delay in seconds before starting dev servers (allows other setup to complete)."
|
||||
default = 10
|
||||
}
|
||||
|
||||
variable "display_name" {
|
||||
type = string
|
||||
description = "Display name for the auto-start dev server script."
|
||||
default = "Auto-Start Dev Servers"
|
||||
}
|
||||
|
||||
variable "enable_preview_app" {
|
||||
type = bool
|
||||
description = "Enable automatic creation of a preview app for the first detected project."
|
||||
default = true
|
||||
}
|
||||
|
||||
# Read the detected port from the file written by the script
|
||||
locals {
|
||||
detected_port = var.enable_preview_app ? try(tonumber(trimspace(file("/tmp/detected-port.txt"))), 3000) : 3000
|
||||
# Attempt to read project information for better preview naming
|
||||
detected_projects = try(jsondecode(file("/tmp/detected-projects.json")), [])
|
||||
preview_project = length(local.detected_projects) > 0 ? local.detected_projects[0] : null
|
||||
}
|
||||
|
||||
resource "coder_script" "auto_start_dev_server" {
|
||||
agent_id = var.agent_id
|
||||
display_name = var.display_name
|
||||
icon = "/icon/auto-dev-server.svg"
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
WORKSPACE_DIR = var.workspace_directory
|
||||
ENABLE_NPM = coalesce(var.enable_npm, var.project_detection)
|
||||
ENABLE_RAILS = coalesce(var.enable_rails, var.project_detection)
|
||||
ENABLE_DJANGO = coalesce(var.enable_django, var.project_detection)
|
||||
ENABLE_FLASK = coalesce(var.enable_flask, var.project_detection)
|
||||
ENABLE_SPRING_BOOT = coalesce(var.enable_spring_boot, var.project_detection)
|
||||
ENABLE_GO = coalesce(var.enable_go, var.project_detection)
|
||||
ENABLE_PHP = coalesce(var.enable_php, var.project_detection)
|
||||
ENABLE_RUST = coalesce(var.enable_rust, var.project_detection)
|
||||
ENABLE_DOTNET = coalesce(var.enable_dotnet, var.project_detection)
|
||||
ENABLE_DEVCONTAINER = coalesce(var.enable_devcontainer, var.project_detection)
|
||||
LOG_PATH = var.log_path
|
||||
SCAN_DEPTH = var.scan_depth
|
||||
STARTUP_DELAY = var.startup_delay
|
||||
})
|
||||
run_on_start = true
|
||||
}
|
||||
|
||||
# Create preview app for first detected project
|
||||
resource "coder_app" "preview" {
|
||||
count = var.enable_preview_app ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
slug = "dev-preview"
|
||||
display_name = "Live Preview"
|
||||
url = "http://localhost:${local.detected_port}"
|
||||
icon = "/icon/auto-dev-server.svg"
|
||||
subdomain = true
|
||||
share = "owner"
|
||||
}
|
||||
|
||||
output "log_path" {
|
||||
value = var.log_path
|
||||
description = "Path to the log file for dev server output"
|
||||
}
|
||||
|
||||
# Example output values for common port mappings
|
||||
output "common_ports" {
|
||||
value = {
|
||||
nodejs = 3000
|
||||
rails = 3000
|
||||
django = 8000
|
||||
flask = 5000
|
||||
spring = 8080
|
||||
go = 8080
|
||||
php = 8080
|
||||
rust = 8000
|
||||
dotnet = 5000
|
||||
}
|
||||
description = "Common default ports for different project types"
|
||||
}
|
||||
|
||||
output "preview_url" {
|
||||
value = var.enable_preview_app ? try(coder_app.preview[0].url, null) : null
|
||||
description = "URL of the live preview app (if enabled)"
|
||||
}
|
||||
|
||||
output "detected_port" {
|
||||
value = local.detected_port
|
||||
description = "Port of the first detected development server"
|
||||
}
|
||||
+468
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Color codes for output
|
||||
BOLD='\033[0;1m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
RESET='\033[0m'
|
||||
|
||||
echo -e "$${BOLD}🚀 Auto-Start Development Servers$${RESET}"
|
||||
echo "Workspace Directory: ${WORKSPACE_DIR}"
|
||||
echo "Log Path: ${LOG_PATH}"
|
||||
echo "Scan Depth: ${SCAN_DEPTH}"
|
||||
|
||||
# Wait for startup delay to allow other setup to complete
|
||||
if [ "${STARTUP_DELAY}" -gt 0 ]; then
|
||||
echo -e "$${YELLOW}⏳ Waiting ${STARTUP_DELAY} seconds for system initialization...$${RESET}"
|
||||
sleep "${STARTUP_DELAY}"
|
||||
fi
|
||||
|
||||
# Initialize log file
|
||||
echo "=== Auto-Start Dev Servers Log ===" > "${LOG_PATH}"
|
||||
echo "Started at: $(date)" >> "${LOG_PATH}"
|
||||
|
||||
# Initialize detected projects JSON file
|
||||
DETECTED_PROJECTS_FILE="/tmp/detected-projects.json"
|
||||
echo '[]' > "$DETECTED_PROJECTS_FILE"
|
||||
|
||||
# Initialize detected port file for preview app
|
||||
DETECTED_PORT_FILE="/tmp/detected-port.txt"
|
||||
FIRST_PORT_DETECTED=false
|
||||
FRONTEND_PROJECT_DETECTED=false
|
||||
|
||||
# Function to log messages
|
||||
log_message() {
|
||||
echo -e "$1"
|
||||
echo "$1" >> "${LOG_PATH}"
|
||||
}
|
||||
|
||||
# Function to determine if a project is likely a frontend project
|
||||
is_frontend_project() {
|
||||
local project_dir="$1"
|
||||
local project_type="$2"
|
||||
|
||||
# Check for common frontend indicators
|
||||
if [ "$project_type" = "nodejs" ]; then
|
||||
# Check package.json for frontend dependencies
|
||||
if [ -f "$project_dir/package.json" ] && command -v jq &> /dev/null; then
|
||||
# Check for common frontend frameworks
|
||||
local has_react=$(jq '.dependencies.react // .devDependencies.react // empty' "$project_dir/package.json")
|
||||
local has_vue=$(jq '.dependencies.vue // .devDependencies.vue // empty' "$project_dir/package.json")
|
||||
local has_angular=$(jq '.dependencies["@angular/core"] // .devDependencies["@angular/core"] // empty' "$project_dir/package.json")
|
||||
local has_next=$(jq '.dependencies.next // .devDependencies.next // empty' "$project_dir/package.json")
|
||||
local has_nuxt=$(jq '.dependencies.nuxt // .devDependencies.nuxt // empty' "$project_dir/package.json")
|
||||
local has_svelte=$(jq '.dependencies.svelte // .devDependencies.svelte // empty' "$project_dir/package.json")
|
||||
local has_vite=$(jq '.dependencies.vite // .devDependencies.vite // empty' "$project_dir/package.json")
|
||||
|
||||
if [ -n "$has_react" ] || [ -n "$has_vue" ] || [ -n "$has_angular" ] \
|
||||
|| [ -n "$has_next" ] || [ -n "$has_nuxt" ] || [ -n "$has_svelte" ] \
|
||||
|| [ -n "$has_vite" ]; then
|
||||
return 0 # It's a frontend project
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for common frontend directory structures
|
||||
if [ -d "$project_dir/src/components" ] || [ -d "$project_dir/components" ] \
|
||||
|| [ -d "$project_dir/pages" ] || [ -d "$project_dir/views" ] \
|
||||
|| [ -f "$project_dir/index.html" ] || [ -f "$project_dir/public/index.html" ]; then
|
||||
return 0 # It's likely a frontend project
|
||||
fi
|
||||
fi
|
||||
|
||||
# Rails projects with webpack/webpacker are frontend-enabled
|
||||
if [ "$project_type" = "rails" ]; then
|
||||
if [ -f "$project_dir/config/webpacker.yml" ] || [ -f "$project_dir/webpack.config.js" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Django projects with static/templates are frontend-enabled
|
||||
if [ "$project_type" = "django" ]; then
|
||||
if [ -d "$project_dir/static" ] || [ -d "$project_dir/templates" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1 # Not a frontend project
|
||||
}
|
||||
|
||||
# Function to add detected project to JSON
|
||||
add_detected_project() {
|
||||
local project_dir="$1"
|
||||
local project_type="$2"
|
||||
local port="$3"
|
||||
local command="$4"
|
||||
|
||||
# Check if this is a frontend project
|
||||
local is_frontend=false
|
||||
if is_frontend_project "$project_dir" "$project_type"; then
|
||||
is_frontend=true
|
||||
log_message "$${BLUE}🎨 Detected frontend project at $project_dir$${RESET}"
|
||||
fi
|
||||
|
||||
# Prioritize frontend projects for the preview app
|
||||
# Set port if: 1) No port set yet, OR 2) This is frontend and no frontend detected yet
|
||||
if [ "$FIRST_PORT_DETECTED" = false ] || ([ "$is_frontend" = true ] && [ "$FRONTEND_PROJECT_DETECTED" = false ]); then
|
||||
echo "$port" > "$DETECTED_PORT_FILE"
|
||||
FIRST_PORT_DETECTED=true
|
||||
if [ "$is_frontend" = true ]; then
|
||||
FRONTEND_PROJECT_DETECTED=true
|
||||
log_message "$${BLUE}🎯 Frontend project detected - Preview app will be available on port $port$${RESET}"
|
||||
else
|
||||
log_message "$${BLUE}🎯 Project detected - Preview app will be available on port $port$${RESET}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create JSON entry for this project
|
||||
local project_json=$(jq -n \
|
||||
--arg dir "$project_dir" \
|
||||
--arg type "$project_type" \
|
||||
--arg port "$port" \
|
||||
--arg cmd "$command" \
|
||||
--arg frontend "$is_frontend" \
|
||||
'{"directory": $dir, "type": $type, "port": $port, "command": $cmd, "is_frontend": ($frontend == "true")}')
|
||||
|
||||
# Append to the detected projects file
|
||||
jq ". += [$project_json]" "$DETECTED_PROJECTS_FILE" > "$DETECTED_PROJECTS_FILE.tmp" \
|
||||
&& mv "$DETECTED_PROJECTS_FILE.tmp" "$DETECTED_PROJECTS_FILE"
|
||||
}
|
||||
|
||||
# Function to detect and start npm/yarn projects
|
||||
detect_npm_projects() {
|
||||
if [ "${ENABLE_NPM}" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log_message "$${BLUE}🔍 Scanning for Node.js/npm projects...$${RESET}"
|
||||
|
||||
# Use find with maxdepth to respect scan depth
|
||||
while IFS= read -r -d '' package_json; do
|
||||
project_dir=$(dirname "$package_json")
|
||||
log_message "$${GREEN}📦 Found Node.js project: $project_dir$${RESET}"
|
||||
|
||||
cd "$project_dir"
|
||||
|
||||
# Check package.json for start script
|
||||
if [ -f "package.json" ] && command -v jq &> /dev/null; then
|
||||
start_script=$(jq -r '.scripts.start // empty' package.json)
|
||||
dev_script=$(jq -r '.scripts.dev // empty' package.json)
|
||||
serve_script=$(jq -r '.scripts.serve // empty' package.json)
|
||||
|
||||
# Determine port (check for common port configurations)
|
||||
local project_port=3000
|
||||
if [ -n "$dev_script" ] && echo "$dev_script" | grep -q "\-\-port"; then
|
||||
project_port=$(echo "$dev_script" | grep -oE "\-\-port[[:space:]]+[0-9]+" | grep -oE "[0-9]+$" || echo "3000")
|
||||
fi
|
||||
|
||||
# Use yarn if yarn.lock exists
|
||||
local pkg_manager="npm"
|
||||
local cmd_prefix=""
|
||||
if [ -f "yarn.lock" ] && command -v yarn &> /dev/null; then
|
||||
pkg_manager="yarn"
|
||||
cmd_prefix=""
|
||||
else
|
||||
cmd_prefix="run "
|
||||
fi
|
||||
|
||||
# Prioritize scripts: 'dev' > 'serve' > 'start' for development environments
|
||||
if [ -n "$dev_script" ]; then
|
||||
if [ "$pkg_manager" = "yarn" ]; then
|
||||
log_message "$${GREEN}🟢 Starting project with 'yarn dev' in $project_dir$${RESET}"
|
||||
nohup yarn dev >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "nodejs" "$project_port" "yarn dev"
|
||||
else
|
||||
log_message "$${GREEN}🟢 Starting project with 'npm run dev' in $project_dir$${RESET}"
|
||||
nohup npm run dev >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "nodejs" "$project_port" "npm run dev"
|
||||
fi
|
||||
elif [ -n "$serve_script" ]; then
|
||||
if [ "$pkg_manager" = "yarn" ]; then
|
||||
log_message "$${GREEN}🟢 Starting project with 'yarn serve' in $project_dir$${RESET}"
|
||||
nohup yarn serve >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "nodejs" "$project_port" "yarn serve"
|
||||
else
|
||||
log_message "$${GREEN}🟢 Starting project with 'npm run serve' in $project_dir$${RESET}"
|
||||
nohup npm run serve >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "nodejs" "$project_port" "npm run serve"
|
||||
fi
|
||||
elif [ -n "$start_script" ]; then
|
||||
if [ "$pkg_manager" = "yarn" ]; then
|
||||
log_message "$${GREEN}🟢 Starting project with 'yarn start' in $project_dir$${RESET}"
|
||||
nohup yarn start >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "nodejs" "$project_port" "yarn start"
|
||||
else
|
||||
log_message "$${GREEN}🟢 Starting project with 'npm start' in $project_dir$${RESET}"
|
||||
nohup npm start >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "nodejs" "$project_port" "npm start"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "package.json" -type f -print0)
|
||||
}
|
||||
|
||||
# Function to detect and start Rails projects
|
||||
detect_rails_projects() {
|
||||
if [ "${ENABLE_RAILS}" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log_message "$${BLUE}🔍 Scanning for Ruby on Rails projects...$${RESET}"
|
||||
|
||||
while IFS= read -r -d '' gemfile; do
|
||||
project_dir=$(dirname "$gemfile")
|
||||
log_message "$${GREEN}💎 Found Rails project: $project_dir$${RESET}"
|
||||
|
||||
cd "$project_dir"
|
||||
|
||||
# Check if it's actually a Rails project
|
||||
if grep -q "gem ['\"]rails['\"]" Gemfile 2> /dev/null; then
|
||||
log_message "$${GREEN}🟢 Starting Rails server in $project_dir$${RESET}"
|
||||
nohup bundle exec rails server >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "rails" "3000" "bundle exec rails server"
|
||||
fi
|
||||
|
||||
done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "Gemfile" -type f -print0)
|
||||
}
|
||||
|
||||
# Function to detect and start Django projects
|
||||
detect_django_projects() {
|
||||
if [ "${ENABLE_DJANGO}" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log_message "$${BLUE}🔍 Scanning for Django projects...$${RESET}"
|
||||
|
||||
while IFS= read -r -d '' manage_py; do
|
||||
project_dir=$(dirname "$manage_py")
|
||||
log_message "$${GREEN}🐍 Found Django project: $project_dir$${RESET}"
|
||||
|
||||
cd "$project_dir"
|
||||
log_message "$${GREEN}🟢 Starting Django development server in $project_dir$${RESET}"
|
||||
nohup python manage.py runserver 0.0.0.0:8000 >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "django" "8000" "python manage.py runserver"
|
||||
|
||||
done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "manage.py" -type f -print0)
|
||||
}
|
||||
|
||||
# Function to detect and start Flask projects
|
||||
detect_flask_projects() {
|
||||
if [ "${ENABLE_FLASK}" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log_message "$${BLUE}🔍 Scanning for Flask projects...$${RESET}"
|
||||
|
||||
while IFS= read -r -d '' requirements_txt; do
|
||||
project_dir=$(dirname "$requirements_txt")
|
||||
|
||||
# Check if Flask is in requirements
|
||||
if grep -q -i "flask" "$requirements_txt" 2> /dev/null; then
|
||||
log_message "$${GREEN}🌶️ Found Flask project: $project_dir$${RESET}"
|
||||
|
||||
cd "$project_dir"
|
||||
|
||||
# Look for common Flask app files
|
||||
for app_file in app.py main.py run.py; do
|
||||
if [ -f "$app_file" ]; then
|
||||
log_message "$${GREEN}🟢 Starting Flask application ($app_file) in $project_dir$${RESET}"
|
||||
export FLASK_ENV=development
|
||||
nohup python "$app_file" >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "flask" "5000" "python $app_file"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "requirements.txt" -type f -print0)
|
||||
}
|
||||
|
||||
# Function to detect and start Spring Boot projects
|
||||
detect_spring_boot_projects() {
|
||||
if [ "${ENABLE_SPRING_BOOT}" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log_message "$${BLUE}🔍 Scanning for Spring Boot projects...$${RESET}"
|
||||
|
||||
# Maven projects
|
||||
while IFS= read -r -d '' pom_xml; do
|
||||
project_dir=$(dirname "$pom_xml")
|
||||
|
||||
# Check if it's a Spring Boot project
|
||||
if grep -q "spring-boot" "$pom_xml" 2> /dev/null; then
|
||||
log_message "$${GREEN}🍃 Found Spring Boot Maven project: $project_dir$${RESET}"
|
||||
|
||||
cd "$project_dir"
|
||||
if command -v ./mvnw &> /dev/null; then
|
||||
log_message "$${GREEN}🟢 Starting Spring Boot application with Maven wrapper in $project_dir$${RESET}"
|
||||
nohup ./mvnw spring-boot:run >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "spring-boot" "8080" "./mvnw spring-boot:run"
|
||||
elif command -v mvn &> /dev/null; then
|
||||
log_message "$${GREEN}🟢 Starting Spring Boot application with Maven in $project_dir$${RESET}"
|
||||
nohup mvn spring-boot:run >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "spring-boot" "8080" "mvn spring-boot:run"
|
||||
fi
|
||||
fi
|
||||
|
||||
done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "pom.xml" -type f -print0)
|
||||
|
||||
# Gradle projects
|
||||
while IFS= read -r -d '' build_gradle; do
|
||||
project_dir=$(dirname "$build_gradle")
|
||||
|
||||
# Check if it's a Spring Boot project
|
||||
if grep -q "spring-boot" "$build_gradle" 2> /dev/null; then
|
||||
log_message "$${GREEN}🍃 Found Spring Boot Gradle project: $project_dir$${RESET}"
|
||||
|
||||
cd "$project_dir"
|
||||
if command -v ./gradlew &> /dev/null; then
|
||||
log_message "$${GREEN}🟢 Starting Spring Boot application with Gradle wrapper in $project_dir$${RESET}"
|
||||
nohup ./gradlew bootRun >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "spring-boot" "8080" "./gradlew bootRun"
|
||||
elif command -v gradle &> /dev/null; then
|
||||
log_message "$${GREEN}🟢 Starting Spring Boot application with Gradle in $project_dir$${RESET}"
|
||||
nohup gradle bootRun >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "spring-boot" "8080" "gradle bootRun"
|
||||
fi
|
||||
fi
|
||||
|
||||
done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "build.gradle" -type f -print0)
|
||||
}
|
||||
|
||||
# Function to detect and start Go projects
|
||||
detect_go_projects() {
|
||||
if [ "${ENABLE_GO}" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log_message "$${BLUE}🔍 Scanning for Go projects...$${RESET}"
|
||||
|
||||
while IFS= read -r -d '' go_mod; do
|
||||
project_dir=$(dirname "$go_mod")
|
||||
log_message "$${GREEN}🐹 Found Go project: $project_dir$${RESET}"
|
||||
|
||||
cd "$project_dir"
|
||||
|
||||
# Look for main.go or check if there's a main function
|
||||
if [ -f "main.go" ]; then
|
||||
log_message "$${GREEN}🟢 Starting Go application in $project_dir$${RESET}"
|
||||
nohup go run main.go >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "go" "8080" "go run main.go"
|
||||
elif [ -f "cmd/main.go" ]; then
|
||||
log_message "$${GREEN}🟢 Starting Go application (cmd/main.go) in $project_dir$${RESET}"
|
||||
nohup go run cmd/main.go >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "go" "8080" "go run cmd/main.go"
|
||||
fi
|
||||
|
||||
done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "go.mod" -type f -print0)
|
||||
}
|
||||
|
||||
# Function to detect and start PHP projects
|
||||
detect_php_projects() {
|
||||
if [ "${ENABLE_PHP}" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log_message "$${BLUE}🔍 Scanning for PHP projects...$${RESET}"
|
||||
|
||||
while IFS= read -r -d '' composer_json; do
|
||||
project_dir=$(dirname "$composer_json")
|
||||
log_message "$${GREEN}🐘 Found PHP project: $project_dir$${RESET}"
|
||||
|
||||
cd "$project_dir"
|
||||
|
||||
# Look for common PHP entry points
|
||||
for entry_file in index.php public/index.php; do
|
||||
if [ -f "$entry_file" ]; then
|
||||
log_message "$${GREEN}🟢 Starting PHP development server in $project_dir$${RESET}"
|
||||
nohup php -S 0.0.0.0:8080 -t "$(dirname "$entry_file")" >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "php" "8080" "php -S 0.0.0.0:8080"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "composer.json" -type f -print0)
|
||||
}
|
||||
|
||||
# Function to detect and start Rust projects
|
||||
detect_rust_projects() {
|
||||
if [ "${ENABLE_RUST}" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log_message "$${BLUE}🔍 Scanning for Rust projects...$${RESET}"
|
||||
|
||||
while IFS= read -r -d '' cargo_toml; do
|
||||
project_dir=$(dirname "$cargo_toml")
|
||||
log_message "$${GREEN}🦀 Found Rust project: $project_dir$${RESET}"
|
||||
|
||||
cd "$project_dir"
|
||||
|
||||
# Check if it's a binary project (has [[bin]] or default main.rs)
|
||||
if grep -q "\[\[bin\]\]" Cargo.toml 2> /dev/null || [ -f "src/main.rs" ]; then
|
||||
log_message "$${GREEN}🟢 Starting Rust application in $project_dir$${RESET}"
|
||||
nohup cargo run >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "rust" "8000" "cargo run"
|
||||
fi
|
||||
|
||||
done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "Cargo.toml" -type f -print0)
|
||||
}
|
||||
|
||||
# Function to detect and start .NET projects
|
||||
detect_dotnet_projects() {
|
||||
if [ "${ENABLE_DOTNET}" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
log_message "$${BLUE}🔍 Scanning for .NET projects...$${RESET}"
|
||||
|
||||
while IFS= read -r -d '' csproj; do
|
||||
project_dir=$(dirname "$csproj")
|
||||
log_message "$${GREEN}🔷 Found .NET project: $project_dir$${RESET}"
|
||||
|
||||
cd "$project_dir"
|
||||
log_message "$${GREEN}🟢 Starting .NET application in $project_dir$${RESET}"
|
||||
nohup dotnet run >> "${LOG_PATH}" 2>&1 &
|
||||
add_detected_project "$project_dir" "dotnet" "5000" "dotnet run"
|
||||
|
||||
done < <(find "${WORKSPACE_DIR}" -maxdepth "${SCAN_DEPTH}" -name "*.csproj" -type f -print0)
|
||||
}
|
||||
|
||||
log_message "Starting auto-detection of development projects..."
|
||||
|
||||
# Expand workspace directory if it contains variables
|
||||
WORKSPACE_DIR=$(eval echo "${WORKSPACE_DIR}")
|
||||
|
||||
# Check if workspace directory exists
|
||||
if [ ! -d "$WORKSPACE_DIR" ]; then
|
||||
log_message "$${RED}❌ Workspace directory does not exist: $WORKSPACE_DIR$${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$WORKSPACE_DIR"
|
||||
|
||||
# Run all detection functions
|
||||
detect_npm_projects
|
||||
detect_rails_projects
|
||||
detect_django_projects
|
||||
detect_flask_projects
|
||||
detect_spring_boot_projects
|
||||
detect_go_projects
|
||||
detect_php_projects
|
||||
detect_rust_projects
|
||||
detect_dotnet_projects
|
||||
|
||||
log_message "$${GREEN}✅ Auto-start scan completed!$${RESET}"
|
||||
log_message "$${YELLOW}💡 Check running processes with 'ps aux | grep -E \"(npm|rails|python|java|go|php|cargo|dotnet)\"'$${RESET}"
|
||||
log_message "$${YELLOW}💡 View logs: tail -f ${LOG_PATH}$${RESET}"
|
||||
|
||||
# Set default port if no projects were detected
|
||||
if [ "$FIRST_PORT_DETECTED" = false ]; then
|
||||
echo "3000" > "$DETECTED_PORT_FILE"
|
||||
log_message "$${YELLOW}⚠️ No projects detected - Preview app will default to port 3000$${RESET}"
|
||||
fi
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
display_name: Nexus Repository
|
||||
description: Configure package managers to use Sonatype Nexus Repository for Maven, npm, PyPI, and Docker registries.
|
||||
icon: ../../../../.icons/nexus-repository.svg
|
||||
verified: false
|
||||
tags: [integration, nexus-repository, maven, npm, pypi, docker]
|
||||
---
|
||||
|
||||
# Sonatype Nexus Repository
|
||||
|
||||
Configure package managers (Maven, npm, Go, PyPI, Docker) to use [Sonatype Nexus Repository](https://help.sonatype.com/en/sonatype-nexus-repository.html) with API token authentication. This module provides secure credential handling, multiple repository support per package manager, and flexible username configuration.
|
||||
|
||||
```tf
|
||||
module "nexus_repository" {
|
||||
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
nexus_url = "https://nexus.example.com"
|
||||
nexus_password = var.nexus_api_token
|
||||
package_managers = {
|
||||
maven = ["maven-public", "maven-releases"]
|
||||
npm = ["npm-public", "@scoped:npm-private"]
|
||||
go = ["go-public", "go-private"]
|
||||
pypi = ["pypi-public", "pypi-private"]
|
||||
docker = ["docker-public", "docker-private"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Nexus Repository Manager 3.x
|
||||
- Valid API token or user credentials
|
||||
- Package managers installed on the workspace (Maven, npm, Go, pip, Docker as needed)
|
||||
|
||||
> [!NOTE]
|
||||
> This module configures package managers but does not install them. You need to handle the installation of Maven, npm, Go, Python pip, and Docker yourself.
|
||||
|
||||
## Examples
|
||||
|
||||
### Configure Maven to use Nexus repositories
|
||||
|
||||
```tf
|
||||
module "nexus_repository" {
|
||||
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
nexus_url = "https://nexus.example.com"
|
||||
nexus_password = var.nexus_api_token
|
||||
package_managers = {
|
||||
maven = ["maven-public", "maven-releases", "maven-snapshots"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configure npm with scoped packages
|
||||
|
||||
```tf
|
||||
module "nexus_repository" {
|
||||
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
nexus_url = "https://nexus.example.com"
|
||||
nexus_password = var.nexus_api_token
|
||||
package_managers = {
|
||||
npm = ["npm-public", "@mycompany:npm-private"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configure Go module proxy
|
||||
|
||||
```tf
|
||||
module "nexus_repository" {
|
||||
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
nexus_url = "https://nexus.example.com"
|
||||
nexus_password = var.nexus_api_token
|
||||
package_managers = {
|
||||
go = ["go-public", "go-private"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configure Python PyPI repositories
|
||||
|
||||
```tf
|
||||
module "nexus_repository" {
|
||||
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
nexus_url = "https://nexus.example.com"
|
||||
nexus_password = var.nexus_api_token
|
||||
package_managers = {
|
||||
pypi = ["pypi-public", "pypi-private"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configure Docker registries
|
||||
|
||||
```tf
|
||||
module "nexus_repository" {
|
||||
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
nexus_url = "https://nexus.example.com"
|
||||
nexus_password = var.nexus_api_token
|
||||
package_managers = {
|
||||
docker = ["docker-public", "docker-private"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use custom username
|
||||
|
||||
```tf
|
||||
module "nexus_repository" {
|
||||
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
nexus_url = "https://nexus.example.com"
|
||||
nexus_username = "custom-user"
|
||||
nexus_password = var.nexus_api_token
|
||||
package_managers = {
|
||||
maven = ["maven-public"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complete configuration for all package managers
|
||||
|
||||
```tf
|
||||
module "nexus_repository" {
|
||||
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
nexus_url = "https://nexus.example.com"
|
||||
nexus_password = var.nexus_api_token
|
||||
package_managers = {
|
||||
maven = ["maven-public", "maven-releases"]
|
||||
npm = ["npm-public", "@company:npm-private"]
|
||||
go = ["go-public", "go-private"]
|
||||
pypi = ["pypi-public", "pypi-private"]
|
||||
docker = ["docker-public", "docker-private"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
executeScriptInContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
} from "~test";
|
||||
|
||||
describe("nexus-repository", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
testRequiredVariables(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
nexus_url: "https://nexus.example.com",
|
||||
nexus_password: "test-password",
|
||||
});
|
||||
|
||||
it("configures Maven settings", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
nexus_url: "https://nexus.example.com",
|
||||
nexus_password: "test-token",
|
||||
package_managers: JSON.stringify({
|
||||
maven: ["maven-public"],
|
||||
}),
|
||||
});
|
||||
|
||||
const output = await executeScriptInContainer(state, "ubuntu:20.04");
|
||||
expect(output.stdout.join("\n")).toContain("☕ Configuring Maven...");
|
||||
expect(output.stdout.join("\n")).toContain("🥳 Configuration complete!");
|
||||
});
|
||||
|
||||
it("configures npm registry", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
nexus_url: "https://nexus.example.com",
|
||||
nexus_password: "test-token",
|
||||
package_managers: JSON.stringify({
|
||||
npm: ["npm-public"],
|
||||
}),
|
||||
});
|
||||
|
||||
const output = await executeScriptInContainer(state, "ubuntu:20.04");
|
||||
expect(output.stdout.join("\n")).toContain("📦 Configuring npm...");
|
||||
expect(output.stdout.join("\n")).toContain("🥳 Configuration complete!");
|
||||
});
|
||||
|
||||
it("configures PyPI repository", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
nexus_url: "https://nexus.example.com",
|
||||
nexus_password: "test-token",
|
||||
package_managers: JSON.stringify({
|
||||
pypi: ["pypi-public"],
|
||||
}),
|
||||
});
|
||||
|
||||
const output = await executeScriptInContainer(state, "ubuntu:20.04");
|
||||
expect(output.stdout.join("\n")).toContain("🐍 Configuring pip...");
|
||||
expect(output.stdout.join("\n")).toContain("🥳 Configuration complete!");
|
||||
});
|
||||
|
||||
it("configures multiple package managers", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
nexus_url: "https://nexus.example.com",
|
||||
nexus_password: "test-token",
|
||||
package_managers: JSON.stringify({
|
||||
maven: ["maven-public"],
|
||||
npm: ["npm-public"],
|
||||
pypi: ["pypi-public"],
|
||||
}),
|
||||
});
|
||||
|
||||
const output = await executeScriptInContainer(state, "ubuntu:20.04");
|
||||
expect(output.stdout.join("\n")).toContain("☕ Configuring Maven...");
|
||||
expect(output.stdout.join("\n")).toContain("📦 Configuring npm...");
|
||||
expect(output.stdout.join("\n")).toContain("🐍 Configuring pip...");
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
"✅ Nexus repository configuration completed!",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles empty package managers", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
nexus_url: "https://nexus.example.com",
|
||||
nexus_password: "test-token",
|
||||
package_managers: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const output = await executeScriptInContainer(state, "ubuntu:20.04");
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
"🤔 no maven repository is set, skipping maven configuration.",
|
||||
);
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
"🤔 no npm repository is set, skipping npm configuration.",
|
||||
);
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
"🤔 no pypi repository is set, skipping pypi configuration.",
|
||||
);
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
"🤔 no docker repository is set, skipping docker configuration.",
|
||||
);
|
||||
});
|
||||
|
||||
it("configures Go module proxy", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
nexus_url: "https://nexus.example.com",
|
||||
nexus_password: "test-token",
|
||||
package_managers: JSON.stringify({
|
||||
go: ["go-public", "go-private"],
|
||||
}),
|
||||
});
|
||||
|
||||
const output = await executeScriptInContainer(state, "ubuntu:20.04");
|
||||
expect(output.stdout.join("\n")).toContain("🐹 Configuring Go...");
|
||||
expect(output.stdout.join("\n")).toContain(
|
||||
"Go proxy configured via GOPROXY environment variable",
|
||||
);
|
||||
expect(output.stdout.join("\n")).toContain("🥳 Configuration complete!");
|
||||
});
|
||||
|
||||
it("validates nexus_url format", async () => {
|
||||
await expect(
|
||||
runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
nexus_url: "invalid-url",
|
||||
nexus_password: "test-token",
|
||||
package_managers: JSON.stringify({}),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("validates username_field values", async () => {
|
||||
await expect(
|
||||
runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
nexus_url: "https://nexus.example.com",
|
||||
nexus_password: "test-token",
|
||||
username_field: "invalid",
|
||||
package_managers: JSON.stringify({}),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "nexus_url" {
|
||||
type = string
|
||||
description = "The base URL of your Nexus repository manager (e.g. https://nexus.example.com)"
|
||||
validation {
|
||||
condition = can(regex("^(https|http)://", var.nexus_url))
|
||||
error_message = "nexus_url must be a valid URL starting with either 'https://' or 'http://'"
|
||||
}
|
||||
}
|
||||
|
||||
variable "nexus_username" {
|
||||
type = string
|
||||
description = "Custom username for Nexus authentication. If not provided, defaults to the Coder username based on the username_field setting"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "nexus_password" {
|
||||
type = string
|
||||
description = "API token or password for Nexus authentication. This value is sensitive and should be stored securely"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
variable "package_managers" {
|
||||
type = object({
|
||||
maven = optional(list(string), [])
|
||||
npm = optional(list(string), [])
|
||||
go = optional(list(string), [])
|
||||
pypi = optional(list(string), [])
|
||||
docker = optional(list(string), [])
|
||||
})
|
||||
default = {
|
||||
maven = []
|
||||
npm = []
|
||||
go = []
|
||||
pypi = []
|
||||
docker = []
|
||||
}
|
||||
description = <<-EOF
|
||||
Configuration for package managers. Each key maps to a list of Nexus repository names:
|
||||
- maven: List of Maven repository names
|
||||
- npm: List of npm repository names (supports scoped packages with "@scope:repo-name")
|
||||
- go: List of Go proxy repository names
|
||||
- pypi: List of PyPI repository names
|
||||
- docker: List of Docker registry names
|
||||
Unused package managers can be omitted.
|
||||
Example:
|
||||
{
|
||||
maven = ["maven-public", "maven-releases"]
|
||||
npm = ["npm-public", "@scoped:npm-private"]
|
||||
go = ["go-public", "go-private"]
|
||||
pypi = ["pypi-public", "pypi-private"]
|
||||
docker = ["docker-public", "docker-private"]
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
variable "username_field" {
|
||||
type = string
|
||||
description = "Field to use for username (\"username\" or \"email\"). Defaults to \"username\". Only used when nexus_username is not provided"
|
||||
default = "username"
|
||||
validation {
|
||||
condition = can(regex("^(email|username)$", var.username_field))
|
||||
error_message = "username_field must be either 'email' or 'username'"
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
locals {
|
||||
username = coalesce(var.nexus_username, var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name)
|
||||
nexus_host = split("/", replace(replace(var.nexus_url, "https://", ""), "http://", ""))[0]
|
||||
}
|
||||
|
||||
locals {
|
||||
# Get first repository name or use default
|
||||
maven_repo = length(var.package_managers.maven) > 0 ? var.package_managers.maven[0] : "maven-public"
|
||||
npm_repo = length(var.package_managers.npm) > 0 ? var.package_managers.npm[0] : "npm-public"
|
||||
go_repo = length(var.package_managers.go) > 0 ? var.package_managers.go[0] : "go-public"
|
||||
pypi_repo = length(var.package_managers.pypi) > 0 ? var.package_managers.pypi[0] : "pypi-public"
|
||||
|
||||
npmrc = <<-EOF
|
||||
registry=${var.nexus_url}/repository/${local.npm_repo}/
|
||||
//${local.nexus_host}/repository/${local.npm_repo}/:username=${local.username}
|
||||
//${local.nexus_host}/repository/${local.npm_repo}/:_password=${base64encode(var.nexus_password)}
|
||||
//${local.nexus_host}/repository/${local.npm_repo}/:always-auth=true
|
||||
EOF
|
||||
}
|
||||
|
||||
resource "coder_script" "nexus" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "nexus-repository"
|
||||
icon = "/icon/nexus-repository.svg"
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
NEXUS_URL = var.nexus_url
|
||||
NEXUS_HOST = local.nexus_host
|
||||
NEXUS_USERNAME = local.username
|
||||
NEXUS_PASSWORD = var.nexus_password
|
||||
HAS_MAVEN = length(var.package_managers.maven) == 0 ? "" : "YES"
|
||||
MAVEN_REPO = local.maven_repo
|
||||
HAS_NPM = length(var.package_managers.npm) == 0 ? "" : "YES"
|
||||
NPMRC = local.npmrc
|
||||
HAS_GO = length(var.package_managers.go) == 0 ? "" : "YES"
|
||||
GO_REPO = local.go_repo
|
||||
HAS_PYPI = length(var.package_managers.pypi) == 0 ? "" : "YES"
|
||||
PYPI_REPO = local.pypi_repo
|
||||
HAS_DOCKER = length(var.package_managers.docker) == 0 ? "" : "YES"
|
||||
REGISTER_DOCKER = join("\n ", formatlist("register_docker \"%s\"", var.package_managers.docker))
|
||||
})
|
||||
run_on_start = true
|
||||
}
|
||||
|
||||
resource "coder_env" "goproxy" {
|
||||
count = length(var.package_managers.go) == 0 ? 0 : 1
|
||||
agent_id = var.agent_id
|
||||
name = "GOPROXY"
|
||||
value = join(",", [
|
||||
for repo in var.package_managers.go :
|
||||
"https://${local.username}:${var.nexus_password}@${local.nexus_host}/repository/${repo}"
|
||||
])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
not_configured() {
|
||||
type=$1
|
||||
echo "🤔 no $type repository is set, skipping $type configuration."
|
||||
}
|
||||
|
||||
config_complete() {
|
||||
echo "🥳 Configuration complete!"
|
||||
}
|
||||
|
||||
register_docker() {
|
||||
repo=$1
|
||||
echo -n "${NEXUS_PASSWORD}" | docker login "${NEXUS_HOST}/repository/$${repo}" --username "${NEXUS_USERNAME}" --password-stdin
|
||||
}
|
||||
|
||||
echo "🚀 Configuring Nexus repository access..."
|
||||
|
||||
# Configure Maven
|
||||
if [ -n "${HAS_MAVEN}" ]; then
|
||||
echo "☕ Configuring Maven..."
|
||||
mkdir -p ~/.m2
|
||||
cat > ~/.m2/settings.xml << 'EOF'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
|
||||
<servers>
|
||||
<server>
|
||||
<id>nexus</id>
|
||||
<username>${NEXUS_USERNAME}</username>
|
||||
<password>${NEXUS_PASSWORD}</password>
|
||||
</server>
|
||||
</servers>
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>nexus-mirror</id>
|
||||
<mirrorOf>*</mirrorOf>
|
||||
<url>${NEXUS_URL}/repository/${MAVEN_REPO}</url>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
</settings>
|
||||
EOF
|
||||
config_complete
|
||||
else
|
||||
not_configured maven
|
||||
fi
|
||||
|
||||
# Configure npm
|
||||
if [ -n "${HAS_NPM}" ]; then
|
||||
echo "📦 Configuring npm..."
|
||||
cat > ~/.npmrc << 'EOF'
|
||||
${NPMRC}
|
||||
EOF
|
||||
config_complete
|
||||
else
|
||||
not_configured npm
|
||||
fi
|
||||
|
||||
# Configure Go
|
||||
if [ -n "${HAS_GO}" ]; then
|
||||
echo "🐹 Configuring Go..."
|
||||
# Go configuration is handled via GOPROXY environment variable
|
||||
# which is set by the Terraform configuration
|
||||
echo "Go proxy configured via GOPROXY environment variable"
|
||||
config_complete
|
||||
else
|
||||
not_configured go
|
||||
fi
|
||||
|
||||
# Configure pip
|
||||
if [ -n "${HAS_PYPI}" ]; then
|
||||
echo "🐍 Configuring pip..."
|
||||
mkdir -p ~/.pip
|
||||
# Create .netrc file for secure credential storage
|
||||
cat > ~/.netrc << EOF
|
||||
machine ${NEXUS_HOST}
|
||||
login ${NEXUS_USERNAME}
|
||||
password ${NEXUS_PASSWORD}
|
||||
EOF
|
||||
chmod 600 ~/.netrc
|
||||
|
||||
# Update pip.conf to use index-url without embedded credentials
|
||||
cat > ~/.pip/pip.conf << 'EOF'
|
||||
[global]
|
||||
index-url = https://${NEXUS_HOST}/repository/${PYPI_REPO}/simple
|
||||
EOF
|
||||
config_complete
|
||||
else
|
||||
not_configured pypi
|
||||
fi
|
||||
|
||||
# Configure Docker
|
||||
if [ -n "${HAS_DOCKER}" ]; then
|
||||
if command -v docker > /dev/null 2>&1; then
|
||||
echo "🐳 Configuring Docker credentials..."
|
||||
mkdir -p ~/.docker
|
||||
${REGISTER_DOCKER}
|
||||
config_complete
|
||||
else
|
||||
echo "🤔 Docker is not installed, skipping Docker configuration."
|
||||
fi
|
||||
else
|
||||
not_configured docker
|
||||
fi
|
||||
|
||||
echo "✅ Nexus repository configuration completed!"
|
||||
@@ -1,7 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Find all directories that contain any .tftest.hcl files and run terraform test in each
|
||||
# Auto-detect which Terraform tests to run based on changed files from paths-filter
|
||||
# Uses paths-filter outputs from GitHub Actions:
|
||||
# ALL_CHANGED_FILES - all files changed in the PR (for logging)
|
||||
# SHARED_CHANGED - boolean indicating if shared infrastructure changed
|
||||
# MODULE_CHANGED_FILES - only files in registry/**/modules/** (for processing)
|
||||
# Runs all tests if shared infrastructure changes, or skips if no changes detected
|
||||
#
|
||||
# This script only runs tests for changed modules. Documentation and template changes are ignored.
|
||||
|
||||
run_dir() {
|
||||
local dir="$1"
|
||||
@@ -9,13 +16,72 @@ run_dir() {
|
||||
(cd "$dir" && terraform init -upgrade -input=false -no-color > /dev/null && terraform test -no-color -verbose)
|
||||
}
|
||||
|
||||
mapfile -t test_dirs < <(find . -type f -name "*.tftest.hcl" -print0 | xargs -0 -I{} dirname {} | sort -u)
|
||||
echo "==> Detecting changed files..."
|
||||
|
||||
if [[ -n "${ALL_CHANGED_FILES:-}" ]]; then
|
||||
echo "Changed files in PR:"
|
||||
echo "$ALL_CHANGED_FILES" | tr ' ' '\n' | sed 's/^/ - /'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "${SHARED_CHANGED:-false}" == "true" ]]; then
|
||||
echo "==> Shared infrastructure changed"
|
||||
echo "==> Running all tests for safety"
|
||||
mapfile -t test_dirs < <(find . -type f -name "*.tftest.hcl" -print0 | xargs -0 -I{} dirname {} | sort -u)
|
||||
elif [[ -z "${MODULE_CHANGED_FILES:-}" ]]; then
|
||||
echo "✓ No module files changed, skipping tests"
|
||||
exit 0
|
||||
else
|
||||
CHANGED_FILES=$(echo "$MODULE_CHANGED_FILES" | tr ' ' '\n')
|
||||
|
||||
MODULE_DIRS=()
|
||||
while IFS= read -r file; do
|
||||
if [[ "$file" =~ \.(md|png|jpg|jpeg|svg)$ ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$file" =~ ^registry/([^/]+)/modules/([^/]+)/ ]]; then
|
||||
namespace="${BASH_REMATCH[1]}"
|
||||
module="${BASH_REMATCH[2]}"
|
||||
module_dir="registry/${namespace}/modules/${module}"
|
||||
|
||||
if [[ -d "$module_dir" ]] && [[ ! " ${MODULE_DIRS[*]} " =~ " ${module_dir} " ]]; then
|
||||
MODULE_DIRS+=("$module_dir")
|
||||
fi
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [[ ${#MODULE_DIRS[@]} -eq 0 ]]; then
|
||||
echo "✓ No Terraform tests to run"
|
||||
echo " (documentation, templates, namespace files, or modules without changes)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "==> Finding .tftest.hcl files in ${#MODULE_DIRS[@]} changed module(s):"
|
||||
for dir in "${MODULE_DIRS[@]}"; do
|
||||
echo " - $dir"
|
||||
done
|
||||
echo ""
|
||||
|
||||
test_dirs=()
|
||||
for module_dir in "${MODULE_DIRS[@]}"; do
|
||||
while IFS= read -r test_file; do
|
||||
test_dir=$(dirname "$test_file")
|
||||
if [[ ! " ${test_dirs[*]} " =~ " ${test_dir} " ]]; then
|
||||
test_dirs+=("$test_dir")
|
||||
fi
|
||||
done < <(find "$module_dir" -type f -name "*.tftest.hcl")
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#test_dirs[@]} -eq 0 ]]; then
|
||||
echo "No .tftest.hcl tests found."
|
||||
echo "✓ No .tftest.hcl tests found in changed modules"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "==> Running terraform test in ${#test_dirs[@]} directory(ies)"
|
||||
echo ""
|
||||
|
||||
status=0
|
||||
for d in "${test_dirs[@]}"; do
|
||||
if ! run_dir "$d"; then
|
||||
|
||||
@@ -2,36 +2,90 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Auto-detect which Terraform modules to validate based on changed files from paths-filter
|
||||
# Uses paths-filter outputs from GitHub Actions:
|
||||
# ALL_CHANGED_FILES - all files changed in the PR (for logging)
|
||||
# SHARED_CHANGED - boolean indicating if shared infrastructure changed
|
||||
# MODULE_CHANGED_FILES - only files in registry/**/modules/** (for processing)
|
||||
# Validates all modules if shared infrastructure changes, or skips if no changes detected
|
||||
#
|
||||
# This script only validates changed modules. Documentation and template changes are ignored.
|
||||
|
||||
validate_terraform_directory() {
|
||||
local dir="$1"
|
||||
echo "Running \`terraform validate\` in $dir"
|
||||
pushd "$dir"
|
||||
pushd "$dir" > /dev/null
|
||||
terraform init -upgrade
|
||||
terraform validate
|
||||
popd
|
||||
popd > /dev/null
|
||||
}
|
||||
|
||||
main() {
|
||||
# Get the directory of the script
|
||||
echo "==> Detecting changed files..."
|
||||
|
||||
if [[ -n "${ALL_CHANGED_FILES:-}" ]]; then
|
||||
echo "Changed files in PR:"
|
||||
echo "$ALL_CHANGED_FILES" | tr ' ' '\n' | sed 's/^/ - /'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
local script_dir=$(dirname "$(readlink -f "$0")")
|
||||
local registry_dir=$(readlink -f "$script_dir/../registry")
|
||||
|
||||
# Code assumes that registry directory will always be in same position
|
||||
# relative to the main script directory
|
||||
local registry_dir="$script_dir/../registry"
|
||||
if [[ "${SHARED_CHANGED:-false}" == "true" ]]; then
|
||||
echo "==> Shared infrastructure changed"
|
||||
echo "==> Validating all modules for safety"
|
||||
local subdirs=$(find "$registry_dir" -mindepth 3 -maxdepth 3 -path "*/modules/*" -type d | sort)
|
||||
elif [[ -z "${MODULE_CHANGED_FILES:-}" ]]; then
|
||||
echo "✓ No module files changed, skipping validation"
|
||||
exit 0
|
||||
else
|
||||
CHANGED_FILES=$(echo "$MODULE_CHANGED_FILES" | tr ' ' '\n')
|
||||
|
||||
# Get all module subdirectories in the registry directory. Code assumes that
|
||||
# Terraform module directories won't begin to appear until three levels deep into
|
||||
# the registry (e.g., registry/coder/modules/coder-login, which will then
|
||||
# have a main.tf file inside it)
|
||||
local subdirs=$(find "$registry_dir" -mindepth 3 -path "*/modules/*" -type d | sort)
|
||||
MODULE_DIRS=()
|
||||
while IFS= read -r file; do
|
||||
if [[ "$file" =~ \.(md|png|jpg|jpeg|svg)$ ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$file" =~ ^registry/([^/]+)/modules/([^/]+)/ ]]; then
|
||||
namespace="${BASH_REMATCH[1]}"
|
||||
module="${BASH_REMATCH[2]}"
|
||||
module_dir="registry/${namespace}/modules/${module}"
|
||||
|
||||
if [[ -d "$module_dir" ]] && [[ ! " ${MODULE_DIRS[*]} " =~ " ${module_dir} " ]]; then
|
||||
MODULE_DIRS+=("$module_dir")
|
||||
fi
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [[ ${#MODULE_DIRS[@]} -eq 0 ]]; then
|
||||
echo "✓ No modules to validate"
|
||||
echo " (documentation, templates, namespace files, or modules without changes)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "==> Validating ${#MODULE_DIRS[@]} changed module(s):"
|
||||
for dir in "${MODULE_DIRS[@]}"; do
|
||||
echo " - $dir"
|
||||
done
|
||||
echo ""
|
||||
|
||||
local subdirs="${MODULE_DIRS[*]}"
|
||||
fi
|
||||
|
||||
status=0
|
||||
for dir in $subdirs; do
|
||||
# Skip over any directories that obviously don't have the necessary
|
||||
# files
|
||||
if test -f "$dir/main.tf"; then
|
||||
validate_terraform_directory "$dir"
|
||||
if ! validate_terraform_directory "$dir"; then
|
||||
status=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
exit $status
|
||||
}
|
||||
|
||||
main
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Auto-detect which TypeScript tests to run based on changed files from paths-filter
|
||||
# Uses paths-filter outputs from GitHub Actions:
|
||||
# ALL_CHANGED_FILES - all files changed in the PR (for logging)
|
||||
# SHARED_CHANGED - boolean indicating if shared infrastructure changed
|
||||
# MODULE_CHANGED_FILES - only files in registry/**/modules/** (for processing)
|
||||
# Runs all tests if shared infrastructure changes
|
||||
#
|
||||
# This script only runs tests for changed modules. Documentation and template changes are ignored.
|
||||
|
||||
echo "==> Detecting changed files..."
|
||||
|
||||
if [[ -n "${ALL_CHANGED_FILES:-}" ]]; then
|
||||
echo "Changed files in PR:"
|
||||
echo "$ALL_CHANGED_FILES" | tr ' ' '\n' | sed 's/^/ - /'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "${SHARED_CHANGED:-false}" == "true" ]]; then
|
||||
echo "==> Shared infrastructure changed"
|
||||
echo "==> Running all tests for safety"
|
||||
exec bun test
|
||||
fi
|
||||
|
||||
if [[ -z "${MODULE_CHANGED_FILES:-}" ]]; then
|
||||
echo "✓ No module files changed, skipping tests"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CHANGED_FILES=$(echo "$MODULE_CHANGED_FILES" | tr ' ' '\n')
|
||||
|
||||
MODULE_DIRS=()
|
||||
while IFS= read -r file; do
|
||||
if [[ "$file" =~ \.(md|png|jpg|jpeg|svg)$ ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$file" =~ ^registry/([^/]+)/modules/([^/]+)/ ]]; then
|
||||
namespace="${BASH_REMATCH[1]}"
|
||||
module="${BASH_REMATCH[2]}"
|
||||
module_dir="registry/${namespace}/modules/${module}"
|
||||
|
||||
if [[ -f "$module_dir/main.test.ts" ]] && [[ ! " ${MODULE_DIRS[*]} " =~ " ${module_dir} " ]]; then
|
||||
MODULE_DIRS+=("$module_dir")
|
||||
fi
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [[ ${#MODULE_DIRS[@]} -eq 0 ]]; then
|
||||
echo "✓ No TypeScript tests to run"
|
||||
echo " (documentation, templates, namespace files, or modules without tests)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "==> Running TypeScript tests for ${#MODULE_DIRS[@]} changed module(s):"
|
||||
for dir in "${MODULE_DIRS[@]}"; do
|
||||
echo " - $dir"
|
||||
done
|
||||
echo ""
|
||||
|
||||
exec bun test "${MODULE_DIRS[@]}"
|
||||
Reference in New Issue
Block a user