chore: sync newest module updates to registry (#84)

## Changes made
- Copied over all changes to existing modules, making sure to preserve
all relative path updates made specifically for the Registry repo
- Copied over all modules that were created since the last sync
(Windsurf, Devcontainers-CLI)
- Copied over changes from the `test.ts` file

## Notes
- This PR does not cover https://github.com/coder/modules/pull/426,
which contains a few changes around updating the Bash scripts and the
contributing README file. @f0ssel tagging you so that you're aware, but
I'll be taking care of the `CONTRIBUTING.md` file

---------

Co-authored-by: M Atif Ali <me@matifali.dev>
This commit is contained in:
Michael Smith 2025-05-09 11:28:38 -04:00 committed by GitHub
parent 7ea1f305af
commit 496b09d93f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1047 additions and 145 deletions

2
.icons/devcontainers.svg Normal file
View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><title>file_type_devcontainer</title><circle cx="16" cy="16" r="14" style="fill:#193e63"/><polygon points="10.777 22.742 9.343 21.348 12.729 17.865 9.346 14.417 10.774 13.017 15.525 17.859 10.777 22.742" style="fill:#add1ea"/><polygon points="21.42 19.101 22.854 17.706 19.468 14.224 22.851 10.776 21.423 9.376 16.672 14.218 21.42 19.101" style="fill:#add1ea"/></svg>

After

Width:  |  Height:  |  Size: 575 B

3
.icons/windsurf.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M897.246 286.869H889.819C850.735 286.808 819.017 318.46 819.017 357.539V515.589C819.017 547.15 792.93 572.716 761.882 572.716C743.436 572.716 725.02 563.433 714.093 547.85L552.673 317.304C539.28 298.16 517.486 286.747 493.895 286.747C457.094 286.747 423.976 318.034 423.976 356.657V515.619C423.976 547.181 398.103 572.746 366.842 572.746C348.335 572.746 329.949 563.463 319.021 547.881L138.395 289.882C134.316 284.038 125.154 286.93 125.154 294.052V431.892C125.154 438.862 127.285 445.619 131.272 451.34L309.037 705.2C319.539 720.204 335.033 731.344 352.9 735.392C397.616 745.557 438.77 711.135 438.77 667.278V508.406C438.77 476.845 464.339 451.279 495.904 451.279H495.995C515.02 451.279 532.857 460.562 543.785 476.145L705.235 706.661C718.659 725.835 739.327 737.218 763.983 737.218C801.606 737.218 833.841 705.9 833.841 667.308V508.376C833.841 476.815 859.41 451.249 890.975 451.249H897.276C901.233 451.249 904.43 448.053 904.43 444.097V294.021C904.43 290.065 901.233 286.869 897.276 286.869H897.246Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -2,6 +2,5 @@
Publish Coder modules and templates for other developers to use.
> [!NOTE]
> This repo is in active development. We needed to make it public for technical reasons, but the user experience of actually navigating through it and contributing will be made much better shortly.

View File

@ -1,5 +1,5 @@
{
"name": "modules",
"name": "registry",
"scripts": {
"fmt": "bun x prettier --write **/*.sh **/*.ts **/*.md *.md && terraform fmt -recursive -diff",
"fmt:ci": "bun x prettier --check **/*.sh **/*.ts **/*.md *.md && terraform fmt -check -recursive -diff",

View File

@ -14,7 +14,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/modules/claude-code/coder"
version = "1.0.31"
version = "1.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@ -25,7 +25,7 @@ module "claude-code" {
## Prerequisites
- Node.js and npm must be installed in your workspace to install Claude Code
- `screen` must be installed in your workspace to run Claude Code in the background
- 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.
@ -43,7 +43,7 @@ The `codercom/oss-dogfood:latest` container image can be used for testing on con
> Join our [Discord channel](https://discord.gg/coder) or
> [contact us](https://coder.com/contact) to get help or share feedback.
Your workspace must have `screen` installed to use this.
Your workspace must have either `screen` or `tmux` installed to use this.
```tf
variable "anthropic_api_key" {
@ -83,14 +83,14 @@ resource "coder_agent" "main" {
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/claude-code/coder"
version = "1.0.31"
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
experiment_use_screen = true # Or use experiment_use_tmux = true to use tmux instead
experiment_report_tasks = true
}
```
@ -102,7 +102,7 @@ Run Claude Code as a standalone app in your workspace. This will install Claude
```tf
module "claude-code" {
source = "registry.coder.com/modules/claude-code/coder"
version = "1.0.31"
version = "1.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true

View File

@ -54,12 +54,35 @@ variable "experiment_use_screen" {
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
@ -74,6 +97,14 @@ resource "coder_script" "claude_code" {
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
@ -84,11 +115,52 @@ resource "coder_script" "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..."
@ -149,20 +221,27 @@ resource "coder_app" "claude_code" {
#!/bin/bash
set -e
if [ "${var.experiment_use_screen}" = "true" ]; then
if screen -list | grep -q "claude-code"; then
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
echo "Attaching to existing Claude Code session." | tee -a "$HOME/.claude-code.log"
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 session." | tee -a "$HOME/.claude-code.log"
screen -S claude-code bash -c 'export LANG=en_US.UTF-8; export LC_ALL=en_US.UTF-8; claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"; exec bash'
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}
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
claude
fi
EOT

View File

@ -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.31"
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.31"
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.31"
version = "1.1.0"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@ -62,7 +62,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.31"
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.31"
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.31"
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.31"
version = "1.1.0"
agent_id = coder_agent.example.id
offline = true
}

View File

@ -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"

View File

@ -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
}
```

View File

@ -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);
});

View File

@ -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
}

View File

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

View File

@ -0,0 +1,105 @@
import { describe, expect, it } from "bun:test";
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);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
});
it("fails with wrong database_path", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
database_path: "nofb",
}).catch((e) => {
if (!e.message.startsWith("\nError: Invalid value for variable")) {
throw e;
}
});
});
it("runs with default", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
});
it("runs with database_path var", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
database_path: ".config/filebrowser.db",
});
const output = await await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
});
it("runs with folder var", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
folder: "/home/coder/project",
});
const output = await await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
});
it("runs with subdomain=false", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
agent_name: "main",
subdomain: false,
});
const output = await await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
});
});

