diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 5ace4600..600a98c2 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,3 +4,5 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ commit-message:
+ prefix: "chore"
diff --git a/.github/typos.toml b/.github/typos.toml
new file mode 100644
index 00000000..ec620a44
--- /dev/null
+++ b/.github/typos.toml
@@ -0,0 +1,2 @@
+[default.extend-words]
+muc = "muc" # For Munich location code
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 2de2364c..63c6062b 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -48,19 +48,8 @@ jobs:
- name: Format
run: bun fmt:ci
- name: typos-action
- uses: crate-ci/typos@v1.17.2
+ uses: crate-ci/typos@v1.32.0
+ with:
+ config: .github/typos.toml
- name: Lint
run: bun lint
- - name: Check version
- shell: bash
- run: |
- # check for version changes
- ./update-version.sh
- # Check if any changes were made in README.md files
- if [[ -n "$(git status --porcelain -- '**/README.md')" ]]; then
- echo "Version mismatch detected. Please run ./update-version.sh and commit the updated README.md files."
- git diff -- '**/README.md'
- exit 1
- else
- echo "No version mismatch detected. All versions are up to date."
- fi
diff --git a/.github/workflows/deploy-registry.yaml b/.github/workflows/deploy-registry.yaml
index 7a67cc83..bc60c06b 100644
--- a/.github/workflows/deploy-registry.yaml
+++ b/.github/workflows/deploy-registry.yaml
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
+ tags:
+ - "release/*/v*" # Matches tags like release/module-name/v1.0.0
jobs:
deploy:
@@ -20,7 +22,7 @@ jobs:
uses: actions/checkout@v4
- name: Authenticate to Google Cloud
- uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935
+ uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193
with:
workload_identity_provider: projects/309789351055/locations/global/workloadIdentityPools/github-actions/providers/github
service_account: registry-v2-github@coder-registry-1.iam.gserviceaccount.com
@@ -34,4 +36,8 @@ jobs:
- name: Deploy to dev.registry.coder.com
run: |
gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch dev
-
\ No newline at end of file
+
+ - name: Deploy to registry.coder.com
+ run: |
+ gcloud builds triggers run 106610ff-41fb-4bd0-90a2-7643583fb9c0 --branch main
+
diff --git a/.icons/claude.svg b/.icons/claude.svg
new file mode 100644
index 00000000..998fb0d5
--- /dev/null
+++ b/.icons/claude.svg
@@ -0,0 +1,4 @@
+
diff --git a/.icons/devcontainers.svg b/.icons/devcontainers.svg
new file mode 100644
index 00000000..fb0443bd
--- /dev/null
+++ b/.icons/devcontainers.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/.icons/goose.svg b/.icons/goose.svg
new file mode 100644
index 00000000..cbbe8419
--- /dev/null
+++ b/.icons/goose.svg
@@ -0,0 +1,4 @@
+
diff --git a/.icons/windsurf.svg b/.icons/windsurf.svg
new file mode 100644
index 00000000..a7684d4c
--- /dev/null
+++ b/.icons/windsurf.svg
@@ -0,0 +1,43 @@
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 60b1260b..2c7ba8bf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -22,7 +22,8 @@ Follow the instructions to ensure that Bun is available globally. Once Bun has b
## Testing a Module
-> **Note:** It is the responsibility of the module author to implement tests for their module. The author must test the module locally before submitting a PR.
+> [!NOTE]
+> It is the responsibility of the module author to implement tests for their module. The author must test the module locally before submitting a PR.
A suite of test-helpers exists to run `terraform apply` on modules with variables, and test script output against containers.
@@ -53,23 +54,44 @@ module "example" {
## Releases
-> [!WARNING]
-> When creating a new release, make sure that your new version number is fully accurate. If a version number is incorrect or does not exist, we may end up serving incorrect/old data for our various tools and providers.
+The release process is automated with these steps:
+
+## 1. Create and Merge PR
+
+- Create a PR with your module changes
+- Get your PR reviewed, approved, and merged to `main`
+
+## 2. Prepare Release (Maintainer Task)
+
+After merging to `main`, a maintainer will:
+
+- View all modules and their current versions:
+
+ ```shell
+ ./release.sh --list
+ ```
+
+- Determine the next version number based on changes:
+
+ - **Patch version** (1.2.3 â 1.2.4): Bug fixes
+ - **Minor version** (1.2.3 â 1.3.0): New features, adding inputs, deprecating inputs
+ - **Major version** (1.2.3 â 2.0.0): Breaking changes (removing inputs, changing input types)
-Much of our release process is automated. To cut a new release:
+- Create and push an annotated tag:
-1. Navigate to [GitHub's Releases page](https://github.com/coder/modules/releases)
-2. Click "Draft a new release"
-3. Click the "Choose a tag" button and type a new release number in the format `v..` (e.g., `v1.18.0`). Then click "Create new tag".
-4. Click the "Generate release notes" button, and clean up the resulting README. Be sure to remove any notes that would not be relevant to end-users (e.g., bumping dependencies).
-5. Once everything looks good, click the "Publish release" button.
+ ```shell
+ # Fetch latest changes
+ git fetch origin
+
+ # Create and push tag
+ ./release.sh module-name 1.2.3 --push
+ ```
-Once the release has been cut, a script will run to check whether there are any modules that will require that the new release number be published to Terraform. If there are any, a new pull request will automatically be generated. Be sure to approve this PR and merge it into the `main` branch.
+ The tag format will be: `release/module-name/v1.2.3`
-Following that, our automated processes will handle publishing new data for [`registry.coder.com`](https://github.com/coder/registry.coder.com/):
+## 3. Publishing to Registry
-1. Publishing new versions to Coder's [Terraform Registry](https://registry.terraform.io/providers/coder/coder/latest)
-2. Publishing new data to the [Coder Registry](https://registry.coder.com)
+Our automated processes will handle publishing new data to [registry.coder.com](https://registry.coder.com).
> [!NOTE]
-> Some data in `registry.coder.com` is fetched on demand from the Module repo's main branch. This data should be updated almost immediately after a new release, but other changes will take some time to propagate.
+> Some data in registry.coder.com is fetched on demand from the [coder/modules](https://github.com/coder/modules) repo's `main` branch. This data should update almost immediately after a release, while other changes will take some time to propagate.
diff --git a/README.md b/README.md
index acec4e5d..81d8d380 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,6 @@
+> [!CAUTION]
+> We are no longer accepting new contributions to this repo. We have moved all modules to https://github.com/coder/registry repo. Please see https://github.com/coder/modules/discussions/469 for more details.
+
Modules
diff --git a/claude-code/README.md b/claude-code/README.md
new file mode 100644
index 00000000..7ff2ae87
--- /dev/null
+++ b/claude-code/README.md
@@ -0,0 +1,114 @@
+---
+display_name: Claude Code
+description: Run Claude Code in your workspace
+icon: ../.icons/claude.svg
+maintainer_github: coder
+verified: true
+tags: [agent, claude-code]
+---
+
+# Claude Code
+
+Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks.
+
+```tf
+module "claude-code" {
+ source = "registry.coder.com/modules/claude-code/coder"
+ version = "1.2.1"
+ agent_id = coder_agent.example.id
+ folder = "/home/coder"
+ install_claude_code = true
+ claude_code_version = "latest"
+}
+```
+
+### Prerequisites
+
+- Node.js and npm must be installed in your workspace to install Claude Code
+- Either `screen` or `tmux` must be installed in your workspace to run Claude Code in the background
+- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
+
+The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces.
+
+## Examples
+
+### Run in the background and report tasks (Experimental)
+
+> This functionality is in early access as of Coder v2.21 and is still evolving.
+> For now, we recommend testing it in a demo or staging environment,
+> rather than deploying to production
+>
+> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents)
+>
+> Join our [Discord channel](https://discord.gg/coder) or
+> [contact us](https://coder.com/contact) to get help or share feedback.
+
+Your workspace must have either `screen` or `tmux` installed to use this.
+
+```tf
+variable "anthropic_api_key" {
+ type = string
+ description = "The Anthropic API key"
+ sensitive = true
+}
+
+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
+}
+
+data "coder_parameter" "ai_prompt" {
+ type = "string"
+ name = "AI Prompt"
+ default = ""
+ description = "Write a prompt for Claude Code"
+ mutable = true
+}
+
+# Set the prompt and system prompt for Claude Code via environment variables
+resource "coder_agent" "main" {
+ # ...
+ env = {
+ CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter
+ CODER_MCP_CLAUDE_TASK_PROMPT = data.coder_parameter.ai_prompt.value
+ CODER_MCP_APP_STATUS_SLUG = "claude-code"
+ CODER_MCP_CLAUDE_SYSTEM_PROMPT = <<-EOT
+ You are a helpful assistant that can help with code.
+ EOT
+ }
+}
+
+module "claude-code" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/claude-code/coder"
+ version = "1.1.0"
+ agent_id = coder_agent.example.id
+ folder = "/home/coder"
+ install_claude_code = true
+ claude_code_version = "0.2.57"
+
+ # Enable experimental features
+ experiment_use_screen = true # Or use experiment_use_tmux = true to use tmux instead
+ experiment_report_tasks = true
+}
+```
+
+## Run standalone
+
+Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it directly without using screen or any task reporting to the Coder UI.
+
+```tf
+module "claude-code" {
+ source = "registry.coder.com/modules/claude-code/coder"
+ version = "1.2.1"
+ agent_id = coder_agent.example.id
+ folder = "/home/coder"
+ install_claude_code = true
+ claude_code_version = "latest"
+
+ # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL
+ icon = "https://registry.npmmirror.com/@lobehub/icons-static-png/1.24.0/files/dark/claude-color.png"
+}
+```
diff --git a/claude-code/main.tf b/claude-code/main.tf
new file mode 100644
index 00000000..cc7b27e0
--- /dev/null
+++ b/claude-code/main.tf
@@ -0,0 +1,249 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.17"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+data "coder_workspace" "me" {}
+
+data "coder_workspace_owner" "me" {}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+variable "icon" {
+ type = string
+ description = "The icon to use for the app."
+ default = "/icon/claude.svg"
+}
+
+variable "folder" {
+ type = string
+ description = "The folder to run Claude Code in."
+ default = "/home/coder"
+}
+
+variable "install_claude_code" {
+ type = bool
+ description = "Whether to install Claude Code."
+ default = true
+}
+
+variable "claude_code_version" {
+ type = string
+ description = "The version of Claude Code to install."
+ default = "latest"
+}
+
+variable "experiment_use_screen" {
+ type = bool
+ description = "Whether to use screen for running Claude Code in the background."
+ default = false
+}
+
+variable "experiment_use_tmux" {
+ type = bool
+ description = "Whether to use tmux instead of screen for running Claude Code in the background."
+ default = false
+}
+
+variable "experiment_report_tasks" {
+ type = bool
+ description = "Whether to enable task reporting."
+ default = false
+}
+
+variable "experiment_pre_install_script" {
+ type = string
+ description = "Custom script to run before installing Claude Code."
+ default = null
+}
+
+variable "experiment_post_install_script" {
+ type = string
+ description = "Custom script to run after installing Claude Code."
+ default = null
+}
+
+locals {
+ encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : ""
+ encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
+}
+
+# Install and Initialize Claude Code
+resource "coder_script" "claude_code" {
+ agent_id = var.agent_id
+ display_name = "Claude Code"
+ icon = var.icon
+ script = <<-EOT
+ #!/bin/bash
+ set -e
+
+ # Function to check if a command exists
+ command_exists() {
+ command -v "$1" >/dev/null 2>&1
+ }
+
+ # Run pre-install script if provided
+ if [ -n "${local.encoded_pre_install_script}" ]; then
+ echo "Running pre-install script..."
+ echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh
+ chmod +x /tmp/pre_install.sh
+ /tmp/pre_install.sh
+ fi
+
+ # Install Claude Code if enabled
+ if [ "${var.install_claude_code}" = "true" ]; then
+ if ! command_exists npm; then
+ echo "Error: npm is not installed. Please install Node.js and npm first."
+ exit 1
+ fi
+ echo "Installing Claude Code..."
+ npm install -g @anthropic-ai/claude-code@${var.claude_code_version}
+ fi
+
+ # Run post-install script if provided
+ if [ -n "${local.encoded_post_install_script}" ]; then
+ echo "Running post-install script..."
+ echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh
+ chmod +x /tmp/post_install.sh
+ /tmp/post_install.sh
+ fi
+
+ if [ "${var.experiment_report_tasks}" = "true" ]; then
+ echo "Configuring Claude Code to report tasks via Coder MCP..."
+ coder exp mcp configure claude-code ${var.folder}
+ fi
+
+ # Handle terminal multiplexer selection (tmux or screen)
+ if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then
+ echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously."
+ echo "Please set only one of them to true."
+ exit 1
+ fi
+
+ # Run with tmux if enabled
+ if [ "${var.experiment_use_tmux}" = "true" ]; then
+ echo "Running Claude Code in the background with tmux..."
+
+ # Check if tmux is installed
+ if ! command_exists tmux; then
+ echo "Error: tmux is not installed. Please install tmux manually."
+ exit 1
+ fi
+
+ touch "$HOME/.claude-code.log"
+
+ export LANG=en_US.UTF-8
+ export LC_ALL=en_US.UTF-8
+
+ # Create a new tmux session in detached mode
+ tmux new-session -d -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions"
+
+ # Send the prompt to the tmux session if needed
+ if [ -n "$CODER_MCP_CLAUDE_TASK_PROMPT" ]; then
+ tmux send-keys -t claude-code "$CODER_MCP_CLAUDE_TASK_PROMPT"
+ sleep 5
+ tmux send-keys -t claude-code Enter
+ fi
+ fi
+
+ # Run with screen if enabled
+ if [ "${var.experiment_use_screen}" = "true" ]; then
+ echo "Running Claude Code in the background..."
+
+ # Check if screen is installed
+ if ! command_exists screen; then
+ echo "Error: screen is not installed. Please install screen manually."
+ exit 1
+ fi
+
+ touch "$HOME/.claude-code.log"
+
+ # Ensure the screenrc exists
+ if [ ! -f "$HOME/.screenrc" ]; then
+ echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.claude-code.log"
+ echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
+ fi
+
+ if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
+ echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log"
+ echo "multiuser on" >> "$HOME/.screenrc"
+ fi
+
+ if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then
+ echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log"
+ echo "acladd $(whoami)" >> "$HOME/.screenrc"
+ fi
+ export LANG=en_US.UTF-8
+ export LC_ALL=en_US.UTF-8
+
+ screen -U -dmS claude-code bash -c '
+ cd ${var.folder}
+ claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"
+ exec bash
+ '
+ # Extremely hacky way to send the prompt to the screen session
+ # This will be fixed in the future, but `claude` was not sending MCP
+ # tasks when an initial prompt is provided.
+ screen -S claude-code -X stuff "$CODER_MCP_CLAUDE_TASK_PROMPT"
+ sleep 5
+ screen -S claude-code -X stuff "^M"
+ else
+ # Check if claude is installed before running
+ if ! command_exists claude; then
+ echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually."
+ exit 1
+ fi
+ fi
+ EOT
+ run_on_start = true
+}
+
+resource "coder_app" "claude_code" {
+ slug = "claude-code"
+ display_name = "Claude Code"
+ agent_id = var.agent_id
+ command = <<-EOT
+ #!/bin/bash
+ set -e
+
+ export LANG=en_US.UTF-8
+ export LC_ALL=en_US.UTF-8
+
+ if [ "${var.experiment_use_tmux}" = "true" ]; then
+ if tmux has-session -t claude-code 2>/dev/null; then
+ echo "Attaching to existing Claude Code tmux session." | tee -a "$HOME/.claude-code.log"
+ tmux attach-session -t claude-code
+ else
+ echo "Starting a new Claude Code tmux session." | tee -a "$HOME/.claude-code.log"
+ tmux new-session -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions | tee -a \"$HOME/.claude-code.log\"; exec bash"
+ fi
+ elif [ "${var.experiment_use_screen}" = "true" ]; then
+ if screen -list | grep -q "claude-code"; then
+ echo "Attaching to existing Claude Code screen session." | tee -a "$HOME/.claude-code.log"
+ screen -xRR claude-code
+ else
+ echo "Starting a new Claude Code screen session." | tee -a "$HOME/.claude-code.log"
+ screen -S claude-code bash -c 'claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"; exec bash'
+ fi
+ else
+ cd ${var.folder}
+ claude
+ fi
+ EOT
+ icon = var.icon
+}
diff --git a/code-server/README.md b/code-server/README.md
index 40fbb788..dc44237f 100644
--- a/code-server/README.md
+++ b/code-server/README.md
@@ -15,7 +15,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.29"
+ version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -30,7 +30,7 @@ module "code-server" {
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.29"
+ version = "1.1.0"
agent_id = coder_agent.example.id
install_version = "4.8.3"
}
@@ -44,7 +44,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.29"
+ version = "1.1.0"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -56,13 +56,13 @@ Enter the `.` into the extensions array and code-server will autom
### Pre-configure Settings
-Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file:
+Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file:
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.29"
+ version = "1.1.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -79,7 +79,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.29"
+ version = "1.1.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
@@ -95,7 +95,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.29"
+ version = "1.1.0"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@@ -108,7 +108,7 @@ Just run code-server in the background, don't fetch it from GitHub:
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.29"
+ version = "1.1.0"
agent_id = coder_agent.example.id
offline = true
}
diff --git a/code-server/main.tf b/code-server/main.tf
index c80e5378..ca4ff3af 100644
--- a/code-server/main.tf
+++ b/code-server/main.tf
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
- version = ">= 0.17"
+ version = ">= 2.1"
}
}
}
@@ -122,6 +122,20 @@ variable "subdomain" {
default = false
}
+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'."
+ }
+}
+
resource "coder_script" "code-server" {
agent_id = var.agent_id
display_name = "code-server"
@@ -166,6 +180,7 @@ resource "coder_app" "code-server" {
subdomain = var.subdomain
share = var.share
order = var.order
+ open_in = var.open_in
healthcheck {
url = "http://localhost:${var.port}/healthz"
diff --git a/code-server/run.sh b/code-server/run.sh
index 457cff7f..99b30c0e 100755
--- a/code-server/run.sh
+++ b/code-server/run.sh
@@ -42,6 +42,11 @@ fi
if [ ! -f "$CODE_SERVER" ] || [ "${USE_CACHED}" != true ]; then
printf "$${BOLD}Installing code-server!\n"
+ # Clean up from other install (in case install prefix changed).
+ if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/code-server" ]; then
+ rm "$CODER_SCRIPT_BIN_DIR/code-server"
+ fi
+
ARGS=(
"--method=standalone"
"--prefix=${INSTALL_PREFIX}"
@@ -58,6 +63,11 @@ if [ ! -f "$CODE_SERVER" ] || [ "${USE_CACHED}" != true ]; then
printf "đĨŗ code-server has been installed in ${INSTALL_PREFIX}\n\n"
fi
+# Make the code-server available in PATH.
+if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/code-server" ]; then
+ ln -s "$CODE_SERVER" "$CODER_SCRIPT_BIN_DIR/code-server"
+fi
+
# Get the list of installed extensions...
LIST_EXTENSIONS=$($CODE_SERVER --list-extensions $EXTENSION_ARG)
readarray -t EXTENSIONS_ARRAY <<< "$LIST_EXTENSIONS"
diff --git a/cursor/README.md b/cursor/README.md
index 46d73b5c..d9a2e17f 100644
--- a/cursor/README.md
+++ b/cursor/README.md
@@ -11,7 +11,7 @@ tags: [ide, cursor, helper]
Add a button to open any workspace with a single click in Cursor IDE.
-Uses the [Coder Remote VS Code Extension](https://github.com/coder/cursor-coder).
+Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder).
```tf
module "cursor" {
diff --git a/devcontainers-cli/README.md b/devcontainers-cli/README.md
new file mode 100644
index 00000000..4b445073
--- /dev/null
+++ b/devcontainers-cli/README.md
@@ -0,0 +1,22 @@
+---
+display_name: devcontainers-cli
+description: devcontainers-cli module provides an easy way to install @devcontainers/cli into a workspace
+icon: ../.icons/devcontainers.svg
+verified: true
+maintainer_github: coder
+tags: [devcontainers]
+---
+
+# devcontainers-cli
+
+The devcontainers-cli module provides an easy way to install [`@devcontainers/cli`](https://github.com/devcontainers/cli) into a workspace. It can be used within any workspace as it runs only if
+@devcontainers/cli is not installed yet.
+`npm` is required and should be pre-installed in order for the module to work.
+
+```tf
+module "devcontainers-cli" {
+ source = "registry.coder.com/modules/devcontainers-cli/coder"
+ version = "1.0.3"
+ agent_id = coder_agent.example.id
+}
+```
diff --git a/devcontainers-cli/main.test.ts b/devcontainers-cli/main.test.ts
new file mode 100644
index 00000000..892d6430
--- /dev/null
+++ b/devcontainers-cli/main.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from "bun:test";
+import {
+ execContainer,
+ executeScriptInContainer,
+ findResourceInstance,
+ runContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+ type TerraformState,
+} from "../test";
+
+const executeScriptInContainerWithPackageManager = async (
+ state: TerraformState,
+ image: string,
+ packageManager: string,
+ shell = "sh",
+): Promise<{
+ exitCode: number;
+ stdout: string[];
+ stderr: string[];
+}> => {
+ const instance = findResourceInstance(state, "coder_script");
+ const id = await runContainer(image);
+
+ // Install the specified package manager
+ if (packageManager === "npm") {
+ await execContainer(id, [shell, "-c", "apk add nodejs npm"]);
+ } else if (packageManager === "pnpm") {
+ await execContainer(id, [
+ shell,
+ "-c",
+ `wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh -`,
+ ]);
+ } else if (packageManager === "yarn") {
+ await execContainer(id, [
+ shell,
+ "-c",
+ "apk add nodejs npm && npm install -g yarn",
+ ]);
+ }
+
+ const pathResp = await execContainer(id, [shell, "-c", "echo $PATH"]);
+ const path = pathResp.stdout.trim();
+
+ console.log(path);
+
+ const resp = await execContainer(
+ id,
+ [shell, "-c", instance.script],
+ [
+ "--env",
+ "CODER_SCRIPT_BIN_DIR=/tmp/coder-script-data/bin",
+ "--env",
+ `PATH=${path}:/tmp/coder-script-data/bin`,
+ ],
+ );
+ const stdout = resp.stdout.trim().split("\n");
+ const stderr = resp.stderr.trim().split("\n");
+ return {
+ exitCode: resp.exitCode,
+ stdout,
+ stderr,
+ };
+};
+
+describe("devcontainers-cli", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "some-agent-id",
+ });
+
+ it("misses all package managers", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ });
+ const output = await executeScriptInContainer(state, "docker:dind");
+ expect(output.exitCode).toBe(1);
+ expect(output.stderr).toEqual([
+ "ERROR: No supported package manager (npm, pnpm, yarn) is installed. Please install one first.",
+ ]);
+ }, 15000);
+
+ it("installs devcontainers-cli with npm", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ });
+
+ const output = await executeScriptInContainerWithPackageManager(
+ state,
+ "docker:dind",
+ "npm",
+ );
+ expect(output.exitCode).toBe(0);
+
+ expect(output.stdout[0]).toEqual(
+ "Installing @devcontainers/cli using npm...",
+ );
+ expect(output.stdout[output.stdout.length - 1]).toEqual(
+ "đĨŗ @devcontainers/cli has been installed into /usr/local/bin/devcontainer!",
+ );
+ }, 15000);
+
+ it("installs devcontainers-cli with yarn", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ });
+
+ const output = await executeScriptInContainerWithPackageManager(
+ state,
+ "docker:dind",
+ "yarn",
+ );
+ expect(output.exitCode).toBe(0);
+
+ expect(output.stdout[0]).toEqual(
+ "Installing @devcontainers/cli using yarn...",
+ );
+ expect(output.stdout[output.stdout.length - 1]).toEqual(
+ "đĨŗ @devcontainers/cli has been installed into /tmp/coder-script-data/bin/devcontainer!",
+ );
+ }, 15000);
+
+ it("displays warning if docker is not installed", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ });
+
+ const output = await executeScriptInContainerWithPackageManager(
+ state,
+ "alpine",
+ "npm",
+ );
+ expect(output.exitCode).toBe(0);
+
+ expect(output.stdout[0]).toEqual(
+ "WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available.",
+ );
+ expect(output.stdout[output.stdout.length - 1]).toEqual(
+ "đĨŗ @devcontainers/cli has been installed into /usr/local/bin/devcontainer!",
+ );
+ }, 15000);
+});
diff --git a/devcontainers-cli/main.tf b/devcontainers-cli/main.tf
new file mode 100644
index 00000000..a2aee348
--- /dev/null
+++ b/devcontainers-cli/main.tf
@@ -0,0 +1,23 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.17"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+resource "coder_script" "devcontainers-cli" {
+ agent_id = var.agent_id
+ display_name = "devcontainers-cli"
+ icon = "/icon/devcontainers.svg"
+ script = templatefile("${path.module}/run.sh", {})
+ run_on_start = true
+}
diff --git a/devcontainers-cli/run.sh b/devcontainers-cli/run.sh
new file mode 100755
index 00000000..bd3c1b1d
--- /dev/null
+++ b/devcontainers-cli/run.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env sh
+
+# If @devcontainers/cli is already installed, we can skip
+if command -v devcontainer > /dev/null 2>&1; then
+ echo "đĨŗ @devcontainers/cli is already installed into $(which devcontainer)!"
+ exit 0
+fi
+
+# Check if docker is installed
+if ! command -v docker > /dev/null 2>&1; then
+ echo "WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available."
+fi
+
+# Determine the package manager to use: npm, pnpm, or yarn
+if command -v yarn > /dev/null 2>&1; then
+ PACKAGE_MANAGER="yarn"
+elif command -v npm > /dev/null 2>&1; then
+ PACKAGE_MANAGER="npm"
+elif command -v pnpm > /dev/null 2>&1; then
+ PACKAGE_MANAGER="pnpm"
+else
+ echo "ERROR: No supported package manager (npm, pnpm, yarn) is installed. Please install one first." 1>&2
+ exit 1
+fi
+
+install() {
+ echo "Installing @devcontainers/cli using $PACKAGE_MANAGER..."
+ if [ "$PACKAGE_MANAGER" = "npm" ]; then
+ npm install -g @devcontainers/cli
+ elif [ "$PACKAGE_MANAGER" = "pnpm" ]; then
+ # Check if PNPM_HOME is set, if not, set it to the script's bin directory
+ # pnpm needs this to be set to install binaries
+ # coder agent ensures this part is part of the PATH
+ # so that the devcontainer command is available
+ if [ -z "$PNPM_HOME" ]; then
+ PNPM_HOME="$CODER_SCRIPT_BIN_DIR"
+ export M_HOME
+ fi
+ pnpm add -g @devcontainers/cli
+ elif [ "$PACKAGE_MANAGER" = "yarn" ]; then
+ yarn global add @devcontainers/cli --prefix "$(dirname "$CODER_SCRIPT_BIN_DIR")"
+ fi
+}
+
+if ! install; then
+ echo "Failed to install @devcontainers/cli" >&2
+ exit 1
+fi
+
+if ! command -v devcontainer > /dev/null 2>&1; then
+ echo "Installation completed but 'devcontainer' command not found in PATH" >&2
+ exit 1
+fi
+
+echo "đĨŗ @devcontainers/cli has been installed into $(which devcontainer)!"
+exit 0
diff --git a/filebrowser/README.md b/filebrowser/README.md
index 13e06577..3a0e56bd 100644
--- a/filebrowser/README.md
+++ b/filebrowser/README.md
@@ -15,7 +15,7 @@ A file browser for your workspace.
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/filebrowser/coder"
- version = "1.0.29"
+ version = "1.0.31"
agent_id = coder_agent.example.id
}
```
@@ -30,7 +30,7 @@ module "filebrowser" {
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/filebrowser/coder"
- version = "1.0.29"
+ version = "1.0.31"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -42,7 +42,7 @@ module "filebrowser" {
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/filebrowser/coder"
- version = "1.0.29"
+ version = "1.0.31"
agent_id = coder_agent.example.id
database_path = ".config/filebrowser.db"
}
@@ -54,7 +54,7 @@ module "filebrowser" {
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/filebrowser/coder"
- version = "1.0.29"
+ version = "1.0.31"
agent_id = coder_agent.example.id
agent_name = "main"
subdomain = false
diff --git a/filebrowser/main.test.ts b/filebrowser/main.test.ts
index 368075b2..34680880 100644
--- a/filebrowser/main.test.ts
+++ b/filebrowser/main.test.ts
@@ -3,9 +3,27 @@ import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
+ type scriptOutput,
testRequiredVariables,
} from "../test";
+function testBaseLine(output: scriptOutput) {
+ expect(output.exitCode).toBe(0);
+
+ const expectedLines = [
+ "\u001b[[0;1mInstalling filebrowser ",
+ "đĨŗ Installation complete! ",
+ "đˇ Starting filebrowser in background... ",
+ "đ Serving /root at http://localhost:13339 ",
+ "đ Logs at /tmp/filebrowser.log",
+ ];
+
+ // we could use expect(output.stdout).toEqual(expect.arrayContaining(expectedLines)), but when it errors, it doesn't say which line is wrong
+ for (const line of expectedLines) {
+ expect(output.stdout).toContain(line);
+ }
+}
+
describe("filebrowser", async () => {
await runTerraformInit(import.meta.dir);
@@ -28,21 +46,15 @@ describe("filebrowser", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
- const output = await executeScriptInContainer(state, "alpine");
- expect(output.exitCode).toBe(0);
- expect(output.stdout).toEqual([
- "\u001b[0;1mInstalling filebrowser ",
- "",
- "đĨŗ Installation complete! ",
- "",
- "đˇ Starting filebrowser in background... ",
- "",
- "đ Serving /root at http://localhost:13339 ",
- "",
- "Running 'filebrowser --noauth --root /root --port 13339 --baseurl ' ",
- "",
- "đ Logs at /tmp/filebrowser.log",
- ]);
+
+ const output = await executeScriptInContainer(
+ state,
+ "alpine/curl",
+ "sh",
+ "apk add bash",
+ );
+
+ testBaseLine(output);
});
it("runs with database_path var", async () => {
@@ -50,21 +62,15 @@ describe("filebrowser", async () => {
agent_id: "foo",
database_path: ".config/filebrowser.db",
});
- const output = await executeScriptInContainer(state, "alpine");
- expect(output.exitCode).toBe(0);
- expect(output.stdout).toEqual([
- "\u001b[0;1mInstalling filebrowser ",
- "",
- "đĨŗ Installation complete! ",
- "",
- "đˇ Starting filebrowser in background... ",
- "",
- "đ Serving /root at http://localhost:13339 ",
- "",
- "Running 'filebrowser --noauth --root /root --port 13339 -d .config/filebrowser.db --baseurl ' ",
- "",
- "đ Logs at /tmp/filebrowser.log",
- ]);
+
+ const output = await await executeScriptInContainer(
+ state,
+ "alpine/curl",
+ "sh",
+ "apk add bash",
+ );
+
+ testBaseLine(output);
});
it("runs with folder var", async () => {
@@ -72,21 +78,12 @@ describe("filebrowser", async () => {
agent_id: "foo",
folder: "/home/coder/project",
});
- const output = await executeScriptInContainer(state, "alpine");
- expect(output.exitCode).toBe(0);
- expect(output.stdout).toEqual([
- "\u001b[0;1mInstalling filebrowser ",
- "",
- "đĨŗ Installation complete! ",
- "",
- "đˇ Starting filebrowser in background... ",
- "",
- "đ Serving /home/coder/project at http://localhost:13339 ",
- "",
- "Running 'filebrowser --noauth --root /home/coder/project --port 13339 --baseurl ' ",
- "",
- "đ Logs at /tmp/filebrowser.log",
- ]);
+ const output = await await executeScriptInContainer(
+ state,
+ "alpine/curl",
+ "sh",
+ "apk add bash",
+ );
});
it("runs with subdomain=false", async () => {
@@ -95,20 +92,14 @@ describe("filebrowser", async () => {
agent_name: "main",
subdomain: false,
});
- const output = await executeScriptInContainer(state, "alpine");
- expect(output.exitCode).toBe(0);
- expect(output.stdout).toEqual([
- "\u001B[0;1mInstalling filebrowser ",
- "",
- "đĨŗ Installation complete! ",
- "",
- "đˇ Starting filebrowser in background... ",
- "",
- "đ Serving /root at http://localhost:13339 ",
- "",
- "Running 'filebrowser --noauth --root /root --port 13339 --baseurl /@default/default.main/apps/filebrowser' ",
- "",
- "đ Logs at /tmp/filebrowser.log",
- ]);
+
+ const output = await await executeScriptInContainer(
+ state,
+ "alpine/curl",
+ "sh",
+ "apk add bash",
+ );
+
+ testBaseLine(output);
});
});
diff --git a/filebrowser/run.sh b/filebrowser/run.sh
index d6afea02..62f04edf 100644
--- a/filebrowser/run.sh
+++ b/filebrowser/run.sh
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
-BOLD='\033[0;1m'
+set -euo pipefail
+
+BOLD='\033[[0;1m'
printf "$${BOLD}Installing filebrowser \n\n"
@@ -11,20 +13,27 @@ fi
printf "đĨŗ Installation complete! \n\n"
-printf "đˇ Starting filebrowser in background... \n\n"
+printf "đ ī¸ Configuring filebrowser \n\n"
ROOT_DIR=${FOLDER}
ROOT_DIR=$${ROOT_DIR/\~/$HOME}
-DB_FLAG=""
-if [ "${DB_PATH}" != "filebrowser.db" ]; then
- DB_FLAG=" -d ${DB_PATH}"
+echo "DB_PATH: ${DB_PATH}"
+
+export FB_DATABASE="${DB_PATH}"
+
+# Check if filebrowser db exists
+if [[ ! -f "${DB_PATH}" ]]; then
+ filebrowser config init 2>&1 | tee -a ${LOG_PATH}
+ filebrowser users add admin "" --perm.admin=true --viewMode=mosaic 2>&1 | tee -a ${LOG_PATH}
fi
-printf "đ Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n"
+filebrowser config set --baseurl=${SERVER_BASE_PATH} --port=${PORT} --auth.method=noauth --root=$ROOT_DIR 2>&1 | tee -a ${LOG_PATH}
+
+printf "đˇ Starting filebrowser in background... \n\n"
-printf "Running 'filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG} --baseurl ${SERVER_BASE_PATH}' \n\n"
+printf "đ Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n"
-filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG} --baseurl ${SERVER_BASE_PATH} > ${LOG_PATH} 2>&1 &
+filebrowser >> ${LOG_PATH} 2>&1 &
printf "đ Logs at ${LOG_PATH} \n\n"
diff --git a/goose/README.md b/goose/README.md
new file mode 100644
index 00000000..89014891
--- /dev/null
+++ b/goose/README.md
@@ -0,0 +1,160 @@
+---
+display_name: Goose
+description: Run Goose in your workspace
+icon: ../.icons/goose.svg
+maintainer_github: coder
+verified: true
+tags: [agent, goose]
+---
+
+# Goose
+
+Run the [Goose](https://block.github.io/goose/) agent in your workspace to generate code and perform tasks.
+
+```tf
+module "goose" {
+ source = "registry.coder.com/modules/goose/coder"
+ version = "1.1.0"
+ agent_id = coder_agent.example.id
+ folder = "/home/coder"
+ install_goose = true
+ goose_version = "v1.0.16"
+}
+```
+
+### Prerequisites
+
+- `screen` must be installed in your workspace to run Goose in the background
+- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
+
+The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces.
+
+## Examples
+
+Your workspace must have `screen` installed to use this.
+
+### Run in the background and report tasks (Experimental)
+
+> This functionality is in early access as of Coder v2.21 and is still evolving.
+> For now, we recommend testing it in a demo or staging environment,
+> rather than deploying to production
+>
+> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents)
+>
+> Join our [Discord channel](https://discord.gg/coder) or
+> [contact us](https://coder.com/contact) to get help or share feedback.
+
+```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 = "The Anthropic API key"
+ sensitive = true
+}
+
+data "coder_parameter" "ai_prompt" {
+ type = "string"
+ name = "AI Prompt"
+ default = ""
+ description = "Write a prompt for Goose"
+ mutable = true
+}
+
+# Set the prompt and system prompt for Goose via environment variables
+resource "coder_agent" "main" {
+ # ...
+ env = {
+ GOOSE_SYSTEM_PROMPT = <<-EOT
+ You are a helpful assistant that can help write code.
+
+ Run all long running tasks (e.g. npm run dev) in the background and not in the foreground.
+
+ Periodically check in on background tasks.
+
+ Notify Coder of the status of the task before and after your steps.
+ EOT
+ GOOSE_TASK_PROMPT = data.coder_parameter.ai_prompt.value
+
+ # An API key is required for experiment_auto_configure
+ # See https://block.github.io/goose/docs/getting-started/providers
+ ANTHROPIC_API_KEY = var.anthropic_api_key # or use a coder_parameter
+ }
+}
+
+module "goose" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/goose/coder"
+ version = "1.1.0"
+ agent_id = coder_agent.example.id
+ folder = "/home/coder"
+ install_goose = true
+ goose_version = "v1.0.16"
+
+ # Enable experimental features
+ experiment_report_tasks = true
+
+ # Run Goose in the background
+ experiment_use_screen = true
+
+ # Avoid configuring Goose manually
+ experiment_auto_configure = true
+
+ # Required for experiment_auto_configure
+ experiment_goose_provider = "anthropic"
+ experiment_goose_model = "claude-3-5-sonnet-latest"
+}
+```
+
+### Adding Custom Extensions (MCP)
+
+You can extend Goose's capabilities by adding custom extensions. For example, to add the desktop-commander extension:
+
+```tf
+module "goose" {
+ # ... other configuration ...
+
+ experiment_pre_install_script = <<-EOT
+ npm i -g @wonderwhy-er/desktop-commander@latest
+ EOT
+
+ experiment_additional_extensions = <<-EOT
+ desktop-commander:
+ args: []
+ cmd: desktop-commander
+ description: Ideal for background tasks
+ enabled: true
+ envs: {}
+ name: desktop-commander
+ timeout: 300
+ type: stdio
+ EOT
+}
+```
+
+This will add the desktop-commander extension to Goose, allowing it to run commands in the background. The extension will be available in the Goose interface and can be used to run long-running processes like development servers.
+
+Note: The indentation in the heredoc is preserved, so you can write the YAML naturally.
+
+## Run standalone
+
+Run Goose as a standalone app in your workspace. This will install Goose and run it directly without using screen or any task reporting to the Coder UI.
+
+```tf
+module "goose" {
+ source = "registry.coder.com/modules/goose/coder"
+ version = "1.1.0"
+ agent_id = coder_agent.example.id
+ folder = "/home/coder"
+ install_goose = true
+ goose_version = "v1.0.16"
+
+ # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL
+ icon = "https://raw.githubusercontent.com/block/goose/refs/heads/main/ui/desktop/src/images/icon.svg"
+}
+```
diff --git a/goose/main.tf b/goose/main.tf
new file mode 100644
index 00000000..0043000e
--- /dev/null
+++ b/goose/main.tf
@@ -0,0 +1,289 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.17"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+data "coder_workspace" "me" {}
+
+data "coder_workspace_owner" "me" {}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+variable "icon" {
+ type = string
+ description = "The icon to use for the app."
+ default = "/icon/goose.svg"
+}
+
+variable "folder" {
+ type = string
+ description = "The folder to run Goose in."
+ default = "/home/coder"
+}
+
+variable "install_goose" {
+ type = bool
+ description = "Whether to install Goose."
+ default = true
+}
+
+variable "goose_version" {
+ type = string
+ description = "The version of Goose to install."
+ default = "stable"
+}
+
+variable "experiment_use_screen" {
+ type = bool
+ description = "Whether to use screen for running Goose in the background."
+ default = false
+}
+
+variable "experiment_report_tasks" {
+ type = bool
+ description = "Whether to enable task reporting."
+ default = false
+}
+
+variable "experiment_auto_configure" {
+ type = bool
+ description = "Whether to automatically configure Goose."
+ default = false
+}
+
+variable "experiment_goose_provider" {
+ type = string
+ description = "The provider to use for Goose (e.g., anthropic)."
+ default = null
+}
+
+variable "experiment_goose_model" {
+ type = string
+ description = "The model to use for Goose (e.g., claude-3-5-sonnet-latest)."
+ default = null
+}
+
+variable "experiment_pre_install_script" {
+ type = string
+ description = "Custom script to run before installing Goose."
+ default = null
+}
+
+variable "experiment_post_install_script" {
+ type = string
+ description = "Custom script to run after installing Goose."
+ default = null
+}
+
+variable "experiment_additional_extensions" {
+ type = string
+ description = "Additional extensions configuration in YAML format to append to the config."
+ 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: goose
+ name: Coder
+ timeout: 3000
+ type: stdio
+developer:
+ display_name: Developer
+ enabled: true
+ name: developer
+ timeout: 300
+ type: builtin
+EOT
+
+ # Add two spaces to each line of extensions to match YAML structure
+ formatted_base = " ${replace(trimspace(local.base_extensions), "\n", "\n ")}"
+ additional_extensions = var.experiment_additional_extensions != null ? "\n ${replace(trimspace(var.experiment_additional_extensions), "\n", "\n ")}" : ""
+
+ combined_extensions = <<-EOT
+extensions:
+${local.formatted_base}${local.additional_extensions}
+EOT
+
+ encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : ""
+ encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
+}
+
+# Install and Initialize Goose
+resource "coder_script" "goose" {
+ agent_id = var.agent_id
+ display_name = "Goose"
+ icon = var.icon
+ script = <<-EOT
+ #!/bin/bash
+ set -e
+
+ # Function to check if a command exists
+ command_exists() {
+ command -v "$1" >/dev/null 2>&1
+ }
+
+ # Run pre-install script if provided
+ if [ -n "${local.encoded_pre_install_script}" ]; then
+ echo "Running pre-install script..."
+ echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh
+ chmod +x /tmp/pre_install.sh
+ /tmp/pre_install.sh
+ fi
+
+ # Install Goose if enabled
+ if [ "${var.install_goose}" = "true" ]; then
+ if ! command_exists npm; then
+ echo "Error: npm is not installed. Please install Node.js and npm first."
+ exit 1
+ fi
+ echo "Installing Goose..."
+ RELEASE_TAG=v${var.goose_version} curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash
+ fi
+
+ # Run post-install script if provided
+ if [ -n "${local.encoded_post_install_script}" ]; then
+ echo "Running post-install script..."
+ echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh
+ chmod +x /tmp/post_install.sh
+ /tmp/post_install.sh
+ fi
+
+ # Configure Goose if auto-configure is enabled
+ if [ "${var.experiment_auto_configure}" = "true" ]; then
+ echo "Configuring Goose..."
+ mkdir -p "$HOME/.config/goose"
+ cat > "$HOME/.config/goose/config.yaml" << EOL
+GOOSE_PROVIDER: ${var.experiment_goose_provider}
+GOOSE_MODEL: ${var.experiment_goose_model}
+${trimspace(local.combined_extensions)}
+EOL
+ fi
+
+ # Write system prompt to config
+ mkdir -p "$HOME/.config/goose"
+ echo "$GOOSE_SYSTEM_PROMPT" > "$HOME/.config/goose/.goosehints"
+
+ # Run with screen if enabled
+ if [ "${var.experiment_use_screen}" = "true" ]; then
+ echo "Running Goose in the background..."
+
+ # Check if screen is installed
+ if ! command_exists screen; then
+ echo "Error: screen is not installed. Please install screen manually."
+ exit 1
+ fi
+
+ touch "$HOME/.goose.log"
+
+ # Ensure the screenrc exists
+ if [ ! -f "$HOME/.screenrc" ]; then
+ echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.goose.log"
+ echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
+ fi
+
+ if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
+ echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.goose.log"
+ echo "multiuser on" >> "$HOME/.screenrc"
+ fi
+
+ if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then
+ echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.goose.log"
+ echo "acladd $(whoami)" >> "$HOME/.screenrc"
+ fi
+ export LANG=en_US.UTF-8
+ export LC_ALL=en_US.UTF-8
+
+ # Determine goose command
+ if command_exists goose; then
+ GOOSE_CMD=goose
+ elif [ -f "$HOME/.local/bin/goose" ]; then
+ GOOSE_CMD="$HOME/.local/bin/goose"
+ else
+ echo "Error: Goose is not installed. Please enable install_goose or install it manually."
+ exit 1
+ fi
+
+ screen -U -dmS goose bash -c "
+ cd ${var.folder}
+ \"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\"
+ /bin/bash
+ "
+ else
+ # Check if goose is installed before running
+ if command_exists goose; then
+ GOOSE_CMD=goose
+ elif [ -f "$HOME/.local/bin/goose" ]; then
+ GOOSE_CMD="$HOME/.local/bin/goose"
+ else
+ echo "Error: Goose is not installed. Please enable install_goose or install it manually."
+ exit 1
+ fi
+ fi
+ EOT
+ run_on_start = true
+}
+
+resource "coder_app" "goose" {
+ slug = "goose"
+ display_name = "Goose"
+ agent_id = var.agent_id
+ command = <<-EOT
+ #!/bin/bash
+ set -e
+
+ # Function to check if a command exists
+ command_exists() {
+ command -v "$1" >/dev/null 2>&1
+ }
+
+ # Determine goose command
+ if command_exists goose; then
+ GOOSE_CMD=goose
+ elif [ -f "$HOME/.local/bin/goose" ]; then
+ GOOSE_CMD="$HOME/.local/bin/goose"
+ else
+ echo "Error: Goose is not installed. Please enable install_goose or install it manually."
+ exit 1
+ fi
+
+ if [ "${var.experiment_use_screen}" = "true" ]; then
+ # Check if session exists first
+ if ! screen -list | grep -q "goose"; then
+ echo "Error: No existing Goose session found. Please wait for the script to start it."
+ exit 1
+ fi
+ # Only attach to existing session
+ screen -xRR goose
+ else
+ cd ${var.folder}
+ export LANG=en_US.UTF-8
+ export LC_ALL=en_US.UTF-8
+ "$GOOSE_CMD" run --text "Review goosehints. Your task: $GOOSE_TASK_PROMPT" --interactive
+ fi
+ EOT
+ icon = var.icon
+}
diff --git a/jetbrains-gateway/README.md b/jetbrains-gateway/README.md
index 73c1e128..f3fc33f7 100644
--- a/jetbrains-gateway/README.md
+++ b/jetbrains-gateway/README.md
@@ -18,7 +18,7 @@ Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prereq
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
- version = "1.0.28"
+ version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"]
@@ -36,7 +36,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
- version = "1.0.28"
+ version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
@@ -50,7 +50,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
- version = "1.0.28"
+ version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["IU", "PY"]
@@ -65,7 +65,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
- version = "1.0.28"
+ version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["IU", "PY"]
@@ -90,7 +90,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
- version = "1.0.28"
+ version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
@@ -108,7 +108,7 @@ Due to the highest priority of the `ide_download_link` parameter in the `(jetbra
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
- version = "1.0.28"
+ version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
diff --git a/jetbrains-gateway/main.tf b/jetbrains-gateway/main.tf
index d197399d..502469f2 100644
--- a/jetbrains-gateway/main.tf
+++ b/jetbrains-gateway/main.tf
@@ -13,6 +13,16 @@ terraform {
}
}
+variable "arch" {
+ type = string
+ description = "The target architecture of the workspace"
+ default = "amd64"
+ validation {
+ condition = contains(["amd64", "arm64"], var.arch)
+ error_message = "Architecture must be either 'amd64' or 'arm64'."
+ }
+}
+
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
@@ -178,78 +188,100 @@ data "http" "jetbrains_ide_versions" {
}
locals {
+ # AMD64 versions of the images just use the version string, while ARM64
+ # versions append "-aarch64". Eg:
+ #
+ # https://download.jetbrains.com/idea/ideaIU-2025.1.tar.gz
+ # https://download.jetbrains.com/idea/ideaIU-2025.1.tar.gz
+ #
+ # We rewrite the data map above dynamically based on the user's architecture parameter.
+ #
+ effective_jetbrains_ide_versions = {
+ for k, v in var.jetbrains_ide_versions : k => {
+ build_number = v.build_number
+ version = var.arch == "arm64" ? "${v.version}-aarch64" : v.version
+ }
+ }
+
+ # When downloading the latest IDE, the download link in the JSON is either:
+ #
+ # linux.download_link
+ # linuxARM64.download_link
+ #
+ download_key = var.arch == "arm64" ? "linuxARM64" : "linux"
+
jetbrains_ides = {
"GO" = {
icon = "/icon/goland.svg",
name = "GoLand",
identifier = "GO",
- build_number = var.jetbrains_ide_versions["GO"].build_number,
- download_link = "${var.download_base_link}/go/goland-${var.jetbrains_ide_versions["GO"].version}.tar.gz"
- version = var.jetbrains_ide_versions["GO"].version
+ build_number = local.effective_jetbrains_ide_versions["GO"].build_number,
+ download_link = "${var.download_base_link}/go/goland-${local.effective_jetbrains_ide_versions["GO"].version}.tar.gz"
+ version = local.effective_jetbrains_ide_versions["GO"].version
},
"WS" = {
icon = "/icon/webstorm.svg",
name = "WebStorm",
identifier = "WS",
- build_number = var.jetbrains_ide_versions["WS"].build_number,
- download_link = "${var.download_base_link}/webstorm/WebStorm-${var.jetbrains_ide_versions["WS"].version}.tar.gz"
- version = var.jetbrains_ide_versions["WS"].version
+ build_number = local.effective_jetbrains_ide_versions["WS"].build_number,
+ download_link = "${var.download_base_link}/webstorm/WebStorm-${local.effective_jetbrains_ide_versions["WS"].version}.tar.gz"
+ version = local.effective_jetbrains_ide_versions["WS"].version
},
"IU" = {
icon = "/icon/intellij.svg",
name = "IntelliJ IDEA Ultimate",
identifier = "IU",
- build_number = var.jetbrains_ide_versions["IU"].build_number,
- download_link = "${var.download_base_link}/idea/ideaIU-${var.jetbrains_ide_versions["IU"].version}.tar.gz"
- version = var.jetbrains_ide_versions["IU"].version
+ build_number = local.effective_jetbrains_ide_versions["IU"].build_number,
+ download_link = "${var.download_base_link}/idea/ideaIU-${local.effective_jetbrains_ide_versions["IU"].version}.tar.gz"
+ version = local.effective_jetbrains_ide_versions["IU"].version
},
"PY" = {
icon = "/icon/pycharm.svg",
name = "PyCharm Professional",
identifier = "PY",
- build_number = var.jetbrains_ide_versions["PY"].build_number,
- download_link = "${var.download_base_link}/python/pycharm-professional-${var.jetbrains_ide_versions["PY"].version}.tar.gz"
- version = var.jetbrains_ide_versions["PY"].version
+ build_number = local.effective_jetbrains_ide_versions["PY"].build_number,
+ download_link = "${var.download_base_link}/python/pycharm-professional-${local.effective_jetbrains_ide_versions["PY"].version}.tar.gz"
+ version = local.effective_jetbrains_ide_versions["PY"].version
},
"CL" = {
icon = "/icon/clion.svg",
name = "CLion",
identifier = "CL",
- build_number = var.jetbrains_ide_versions["CL"].build_number,
- download_link = "${var.download_base_link}/cpp/CLion-${var.jetbrains_ide_versions["CL"].version}.tar.gz"
- version = var.jetbrains_ide_versions["CL"].version
+ build_number = local.effective_jetbrains_ide_versions["CL"].build_number,
+ download_link = "${var.download_base_link}/cpp/CLion-${local.effective_jetbrains_ide_versions["CL"].version}.tar.gz"
+ version = local.effective_jetbrains_ide_versions["CL"].version
},
"PS" = {
icon = "/icon/phpstorm.svg",
name = "PhpStorm",
identifier = "PS",
- build_number = var.jetbrains_ide_versions["PS"].build_number,
- download_link = "${var.download_base_link}/webide/PhpStorm-${var.jetbrains_ide_versions["PS"].version}.tar.gz"
- version = var.jetbrains_ide_versions["PS"].version
+ build_number = local.effective_jetbrains_ide_versions["PS"].build_number,
+ download_link = "${var.download_base_link}/webide/PhpStorm-${local.effective_jetbrains_ide_versions["PS"].version}.tar.gz"
+ version = local.effective_jetbrains_ide_versions["PS"].version
},
"RM" = {
icon = "/icon/rubymine.svg",
name = "RubyMine",
identifier = "RM",
- build_number = var.jetbrains_ide_versions["RM"].build_number,
- download_link = "${var.download_base_link}/ruby/RubyMine-${var.jetbrains_ide_versions["RM"].version}.tar.gz"
- version = var.jetbrains_ide_versions["RM"].version
+ build_number = local.effective_jetbrains_ide_versions["RM"].build_number,
+ download_link = "${var.download_base_link}/ruby/RubyMine-${local.effective_jetbrains_ide_versions["RM"].version}.tar.gz"
+ version = local.effective_jetbrains_ide_versions["RM"].version
},
"RD" = {
icon = "/icon/rider.svg",
name = "Rider",
identifier = "RD",
- build_number = var.jetbrains_ide_versions["RD"].build_number,
- download_link = "${var.download_base_link}/rider/JetBrains.Rider-${var.jetbrains_ide_versions["RD"].version}.tar.gz"
- version = var.jetbrains_ide_versions["RD"].version
+ build_number = local.effective_jetbrains_ide_versions["RD"].build_number,
+ download_link = "${var.download_base_link}/rider/JetBrains.Rider-${local.effective_jetbrains_ide_versions["RD"].version}.tar.gz"
+ version = local.effective_jetbrains_ide_versions["RD"].version
},
"RR" = {
icon = "/icon/rustrover.svg",
name = "RustRover",
identifier = "RR",
- build_number = var.jetbrains_ide_versions["RR"].build_number,
- download_link = "${var.download_base_link}/rustrover/RustRover-${var.jetbrains_ide_versions["RR"].version}.tar.gz"
- version = var.jetbrains_ide_versions["RR"].version
+ build_number = local.effective_jetbrains_ide_versions["RR"].build_number,
+ download_link = "${var.download_base_link}/rustrover/RustRover-${local.effective_jetbrains_ide_versions["RR"].version}.tar.gz"
+ version = local.effective_jetbrains_ide_versions["RR"].version
}
}
@@ -258,7 +290,7 @@ locals {
key = var.latest ? keys(local.json_data)[0] : ""
display_name = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].name
identifier = data.coder_parameter.jetbrains_ide.value
- download_link = var.latest ? local.json_data[local.key][0].downloads.linux.link : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].download_link
+ download_link = var.latest ? local.json_data[local.key][0].downloads[local.download_key].link : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].download_link
build_number = var.latest ? local.json_data[local.key][0].build : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].build_number
version = var.latest ? local.json_data[local.key][0].version : var.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].version
}
diff --git a/jfrog-token/README.md b/jfrog-token/README.md
index 146dc7f7..ce165222 100644
--- a/jfrog-token/README.md
+++ b/jfrog-token/README.md
@@ -15,7 +15,7 @@ Install the JF CLI and authenticate package managers with Artifactory using Arti
```tf
module "jfrog" {
source = "registry.coder.com/modules/jfrog-token/coder"
- version = "1.0.19"
+ version = "1.0.30"
agent_id = coder_agent.example.id
jfrog_url = "https://XXXX.jfrog.io"
artifactory_access_token = var.artifactory_access_token
@@ -42,7 +42,7 @@ For detailed instructions, please see this [guide](https://coder.com/docs/v2/lat
```tf
module "jfrog" {
source = "registry.coder.com/modules/jfrog-token/coder"
- version = "1.0.19"
+ version = "1.0.30"
agent_id = coder_agent.example.id
jfrog_url = "https://YYYY.jfrog.io"
artifactory_access_token = var.artifactory_access_token # An admin access token
@@ -75,7 +75,7 @@ The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extensio
```tf
module "jfrog" {
source = "registry.coder.com/modules/jfrog-token/coder"
- version = "1.0.19"
+ version = "1.0.30"
agent_id = coder_agent.example.id
jfrog_url = "https://XXXX.jfrog.io"
artifactory_access_token = var.artifactory_access_token
@@ -95,7 +95,7 @@ data "coder_workspace" "me" {}
module "jfrog" {
source = "registry.coder.com/modules/jfrog-token/coder"
- version = "1.0.19"
+ version = "1.0.30"
agent_id = coder_agent.example.id
jfrog_url = "https://XXXX.jfrog.io"
artifactory_access_token = var.artifactory_access_token
diff --git a/jfrog-token/main.test.ts b/jfrog-token/main.test.ts
index 2c856720..4ba2f52d 100644
--- a/jfrog-token/main.test.ts
+++ b/jfrog-token/main.test.ts
@@ -20,6 +20,7 @@ describe("jfrog-token", async () => {
refreshable?: boolean;
expires_in?: number;
username_field?: string;
+ username?: string;
jfrog_server_id?: string;
configure_code_server?: boolean;
};
diff --git a/jfrog-token/main.tf b/jfrog-token/main.tf
index f6f5f5b0..720e2d8c 100644
--- a/jfrog-token/main.tf
+++ b/jfrog-token/main.tf
@@ -68,6 +68,12 @@ variable "username_field" {
}
}
+variable "username" {
+ type = string
+ description = "Username to use for Artifactory. Overrides the field specified in `username_field`"
+ default = null
+}
+
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
@@ -99,8 +105,8 @@ variable "package_managers" {
}
locals {
- # The username field to use for artifactory
- username = var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name
+ # The username to use for artifactory
+ username = coalesce(var.username, var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name)
jfrog_host = split("://", var.jfrog_url)[1]
common_values = {
JFROG_URL = var.jfrog_url
diff --git a/jupyterlab/README.md b/jupyterlab/README.md
index c285c275..abebdc82 100644
--- a/jupyterlab/README.md
+++ b/jupyterlab/README.md
@@ -17,7 +17,7 @@ A module that adds JupyterLab in your Coder template.
module "jupyterlab" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jupyterlab/coder"
- version = "1.0.23"
+ version = "1.0.31"
agent_id = coder_agent.example.id
}
```
diff --git a/jupyterlab/main.test.ts b/jupyterlab/main.test.ts
index cf9ac1f0..a9789c39 100644
--- a/jupyterlab/main.test.ts
+++ b/jupyterlab/main.test.ts
@@ -33,6 +33,33 @@ const executeScriptInContainerWithPip = async (
};
};
+// executes the coder script after installing pip
+const executeScriptInContainerWithUv = async (
+ state: TerraformState,
+ image: string,
+ shell = "sh",
+): Promise<{
+ exitCode: number;
+ stdout: string[];
+ stderr: string[];
+}> => {
+ const instance = findResourceInstance(state, "coder_script");
+ const id = await runContainer(image);
+ const respPipx = await execContainer(id, [
+ shell,
+ "-c",
+ "apk --no-cache add uv gcc musl-dev linux-headers && uv venv",
+ ]);
+ const resp = await execContainer(id, [shell, "-c", instance.script]);
+ const stdout = resp.stdout.trim().split("\n");
+ const stderr = resp.stderr.trim().split("\n");
+ return {
+ exitCode: resp.exitCode,
+ stdout,
+ stderr,
+ };
+};
+
describe("jupyterlab", async () => {
await runTerraformInit(import.meta.dir);
@@ -40,19 +67,36 @@ describe("jupyterlab", async () => {
agent_id: "foo",
});
- it("fails without pipx", async () => {
+ it("fails without installers", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
const output = await executeScriptInContainer(state, "alpine");
expect(output.exitCode).toBe(1);
expect(output.stdout).toEqual([
- "\u001B[0;1mInstalling jupyterlab!",
- "pipx is not installed",
- "Please install pipx in your Dockerfile/VM image before running this script",
+ "Checking for a supported installer",
+ "No valid installer is not installed",
+ "Please install pipx or uv in your Dockerfile/VM image before running this script",
]);
});
+ // TODO: Add faster test to run with uv.
+ // currently times out.
+ // it("runs with uv", async () => {
+ // const state = await runTerraformApply(import.meta.dir, {
+ // agent_id: "foo",
+ // });
+ // const output = await executeScriptInContainerWithUv(state, "python:3-alpine");
+ // expect(output.exitCode).toBe(0);
+ // expect(output.stdout).toEqual([
+ // "Checking for a supported installer",
+ // "uv is installed",
+ // "\u001B[0;1mInstalling jupyterlab!",
+ // "đĨŗ jupyterlab has been installed",
+ // "đˇ Starting jupyterlab in background...check logs at /tmp/jupyterlab.log",
+ // ]);
+ // });
+
// TODO: Add faster test to run with pipx.
// currently times out.
// it("runs with pipx", async () => {
diff --git a/jupyterlab/run.sh b/jupyterlab/run.sh
index 0c3780dc..be686e55 100755
--- a/jupyterlab/run.sh
+++ b/jupyterlab/run.sh
@@ -1,4 +1,23 @@
#!/usr/bin/env sh
+INSTALLER=""
+check_available_installer() {
+ # check if pipx is installed
+ echo "Checking for a supported installer"
+ if command -v pipx > /dev/null 2>&1; then
+ echo "pipx is installed"
+ INSTALLER="pipx"
+ return
+ fi
+ # check if uv is installed
+ if command -v uv > /dev/null 2>&1; then
+ echo "uv is installed"
+ INSTALLER="uv"
+ return
+ fi
+ echo "No valid installer is not installed"
+ echo "Please install pipx or uv in your Dockerfile/VM image before running this script"
+ exit 1
+}
if [ -n "${BASE_URL}" ]; then
BASE_URL_FLAG="--ServerApp.base_url=${BASE_URL}"
@@ -6,27 +25,31 @@ fi
BOLD='\033[0;1m'
-printf "$${BOLD}Installing jupyterlab!\n"
-
# check if jupyterlab is installed
if ! command -v jupyter-lab > /dev/null 2>&1; then
# install jupyterlab
- # check if pipx is installed
- if ! command -v pipx > /dev/null 2>&1; then
- echo "pipx is not installed"
- echo "Please install pipx in your Dockerfile/VM image before running this script"
- exit 1
- fi
- # install jupyterlab
- pipx install -q jupyterlab
- printf "%s\n\n" "đĨŗ jupyterlab has been installed"
+ check_available_installer
+ printf "$${BOLD}Installing jupyterlab!\n"
+ case $INSTALLER in
+ uv)
+ uv pip install -q jupyterlab \
+ && printf "%s\n" "đĨŗ jupyterlab has been installed"
+ JUPYTER="$HOME/.venv/bin/jupyter-lab"
+ ;;
+ pipx)
+ pipx install jupyterlab \
+ && printf "%s\n" "đĨŗ jupyterlab has been installed"
+ JUPYTER="$HOME/.local/bin/jupyter-lab"
+ ;;
+ esac
else
printf "%s\n\n" "đĨŗ jupyterlab is already installed"
+ JUPYTER=$(command -v jupyter-lab)
fi
printf "đˇ Starting jupyterlab in background..."
printf "check logs at ${LOG_PATH}"
-$HOME/.local/bin/jupyter-lab --no-browser \
+$JUPYTER --no-browser \
"$BASE_URL_FLAG" \
--ServerApp.ip='*' \
--ServerApp.port="${PORT}" \
diff --git a/package.json b/package.json
index eea421d8..a122f4f2 100644
--- a/package.json
+++ b/package.json
@@ -2,10 +2,9 @@
"name": "modules",
"scripts": {
"test": "bun test",
- "fmt": "bun x prettier -w **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt **/*.tf .sample/main.tf",
+ "fmt": "bun x prettier -w **/*.sh .sample/run.sh new.sh terraform_validate.sh release.sh update_version.sh **/*.ts **/*.md *.md && terraform fmt **/*.tf .sample/main.tf",
"fmt:ci": "bun x prettier --check **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt -check **/*.tf .sample/main.tf",
- "lint": "bun run lint.ts && ./terraform_validate.sh",
- "update-version": "./update-version.sh"
+ "lint": "bun run lint.ts && ./terraform_validate.sh"
},
"devDependencies": {
"bun-types": "^1.1.23",
diff --git a/release.sh b/release.sh
new file mode 100755
index 00000000..b9163918
--- /dev/null
+++ b/release.sh
@@ -0,0 +1,196 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat << EOF
+Usage: $0 [OPTIONS] []
+
+Create annotated git tags for module releases.
+
+This script is used by maintainers to create annotated tags for module
+releases. When a tag is pushed, it triggers a GitHub workflow that
+updates README versions.
+
+Options:
+ -l, --list List all modules with their versions
+ -n, --dry-run Show what would be done without making changes
+ -p, --push Push the created tag to the remote repository
+ -h, --help Show this help message
+
+Examples:
+ $0 --list
+ $0 nodejs 1.2.3
+ $0 nodejs 1.2.3 --push
+ $0 --dry-run nodejs 1.2.3
+EOF
+ exit "${1:-0}"
+}
+
+check_getopt() {
+ # Check if we have GNU or BSD getopt.
+ if getopt --test > /dev/null 2>&1; then
+ # Exit status 4 means GNU getopt is available.
+ if [[ $? -ne 4 ]]; then
+ echo "Error: GNU getopt is not available." >&2
+ echo "On macOS, you can install GNU getopt and add it to your PATH:" >&2
+ echo
+ echo $'\tbrew install gnu-getopt' >&2
+ echo $'\texport PATH="$(brew --prefix gnu-getopt)/bin:$PATH"' >&2
+ exit 1
+ fi
+ fi
+}
+
+maybe_dry_run() {
+ if [[ $dry_run == true ]]; then
+ echo "[DRY RUN] $*"
+ return 0
+ fi
+ "$@"
+}
+
+get_readme_version() {
+ grep -o 'version *= *"[0-9]\+\.[0-9]\+\.[0-9]\+"' "$1" \
+ | head -1 \
+ | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' \
+ || echo "0.0.0"
+}
+
+list_modules() {
+ printf "\nListing all modules and their latest versions:\n"
+ printf "%s\n" "--------------------------------------------------------------"
+ printf "%-30s %-15s %-15s\n" "MODULE" "README VERSION" "LATEST TAG"
+ printf "%s\n" "--------------------------------------------------------------"
+
+ # Process each module directory.
+ for dir in */; do
+ # Skip non-module directories.
+ [[ ! -d $dir || ! -f ${dir}README.md || $dir == ".git/" ]] && continue
+
+ module="${dir%/}"
+ readme_version=$(get_readme_version "${dir}README.md")
+ latest_tag=$(git tag -l "release/${module}/v*" | sort -V | tail -n 1)
+ tag_version="none"
+ if [[ -n $latest_tag ]]; then
+ tag_version="${latest_tag#"release/${module}/v"}"
+ fi
+
+ printf "%-30s %-15s %-15s\n" "$module" "$readme_version" "$tag_version"
+ done
+
+ printf "%s\n" "--------------------------------------------------------------"
+}
+
+is_valid_version() {
+ if ! [[ $1 =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Error: Version must be in format X.Y.Z (e.g., 1.2.3)" >&2
+ return 1
+ fi
+}
+
+get_tag_name() {
+ local module="$1"
+ local version="$2"
+ local tag_name="release/$module/v$version"
+ local readme_path="$module/README.md"
+
+ if [[ ! -d $module || ! -f $readme_path ]]; then
+ echo "Error: Module '$module' not found or missing README.md" >&2
+ return 1
+ fi
+
+ local readme_version
+ readme_version=$(get_readme_version "$readme_path")
+
+ {
+ echo "Module: $module"
+ echo "Current README version: $readme_version"
+ echo "New tag version: $version"
+ echo "Tag name: $tag_name"
+ } >&2
+
+ echo "$tag_name"
+}
+
+# Ensure getopt is available.
+check_getopt
+
+# Set defaults.
+list=false
+dry_run=false
+push=false
+module=
+version=
+
+# Parse command-line options.
+if ! temp=$(getopt -o ldph --long list,dry-run,push,help -n "$0" -- "$@"); then
+ echo "Error: Failed to parse arguments" >&2
+ usage 1
+fi
+eval set -- "$temp"
+
+while true; do
+ case "$1" in
+ -l | --list)
+ list=true
+ shift
+ ;;
+ -d | --dry-run)
+ dry_run=true
+ shift
+ ;;
+ -p | --push)
+ push=true
+ shift
+ ;;
+ -h | --help)
+ usage
+ ;;
+ --)
+ shift
+ break
+ ;;
+ *)
+ echo "Error: Internal error!" >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [[ $list == true ]]; then
+ list_modules
+ exit 0
+fi
+
+if [[ $# -ne 2 ]]; then
+ echo "Error: MODULE and VERSION are required when not using --list" >&2
+ usage 1
+fi
+
+module="$1"
+version="$2"
+
+if ! is_valid_version "$version"; then
+ exit 1
+fi
+
+if ! tag_name=$(get_tag_name "$module" "$version"); then
+ exit 1
+fi
+
+if git rev-parse -q --verify "refs/tags/$tag_name" > /dev/null 2>&1; then
+ echo "Notice: Tag '$tag_name' already exists" >&2
+else
+ maybe_dry_run git tag -a "$tag_name" -m "Release $module v$version"
+ if [[ $push == true ]]; then
+ maybe_dry_run echo "Tag '$tag_name' created."
+ else
+ maybe_dry_run echo "Tag '$tag_name' created locally. Use --push to push it to remote."
+ maybe_dry_run "âšī¸ Note: Remember to push the tag when ready."
+ fi
+fi
+
+if [[ $push == true ]]; then
+ maybe_dry_run git push origin "$tag_name"
+ maybe_dry_run echo "Success! Tag '$tag_name' pushed to remote."
+fi
diff --git a/test.ts b/test.ts
index 0c48ee99..e466cb12 100644
--- a/test.ts
+++ b/test.ts
@@ -30,6 +30,12 @@ export const runContainer = async (
return containerID.trim();
};
+export interface scriptOutput {
+ exitCode: number;
+ stdout: string[];
+ stderr: string[];
+}
+
/**
* Finds the only "coder_script" resource in the given state and runs it in a
* container.
@@ -38,13 +44,15 @@ export const executeScriptInContainer = async (
state: TerraformState,
image: string,
shell = "sh",
-): Promise<{
- exitCode: number;
- stdout: string[];
- stderr: string[];
-}> => {
+ before?: string,
+): Promise => {
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer(image);
+
+ if (before) {
+ const respBefore = await execContainer(id, [shell, "-c", before]);
+ }
+
const resp = await execContainer(id, [shell, "-c", instance.script]);
const stdout = resp.stdout.trim().split("\n");
const stderr = resp.stderr.trim().split("\n");
@@ -58,12 +66,13 @@ export const executeScriptInContainer = async (
export const execContainer = async (
id: string,
cmd: string[],
+ args?: string[],
): Promise<{
exitCode: number;
stderr: string;
stdout: string;
}> => {
- const proc = spawn(["docker", "exec", id, ...cmd], {
+ const proc = spawn(["docker", "exec", ...(args ?? []), id, ...cmd], {
stderr: "pipe",
stdout: "pipe",
});
diff --git a/update-version.sh b/update-version.sh
deleted file mode 100755
index 09547f9c..00000000
--- a/update-version.sh
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/usr/bin/env bash
-
-# This script increments the version number in the README.md files of all modules
-# by 1 patch version. It is intended to be run from the root
-# of the repository or by using the `bun update-version` command.
-
-set -euo pipefail
-
-current_tag=$(git describe --tags --abbrev=0)
-
-# Increment the patch version
-LATEST_TAG=$(echo "$current_tag" | sed 's/^v//' | awk -F. '{print $1"."$2"."$3+1}') || exit $?
-
-# List directories with changes that are not README.md or test files
-mapfile -t changed_dirs < <(git diff --name-only "$current_tag" -- ':!**/README.md' ':!**/*.test.ts' | xargs dirname | grep -v '^\.' | sort -u)
-
-echo "Directories with changes: ${changed_dirs[*]}"
-
-# Iterate over directories and update version in README.md
-for dir in "${changed_dirs[@]}"; do
- if [[ -f "$dir/README.md" ]]; then
- file="$dir/README.md"
- tmpfile=$(mktemp /tmp/tempfile.XXXXXX)
- awk -v tag="$LATEST_TAG" '
- BEGIN { in_code_block = 0; in_nested_block = 0 }
- {
- # Detect the start and end of Markdown code blocks.
- if ($0 ~ /^```/) {
- in_code_block = !in_code_block
- # Reset nested block tracking when exiting a code block.
- if (!in_code_block) {
- in_nested_block = 0
- }
- }
-
- # Handle nested blocks within a code block.
- if (in_code_block) {
- # Detect the start of a nested block (skipping "module" blocks).
- if ($0 ~ /{/ && !($1 == "module" || $1 ~ /^[a-zA-Z0-9_]+$/)) {
- in_nested_block++
- }
-
- # Detect the end of a nested block.
- if ($0 ~ /}/ && in_nested_block > 0) {
- in_nested_block--
- }
-
- # Update "version" only if not in a nested block.
- if (!in_nested_block && $1 == "version" && $2 == "=") {
- sub(/"[^"]*"/, "\"" tag "\"")
- }
- }
-
- print
- }
- ' "$file" > "$tmpfile" && mv "$tmpfile" "$file"
-
- # Check if the README.md file has changed
- if ! git diff --quiet -- "$dir/README.md"; then
- echo "Bumping version in $dir/README.md from $current_tag to $LATEST_TAG (incremented)"
- else
- echo "Version in $dir/README.md is already up to date"
- fi
- fi
-done
diff --git a/update_version.sh b/update_version.sh
new file mode 100755
index 00000000..39430cdd
--- /dev/null
+++ b/update_version.sh
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat << EOF
+Usage: $0 [OPTIONS]
+
+Update or check the version in a module's README.md file.
+
+Options:
+ -c, --check Check if README.md version matches VERSION without updating
+ -h, --help Display this help message and exit
+
+Examples:
+ $0 code-server 1.2.3 # Update version in code-server/README.md
+ $0 --check code-server 1.2.3 # Check if version matches 1.2.3
+EOF
+ exit "${1:-0}"
+}
+
+is_valid_version() {
+ if ! [[ $1 =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Error: Version must be in format X.Y.Z (e.g., 1.2.3)" >&2
+ return 1
+ fi
+}
+
+update_version() {
+ local file="$1" current_tag="$2" latest_tag="$3" tmpfile
+ tmpfile=$(mktemp)
+
+ echo "Updating version in $file from $current_tag to $latest_tag..."
+
+ awk -v tag="$latest_tag" '
+ BEGIN { in_code_block = 0; in_nested_block = 0 }
+ {
+ # Detect the start and end of Markdown code blocks.
+ if ($0 ~ /^```/) {
+ in_code_block = !in_code_block
+ # Reset nested block tracking when exiting a code block.
+ if (!in_code_block) {
+ in_nested_block = 0
+ }
+ }
+
+ # Handle nested blocks within a code block.
+ if (in_code_block) {
+ # Detect the start of a nested block (skipping "module" blocks).
+ if ($0 ~ /{/ && !($1 == "module" || $1 ~ /^[a-zA-Z0-9_]+$/)) {
+ in_nested_block++
+ }
+
+ # Detect the end of a nested block.
+ if ($0 ~ /}/ && in_nested_block > 0) {
+ in_nested_block--
+ }
+
+ # Update "version" only if not in a nested block.
+ if (!in_nested_block && $1 == "version" && $2 == "=") {
+ sub(/"[^"]*"/, "\"" tag "\"")
+ }
+ }
+
+ print
+ }
+ ' "$file" > "$tmpfile" && mv "$tmpfile" "$file"
+}
+
+get_readme_version() {
+ grep -o 'version *= *"[0-9]\+\.[0-9]\+\.[0-9]\+"' "$1" \
+ | head -1 \
+ | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' \
+ || echo "0.0.0"
+}
+
+# Set defaults.
+check_only=false
+
+# Parse command-line options.
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c | --check)
+ check_only=true
+ shift
+ ;;
+ -h | --help)
+ usage 0
+ ;;
+ -*)
+ echo "Error: Unknown option: $1" >&2
+ usage 1
+ ;;
+ *)
+ break
+ ;;
+ esac
+done
+
+if [[ $# -ne 2 ]]; then
+ echo "Error: MODULE and VERSION are required" >&2
+ usage 1
+fi
+
+module_name="$1"
+version="$2"
+
+if [[ ! -d $module_name ]]; then
+ echo "Error: Module directory '$module_name' not found" >&2
+ echo >&2
+ echo "Available modules:" >&2
+ echo >&2
+ find . -type d -mindepth 1 -maxdepth 1 -not -path "*/\.*" | sed 's|^./|\t|' | sort >&2
+ exit 1
+fi
+
+if ! is_valid_version "$version"; then
+ exit 1
+fi
+
+readme_path="$module_name/README.md"
+if [[ ! -f $readme_path ]]; then
+ echo "Error: README.md not found in '$module_name' directory" >&2
+ exit 1
+fi
+
+readme_version=$(get_readme_version "$readme_path")
+
+# In check mode, just return success/failure based on version match.
+if [[ $check_only == true ]]; then
+ if [[ $readme_version == "$version" ]]; then
+ echo "â Success: Version in $readme_path matches $version"
+ exit 0
+ else
+ echo "â Error: Version mismatch in $readme_path"
+ echo "Expected: $version"
+ echo "Found: $readme_version"
+ exit 1
+ fi
+fi
+
+if [[ $readme_version != "$version" ]]; then
+ update_version "$readme_path" "$readme_version" "$version"
+ echo "â Version updated successfully to $version"
+else
+ echo "âšī¸ Version in $readme_path already set to $version, no update needed"
+fi
diff --git a/vault-jwt/README.md b/vault-jwt/README.md
index 66070397..1907dbf0 100644
--- a/vault-jwt/README.md
+++ b/vault-jwt/README.md
@@ -10,16 +10,17 @@ tags: [helper, integration, vault, jwt, oidc]
# Hashicorp Vault Integration (JWT)
-This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces by reusing the [OIDC](https://coder.com/docs/admin/users/oidc-auth) access token from Coder's OIDC authentication method. This requires configuring the Vault [JWT/OIDC](https://developer.hashicorp.com/vault/docs/auth/jwt#configuration) auth method.
+This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces by reusing the [OIDC](https://coder.com/docs/admin/users/oidc-auth) access token from Coder's OIDC authentication method or another source of jwt token. This requires configuring the Vault [JWT/OIDC](https://developer.hashicorp.com/vault/docs/auth/jwt#configuration) auth method.
```tf
module "vault" {
- count = data.coder_workspace.me.start_count
- source = "registry.coder.com/modules/vault-jwt/coder"
- version = "1.0.20"
- agent_id = coder_agent.example.id
- vault_addr = "https://vault.example.com"
- vault_jwt_role = "coder" # The Vault role to use for authentication
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/vault-jwt/coder"
+ version = "1.1.0"
+ agent_id = coder_agent.example.id
+ vault_addr = "https://vault.example.com"
+ vault_jwt_role = "coder" # The Vault role to use for authentication
+ vault_jwt_token = "eyJhbGciOiJIUzI1N..." # optional, if not present, defaults to user's oidc authentication token
}
```
@@ -43,7 +44,7 @@ curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/d
module "vault" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vault-jwt/coder"
- version = "1.0.20"
+ version = "1.0.31"
agent_id = coder_agent.example.id
vault_addr = "https://vault.example.com"
vault_jwt_auth_path = "oidc"
@@ -59,7 +60,7 @@ data "coder_workspace_owner" "me" {}
module "vault" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vault-jwt/coder"
- version = "1.0.20"
+ version = "1.0.31"
agent_id = coder_agent.example.id
vault_addr = "https://vault.example.com"
vault_jwt_role = data.coder_workspace_owner.me.groups[0]
@@ -72,10 +73,113 @@ module "vault" {
module "vault" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vault-jwt/coder"
- version = "1.0.20"
+ version = "1.0.31"
agent_id = coder_agent.example.id
vault_addr = "https://vault.example.com"
vault_jwt_role = "coder" # The Vault role to use for authentication
vault_cli_version = "1.17.5"
}
```
+
+### Use a custom JWT token
+
+```tf
+
+terraform {
+ required_providers {
+ jwt = {
+ source = "geektheripper/jwt"
+ version = "1.1.4"
+ }
+ time = {
+ source = "hashicorp/time"
+ version = "0.11.1"
+ }
+ }
+}
+
+
+resource "jwt_signed_token" "vault" {
+ count = data.coder_workspace.me.start_count
+ algorithm = "RS256"
+ # `openssl genrsa -out key.pem 4096` and `openssl rsa -in key.pem -pubout > pub.pem` to generate keys
+ key = file("key.pem")
+ claims_json = jsonencode({
+ iss = "https://code.example.com"
+ sub = "${data.coder_workspace.me.id}"
+ aud = "https://vault.example.com"
+ iat = provider::time::rfc3339_parse(plantimestamp()).unix
+ # Uncomment to set an expiry on the JWT token(default 3600 seconds).
+ # workspace will need to be restarted to generate a new token if it expires
+ #exp = provider::time::rfc3339_parse(timeadd(timestamp(), 3600)).unix agent = coder_agent.main.id
+ provisioner = data.coder_provisioner.main.id
+ provisioner_arch = data.coder_provisioner.main.arch
+ provisioner_os = data.coder_provisioner.main.os
+
+ workspace = data.coder_workspace.me.id
+ workspace_url = data.coder_workspace.me.access_url
+ workspace_port = data.coder_workspace.me.access_port
+ workspace_name = data.coder_workspace.me.name
+ template = data.coder_workspace.me.template_id
+ template_name = data.coder_workspace.me.template_name
+ template_version = data.coder_workspace.me.template_version
+ owner = data.coder_workspace_owner.me.id
+ owner_name = data.coder_workspace_owner.me.name
+ owner_email = data.coder_workspace_owner.me.email
+ owner_login_type = data.coder_workspace_owner.me.login_type
+ owner_groups = data.coder_workspace_owner.me.groups
+ })
+}
+
+module "vault" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/vault-jwt/coder"
+ version = "1.1.0"
+ agent_id = coder_agent.example.id
+ vault_addr = "https://vault.example.com"
+ vault_jwt_role = "coder" # The Vault role to use for authentication
+ vault_jwt_token = jwt_signed_token.vault[0].token
+}
+```
+
+#### Example Vault JWT role
+
+```shell
+vault write auth/JWT_MOUNT/role/workspace - << EOF
+{
+ "user_claim": "sub",
+ "bound_audiences": "https://vault.example.com",
+ "role_type": "jwt",
+ "ttl": "1h",
+ "claim_mappings": {
+ "owner": "owner",
+ "owner_email": "owner_email",
+ "owner_login_type": "owner_login_type",
+ "owner_name": "owner_name",
+ "provisioner": "provisioner",
+ "provisioner_arch": "provisioner_arch",
+ "provisioner_os": "provisioner_os",
+ "sub": "sub",
+ "template": "template",
+ "template_name": "template_name",
+ "template_version": "template_version",
+ "workspace": "workspace",
+ "workspace_name": "workspace_name",
+ "workspace_id": "workspace_id"
+ }
+}
+EOF
+```
+
+#### Example workspace access Vault policy
+
+```tf
+path "kv/data/app/coder/{{identity.entity.aliases..metadata.owner_name}}/{{identity.entity.aliases..metadata.workspace_name}}" {
+ capabilities = ["create", "read", "update", "delete", "list", "subscribe"]
+ subscribe_event_types = ["*"]
+}
+path "kv/metadata/app/coder/{{identity.entity.aliases..metadata.owner_name}}/{{identity.entity.aliases..metadata.workspace_name}}" {
+ capabilities = ["create", "read", "update", "delete", "list", "subscribe"]
+ subscribe_event_types = ["*"]
+}
+```
diff --git a/vault-jwt/main.tf b/vault-jwt/main.tf
index adcc34d4..17288e00 100644
--- a/vault-jwt/main.tf
+++ b/vault-jwt/main.tf
@@ -20,6 +20,13 @@ variable "vault_addr" {
description = "The address of the Vault server."
}
+variable "vault_jwt_token" {
+ type = string
+ description = "The JWT token used for authentication with Vault."
+ default = null
+ sensitive = true
+}
+
variable "vault_jwt_auth_path" {
type = string
description = "The path to the Vault JWT auth method."
@@ -46,7 +53,7 @@ resource "coder_script" "vault" {
display_name = "Vault (GitHub)"
icon = "/icon/vault.svg"
script = templatefile("${path.module}/run.sh", {
- CODER_OIDC_ACCESS_TOKEN : data.coder_workspace_owner.me.oidc_access_token,
+ CODER_OIDC_ACCESS_TOKEN : var.vault_jwt_token != null ? var.vault_jwt_token : data.coder_workspace_owner.me.oidc_access_token,
VAULT_JWT_AUTH_PATH : var.vault_jwt_auth_path,
VAULT_JWT_ROLE : var.vault_jwt_role,
VAULT_CLI_VERSION : var.vault_cli_version,
diff --git a/vault-jwt/run.sh b/vault-jwt/run.sh
index ef45884d..d95b45a2 100644
--- a/vault-jwt/run.sh
+++ b/vault-jwt/run.sh
@@ -107,6 +107,6 @@ rm -rf "$TMP"
# Authenticate with Vault
printf "đ Authenticating with Vault ...\n\n"
-echo "$${CODER_OIDC_ACCESS_TOKEN}" | vault write auth/"$${VAULT_JWT_AUTH_PATH}"/login role="$${VAULT_JWT_ROLE}" jwt=-
+echo "$${CODER_OIDC_ACCESS_TOKEN}" | vault write -field=token auth/"$${VAULT_JWT_AUTH_PATH}"/login role="$${VAULT_JWT_ROLE}" jwt=- | vault login -
printf "đĨŗ Vault authentication complete!\n\n"
printf "You can now use Vault CLI to access secrets.\n"
diff --git a/vscode-web/README.md b/vscode-web/README.md
index 7d22feaa..5846c04c 100644
--- a/vscode-web/README.md
+++ b/vscode-web/README.md
@@ -15,7 +15,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-web/coder"
- version = "1.0.29"
+ version = "1.0.30"
agent_id = coder_agent.example.id
accept_license = true
}
@@ -31,7 +31,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-web/coder"
- version = "1.0.29"
+ version = "1.0.30"
agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder"
@@ -45,7 +45,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-web/coder"
- version = "1.0.29"
+ version = "1.0.30"
agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true
@@ -54,13 +54,13 @@ module "vscode-web" {
### Pre-configure Settings
-Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file:
+Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file:
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-web/coder"
- version = "1.0.29"
+ version = "1.0.30"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -69,3 +69,18 @@ module "vscode-web" {
accept_license = true
}
```
+
+### Pin a specific VS Code Web version
+
+By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases).
+
+```tf
+module "vscode-web" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/vscode-web/coder"
+ version = "1.0.30"
+ agent_id = coder_agent.example.id
+ commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
+ accept_license = true
+}
+```
diff --git a/vscode-web/main.tf b/vscode-web/main.tf
index 4a2f04ea..11e220cd 100644
--- a/vscode-web/main.tf
+++ b/vscode-web/main.tf
@@ -59,6 +59,12 @@ variable "install_prefix" {
default = "/tmp/vscode-web"
}
+variable "commit_id" {
+ type = string
+ description = "Specify the commit ID of the VS Code Web binary to pin to a specific version. If left empty, the latest stable version is used."
+ default = ""
+}
+
variable "extensions" {
type = list(string)
description = "A list of extensions to install."
@@ -151,6 +157,7 @@ resource "coder_script" "vscode-web" {
FOLDER : var.folder,
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
SERVER_BASE_PATH : local.server_base_path,
+ COMMIT_ID : var.commit_id,
})
run_on_start = true
diff --git a/vscode-web/run.sh b/vscode-web/run.sh
index c3423dff..588cec56 100755
--- a/vscode-web/run.sh
+++ b/vscode-web/run.sh
@@ -59,8 +59,15 @@ case "$ARCH" in
;;
esac
-HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-linux-$ARCH-web | cut -d '"' -f 2)
-output=$(curl -fsSL https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-linux-$ARCH-web.tar.gz | tar -xz -C ${INSTALL_PREFIX} --strip-components 1)
+# Check if a specific VS Code Web commit ID was provided
+if [ -n "${COMMIT_ID}" ]; then
+ HASH="${COMMIT_ID}"
+else
+ HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-linux-$ARCH-web | cut -d '"' -f 2)
+fi
+printf "$${BOLD}VS Code Web commit id version $HASH.\n"
+
+output=$(curl -fsSL "https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-linux-$ARCH-web.tar.gz" | tar -xz -C "${INSTALL_PREFIX}" --strip-components 1)
if [ $? -ne 0 ]; then
echo "Failed to install Microsoft Visual Studio Code Server: $output"
diff --git a/windsurf/README.md b/windsurf/README.md
new file mode 100644
index 00000000..93f25ebb
--- /dev/null
+++ b/windsurf/README.md
@@ -0,0 +1,37 @@
+---
+display_name: Windsurf Editor
+description: Add a one-click button to launch Windsurf Editor
+icon: ../.icons/windsurf.svg
+maintainer_github: coder
+verified: true
+tags: [ide, windsurf, helper, ai]
+---
+
+# Windsurf Editor
+
+Add a button to open any workspace with a single click in Windsurf Editor.
+
+Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder).
+
+```tf
+module "windsurf" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/windsurf/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.example.id
+}
+```
+
+## Examples
+
+### Open in a specific directory
+
+```tf
+module "windsurf" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/windsurf/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.example.id
+ folder = "/home/coder/project"
+}
+```
diff --git a/windsurf/main.test.ts b/windsurf/main.test.ts
new file mode 100644
index 00000000..a158962a
--- /dev/null
+++ b/windsurf/main.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "bun:test";
+import {
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("windsurf", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ it("default output", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ });
+ expect(state.outputs.windsurf_url.value).toBe(
+ "windsurf://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
+ );
+
+ const coder_app = state.resources.find(
+ (res) => res.type === "coder_app" && res.name === "windsurf",
+ );
+
+ expect(coder_app).not.toBeNull();
+ expect(coder_app?.instances.length).toBe(1);
+ expect(coder_app?.instances[0].attributes.order).toBeNull();
+ });
+
+ it("adds folder", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ folder: "/foo/bar",
+ });
+ expect(state.outputs.windsurf_url.value).toBe(
+ "windsurf://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
+ );
+ });
+
+ it("adds folder and open_recent", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ folder: "/foo/bar",
+ open_recent: true,
+ });
+ expect(state.outputs.windsurf_url.value).toBe(
+ "windsurf://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
+ );
+ });
+
+ it("adds folder but not open_recent", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ folder: "/foo/bar",
+ open_recent: false,
+ });
+ expect(state.outputs.windsurf_url.value).toBe(
+ "windsurf://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
+ );
+ });
+
+ it("adds open_recent", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ open_recent: true,
+ });
+ expect(state.outputs.windsurf_url.value).toBe(
+ "windsurf://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
+ );
+ });
+
+ it("expect order to be set", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ order: 22,
+ });
+
+ const coder_app = state.resources.find(
+ (res) => res.type === "coder_app" && res.name === "windsurf",
+ );
+
+ expect(coder_app).not.toBeNull();
+ expect(coder_app?.instances.length).toBe(1);
+ expect(coder_app?.instances[0].attributes.order).toBe(22);
+ });
+});
diff --git a/windsurf/main.tf b/windsurf/main.tf
new file mode 100644
index 00000000..1d836d7e
--- /dev/null
+++ b/windsurf/main.tf
@@ -0,0 +1,62 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.23"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "folder" {
+ type = string
+ description = "The folder to open in Cursor IDE."
+ default = ""
+}
+
+variable "open_recent" {
+ type = bool
+ description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open."
+ default = false
+}
+
+variable "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
+}
+
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+
+resource "coder_app" "windsurf" {
+ agent_id = var.agent_id
+ external = true
+ icon = "/icon/windsurf.svg"
+ slug = "windsurf"
+ display_name = "Windsurf Editor"
+ order = var.order
+ url = join("", [
+ "windsurf://coder.coder-remote/open",
+ "?owner=",
+ data.coder_workspace_owner.me.name,
+ "&workspace=",
+ data.coder_workspace.me.name,
+ var.folder != "" ? join("", ["&folder=", var.folder]) : "",
+ var.open_recent ? "&openRecent" : "",
+ "&url=",
+ data.coder_workspace.me.access_url,
+ "&token=$SESSION_TOKEN",
+ ])
+}
+
+output "windsurf_url" {
+ value = coder_app.windsurf.url
+ description = "Windsurf Editor URL."
+}