Compare commits

...

25 Commits

Author SHA1 Message Date
Atif Ali 0ff3dbcc48 chore(claude-code): limit MCP tools for task reporting (#507) 2025-10-24 23:14:34 +05:00
netsgnut a327e79bc4 fix(kasmvnc): change installed check and bump default version (#505)
## Description

This PR makes the following changes to the `coder/modules/kasmvnc`:
- Change the installation check from checking `vncserver` to
`kasmvncserver`.
- Bump the default KasmVNC installation version to
[1.4.0](https://docs.kasmvnc.com/docs/release_notes/1.4.0).

In images where there is already TightVNC installed, the current
installation check will erroneously report that KasmVNC is already
installed. By checking `kasmvncserver` instead, it ensures KasmVNC is
installed.

Tested on Debian, Kali and Alpine-based images.

## Type of Change

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

## Module Information

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

## Testing & Validation

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

## Related Issues

None
2025-10-24 17:47:51 +00:00
Harsh Singh Panwar bc39c2ee29 Aider module support agentAPI (#356)
Closes #239

/claim #239

## Description

video :-
https://www.loom.com/share/d1d1d54d48bc45c4a48271ca9a387a88?sid=933e250d-78f8-4a7f-9745-0e908c0ee4d9
<!-- Briefly describe what this PR does and why -->

## Type of Change

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

## Module Information

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

**Path:** `registry/coder/modules/aider`  
**New version:** `v1.0.0`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: DevCats <christofer@coder.com>
2025-10-24 10:25:40 -05:00
Danielle Maywood e3ff43c0a6 refactor(coder/agentapi): support terraform-provider-coder v2.12.0 (#485)
In terraform-provider-coder v2.12.0 and the
up-coming coder v2.28 release we have removed the
requirement for the "AI Prompt" parameter, and are intending on slightly
re-designing the API of the AI task modules.

Instead of `agentapi` defining the `coder_ai_task` resource, it will
output the `task_app_id`. Consumers of the module will then be expected
to create the `coder_ai_task` resource themselves with this
`task_app_id`.
2025-10-24 11:54:12 +01:00
Yevhenii Shcherbina 30123e7ea3 feat: add boundary pprof server in claude-code module (#503) 2025-10-23 14:18:30 -04:00
djarbz f7c1be71f7 Add [copyparty] module (#486)
## Description

This PR adds a module to install Copyparty as an alternative to
Filebrowser.

## Type of Change

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

## Module Information

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

**Path:** `registry/djarbz/modules/copyparty`  
**New version:** `v0.1.0`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
None

---------

Co-authored-by: DevCats <christofer@coder.com>
2025-10-23 11:19:05 -05:00
DevCats 19519a0a13 fix: add shebang to zed coder_script (#504)
## Description

Add `#!/bin/sh` to zed_settings coder_script
<!-- Briefly describe what this PR does and why -->

## Type of Change

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

## Module Information

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

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

## Testing & Validation

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

## Related Issues

https://github.com/coder/registry/issues/482
<!-- Link related issues or write "None" if not applicable -->
2025-10-23 07:39:27 -05:00
Rowan Smith 63e42283ce chore: Update templates from Always to IfNotPresent for image_pull_policy (#501)
## Description

Change `image_pull_policy` from `Always` to `IfNotPresent` on Coder
owned templates. Given these are a reference point for users and
customers and they copy them into their own templates I think it makes
sense to encourage the use of caching of images.

## Type of Change

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

## Template Information

**Path:** 


https://github.com/coder/registry/tree/main/registry/coder/templates/kubernetes-devcontainer

https://github.com/coder/registry/tree/main/registry/coder/templates/kubernetes-envbox

https://github.com/coder/registry/tree/main/registry/coder/templates/kubernetes

## Testing & Validation

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

## Related Issues

None
2025-10-23 04:28:58 +00:00
Eric Paulsen 0c5a8a2354 add nfs-deployment template (#502)
## Description

this PR adds a new template to the registry, which shows how to mount an
NFS share to a K8s deployment workspace.

## Type of Change

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

## Template Information

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

**Path:** `registry/ericpaulsen/templates/nfs-deployment`

## Testing & Validation

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

## Related Issues

None

---------

Co-authored-by: DevCats <christofer@coder.com>
2025-10-22 11:51:58 -05:00
DevCats 51ec6e3212 fix: resolve issues with claude-code session resumption (#496)
## Description

Fixes session resumption logic by having the continue flag decide
whether to continue a workspace based on session history

## Type of Change

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

## Module Information

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

**Path:** `registry/coder/modules/claude-code`  
**New version:** `v3.2.2`  
**Breaking change:** [ ] Yes [X] No

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-22 10:58:01 -05:00
DevCats 843b1f1e5a chore: change copilot default version to latest (#499)
## Description

Changes `copilot_version` default to `latest`

## Type of Change

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

## Module Information

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

**Path:** `registry/coder-labs/modules/copilot`  
**New version:** `v0.2.2`  
**Breaking change:** [ ] Yes [X] No

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2025-10-22 07:33:09 -05:00
Jiachen Jiang 583918bfef added example of boundary to claude code module (#500) 2025-10-21 16:33:15 -05:00
Benjamin Peinhardt a1786a09ea update claude-code module version (#498)
The version for the claude-code module should have been updated in
https://github.com/coder/registry/pull/455. This PR updates the module
version so we can cut a release 😎
2025-10-21 13:46:32 -05:00
Benjamin Peinhardt a35986d7df feat: initial boundary integration with claude code (#455)
Closes #

## Description

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

## Type of Change

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

## Module Information

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

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

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: YEVHENII SHCHERBINA <yevhenii@coder.com>
2025-10-21 13:44:26 -04:00
Mathias Fredriksson e34320cb0b feat: add archive module (#422)
This change adds a new `archive` module to the Coder registry. It can be
used to archive user-data from pre-defined locations and restore it as
well.

Here we also explore:

- A new method of passing arrays from Terraform to Bash
- A new method of writing Bash scripts that minimizes the interaction
with terraform interpolation
- Extensive test-suite that not only tests that Terraform options can be
selected, but also the resulting script behaviors

---------

Co-authored-by: Cian Johnston <cian@coder.com>
Co-authored-by: DevCats <christofer@coder.com>
2025-10-17 08:14:56 -05:00
35C4n0r ca7bc42946 feat: update auth setup in codex (#472)
Closes #

## Description

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

## Type of Change

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

## Module Information

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

**Path:** `registry/coder-labs/modules/codex`  
**New version:** `v3.0.0`  
**Breaking change:** [X] Yes [ ] No

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: DevCats <christofer@coder.com>
2025-10-16 15:25:57 -05:00
35C4n0r a599302774 feat: amp upgrades for better ux (#390)
Closes #

## Description
- remove default node installation
- users can pass amp versions now
- move env variables to terraform variable (system prompt and ai prompt)

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

## Type of Change

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

## Module Information

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

**Path:** `registry/coder-labs/modules/sourcegraph-amp`  
**New version:** `v2.0.0`  
**Breaking change:** [x] Yes [ ] No

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: Atif Ali <me@matifali.dev>
2025-10-16 15:21:17 -05:00
DevCats ff09c415e8 feat: change tf test and validation to use paths-filter (#483)
## Description

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

## Type of Change

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

## Module Information

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

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

## Template Information

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

**Path:** `registry/[namespace]/templates/[template-name]`

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2025-10-16 14:21:03 -05:00
DevCats 90873e8009 ci: update CI workflow to run TypeScript tests with new script (#480) 2025-10-15 14:03:12 -05:00
DevCats 2168360195 fix: add folder to all Agent Modules (#481)
## Description

Make sure folder is passed to agentapi in all Agent modules.
<!-- Briefly describe what this PR does and why -->

## Type of Change

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

## Module Information

**Path:** `registry/coder-labs/modules/auggie`  
**New version:** `v0.2.1`  
**Breaking change:** [ ] Yes [X] No

**Path:** `registry/coder-labs/modules/cursor-cli`  
**New version:** `v0.2.1`  
**Breaking change:** [ ] Yes [X] No

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

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

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2025-10-15 12:20:08 -05:00
Riajul Islam da5a2ba6a8 feat(git-clone module): added post_clone_script. (#357)
Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: Atif Ali <atif@coder.com>
2025-10-15 12:53:17 +00:00
Anas 63cad25954 fix(amazon-q): pass workdir variable into agentapi folder variable (#478)
Co-authored-by: DevCats <christofer@coder.com>
2025-10-15 17:44:58 +05:00
Hulto cd759bd9a1 goose module: pass folder along to agentapi (#412)
Co-authored-by: DevCats <christofer@coder.com>
2025-10-15 17:44:18 +05:00
DevCats 54a7bb0001 docs: add usage examples for bedrock and vertex (#431)
Closes #

## Description

Adds Usage Examples for Vertex and Bedrock as described in the linked
documentation.

## Type of Change

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

## Module Information

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

**Path:** `registry/coder/modules/claude-code`  
**New version:** `v3.0.1`  
**Breaking change:** [ ] Yes [X] No

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2025-10-14 12:06:32 -05:00
Matt Hazinski 50f4d5388b fix(codex): pass folder variable to agentapi module (#477)
## Description

The folder variable was not being passed from the codex module to the
agentapi module, causing agentapi to use its default value of
`/home/coder` instead of the user-specified folder path.

This resulted in permission errors when the codex module tried to create
directories in `/home/coder` when users specified a different folder
like `/home/matt/foo`.

Fix by adding `folder = var.folder` to the agentapi module invocation.

## Type of Change

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

## Module Information

Path: registry/coder-labs/modules/codex
New version: v2.1.1
Breaking change: [ ] Yes [X] No

## Testing & Validation

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

## Related Issues
Fixes https://github.com/coder/registry/issues/476

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: DevelopmentCats <christofer@coder.com>
2025-10-14 16:49:52 +00:00
73 changed files with 4086 additions and 1027 deletions
+2
View File
@@ -5,6 +5,8 @@ Hashi = "Hashi"
HashiCorp = "HashiCorp"
mavrickrishi = "mavrickrishi" # Username
mavrick = "mavrick" # Username
inh = "inh" # Option in setpriv command
exportfs = "exportfs" # nfs related binary
[files]
extend-exclude = ["registry/coder/templates/aws-devcontainer/architecture.svg"] #False positive
+34 -2
View File
@@ -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,10 +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
run: ./scripts/terraform_test_all.sh
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
+210
View File
@@ -0,0 +1,210 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="300mm"
height="207mm"
viewBox="0 0 300 207"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<title
id="title1">copyparty_logo</title>
<defs
id="defs1">
<linearGradient
inkscape:collect="always"
id="linearGradient1">
<stop
style="stop-color:#ffcc55;stop-opacity:1"
offset="0"
id="stop1" />
<stop
style="stop-color:#ffcc00;stop-opacity:1"
offset="0.2"
id="stop2" />
<stop
style="stop-color:#ff8800;stop-opacity:1"
offset="1"
id="stop3" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1"
id="linearGradient2"
x1="15"
y1="15"
x2="15"
y2="143"
gradientUnits="userSpaceOnUse" />
</defs>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>copyparty_logo</dc:title>
<dc:source>github.com/9001/copyparty</dc:source>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="kassett">
<rect
style="fill:#333333"
id="rect1"
width="300"
height="205"
x="0"
y="0"
rx="12"
ry="12" />
<rect
style="fill:url(#linearGradient2)"
id="rect2"
width="270"
height="128"
x="15"
y="15"
rx="8"
ry="8" />
<rect
style="fill:#333333"
id="rect3"
width="172"
height="52"
x="64"
y="72"
rx="26"
ry="26" />
<circle
style="fill:#cccccc"
id="circle1"
cx="91"
cy="98"
r="18" />
<circle
style="fill:#cccccc"
id="circle2"
cx="209"
cy="98"
r="18" />
<path
style="fill:#737373;stroke-width:1px"
d="m 48,207 10,-39 c 1.79,-6.2 5.6,-7.8 12,-8 60,-1 100,-1 160,0 6.4,0.2 10,1.8 12,8 l 10,39 z"
id="path1"
sodipodi:nodetypes="ccccccc" />
</g>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="tekst"
style="display:none">
<text
xml:space="preserve"
style="font-size:38.8056px;line-height:1.25;font-family:Akbar;-inkscape-font-specification:Akbar;letter-spacing:3.70417px;word-spacing:0px;fill:#333333"
x="47.153069"
y="55.548954"
id="text1"><tspan
sodipodi:role="line"
id="tspan1"
x="47.153069"
y="55.548954"
style="-inkscape-font-specification:Akbar"
rotate="0 0">copyparty</tspan></text>
</g>
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="stensatt">
<path
d="m 63.5,50.9 q -0.85,0.93 -4.73,2.3 -3.6,1.3 -4.4,1.3 -3.3,0 -5.1,-2.1 -1.75,-2 -1.75,-5.36 0,-4.6 3.76,-7.64 3.3,-2.7 7.3,-2.7 0.4,0 0.93,0.74 0.54,0.7 0.54,1.16 0,2.06 -2.2,2.7 -1.36,0.4 -4.04,1.16 -2.2,1.16 -2.2,4.4 0,3.2 2.9,3.2 0.85,0 0.85,0 0.54,0 1.44,-0.16 1.1,-0.23 2.9,-0.74 1.8,-0.54 2.13,-0.54 0.4,0 1.75,0.6 z"
style="fill:#333333"
id="path11" />
<path
d="m 87.6,45 q 0,4.2 -3.7,6.95 -3.2,2.3 -6.87,2.3 -3.4,0 -6,-2.6 -2.5,-2.6 -2.5,-6 0,-3.6 3.14,-6.64 3.2,-3 6.8,-3 3.5,0 6.3,2.76 2.83,2.76 2.83,6.25 z m -3.4,0.16 q 0,-2.25 -1.75,-3.7 -1.7,-1.5 -4,-1.5 -0.1,0 -1.6,1.6 -1.44,1.55 -2.44,1.55 -0.6,0 -0.8,-0.3 -1.16,2.3 -1.16,3 0,2.25 2.13,3.4 1.6,0.9 3.6,0.9 2,0 3.76,-1.1 2.25,-1.4 2.25,-3.84 z"
style="fill:#333333"
id="path12" />
<path
d="m 112.8,46.8 q 0,2.8 -1.9,4.4 -1.8,1.5 -4.7,1.5 -0.7,0 -2.7,-0.4 -1.9,-0.4 -2.6,-0.4 -2.1,0 -2.1,2.64 0,0.85 0.23,2.6 0.2,1.75 0.2,2.6 0,1.9 -0.77,2.83 -1.44,0 -3,-0.85 -1.46,-9.5 -1.46,-12 0,-3.65 1.75,-8.1 2.37,-6.05 6.45,-6.05 3.7,0 7.3,4.1 3.3,3.84 3.3,7.14 z m -3.8,0.2 q -0.6,-2.2 -2.6,-4.4 -2.3,-2.5 -4.3,-2.5 -1.3,0 -2.33,2.2 -0.9,1.8 -0.9,3.26 0,0.47 0.38,1.24 0.43,0.8 0.85,0.8 1.1,0 3.2,0.3 2.1,0.3 3.2,0.3 0.3,0 1.3,-0.4 1,-0.47 1.3,-0.74 z"
style="fill:#333333"
id="path13" />
<path
d="m 133,40 q -2.1,4.1 -3.2,7 -0.1,0.3 -1.6,4.5 -0.4,1.36 -1,4.2 -0.5,2.83 -1,4.2 -1,2.83 -2.3,2.64 -1.4,-0.2 -1.6,-1.6 0,-0.2 0,-0.5 0,-0.16 0.3,-1.5 1,-5.04 1,-6.44 0,-0.54 -0.1,-0.74 -1.4,-2.44 -4.1,-7.4 -2.7,-4.97 -2.4,-7.7 1.5,-1.36 2.1,-1.36 0.4,0 1.1,0.6 0.6,0.6 0.7,1.1 0.8,6.2 4.9,11.1 1,-1.8 1.8,-4.04 0.5,-1.4 1.6,-4.15 1.9,-4.46 3.4,-4.46 0.2,0 0.4,0.1 0.9,0.3 1.3,2.8 z"
style="fill:#333333"
id="path14" />
<path
d="m 157.5,48 q 0,2.8 -1.9,4.4 -1.8,1.5 -4.7,1.5 -0.7,0 -2.7,-0.4 -1.9,-0.4 -2.6,-0.4 -2,0 -2,2.64 0,0.85 0.2,2.6 0.2,1.75 0.2,2.6 0,1.9 -0.7,2.83 -1.5,0 -3,-0.85 -1.5,-9.5 -1.5,-11.95 0,-3.65 1.8,-8.1 2.3,-6.05 6.4,-6.05 3.7,0 7.2,4.1 3.3,3.84 3.3,7.14 z m -3.8,0.2 q -0.6,-2.2 -2.6,-4.4 -2.3,-2.5 -4.3,-2.5 -1.3,0 -2.3,2.2 -0.9,1.8 -0.9,3.26 0,0.47 0.4,1.24 0.4,0.8 0.8,0.8 1.1,0 3.2,0.3 2.1,0.3 3.2,0.3 0.3,0 1.3,-0.4 1,-0.47 1.3,-0.74 z"
style="fill:#333333"
id="path15" />
<path
d="m 182,53.3 q 0,0.9 -0.6,1.5 -0.6,0.6 -1.4,0.6 -1.6,0 -3,-0.9 -1.4,-0.93 -2.1,-2.3 -0.7,-0.1 -1.5,0.85 -0.9,1.16 -1.1,1.24 -1.2,0.54 -3.9,0.54 -2.2,0 -3.9,-2.44 -1.5,-2.13 -1.5,-4 0,-3.4 3.4,-6.4 3.2,-2.9 6.7,-2.9 0.9,0 1.7,0.6 0.8,0.6 0.8,1.44 0,0.54 -0.4,1.1 2.4,0.9 2.4,2.83 0,0.35 -0.1,1.05 -0.1,0.7 -0.1,1.05 0,0.4 0.1,0.6 0.5,1.3 2.5,3.4 1.9,1.9 1.9,2.2 z m -8.1,-10.1 q -0.4,0 -1.1,-0.1 -0.8,-0.16 -1.1,-0.16 -1.3,0 -3.2,1.94 -1.9,1.94 -1.9,3.3 0,0.8 0.7,1.8 0.9,1.3 2.2,1.3 2.6,0 3.5,-2.9 0.5,-2.6 1,-5.16 z"
style="fill:#333333"
id="path16" />
<path
d="m 203.8,42.4 q -0.4,0.4 -1.5,0.4 -0.9,0 -2.5,-0.3 -1.7,-0.3 -2.5,-0.3 -4.7,0 -5.5,6.9 -0.3,3.1 -0.4,3.3 -0.4,1 -1.7,2.3 h -1.1 q -0.7,-1.2 -1.3,-4.1 -0.6,-2.76 -0.6,-4.27 0,-1.16 0.1,-1.5 0.2,-0.54 1,-0.54 0.3,0 0.6,0.3 0.4,0.3 0.4,0.3 1.9,-3.53 3.1,-4.6 1.8,-1.7 5.1,-1.7 1.4,0 3.6,0.9 2.8,1.16 3.3,2.8 z"
style="fill:#333333"
id="path17" />
<path
d="m 229.5,37.16 q 0.3,0.8 0.3,1.44 0,1.86 -2.4,1.86 -1,0 -3.5,-0.5 -2.5,-0.54 -3.4,-0.54 -1.3,0 -1.5,0.1 -0.4,0.2 -0.4,1.2 0,2.2 0.6,6.9 0.7,5.86 1.6,6.13 -0.4,0.35 -0.4,1.1 -1.2,0.7 -2.6,0.7 -1.4,0 -2,-3.9 -0.2,-1.36 -0.5,-7.76 -0.2,-4.6 -0.8,-5.5 -0.3,-0.47 -4.3,-0.35 -1,0 -1.6,0.1 -0.5,0 -0.3,0 -0.8,0 -1.2,-0.7 -0.5,-1.3 -0.5,-1.4 0,-1.44 4.1,-2 1.6,-0.16 4.7,-0.5 0,-0.85 -0.1,-2.56 0,-1.75 0,-2.6 0,-4.35 2.1,-4.35 0.5,0 1.1,0.6 0.6,0.6 0.6,1.1 v 7.9 q 1.1,1.2 5,1.7 3.9,0.5 5.3,1.86 z"
style="fill:#333333"
id="path18" />
<path
d="m 251.2,40.2 q -2,4.1 -3.2,7 -0.1,0.3 -1.5,4.5 -0.5,1.36 -1,4.2 -0.5,2.83 -1,4.2 -1,2.83 -2.4,2.64 -1.4,-0.2 -1.5,-1.6 -0.1,-0.2 -0.1,-0.5 0,-0.16 0.3,-1.5 1.1,-5.04 1.1,-6.44 0,-0.54 -0.1,-0.74 -1.4,-2.44 -4.1,-7.4 -2.7,-4.97 -2.4,-7.7 1.4,-1.36 2.1,-1.36 0.4,0 1,0.6 0.6,0.6 0.7,1.1 0.9,6.2 4.9,11.1 1,-1.8 1.9,-4.04 0.5,-1.4 1.6,-4.15 1.8,-4.46 3.4,-4.46 0.2,0 0.4,0.1 0.8,0.3 1.2,2.8 z"
style="fill:#333333"
id="path19" />
</g>
<g
inkscape:groupmode="layer"
id="layer5"
inkscape:label="tagger">
<g
id="g1">
<path
id="path4"
style="fill:#333333"
d="m 111.4,83.335 -9.526,5.5 2.5,4.33 9.526,-5.5 z m -33.775,19.5 -9.526,5.5 2.5,4.33 9.526,-5.5 z"
sodipodi:nodetypes="cccccccccc" />
<path
id="path5"
style="fill:#333333"
d="M 88.5,73 V 84 h 5 V 73 Z m 0,39 v 11 h 5 V 112 Z"
sodipodi:nodetypes="cccccccccc" />
<path
id="path6"
style="fill:#333333"
d="m 68.1,87.665 9.526,5.5 2.5,-4.33 -9.526,-5.5 z m 33.775,19.5 9.527,5.5 2.5,-4.33 -9.527,-5.5 z"
sodipodi:nodetypes="cccccccccc" />
</g>
<g
id="g2"
transform="rotate(30,150,318.19)">
<path
id="path7"
style="fill:#333333"
d="m 111.4,83.335 -9.526,5.5 2.5,4.33 9.526,-5.5 z m -33.775,19.5 -9.526,5.5 2.5,4.33 9.526,-5.5 z"
sodipodi:nodetypes="cccccccccc" />
<path
id="path8"
style="fill:#333333"
d="M 88.5,73 V 84 h 5 V 73 Z m 0,39 v 11 h 5 V 112 Z"
sodipodi:nodetypes="cccccccccc" />
<path
id="path9"
style="fill:#333333"
d="m 68.1,87.665 9.526,5.5 2.5,-4.33 -9.526,-5.5 z m 33.775,19.5 9.527,5.5 2.5,-4.33 -9.527,-5.5 z"
sodipodi:nodetypes="cccccccccc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

+1
View File
@@ -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

+2 -1
View File
@@ -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": {
@@ -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);
});
+134
View File
@@ -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
}
+3 -3
View File
@@ -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.2.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.2.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.2.0"
version = "0.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
@@ -174,6 +174,7 @@ 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" {
@@ -181,6 +182,7 @@ module "agentapi" {
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
+10 -9
View File
@@ -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.1.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.1.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.1.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.1.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 () => {
+43 -6
View File
@@ -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."
@@ -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")
@@ -131,15 +162,18 @@ module "agentapi" {
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"
@@ -13,7 +13,7 @@ Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-c
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.2.1"
version = "0.2.2"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
}
@@ -51,7 +51,7 @@ data "coder_parameter" "ai_prompt" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.2.1"
version = "0.2.2"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
@@ -71,12 +71,12 @@ Customize tool permissions, MCP servers, and Copilot settings:
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.2.1"
version = "0.2.2"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
# Version pinning (defaults to "0.0.334", use "latest" for newest version)
copilot_version = "latest"
# Version pinning (defaults to "latest", use specific version if desired)
copilot_version = "0.0.334"
# Tool permissions
allow_tools = ["shell(git)", "shell(npm)", "write"]
@@ -142,7 +142,7 @@ variable "github_token" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.2.1"
version = "0.2.2"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
github_token = var.github_token
@@ -156,7 +156,7 @@ Run Copilot as a command-line tool without task reporting or web interface. This
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.2.1"
version = "0.2.2"
agent_id = coder_agent.example.id
workdir = "/home/coder"
report_tasks = false
+1 -1
View File
@@ -104,7 +104,7 @@ variable "agentapi_version" {
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 = "0.0.334"
default = "latest"
}
variable "report_tasks" {
@@ -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.2.0"
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.2.0"
version = "0.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
@@ -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
@@ -134,6 +135,7 @@ module "agentapi" {
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
+4 -4
View File
@@ -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.1.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.1.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.1.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.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
@@ -172,6 +172,7 @@ 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" {
@@ -179,6 +180,7 @@ module "agentapi" {
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
@@ -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.1.0"
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.1.0"
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" {
@@ -72,18 +53,84 @@ variable "agentapi_version" {
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,7 +198,8 @@ 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" {
@@ -154,13 +207,15 @@ module "agentapi" {
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
+1 -1
View File
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
```tf
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.2.0"
version = "2.0.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
+3 -5
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.7"
version = ">= 2.12"
}
}
}
@@ -239,8 +239,6 @@ resource "coder_app" "agentapi_cli" {
group = var.cli_app_group
}
resource "coder_ai_task" "agentapi" {
sidebar_app {
id = coder_app.agentapi_web.id
}
output "task_app_id" {
value = coder_app.agentapi_web.id
}
+54 -228
View File
@@ -8,76 +8,58 @@ tags: [agent, ai, aider]
# Aider
Run [Aider](https://aider.chat) AI pair programming in your workspace. This module installs Aider and provides a persistent session using screen or tmux.
Run [Aider](https://aider.chat) AI pair programming in your workspace. This module installs Aider with AgentAPI for seamless Coder Tasks Support.
```tf
module "aider" {
source = "registry.coder.com/coder/aider/coder"
version = "1.1.2"
agent_id = coder_agent.example.id
}
```
## Features
- **Interactive Parameter Selection**: Choose your AI provider, model, and configuration options when creating the workspace
- **Multiple AI Providers**: Supports Anthropic (Claude), OpenAI, DeepSeek, GROQ, and OpenRouter
- **Persistent Sessions**: Uses screen (default) or tmux to keep Aider running in the background
- **Optional Dependencies**: Install Playwright for web page scraping and PortAudio for voice coding
- **Project Integration**: Works with any project directory, including Git repositories
- **Browser UI**: Use Aider in your browser with a modern web interface instead of the terminal
- **Non-Interactive Mode**: Automatically processes tasks when provided via the `task_prompt` variable
## Module Parameters
> [!NOTE]
> The `use_screen` and `use_tmux` parameters cannot both be enabled at the same time. By default, `use_screen` is set to `true` and `use_tmux` is set to `false`.
## Usage Examples
### Basic setup with API key
```tf
variable "anthropic_api_key" {
variable "api_key" {
type = string
description = "Anthropic API key"
description = "API key"
sensitive = true
}
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.1.2"
agent_id = coder_agent.example.id
ai_api_key = var.anthropic_api_key
}
```
This basic setup will:
- Install Aider in the workspace
- Create a persistent screen session named "aider"
- Configure Aider to use Anthropic Claude 3.7 Sonnet model
- Enable task reporting (configures Aider to report tasks to Coder MCP)
### Using OpenAI with tmux
```tf
variable "openai_api_key" {
type = string
description = "OpenAI API key"
sensitive = true
}
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.1.2"
version = "2.0.0"
agent_id = coder_agent.example.id
use_tmux = true
ai_provider = "openai"
ai_model = "4o" # Uses Aider's built-in alias for gpt-4o
ai_api_key = var.openai_api_key
api_key = var.api_key
ai_provider = "google"
model = "gemini"
}
```
## Prerequisites
- pipx is automatically installed if not already available
## Usage Example
```tf
data "coder_parameter" "ai_prompt" {
name = "AI Prompt"
description = "Write an initial prompt for Aider to work on."
type = "string"
default = ""
mutable = true
}
variable "gemini_api_key" {
type = string
description = "Gemini API key"
sensitive = true
}
module "aider" {
source = "registry.coder.com/coder/aider/coder"
version = "2.0.0"
agent_id = coder_agent.example.id
api_key = var.gemini_api_key
install_aider = true
workdir = "/home/coder"
ai_provider = "google"
model = "gemini"
install_agentapi = true
ai_prompt = data.coder_parameter.ai_prompt.value
system_prompt = "..."
}
```
@@ -93,174 +75,16 @@ variable "custom_api_key" {
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.1.2"
version = "2.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
ai_provider = "custom"
custom_env_var_name = "MY_CUSTOM_API_KEY"
ai_model = "custom-model"
ai_api_key = var.custom_api_key
model = "custom-model"
api_key = var.custom_api_key
}
```
### Adding Custom Extensions (Experimental)
You can extend Aider's capabilities by adding custom extensions:
```tf
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.1.2"
agent_id = coder_agent.example.id
ai_api_key = var.anthropic_api_key
experiment_pre_install_script = <<-EOT
pip install some-custom-dependency
EOT
experiment_additional_extensions = <<-EOT
custom-extension:
args: []
cmd: custom-extension-command
description: A custom extension for Aider
enabled: true
envs: {}
name: custom-extension
timeout: 300
type: stdio
EOT
}
```
Note: The indentation in the heredoc is preserved, so you can write the YAML naturally.
## Task Reporting (Experimental)
> This functionality is in early access as of Coder v2.21 and is still evolving.
> For now, we recommend testing it in a demo or staging environment,
> rather than deploying to production
>
> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents)
>
> Join our [Discord channel](https://discord.gg/coder) or
> [contact us](https://coder.com/contact) to get help or share feedback.
Your workspace must have either `screen` or `tmux` installed to use this.
Task reporting is **enabled by default** in this module, allowing you to:
- Send an initial prompt to Aider during workspace creation
- Monitor task progress in the Coder UI
- Use the `coder_parameter` resource to collect prompts from users
### Setting up Task Reporting
To use task reporting effectively:
1. Add the Coder Login module to your template
2. Configure the necessary variables to pass the task prompt
3. Optionally add a coder_parameter to collect prompts from users
Here's a complete example:
```tf
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/coder-login/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
}
variable "anthropic_api_key" {
type = string
description = "Anthropic API key"
sensitive = true
}
data "coder_parameter" "ai_prompt" {
type = "string"
name = "AI Prompt"
default = ""
description = "Write a prompt for Aider"
mutable = true
ephemeral = true
}
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.1.2"
agent_id = coder_agent.example.id
ai_api_key = var.anthropic_api_key
task_prompt = data.coder_parameter.ai_prompt.value
# Optionally customize the system prompt
system_prompt = <<-EOT
You are a helpful Coding assistant. Aim to autonomously investigate
and solve issues the user gives you and test your work, whenever possible.
Avoid shortcuts like mocking tests. When you get stuck, you can ask the user
but opt for autonomy.
YOU MUST REPORT ALL TASKS TO CODER.
When reporting tasks, you MUST follow these EXACT instructions:
- IMMEDIATELY report status after receiving ANY user message.
- Be granular. If you are investigating with multiple steps, report each step to coder.
Task state MUST be one of the following:
- Use "state": "working" when actively processing WITHOUT needing additional user input.
- Use "state": "complete" only when finished with a task.
- Use "state": "failure" when you need ANY user input, lack sufficient details, or encounter blockers.
Task summaries MUST:
- Include specifics about what you're doing.
- Include clear and actionable steps for the user.
- Be less than 160 characters in length.
EOT
}
```
When a task prompt is provided via the `task_prompt` variable, the module automatically:
1. Combines the system prompt with the task prompt into a single message in the format:
```
SYSTEM PROMPT:
[system_prompt content]
This is your current task: [task_prompt]
```
2. Executes the task during workspace creation using the `--message` and `--yes-always` flags
3. Logs task output to `$HOME/.aider.log` for reference
If you want to disable task reporting, set `experiment_report_tasks = false` in your module configuration.
## Using Aider in Your Workspace
After the workspace starts, Aider will be installed and configured according to your parameters. A persistent session will automatically be started during workspace creation.
### Session Options
You can run Aider in three different ways:
1. **Direct Mode**: Aider starts directly in the specified folder when you click the app button
- Simple setup without persistent context
- Suitable for quick coding sessions
2. **Screen Mode** (Default): Run Aider in a screen session that persists across connections
- Session name: "aider" (or configured via `session_name`)
3. **Tmux Mode**: Run Aider in a tmux session instead of screen
- Set `use_tmux = true` to enable
- Session name: "aider" (or configured via `session_name`)
- Configures tmux with mouse support for shared sessions
Persistent sessions (screen/tmux) allow you to:
- Disconnect and reconnect without losing context
- Run Aider in the background while doing other work
- Switch between terminal and browser interfaces
### Available AI Providers and Models
Aider supports various providers and models, and this module integrates directly with Aider's built-in model aliases:
@@ -280,10 +104,12 @@ For a complete and up-to-date list of supported aliases and models, please refer
## Troubleshooting
If you encounter issues:
- If `aider` is not found, ensure `install_aider = true` and your API key is valid
- Logs are written under `/home/coder/.aider-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
1. **Screen/Tmux issues**: If you can't reconnect to your session, check if the session exists with `screen -list` or `tmux list-sessions`
2. **API key issues**: Ensure you've entered the correct API key for your selected provider
3. **Browser mode issues**: If the browser interface doesn't open, check that you're accessing it from a machine that can reach your Coder workspace
## References
For more information on using Aider, see the [Aider documentation](https://aider.chat/docs/).
- [Aider Documentation](https://aider.chat/docs)
- [AgentAPI Documentation](https://github.com/coder/agentapi)
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
+114 -83
View File
@@ -1,107 +1,138 @@
import { describe, expect, it } from "bun:test";
import {
findResourceInstance,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
test,
afterEach,
describe,
setDefaultTimeout,
beforeAll,
expect,
} from "bun:test";
import { execContainer, readFileContainer, runTerraformInit } from "~test";
import {
loadTestFile,
writeExecutable,
setup as setupUtil,
execModuleScript,
expectAgentAPIStarted,
} from "../../../coder/modules/agentapi/test-util";
describe("aider", async () => {
await runTerraformInit(import.meta.dir);
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
cleanupFunctions.push(cleanup);
};
afterEach(async () => {
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
cleanupFunctions = [];
for (const cleanup of cleanupFnsCopy) {
try {
await cleanup();
} catch (error) {
console.error("Error during cleanup:", error);
}
}
});
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
interface SetupProps {
skipAgentAPIMock?: boolean;
skipAiderMock?: boolean;
moduleVariables?: Record<string, string>;
agentapiMockScript?: string;
}
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
const projectDir = "/home/coder/project";
const { id } = await setupUtil({
moduleDir: import.meta.dir,
moduleVariables: {
install_aider: props?.skipAiderMock ? "true" : "false",
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
aider_model: "test-model",
...props?.moduleVariables,
},
registerCleanup,
projectDir,
skipAgentAPIMock: props?.skipAgentAPIMock,
agentapiMockScript: props?.agentapiMockScript,
});
it("configures task prompt correctly", async () => {
const testPrompt = "Add a hello world function";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
task_prompt: testPrompt,
// Place the Aider mock CLI binary inside the container
if (!props?.skipAiderMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/aider",
content: await loadTestFile(`${import.meta.dir}`, "aider-mock.sh"),
});
}
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain(
`This is your current task: ${testPrompt}`,
);
expect(instance.script).toContain("aider --architect --yes-always");
return { id };
};
setDefaultTimeout(60 * 1000);
describe("Aider", async () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
it("handles custom system prompt", async () => {
const customPrompt = "Report all tasks with state: working";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
system_prompt: customPrompt,
test("happy-path", async () => {
const { id } = await setup({
moduleVariables: {
model: "gemini",
},
});
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain(customPrompt);
await execModuleScript(id);
await expectAgentAPIStarted(id);
});
it("handles pre and post install scripts", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
experiment_pre_install_script: "echo 'Pre-install script executed'",
experiment_post_install_script: "echo 'Post-install script executed'",
test("api-key", async () => {
const apiKey = "test-api-key-123";
const { id } = await setup({
moduleVariables: {
api_key: apiKey,
model: "gemini",
},
});
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain("Running pre-install script");
expect(instance.script).toContain("Running post-install script");
expect(instance.script).toContain("base64 -d > /tmp/pre_install.sh");
expect(instance.script).toContain("base64 -d > /tmp/post_install.sh");
await execModuleScript(id);
const resp = await readFileContainer(
id,
"/home/coder/.aider-module/agentapi-start.log",
);
expect(resp).toContain("API key provided!");
});
it("validates that use_screen and use_tmux cannot both be true", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
use_screen: true,
use_tmux: true,
test("custom-folder", async () => {
const workdir = "/tmp/aider-test";
const { id } = await setup({
moduleVariables: {
workdir,
model: "gemini",
},
});
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain(
"Error: Both use_screen and use_tmux cannot be enabled at the same time",
await execModuleScript(id);
const resp = await readFileContainer(
id,
"/home/coder/.aider-module/install.log",
);
expect(instance.script).toContain("exit 1");
expect(resp).toContain(workdir);
});
it("configures Aider with known provider and model", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
ai_provider: "anthropic",
ai_model: "sonnet",
ai_api_key: "test-anthropic-key",
test("pre-post-install-scripts", async () => {
const { id } = await setup({
moduleVariables: {
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
post_install_script: "#!/bin/bash\necho 'post-install-script'",
model: "gemini",
},
});
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain(
'export ANTHROPIC_API_KEY=\\"test-anthropic-key\\"',
await execModuleScript(id);
const preLog = await readFileContainer(
id,
"/home/coder/.aider-module/pre_install.log",
);
expect(instance.script).toContain("--model sonnet");
expect(instance.script).toContain(
"Starting Aider using anthropic provider and model: sonnet",
);
});
it("handles custom provider with custom env var and API key", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
ai_provider: "custom",
custom_env_var_name: "MY_CUSTOM_API_KEY",
ai_model: "custom-model",
ai_api_key: "test-custom-key",
});
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain(
'export MY_CUSTOM_API_KEY=\\"test-custom-key\\"',
);
expect(instance.script).toContain("--model custom-model");
expect(instance.script).toContain(
"Starting Aider using custom provider and model: custom-model",
expect(preLog).toContain("pre-install-script");
const postLog = await readFileContainer(
id,
"/home/coder/.aider-module/post_install.log",
);
expect(postLog).toContain("post-install-script");
});
});
+165 -394
View File
@@ -36,87 +36,84 @@ variable "icon" {
default = "/icon/aider.svg"
}
variable "folder" {
variable "workdir" {
type = string
description = "The folder to run Aider in."
default = "/home/coder"
}
variable "report_tasks" {
type = bool
description = "Whether to enable task reporting to Coder UI via AgentAPI"
default = false
}
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 Aider"
default = false
}
variable "web_app_display_name" {
type = string
description = "Display name for the web app"
default = "Aider"
}
variable "cli_app_display_name" {
type = string
description = "Display name for the CLI app"
default = "Aider CLI"
}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing Aider."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing Aider."
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 Aider."
default = ""
}
# ---------------------------------------------
variable "install_aider" {
type = bool
description = "Whether to install Aider."
default = true
}
variable "aider_version" {
type = string
description = "The version of Aider to install."
default = "latest"
}
variable "use_screen" {
type = bool
description = "Whether to use screen for running Aider in the background"
default = true
}
variable "use_tmux" {
type = bool
description = "Whether to use tmux instead of screen for running Aider in the background"
default = false
}
variable "session_name" {
type = string
description = "Name for the persistent session (screen or tmux)"
default = "aider"
}
variable "experiment_report_tasks" {
type = bool
description = "Whether to enable task reporting."
default = true
}
variable "system_prompt" {
type = string
description = "System prompt for instructing Aider on task reporting and behavior"
default = <<-EOT
You are a helpful Coding assistant. Aim to autonomously investigate
and solve issues the user gives you and test your work, whenever possible.
Avoid shortcuts like mocking tests. When you get stuck, you can ask the user
but opt for autonomy.
YOU MUST REPORT ALL TASKS TO CODER.
When reporting tasks, you MUST follow these EXACT instructions:
- IMMEDIATELY report status after receiving ANY user message.
- Be granular. If you are investigating with multiple steps, report each step to coder.
Task state MUST be one of the following:
- Use "state": "working" when actively processing WITHOUT needing additional user input.
- Use "state": "complete" only when finished with a task.
- Use "state": "failure" when you need ANY user input, lack sufficient details, or encounter blockers.
Task summaries MUST:
- Include specifics about what you're doing.
- Include clear and actionable steps for the user.
- Be less than 160 characters in length.
EOT
}
variable "task_prompt" {
type = string
description = "Task prompt to use with Aider"
default = ""
}
variable "experiment_pre_install_script" {
type = string
description = "Custom script to run before installing Aider."
default = null
}
variable "experiment_post_install_script" {
type = string
description = "Custom script to run after installing Aider."
default = null
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 "experiment_additional_extensions" {
@@ -128,20 +125,19 @@ variable "experiment_additional_extensions" {
variable "ai_provider" {
type = string
description = "AI provider to use with Aider (openai, anthropic, azure, google, etc.)"
default = "anthropic"
default = "google"
validation {
condition = contains(["openai", "anthropic", "azure", "google", "cohere", "mistral", "ollama", "custom"], var.ai_provider)
error_message = "ai_provider must be one of: openai, anthropic, azure, google, cohere, mistral, ollama, custom"
error_message = "provider must be one of: openai, anthropic, azure, google, cohere, mistral, ollama, custom"
}
}
variable "ai_model" {
variable "model" {
type = string
description = "AI model to use with Aider. Can use Aider's built-in aliases like '4o' (gpt-4o), 'sonnet' (claude-3-7-sonnet), 'opus' (claude-3-opus), etc."
default = "sonnet"
}
variable "ai_api_key" {
variable "api_key" {
type = string
description = "API key for the selected AI provider. This will be set as the appropriate environment variable based on the provider."
default = ""
@@ -154,55 +150,66 @@ variable "custom_env_var_name" {
default = ""
}
variable "base_aider_config" {
type = string
description = <<-EOT
Base Aider configuration in yaml format. Will be stored in .aider.conf.yml file.
options include:
read:
- CONVENTIONS.md
- anotherfile.txt
- thirdfile.py
model: xxx
##Specify the OpenAI API key
openai-api-key: xxx
## (deprecated, use --set-env OPENAI_API_TYPE=<value>)
openai-api-type: xxx
## (deprecated, use --set-env OPENAI_API_VERSION=<value>)
openai-api-version: xxx
## (deprecated, use --set-env OPENAI_API_DEPLOYMENT_ID=<value>)
openai-api-deployment-id: xxx
## Set an environment variable (to control API settings, can be used multiple times)
set-env: xxx
## Specify multiple values like this:
set-env:
- xxx
- yyy
- zzz
Reference : https://aider.chat/docs/config/aider_conf.html
EOT
default = null
}
locals {
base_extensions = <<-EOT
coder:
args:
- exp
- mcp
- server
cmd: coder
description: Report ALL tasks and statuses (in progress, done, failed) you are working on.
enabled: true
envs:
CODER_MCP_APP_STATUS_SLUG: aider
name: Coder
timeout: 3000
type: stdio
developer:
display_name: Developer
enabled: true
name: developer
timeout: 300
type: builtin
EOT
app_slug = "aider"
base_aider_config = var.base_aider_config != null ? "${replace(trimspace(var.base_aider_config), "\n", "\n ")}" : ""
task_reporting_prompt = <<-EOT
formatted_base = " ${replace(trimspace(local.base_extensions), "\n", "\n ")}"
additional_extensions = var.experiment_additional_extensions != null ? "\n ${replace(trimspace(var.experiment_additional_extensions), "\n", "\n ")}" : ""
-- 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
combined_extensions = <<-EOT
extensions:
${local.formatted_base}${local.additional_extensions}
EOT
encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : ""
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
# Combine system prompt and task prompt for aider
combined_prompt = trimspace(<<-EOT
SYSTEM PROMPT:
${var.system_prompt}
This is your current task: ${var.task_prompt}
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>"
# Map providers to their environment variable names
provider_env_vars = {
openai = "OPENAI_API_KEY"
anthropic = "ANTHROPIC_API_KEY"
azure = "AZURE_OPENAI_API_KEY"
google = "GOOGLE_API_KEY"
google = "GEMINI_API_KEY"
cohere = "COHERE_API_KEY"
mistral = "MISTRAL_API_KEY"
ollama = "OLLAMA_HOST"
@@ -214,296 +221,60 @@ EOT
# Model flag for aider command
model_flag = var.ai_provider == "ollama" ? "--ollama-model" : "--model"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".aider-module"
}
# Install and Initialize Aider
resource "coder_script" "aider" {
agent_id = var.agent_id
display_name = "Aider"
icon = var.icon
script = <<-EOT
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.2.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
web_app_icon = var.icon
web_app_display_name = 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
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 -e
set -o errexit
set -o pipefail
command_exists() {
command -v "$1" >/dev/null 2>&1
}
echo "Setting up Aider AI pair programming..."
if [ "${var.use_screen}" = "true" ] && [ "${var.use_tmux}" = "true" ]; then
echo "Error: Both use_screen and use_tmux cannot be enabled at the same time."
exit 1
fi
mkdir -p "${var.folder}"
if [ "$(uname)" = "Linux" ]; then
echo "Checking dependencies for Linux..."
if [ "${var.use_tmux}" = "true" ]; then
if ! command_exists tmux; then
echo "Installing tmux for persistent sessions..."
if command -v apt-get >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y -qq tmux
else
apt-get update -qq || echo "Warning: Cannot update package lists without sudo privileges"
apt-get install -y -qq tmux || echo "Warning: Cannot install tmux without sudo privileges"
fi
elif command -v dnf >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo dnf install -y -q tmux
else
dnf install -y -q tmux || echo "Warning: Cannot install tmux without sudo privileges"
fi
else
echo "Warning: Unable to install tmux on this system. Neither apt-get nor dnf found."
fi
else
echo "tmux is already installed, skipping installation."
fi
elif [ "${var.use_screen}" = "true" ]; then
if ! command_exists screen; then
echo "Installing screen for persistent sessions..."
if command -v apt-get >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y -qq screen
else
apt-get update -qq || echo "Warning: Cannot update package lists without sudo privileges"
apt-get install -y -qq screen || echo "Warning: Cannot install screen without sudo privileges"
fi
elif command -v dnf >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo dnf install -y -q screen
else
dnf install -y -q screen || echo "Warning: Cannot install screen without sudo privileges"
fi
else
echo "Warning: Unable to install screen on this system. Neither apt-get nor dnf found."
fi
else
echo "screen is already installed, skipping installation."
fi
fi
else
echo "This module currently only supports Linux workspaces."
exit 1
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_aider}" = "true" ]; then
echo "Installing Aider..."
if ! command_exists python3 || ! command_exists pip3; then
echo "Installing Python dependencies required for Aider..."
if command -v apt-get >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y -qq python3-pip python3-venv
else
apt-get update -qq || echo "Warning: Cannot update package lists without sudo privileges"
apt-get install -y -qq python3-pip python3-venv || echo "Warning: Cannot install Python packages without sudo privileges"
fi
elif command -v dnf >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo dnf install -y -q python3-pip python3-virtualenv
else
dnf install -y -q python3-pip python3-virtualenv || echo "Warning: Cannot install Python packages without sudo privileges"
fi
else
echo "Warning: Unable to install Python on this system. Neither apt-get nor dnf found."
fi
else
echo "Python is already installed, skipping installation."
fi
if ! command_exists aider; then
curl -LsSf https://aider.chat/install.sh | sh
fi
if [ -f "$HOME/.bashrc" ]; then
if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.bashrc"; then
echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.bashrc"
fi
fi
if [ -f "$HOME/.zshrc" ]; then
if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.zshrc"; then
echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.zshrc"
fi
fi
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 [ "${var.experiment_report_tasks}" = "true" ]; then
echo "Configuring Aider to report tasks via Coder MCP..."
mkdir -p "$HOME/.config/aider"
cat > "$HOME/.config/aider/config.yml" << EOL
${trimspace(local.combined_extensions)}
EOL
echo "Added Coder MCP extension to Aider config.yml"
fi
echo "Starting persistent Aider session..."
touch "$HOME/.aider.log"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
export PATH="$HOME/bin:$PATH"
if [ "${var.use_tmux}" = "true" ]; then
if [ -n "${var.task_prompt}" ]; then
echo "Running Aider with message in tmux session..."
# Configure tmux for shared sessions
if [ ! -f "$HOME/.tmux.conf" ]; then
echo "Creating ~/.tmux.conf with shared session settings..."
echo "set -g mouse on" > "$HOME/.tmux.conf"
fi
if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then
echo "Adding 'set -g mouse on' to ~/.tmux.conf..."
echo "set -g mouse on" >> "$HOME/.tmux.conf"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
tmux new-session -d -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\""
echo "Aider task started in tmux session '${var.session_name}'. Check the UI for progress."
else
# Configure tmux for shared sessions
if [ ! -f "$HOME/.tmux.conf" ]; then
echo "Creating ~/.tmux.conf with shared session settings..."
echo "set -g mouse on" > "$HOME/.tmux.conf"
fi
if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then
echo "Adding 'set -g mouse on' to ~/.tmux.conf..."
echo "set -g mouse on" >> "$HOME/.tmux.conf"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
tmux new-session -d -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${var.system_prompt}\""
echo "Tmux session '${var.session_name}' started. Access it by clicking the Aider button."
fi
else
if [ -n "${var.task_prompt}" ]; then
echo "Running Aider with message in screen session..."
if [ ! -f "$HOME/.screenrc" ]; then
echo "Creating ~/.screenrc and adding multiuser settings..."
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..."
echo "multiuser on" >> "$HOME/.screenrc"
fi
if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then
echo "Adding 'acladd $(whoami)' to ~/.screenrc..."
echo "acladd $(whoami)" >> "$HOME/.screenrc"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
screen -U -dmS ${var.session_name} bash -c "
cd ${var.folder}
export PATH=\"$HOME/bin:$HOME/.local/bin:$PATH\"
export ${local.env_var_name}=\"${var.ai_api_key}\"
aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"
/bin/bash
"
echo "Aider task started in screen session '${var.session_name}'. Check the UI for progress."
else
if [ ! -f "$HOME/.screenrc" ]; then
echo "Creating ~/.screenrc and adding multiuser settings..."
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..."
echo "multiuser on" >> "$HOME/.screenrc"
fi
if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then
echo "Adding 'acladd $(whoami)' to ~/.screenrc..."
echo "acladd $(whoami)" >> "$HOME/.screenrc"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
screen -U -dmS ${var.session_name} bash -c "
cd ${var.folder}
export PATH=\"$HOME/bin:$HOME/.local/bin:$PATH\"
export ${local.env_var_name}=\"${var.ai_api_key}\"
aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"
/bin/bash
"
echo "Screen session '${var.session_name}' started. Access it by clicking the Aider button."
fi
fi
echo "Aider setup complete!"
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
chmod +x /tmp/start.sh
ARG_WORKDIR='${var.workdir}' \
ARG_API_KEY='${base64encode(var.api_key)}' \
ARG_MODEL='${var.model}' \
ARG_PROVIDER='${var.ai_provider}' \
ARG_ENV_API_NAME_HOLDER='${local.env_var_name}' \
ARG_SYSTEM_PROMPT='${base64encode(local.final_system_prompt)}' \
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
/tmp/start.sh
EOT
run_on_start = true
}
# Aider CLI app
resource "coder_app" "aider_cli" {
agent_id = var.agent_id
slug = "aider"
display_name = "Aider"
icon = var.icon
command = <<-EOT
install_script = <<-EOT
#!/bin/bash
set -e
set -o errexit
set -o pipefail
export PATH="$HOME/bin:$HOME/.local/bin:$PATH"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
if [ "${var.use_tmux}" = "true" ]; then
if tmux has-session -t ${var.session_name} 2>/dev/null; then
echo "Attaching to existing Aider tmux session..."
tmux attach-session -t ${var.session_name}
else
echo "Starting new Aider tmux session..."
tmux new-session -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; aider ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"; exec bash"
fi
elif [ "${var.use_screen}" = "true" ]; then
if ! screen -list | grep -q "${var.session_name}"; then
echo "Error: No existing Aider session found. Please wait for the script to start it."
exit 1
fi
screen -xRR ${var.session_name}
else
cd "${var.folder}"
echo "Starting Aider directly..."
export ${local.env_var_name}="${var.ai_api_key}"
aider ${local.model_flag} ${var.ai_model} --message "${local.combined_prompt}"
fi
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
chmod +x /tmp/install.sh
ARG_WORKDIR='${var.workdir}' \
ARG_INSTALL_AIDER='${var.install_aider}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_AIDER_CONFIG="$(echo -n '${base64encode(local.base_aider_config)}' | base64 -d)" \
/tmp/install.sh
EOT
order = var.order
group = var.group
}
@@ -0,0 +1,149 @@
run "test_aider_basic" {
command = plan
variables {
agent_id = "test-agent-123"
workdir = "/home/coder"
model = "gemini"
}
assert {
condition = var.workdir == "/home/coder"
error_message = "Workdir variable should default to /home/coder"
}
assert {
condition = var.agent_id == "test-agent-123"
error_message = "Agent ID variable should be set correctly"
}
assert {
condition = var.install_aider == true
error_message = "install_aider should default to true"
}
assert {
condition = var.install_agentapi == true
error_message = "install_agentapi should default to true"
}
assert {
condition = var.report_tasks == false
error_message = "report_tasks should default to false"
}
}
run "test_with_api_key" {
command = plan
variables {
agent_id = "test-agent-456"
workdir = "/home/coder/workspace"
api_key = "test-api-key-123"
model = "gemini"
}
assert {
condition = var.api_key == "test-api-key-123"
error_message = "API key value should match the input"
}
}
run "test_custom_options" {
command = plan
variables {
agent_id = "test-agent-789"
workdir = "/home/coder/custom"
order = 5
group = "development"
icon = "/icon/custom.svg"
model = "4o"
ai_prompt = "Help me write better code"
install_aider = false
install_agentapi = false
agentapi_version = "v0.10.0"
api_key = ""
base_aider_config = "read:\n - CONVENTIONS.md"
}
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 == "4o"
error_message = "Model variable should be set to '4o'"
}
assert {
condition = var.ai_prompt == "Help me write better code"
error_message = "AI prompt variable should be set correctly"
}
assert {
condition = var.install_aider == false
error_message = "install_aider should be set to false"
}
assert {
condition = var.install_agentapi == false
error_message = "install_agentapi should be set to false"
}
assert {
condition = var.agentapi_version == "v0.10.0"
error_message = "AgentAPI version should be set to 'v0.10.0'"
}
}
run "test_with_scripts" {
command = plan
variables {
agent_id = "test-agent-scripts"
workdir = "/home/coder/scripts"
model = "gemini"
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_ai_provider_env_mapping" {
command = plan
variables {
agent_id = "test-agent-provider"
workdir = "/home/coder/test"
ai_provider = "google"
model = "gemini"
custom_env_var_name = ""
}
# Ensure provider -> env var mapping works as expected (based on locals.provider_env_vars)
assert {
condition = var.ai_provider == "google"
error_message = "AI provider should be set to 'google' for this test"
}
}
@@ -0,0 +1,49 @@
#!/bin/bash
set -euo pipefail
# Function to check if a command exists
command_exists() {
command -v "$1" > /dev/null 2>&1
}
# Inputs
ARG_WORKDIR=${ARG_WORKDIR:-/home/coder}
ARG_INSTALL_AIDER=${ARG_INSTALL_AIDER:-true}
ARG_AIDER_CONFIG=${ARG_AIDER_CONFIG:-}
echo "--------------------------------"
echo "Install flag: $ARG_INSTALL_AIDER"
echo "Workspace: $ARG_WORKDIR"
echo "--------------------------------"
function install_aider() {
echo "pipx installing..."
sudo apt-get install -y pipx
echo "pipx installed!"
pipx ensurepath
mkdir -p "$ARG_WORKDIR/.local/bin"
export PATH="$HOME/.local/bin:$ARG_WORKDIR/.local/bin:$PATH"
if ! command_exists aider; then
echo "Installing Aider via pipx..."
pipx install --force aider-install
aider-install
fi
echo "Aider installed: $(aider --version || echo 'Aider installation check failed')"
}
function configure_aider_settings() {
if [ -n "${ARG_AIDER_CONFIG}" ]; then
echo "Configuring Aider environment variables and model"
mkdir -p "$HOME/.config/aider"
echo "$ARG_AIDER_CONFIG" > "$HOME/.config/aider/.aider.conf.yml"
echo "Aider config created at $HOME/.config/aider/.aider.conf.yml"
else
printf "No Aider environment variables or model configured\n"
fi
}
install_aider
configure_aider_settings
@@ -0,0 +1,55 @@
#!/bin/bash
set -euo pipefail
# Ensure pipx-installed apps are in PATH
export PATH="$HOME/.local/bin:$PATH"
ARG_WORKDIR=${ARG_WORKDIR:-/home/coder}
ARG_API_KEY=$(echo -n "${ARG_API_KEY:-}" | base64 -d)
ARG_SYSTEM_PROMPT=$(echo -n "${ARG_SYSTEM_PROMPT:-}" | base64 -d 2> /dev/null || echo "")
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d 2> /dev/null || echo "")
ARG_MODEL=${ARG_MODEL:-}
ARG_PROVIDER=${ARG_PROVIDER:-}
ARG_ENV_API_NAME_HOLDER=${ARG_ENV_API_NAME_HOLDER:-}
echo "--------------------------------"
echo "Provider: $ARG_PROVIDER"
echo "Model: $ARG_MODEL"
echo "--------------------------------"
if [ -n "$ARG_API_KEY" ]; then
printf "API key provided!\n"
export $ARG_ENV_API_NAME_HOLDER=$ARG_API_KEY
else
printf "API key not provided.\n"
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"
}
start_agentapi() {
echo "Starting in directory: $ARG_WORKDIR"
cd "$ARG_WORKDIR"
local initial_prompt
initial_prompt=$(build_initial_prompt)
if [ -n "$initial_prompt" ]; then
echo "Starting agentapi with initial prompt"
agentapi server -I="$initial_prompt" --type aider --term-width=67 --term-height=1190 -- aider --model $ARG_MODEL --yes-always
else
agentapi server --term-width=67 --term-height=1190 -- aider --model $ARG_MODEL --yes-always
fi
}
# TODO: Implement MCP server for coder when Aider support MCP servers.
start_agentapi
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
if [[ "$1" == "--version" ]]; then
echo "HELLO: $(bash -c env)"
echo "aider version v0.86.0"
exit 0
fi
set -e
while true; do
echo "$(date) - aider-agent-mock"
sleep 15
done
+9 -9
View File
@@ -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.1.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.1.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.1.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.1.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.1.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.1.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.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -340,7 +340,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -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.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
+2 -2
View File
@@ -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"
@@ -218,6 +217,7 @@ module "agentapi" {
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
+180 -4
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.1.0"
version = "3.3.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -32,8 +32,29 @@ module "claude-code" {
- 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)
### 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
### Usage with Agent Boundaries
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
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.3.1"
}
```
### 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.
@@ -49,7 +70,7 @@ data "coder_parameter" "ai_prompt" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.1.0"
version = "3.3.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
@@ -85,7 +106,7 @@ 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 = "3.1.0"
version = "3.3.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
install_claude_code = true
@@ -108,13 +129,168 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.0.3"
version = "3.3.1"
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.3.1"
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.3.1"
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
If you encounter any issues, check the log files in the `~/.claude-module` directory within your workspace for detailed information.
@@ -167,7 +167,7 @@ describe("claude-code", async () => {
const { id } = await setup({
moduleVariables: {
permission_mode: mode,
task_prompt: "test prompt",
ai_prompt: "test prompt",
},
});
await execModuleScript(id);
@@ -185,7 +185,7 @@ describe("claude-code", async () => {
const { id } = await setup({
moduleVariables: {
model: model,
task_prompt: "test prompt",
ai_prompt: "test prompt",
},
});
await execModuleScript(id);
@@ -198,13 +198,24 @@ describe("claude-code", async () => {
expect(startLog.stdout).toContain(`--model ${model}`);
});
test("claude-continue-previous-conversation", async () => {
test("claude-continue-resume-existing-session", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
task_prompt: "test prompt",
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, [
@@ -212,7 +223,9 @@ describe("claude-code", async () => {
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain("--continue");
expect(startLog.stdout).toContain("--resume");
expect(startLog.stdout).toContain(taskSessionId);
expect(startLog.stdout).toContain("Resuming existing task session");
});
test("pre-post-install-scripts", async () => {
+61 -2
View File
@@ -134,8 +134,8 @@ variable "resume_session_id" {
variable "continue" {
type = bool
description = "Load the most recent conversation in the current directory. Task will fail in a new workspace with no conversation/session to continue"
default = false
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 "dangerously_skip_permissions" {
@@ -192,6 +192,54 @@ variable "claude_md_path" {
default = "$HOME/.claude/CLAUDE.md"
}
variable "enable_boundary" {
type = bool
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"
}
variable "enable_boundary_pprof" {
type = bool
description = "Whether to enable coder boundary pprof server"
default = false
}
variable "boundary_pprof_port" {
type = string
description = "Port for pprof server used by Boundary"
default = "6067"
}
resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1
@@ -229,6 +277,8 @@ locals {
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://", "")
# Required prompts for the module to properly report task status to Coder
report_tasks_system_prompt = <<-EOT
@@ -299,6 +349,15 @@ module "agentapi" {
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_ENABLE_BOUNDARY_PPROF='${var.enable_boundary_pprof}' \
ARG_BOUNDARY_PPROF_PORT='${var.boundary_pprof_port}' \
ARG_CODER_HOST='${local.coder_host}' \
/tmp/start.sh
EOT
@@ -188,6 +188,32 @@ run "test_claude_code_permission_mode_validation" {
}
}
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
@@ -267,4 +293,4 @@ run "test_claude_report_tasks_disabled" {
condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "</system>")
error_message = "System prompt should end with </system>"
}
}
}
@@ -91,11 +91,6 @@ function report_tasks() {
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
}
@@ -17,6 +17,14 @@ 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_ENABLE_BOUNDARY_PPROF=${ARG_ENABLE_BOUNDARY_PPROF:-false}
ARG_BOUNDARY_PPROF_PORT=${ARG_BOUNDARY_PPROF_PORT:-"6067"}
ARG_CODER_HOST=${ARG_CODER_HOST:-}
echo "--------------------------------"
@@ -27,6 +35,12 @@ printf "ARG_DANGEROUSLY_SKIP_PERMISSIONS: %s\n" "$ARG_DANGEROUSLY_SKIP_PERMISSIO
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 "--------------------------------"
@@ -35,6 +49,14 @@ echo "--------------------------------"
# 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"
@@ -44,41 +66,122 @@ function validate_claude_installation() {
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 build_claude_args() {
function start_agentapi() {
# For Task reporting
export CODER_MCP_ALLOWED_TOOLS="coder_report_task"
mkdir -p "$ARG_WORKDIR"
cd "$ARG_WORKDIR"
if [ -n "$ARG_MODEL" ]; then
ARGS+=(--model "$ARG_MODEL")
fi
if [ -n "$ARG_RESUME_SESSION_ID" ]; then
ARGS+=(--resume "$ARG_RESUME_SESSION_ID")
fi
if [ "$ARG_CONTINUE" = "true" ]; then
ARGS+=(--continue)
fi
if [ -n "$ARG_PERMISSION_MODE" ]; then
ARGS+=(--permission-mode "$ARG_PERMISSION_MODE")
fi
}
function start_agentapi() {
mkdir -p "$ARG_WORKDIR"
cd "$ARG_WORKDIR"
if [ -n "$ARG_AI_PROMPT" ]; then
ARGS+=(--dangerously-skip-permissions "$ARG_AI_PROMPT")
else
if [ -n "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" ]; then
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[@]}")"
agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${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)
if [ "${ARG_ENABLE_BOUNDARY_PPROF:-false}" = "true" ]; then
# Enable boundary pprof server on specified port
BOUNDARY_ARGS+=(--pprof)
BOUNDARY_ARGS+=(--pprof-port ${ARG_BOUNDARY_PPROF_PORT})
fi
# 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
build_claude_args
start_agentapi
+33 -10
View File
@@ -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.2"
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.2"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
base_dir = "~/projects/coder"
@@ -43,7 +43,7 @@ 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.2"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -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.2"
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.2"
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.2"
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.2"
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.2"
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.2"
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
}
```
+18 -1
View File
@@ -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");
});
});
+9
View File
@@ -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"
+11 -1
View File
@@ -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
+2 -2
View File
@@ -13,7 +13,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener
```tf
module "goose" {
source = "registry.coder.com/coder/goose/coder"
version = "2.2.0"
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.2.0"
version = "2.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
+2
View File
@@ -135,6 +135,7 @@ 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" {
@@ -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
+1 -1
View File
@@ -14,7 +14,7 @@ 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.4"
version = "1.2.5"
agent_id = coder_agent.example.id
desktop_environment = "xfce"
subdomain = true
+1 -1
View File
@@ -23,7 +23,7 @@ variable "port" {
variable "kasm_version" {
type = string
description = "Version of KasmVNC to install."
default = "1.3.2"
default = "1.4.0"
}
variable "desktop_environment" {
+7 -7
View File
@@ -8,10 +8,10 @@ error() {
exit 1
}
# Function to check if vncserver is already installed
# Function to check if KasmVNC is already installed
check_installed() {
if command -v vncserver &> /dev/null; then
echo "vncserver is already installed."
if command -v kasmvncserver &> /dev/null; then
echo "KasmVNC is already installed."
return 0 # Don't exit, just indicate it's installed
else
return 1 # Indicates not installed
@@ -158,7 +158,7 @@ case "$arch" in
;;
esac
# Check if vncserver is installed, and install if not
# Check if KasmVNC is installed, and install if not
if ! check_installed; then
# Check for NOPASSWD sudo (required)
if ! command -v sudo &> /dev/null || ! sudo -n true 2> /dev/null; then
@@ -188,7 +188,7 @@ if ! check_installed; then
;;
esac
else
echo "vncserver already installed. Skipping installation."
echo "KasmVNC already installed. Skipping installation."
fi
if command -v sudo &> /dev/null && sudo -n true 2> /dev/null; then
@@ -227,7 +227,7 @@ EOF
# This password is not used since we start the server without auth.
# The server is protected via the Coder session token / tunnel
# and does not listen publicly
echo -e "password\npassword\n" | vncpasswd -wo -u "$USER"
echo -e "password\npassword\n" | kasmvncpasswd -wo -u "$USER"
get_http_dir() {
# determine the served file path
@@ -290,7 +290,7 @@ VNC_LOG="/tmp/kasmvncserver.log"
printf "🚀 Starting KasmVNC server...\n"
set +e
vncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > "$VNC_LOG" 2>&1
kasmvncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > "$VNC_LOG" 2>&1
RETVAL=$?
set -e
+5 -5
View File
@@ -19,7 +19,7 @@ Zed is a high-performance, multiplayer code editor from the creators of Atom and
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
}
```
@@ -32,7 +32,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -44,7 +44,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
display_name = "Zed Editor"
order = 1
@@ -57,7 +57,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
agent_name = coder_agent.example.name
}
@@ -73,7 +73,7 @@ You can declaratively set/merge settings with the `settings` input. Provide a JS
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
settings = jsonencode({
+1
View File
@@ -73,6 +73,7 @@ resource "coder_script" "zed_settings" {
icon = "/icon/zed.svg"
run_on_start = true
script = <<-EOT
#!/bin/sh
set -eu
SETTINGS_JSON='${replace(var.settings, "\"", "\\\"")}'
if [ -z "$${SETTINGS_JSON}" ] || [ "$${SETTINGS_JSON}" = "{}" ]; then
@@ -264,7 +264,7 @@ resource "kubernetes_deployment" "main" {
container {
name = "dev"
image = var.cache_repo == "" ? local.devcontainer_builder_image : envbuilder_cached_image.cached.0.image
image_pull_policy = "Always"
image_pull_policy = "IfNotPresent"
security_context {
privileged = true
}
@@ -455,4 +455,4 @@ resource "coder_metadata" "container_info" {
key = "cache repo"
value = var.cache_repo == "" ? "not enabled" : var.cache_repo
}
}
}
@@ -152,7 +152,7 @@ resource "kubernetes_pod" "main" {
name = "dev"
# We highly recommend pinning this to a specific release of envbox, as the latest tag may change.
image = "ghcr.io/coder/envbox:latest"
image_pull_policy = "Always"
image_pull_policy = "IfNotPresent"
command = ["/envbox", "docker"]
security_context {
@@ -310,4 +310,4 @@ resource "kubernetes_pod" "main" {
}
}
}
}
}
+1 -1
View File
@@ -287,7 +287,7 @@ resource "kubernetes_deployment" "main" {
container {
name = "dev"
image = "codercom/enterprise-base:ubuntu"
image_pull_policy = "Always"
image_pull_policy = "IfNotPresent"
command = ["sh", "-c", coder_agent.main.init_script]
security_context {
run_as_user = "1000"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+11
View File
@@ -0,0 +1,11 @@
---
display_name: "Austin"
bio: "IT Pro by day, script kiddie at night."
avatar: "./.images/avatar.png"
github: "djarbz"
status: "community"
---
# Austin
I like to program as a hobby.
@@ -0,0 +1,68 @@
---
display_name: copyparty
description: A web based file explorer alternative to Filebrowser.
icon: ../../../../.icons/copyparty.svg
verified: false
tags: [files, filebrowser, web, copyparty]
---
# copyparty
<!-- Describes what this module does -->
This module installs Copyparty, an alternative to Filebrowser.
[Copyparty](https://github.com/9001/copyparty) is a portable file server with accelerated resumable uploads, dedup, WebDAV, FTP, TFTP, zeroconf, media indexer, thumbnails++ all in one file, no deps
```tf
module "copyparty" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/djarbz/copyparty/coder"
version = "1.0.0"
}
```
<!-- Add a screencast or screenshot here put them in .images directory -->
![copyparty-browser-fs8](../../.images/copyparty_screenshot.png)
## Examples
### Example 1
Some basic command line options:
```tf
module "copyparty" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/djarbz/copyparty/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
arguments = [
"-v", "/home/coder/:/home:r", # Share home directory (read-only)
"-v", "${local.repo_dir}:/repo:rw", # Share project directory (read-write)
"-e2dsa", # Enables general file indexing"
]
}
```
### Example 2
```tf
module "copyparty" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/djarbz/copyparty/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
subdomain = true
arguments = [
"-v", "/tmp:/tmp:r", # Share tmp directory (read-only)
"-v", "/home/coder/:/home:rw", # Share home directory (read-write)
"-v", "${local.root_dir}:/work:A:c,dotsrch", # Share work directory (All Perms)
"-e2dsa", # Enables general file indexing"
"--re-maxage", "900", # Rescan filesystem for changes every SEC
"--see-dots", # Show dotfiles by default if user has correct permissions on volume
"--xff-src=lan", # List of trusted reverse-proxy CIDRs (comma-separated) or `lan` for private IPs.
"--rproxy", "1", # Which ip to associate clients with, index of X-FWD IP.
]
}
```
@@ -0,0 +1,181 @@
# --- Test Case 1: Required Variables ---
run "plan_with_required_vars" {
command = plan
variables {
agent_id = "example-agent-id"
}
}
# --- Test Case 2: Coder App URL uses custom port ---
run "app_url_uses_port" {
command = plan
variables {
agent_id = "example-agent-id"
port = 19999
}
assert {
condition = resource.coder_app.copyparty.url == "http://localhost:19999"
error_message = "Expected copyparty app URL to include configured port"
}
}
# --- Test Case 3: Default Values ---
run "test_defaults" {
# This run block applies the module with default values
# (except for the required 'agent_id' provided above).
variables {
agent_id = "example-agent-id"
}
# --- Asserts for coder_app "copyparty" ---
assert {
condition = resource.coder_app.copyparty.display_name == "copyparty"
error_message = "Default display_name is incorrect"
}
assert {
condition = resource.coder_app.copyparty.slug == "copyparty"
error_message = "Default slug is incorrect"
}
assert {
condition = resource.coder_app.copyparty.url == "http://localhost:3923"
error_message = "Default URL is incorrect, expected port 3923"
}
assert {
condition = resource.coder_app.copyparty.subdomain == false
error_message = "Default subdomain should be false"
}
assert {
condition = resource.coder_app.copyparty.share == "owner"
error_message = "Default share value should be 'owner'"
}
assert {
condition = resource.coder_app.copyparty.open_in == "slim-window"
error_message = "Default open_in value should be 'slim-window'"
}
# --- Asserts for coder_script "copyparty" ---
assert {
condition = coder_script.copyparty.display_name == "copyparty"
error_message = "Script display_name is incorrect"
}
# Check rendered script content (this assumes your run.sh uses the variables)
assert {
condition = strcontains(coder_script.copyparty.script, "PORT=\"3923\"")
error_message = "Script content does not reflect default port"
}
assert {
condition = strcontains(coder_script.copyparty.script, "LOG_PATH=\"/tmp/copyparty.log\"")
error_message = "Script content does not reflect default log_path"
}
assert {
condition = strcontains(coder_script.copyparty.script, "IFS=',' read -r -a ARGUMENTS \u003c\u003c\u003c \"\"")
error_message = "Script content does not reflect default empty arguments"
}
}
# --- Test Case 4: Custom Values ---
run "test_custom_values" {
# Override default variables for this specific run
variables {
agent_id = "example-agent-id"
port = 8080
slug = "my-custom-app"
display_name = "My Custom App"
share = "authenticated"
open_in = "tab"
pinned_version = "v1.2.3"
arguments = ["--verbose", "-v"]
log_path = "/var/log/custom.log"
}
# --- Asserts for coder_app "copyparty" ---
assert {
condition = resource.coder_app.copyparty.display_name == "My Custom App"
error_message = "Custom display_name was not applied"
}
assert {
condition = resource.coder_app.copyparty.slug == "my-custom-app"
error_message = "Custom slug was not applied"
}
assert {
condition = resource.coder_app.copyparty.url == "http://localhost:8080"
error_message = "Custom port was not applied to URL"
}
assert {
condition = resource.coder_app.copyparty.share == "authenticated"
error_message = "Custom share value was not applied"
}
assert {
condition = resource.coder_app.copyparty.open_in == "tab"
error_message = "Custom open_in value was not applied"
}
# --- Asserts for coder_script "copyparty" ---
assert {
condition = strcontains(coder_script.copyparty.script, "PORT=\"8080\"")
error_message = "Script content does not reflect custom port"
}
assert {
condition = strcontains(coder_script.copyparty.script, "PINNED_VERSION=\"v1.2.3\"")
error_message = "Script content does not reflect custom pinned_version"
}
assert {
condition = strcontains(coder_script.copyparty.script, "IFS=',' read -r -a ARGUMENTS \u003c\u003c\u003c \"--verbose,-v\"")
error_message = "Script content does not reflect custom arguments"
}
assert {
condition = strcontains(coder_script.copyparty.script, "LOG_PATH=\"/var/log/custom.log\"")
error_message = "Script content does not reflect custom log_path"
}
}
# --- Test Case 5: Validation Failure (open_in) ---
run "test_invalid_open_in" {
# This is a 'plan' test that expects a failure
command = plan
variables {
agent_id = "example-agent-id"
open_in = "invalid-value"
}
# Expect this plan to fail due to the validation rule in 'var.open_in'
expect_failures = [
var.open_in,
]
}
# --- Test Case 6: Validation Failure (share) ---
run "test_invalid_share" {
# This is a 'plan' test that expects a failure
command = plan
variables {
agent_id = "example-agent-id"
share = "everyone" # This is not 'owner', 'authenticated', or 'public'
}
# Expect this plan to fail due to the validation rule in 'var.share'
expect_failures = [
var.share,
]
}
+174
View File
@@ -0,0 +1,174 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
locals {
# A built-in icon like "/icon/code.svg" or a full URL of icon
icon_url = "/icon/copyparty.svg"
# a map of all possible values
# options = {
# "Option 1" = {
# "name" = "Option 1",
# "value" = "1"
# "icon" = "/emojis/1.png"
# }
# "Option 2" = {
# "name" = "Option 2",
# "value" = "2"
# "icon" = "/emojis/2.png"
# }
# }
}
# Add required variables for your modules and remove any unneeded variables
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "log_path" {
type = string
description = "The path to log copyparty to."
default = "/tmp/copyparty.log"
}
variable "port" {
type = number
description = "ports to listen on (comma/range); ignored for unix-sockets (default: 3923)"
default = 3923
}
variable "slug" {
type = string
description = "The slug for the copyparty application."
default = "copyparty"
}
variable "display_name" {
type = string
description = "The display name for the copyparty application."
default = "copyparty"
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "open_in" {
type = string
description = <<-EOT
Determines where the app will be opened. Valid values are `"tab"` and `"slim-window" (default)`.
`"tab"` opens in a new tab in the same browser window.
`"slim-window"` opens a new browser window without navigation controls.
EOT
default = "slim-window"
validation {
condition = contains(["tab", "slim-window"], var.open_in)
error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'."
}
}
variable "subdomain" {
type = bool
description = <<-EOT
Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = false
}
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 "mutable" {
# type = bool
# description = "Whether the parameter is mutable."
# default = true
# }
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
}
# Add other variables here
variable "pinned_version" {
type = string
description = "Install a specific version in semver format (v1.19.16)."
default = ""
}
variable "arguments" {
type = list(string)
description = "A list of arguments to pass to the application."
default = []
}
resource "coder_script" "copyparty" {
agent_id = var.agent_id
display_name = "copyparty"
icon = local.icon_url
script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path,
PORT : var.port,
PINNED_VERSION : var.pinned_version,
ARGUMENTS : join(",", var.arguments),
})
run_on_start = true
run_on_stop = false
}
resource "coder_app" "copyparty" {
agent_id = var.agent_id
slug = var.slug
display_name = var.display_name
url = "http://localhost:${var.port}"
icon = local.icon_url
subdomain = var.subdomain
share = var.share
order = var.order
group = var.group
open_in = var.open_in
# Remove if the app does not have a healthcheck endpoint
healthcheck {
url = "http://localhost:${var.port}"
interval = 5
threshold = 6
}
}
# data "coder_parameter" "copyparty" {
# type = "list(string)"
# name = "copyparty"
# display_name = "copyparty"
# icon = local.icon_url
# mutable = var.mutable
# default = local.options["Option 1"]["value"]
# dynamic "option" {
# for_each = local.options
# content {
# icon = option.value.icon
# name = option.value.name
# value = option.value.value
# }
# }
# }
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Convert templated variables to shell variables
# This variable is assigned to itself, so the assignment does nothing.
# shellcheck disable=SC2269
LOG_PATH="${LOG_PATH}"
# Ports to listen on (comma/range); ignored for unix-sockets (default: 3923)
PORT="${PORT}"
# Pinned version (e.g., v1.19.16); overrides latest release discovery if set
PINNED_VERSION="${PINNED_VERSION}"
# Custom CLI Arguments# The variable from Terraform is a single, comma-separated string.
# We need to split it into a proper bash array using the comma (,) as the delimiter.
IFS=',' read -r -a ARGUMENTS <<< "${ARGUMENTS}"
# VARIABLE appears unused. Verify use (or export if used externally).
# shellcheck disable=SC2034
MODULE_NAME="Copyparty"
# VARIABLE appears unused. Verify use (or export if used externally).
# shellcheck disable=SC2034
BOLD='\033[0;1m'
printf '%sInstalling %s ...\n\n' "$${BOLD}" "$${MODULE_NAME}"
# Add code here
# Use variables from the templatefile function in main.tf
# e.g. LOG_PATH, PORT, etc.
printf "🐍 Verifying Python 3 installation...\n"
if ! command -v python3 &> /dev/null; then
printf "❌ Python3 could not be found. Please install it to continue.\n"
exit 1
fi
printf "✅ Python3 is installed.\n\n"
RELEASE_TO_INSTALL=""
# Install provided version to pin, otherwise discover latest github release from `https://github.com/9001/copyparty`.
if [[ -n "$${PINNED_VERSION}" ]]; then
printf "📌 Pinned version specified: %s\n" "$${PINNED_VERSION}"
# Verify that it is in v#.#.# format
if [[ ! "$${PINNED_VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
printf "❌ Invalid format for PINNED_VERSION. Expected 'v#.#.#' (e.g., v1.19.16).\n"
exit 1
fi
RELEASE_TO_INSTALL="$${PINNED_VERSION}"
printf "✅ Using pinned version %s.\n\n" "$${RELEASE_TO_INSTALL}"
else
printf "🔎 Discovering latest release from GitHub...\n"
# Use curl to get the latest release tag from the GitHub API and sed to parse it
LATEST_RELEASE=$(curl -fsSL https://api.github.com/repos/9001/copyparty/releases/latest | grep '"tag_name":' | sed -E 's/.*"(v[^"]+)".*/\1/')
if [[ -z "$${LATEST_RELEASE}" ]]; then
printf "❌ Could not determine the latest release. Please check your internet connection.\n"
exit 1
fi
RELEASE_TO_INSTALL="$${LATEST_RELEASE}"
printf "🏷️ Latest release is %s.\n\n" "$${RELEASE_TO_INSTALL}"
fi
# Download appropriate release version assets: `copyparty-sfx.py` and `helptext.html`.
printf "🚀 Downloading copyparty v%s...\n" "$${RELEASE_TO_INSTALL}"
DOWNLOAD_URL="https://github.com/9001/copyparty/releases/download/$${RELEASE_TO_INSTALL}"
printf "⏬ Downloading copyparty-sfx.py...\n"
if ! curl -fsSL -o /tmp/copyparty-sfx.py "$${DOWNLOAD_URL}/copyparty-sfx.py"; then
printf "❌ Failed to download copyparty-sfx.py.\n"
exit 1
fi
printf "⏬ Downloading helptext.html...\n"
if ! curl -fsSL -o /tmp/helptext.html "$${DOWNLOAD_URL}/helptext.html"; then
# This is not a fatal error, just a warning.
printf "⚠️ Could not download helptext.html. The application will still work.\n"
fi
chmod +x /tmp/copyparty-sfx.py
printf "✅ Download complete.\n\n"
printf "🥳 Installation complete!\n\n"
# Build a clean, quoted string of the command for logging purposes only.
log_command="python3 /tmp/copyparty-sfx.py -p '$${PORT}'"
for arg in "$${ARGUMENTS[@]}"; do
# printf "DEBUG: ARG [$${arg}]\n"
log_command+=" '$${arg}'"
done
# Clear the log file and write the header and command string using printf.
{
printf "=== Starting copyparty at %s ===\n" "$(date)"
printf "EXECUTING: %s\n" "$${log_command}"
} > "$${LOG_PATH}"
printf "👷 Starting %s in background...\n\n" "$${MODULE_NAME}"
# Execute the actual command using the robust array expansion.
# Then, append its output (stdout and stderr) to the log file.
python3 /tmp/copyparty-sfx.py -p "$${PORT}" "$${ARGUMENTS[@]}" >> "$${LOG_PATH}" 2>&1 &
printf "✅ Service started. Check logs at %s\n\n" "$${LOG_PATH}"
@@ -0,0 +1,70 @@
---
display_name: "NFS K8s Deployment"
description: "Mount an NFS share to a Coder K8s workspace"
icon: "../../../../.icons/folder.svg"
verified: false
tags: ["kubernetes", "shared-dir", "nfs"]
---
# NFS K8s Deployment
This template provisions a Coder workspace as a Kubernetes Deployment, with an NFS share mounted
as a volume. The NFS share will synchronize the server-side files onto the client (Coder workspace)
When you stop the Coder workspace and rebuild, the NFS share will be re-mounted, and the changes persisted.
Note the `volume` and `volume_mount` blocks in the deployment and container spec,
respectively:
```terraform
resource "kubernetes_deployment" "main" {
spec {
template {
spec {
container {
volume_mount {
mount_path = data.coder_parameter.nfs_mount_path.value # mount path in the container
name = "nfs-share"
}
}
volume {
name = "nfs-share"
nfs {
path = data.coder_parameter.nfs_mount_path.value # path to be exported from the server
server = data.coder_parameter.nfs_server.value # server IP address
}
}
}
}
}
}
```
## server-side configuration
1. Create an NFS mount on the server for the clients to access:
```console
export NFS_MNT_PATH=/mnt/nfs_share
# Create directory to shaare
sudo mkdir -p $NFS_MNT_PATH
# Assign UID & GIDs access
sudo chown -R uid:gid $NFS_MNT_PATH
sudo chmod 777 $NFS_MNT_PATH
```
1. Grant access to the client by updating the `/etc/exports` file, which
controls the directories shared with remote clients. See
[Red Hat's docs for more information about the configuration options](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/5/html/deployment_guide/s1-nfs-server-config-exports).
```console
# Provides read/write access to clients accessing the NFS from any IP address.
/mnt/nfs_share *(rw,sync,no_subtree_check)
```
1. Export the NFS file share directory. You must do this every time you change
`/etc/exports`.
```console
sudo exportfs -a
sudo systemctl restart <nfs-package>
```
@@ -0,0 +1,348 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
kubernetes = {
source = "hashicorp/kubernetes"
}
}
}
provider "coder" {
}
provider "kubernetes" {
config_path = var.use_kubeconfig == true ? "~/.kube/config" : null
}
variable "use_kubeconfig" {
type = bool
description = <<-EOF
Use host kubeconfig? (true/false)
Set this to false if the Coder host is itself running as a Pod on the same
Kubernetes cluster as you are deploying workspaces to.
Set this to true if the Coder host is running outside the Kubernetes cluster
for workspaces. A valid "~/.kube/config" must be present on the Coder host.
EOF
default = false
}
variable "namespace" {
type = string
description = "The Kubernetes namespace to create workspaces in (must exist prior to creating workspaces). If the Coder host is itself running as a Pod on the same Kubernetes cluster as you are deploying workspaces to, set this to the same namespace."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_parameter" "cpu" {
name = "cpu"
display_name = "CPU"
description = "The number of CPU cores"
default = "2"
icon = "/icon/memory.svg"
mutable = true
option {
name = "2 Cores"
value = "2"
}
option {
name = "4 Cores"
value = "4"
}
option {
name = "6 Cores"
value = "6"
}
option {
name = "8 Cores"
value = "8"
}
}
data "coder_parameter" "memory" {
name = "memory"
display_name = "Memory"
description = "The amount of memory in GB"
default = "2"
icon = "/icon/memory.svg"
mutable = true
option {
name = "2 GB"
value = "2"
}
option {
name = "4 GB"
value = "4"
}
option {
name = "6 GB"
value = "6"
}
option {
name = "8 GB"
value = "8"
}
}
data "coder_parameter" "home_disk_size" {
name = "home_disk_size"
display_name = "Home disk size"
description = "The size of the home disk in GB"
default = "10"
type = "number"
icon = "/emojis/1f4be.png"
mutable = false
validation {
min = 1
max = 99999
}
}
data "coder_parameter" "nfs_server" {
name = "nfs_server"
type = "string"
display_name = "NFS Server IP"
description = "The NFS server IP address to use for the workspace"
}
data "coder_parameter" "nfs_mount_path" {
name = "nfs_mount_path"
type = "string"
display_name = "NFS Mount Path"
description = "The path in your workspace container to mount the NFS share to"
default = "/mnt/nfs-share"
validation {
regex = "^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*$"
error = "NFS mount path must be a valid path in your workspace container"
}
}
resource "coder_agent" "coder" {
os = "linux"
arch = "amd64"
# The following metadata blocks are optional. They are used to display
# information about your workspace in the dashboard. You can remove them
# if you don't want to display any information.
# For basic resources, you can use the `coder stat` command.
# If you need more control, you can write your own script.
metadata {
display_name = "CPU Usage"
key = "0_cpu_usage"
script = "coder stat cpu"
interval = 10
timeout = 1
}
metadata {
display_name = "RAM Usage"
key = "1_ram_usage"
script = "coder stat mem"
interval = 10
timeout = 1
}
metadata {
display_name = "Home Disk"
key = "3_home_disk"
script = "coder stat disk --path $${HOME}"
interval = 60
timeout = 1
}
metadata {
display_name = "CPU Usage (Host)"
key = "4_cpu_usage_host"
script = "coder stat cpu --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Memory Usage (Host)"
key = "5_mem_usage_host"
script = "coder stat mem --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Load Average (Host)"
key = "6_load_host"
# get load avg scaled by number of cores
script = <<EOT
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
EOT
interval = 60
timeout = 1
}
}
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.3.1"
agent_id = coder_agent.coder.id
accept_license = true
}
resource "kubernetes_deployment" "main" {
count = data.coder_workspace.me.start_count
depends_on = [
kubernetes_persistent_volume_claim.home
]
wait_for_rollout = false
metadata {
name = "coder-${data.coder_workspace.me.id}"
namespace = var.namespace
labels = {
"app.kubernetes.io/name" = "coder-workspace"
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
"app.kubernetes.io/part-of" = "coder"
"com.coder.resource" = "true"
"com.coder.workspace.id" = data.coder_workspace.me.id
"com.coder.workspace.name" = data.coder_workspace.me.name
"com.coder.user.id" = data.coder_workspace_owner.me.id
"com.coder.user.username" = data.coder_workspace_owner.me.name
}
annotations = {
"com.coder.user.email" = data.coder_workspace_owner.me.email
}
}
spec {
replicas = 1
selector {
match_labels = {
"app.kubernetes.io/name" = "coder-workspace"
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
"app.kubernetes.io/part-of" = "coder"
"com.coder.resource" = "true"
"com.coder.workspace.id" = data.coder_workspace.me.id
"com.coder.workspace.name" = data.coder_workspace.me.name
"com.coder.user.id" = data.coder_workspace_owner.me.id
"com.coder.user.username" = data.coder_workspace_owner.me.name
}
}
strategy {
type = "Recreate"
}
template {
metadata {
labels = {
"app.kubernetes.io/name" = "coder-workspace"
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
"app.kubernetes.io/part-of" = "coder"
"com.coder.resource" = "true"
"com.coder.workspace.id" = data.coder_workspace.me.id
"com.coder.workspace.name" = data.coder_workspace.me.name
"com.coder.user.id" = data.coder_workspace_owner.me.id
"com.coder.user.username" = data.coder_workspace_owner.me.name
}
}
spec {
container {
name = "dev"
image = "codercom/enterprise-base:ubuntu"
image_pull_policy = "Always"
command = ["sh", "-c", coder_agent.coder.init_script]
env {
name = "CODER_AGENT_TOKEN"
value = coder_agent.coder.token
}
resources {
requests = {
"cpu" = "250m"
"memory" = "512Mi"
}
limits = {
"cpu" = "${data.coder_parameter.cpu.value}"
"memory" = "${data.coder_parameter.memory.value}Gi"
}
}
volume_mount {
mount_path = "/home/${lower(data.coder_workspace_owner.me.name)}"
name = "home"
read_only = false
}
volume_mount {
mount_path = data.coder_parameter.nfs_mount_path.value
name = "nfs-share"
}
}
volume {
name = "home"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.home.metadata.0.name
read_only = false
}
}
volume {
name = "nfs-share"
nfs {
path = data.coder_parameter.nfs_mount_path.value
server = data.coder_parameter.nfs_server.value
}
}
affinity {
// This affinity attempts to spread out all workspace pods evenly across
// nodes.
pod_anti_affinity {
preferred_during_scheduling_ignored_during_execution {
weight = 1
pod_affinity_term {
topology_key = "kubernetes.io/hostname"
label_selector {
match_expressions {
key = "app.kubernetes.io/name"
operator = "In"
values = ["coder-workspace"]
}
}
}
}
}
}
}
}
}
}
resource "kubernetes_persistent_volume_claim" "home" {
metadata {
name = "${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace_owner.me.name)}-home"
namespace = var.namespace
labels = {
"app.kubernetes.io/name" = "coder-pvc"
"app.kubernetes.io/instance" = "coder-pvc-${data.coder_workspace.me.id}"
"app.kubernetes.io/part-of" = "coder"
//Coder-specific labels.
"com.coder.resource" = "true"
"com.coder.workspace.id" = data.coder_workspace.me.id
"com.coder.workspace.name" = data.coder_workspace.me.name
"com.coder.user.id" = data.coder_workspace_owner.me.id
"com.coder.user.username" = data.coder_workspace_owner.me.name
}
annotations = {
"com.coder.user.email" = data.coder_workspace_owner.me.email
}
}
wait_until_bound = false
spec {
access_modes = ["ReadWriteOnce"]
resources {
requests = {
storage = "${data.coder_parameter.home_disk_size.value}Gi"
}
}
}
}
+69 -3
View File
@@ -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
+66 -12
View File
@@ -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
+63
View File
@@ -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[@]}"