View File

@ -1,5 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
BOLD='\033[[0;1m'
printf "$${BOLD}Installing filebrowser \n\n"

View File

@ -14,7 +14,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener
```tf
module "goose" {
source = "registry.coder.com/modules/goose/coder"
version = "1.0.31"
version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
@ -90,7 +90,7 @@ resource "coder_agent" "main" {
module "goose" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/goose/coder"
version = "1.0.31"
version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
@ -111,6 +111,36 @@ module "goose" {
}
```
### 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.
@ -118,7 +148,7 @@ Run Goose as a standalone app in your workspace. This will install Goose and run
```tf
module "goose" {
source = "registry.coder.com/modules/goose/coder"
version = "1.0.31"
version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true

View File

@ -78,6 +78,60 @@ variable "experiment_goose_model" {
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
@ -92,6 +146,14 @@ resource "coder_script" "goose" {
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
@ -102,6 +164,14 @@ resource "coder_script" "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..."
@ -109,29 +179,14 @@ resource "coder_script" "goose" {
cat > "$HOME/.config/goose/config.yaml" << EOL
GOOSE_PROVIDER: ${var.experiment_goose_provider}
GOOSE_MODEL: ${var.experiment_goose_model}
extensions:
coder:
args:
- exp
- mcp
- server
cmd: coder
description: Report ALL tasks and statuses (in progress, done, failed) before and after starting
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
${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..."
@ -162,14 +217,28 @@ EOL
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
screen -U -dmS goose bash -c '
# 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}
$HOME/.local/bin/goose run --text "$GOOSE_SYSTEM_PROMPT. Your task: $GOOSE_TASK_PROMPT" --interactive | tee -a "$HOME/.goose.log"
exec bash
'
\"$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 $HOME/.local/bin/goose; then
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
@ -186,21 +255,34 @@ resource "coder_app" "goose" {
#!/bin/bash
set -e
if [ "${var.experiment_use_screen}" = "true" ]; then
if screen -list | grep -q "goose"; then
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
echo "Attaching to existing Goose session." | tee -a "$HOME/.goose.log"
screen -xRR goose
# 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 "Starting a new Goose session." | tee -a "$HOME/.goose.log"
screen -S goose bash -c 'export LANG=en_US.UTF-8; export LC_ALL=en_US.UTF-8; $HOME/.local/bin/goose run --text "Always report status and instructions to Coder, before and after your steps" --interactive | tee -a "$HOME/.goose.log"; exec bash'
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
$HOME/.local/bin/goose
"$GOOSE_CMD" run --text "Review goosehints. Your task: $GOOSE_TASK_PROMPT" --interactive
fi
EOT
icon = var.icon

View File

@ -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"]
@ -26,7 +26,7 @@ module "jetbrains_gateway" {
}
```
![JetBrains Gateway IDes list](../../.images/jetbrains-gateway.png)
![JetBrains Gateway IDes list](../.images/jetbrains-gateway.png)
## Examples
@ -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"]

View File

@ -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
}

View File

@ -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.30"
version = "1.0.31"
agent_id = coder_agent.example.id
}
```

View File

@ -32,23 +32,24 @@ if ! command -v jupyter-lab > /dev/null 2>&1; then
printf "$${BOLD}Installing jupyterlab!\n"
case $INSTALLER in
uv)
uv pip install -q jupyterlab \
&& printf "%s\n" "🥳 jupyterlab has been installed"
JUPYTERPATH="$HOME/.venv/bin/"
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"
JUPYTERPATH="$HOME/.local/bin"
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}"
$JUPYTERPATH/jupyter-lab --no-browser \
$JUPYTER --no-browser \
"$BASE_URL_FLAG" \
--ServerApp.ip='*' \
--ServerApp.port="${PORT}" \

View File

@ -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"
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.<MOUNT_ACCESSOR>.metadata.owner_name}}/{{identity.entity.aliases.<MOUNT_ACCESSOR>.metadata.workspace_name}}" {
capabilities = ["create", "read", "update", "delete", "list", "subscribe"]
subscribe_event_types = ["*"]
}
path "kv/metadata/app/coder/{{identity.entity.aliases.<MOUNT_ACCESSOR>.metadata.owner_name}}/{{identity.entity.aliases.<MOUNT_ACCESSOR>.metadata.workspace_name}}" {
capabilities = ["create", "read", "update", "delete", "list", "subscribe"]
subscribe_event_types = ["*"]
}
```

View File

@ -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,

View File

@ -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"

View File

@ -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"
}
```

View File

@ -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);
});
});

View File

@ -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."
}

View File

@ -23,13 +23,13 @@ describe("exoscale-instance-type", async () => {
expect(state.outputs.value.value).toBe("gpu3.huge");
});
it("fails because of wrong categroy definition", async () => {
it("fails because of wrong category definition", async () => {
expect(async () => {
await runTerraformApply(import.meta.dir, {
default: "gpu3.huge",
// type_category: ["standard"] is standard
});
}).toThrow('default value "gpu3.huge" must be defined as one of options');
}).toThrow(/value "gpu3.huge" must be defined as one of options/);
});
it("set custom order for coder_parameter", async () => {

View File

@ -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<scriptOutput> => {
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer(image);
if (before) {
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",
});