diff --git a/.icons/devcontainers.svg b/.icons/devcontainers.svg
new file mode 100644
index 000000000..fb0443bd1
--- /dev/null
+++ b/.icons/devcontainers.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/.icons/windsurf.svg b/.icons/windsurf.svg
new file mode 100644
index 000000000..2e4e4e492
--- /dev/null
+++ b/.icons/windsurf.svg
@@ -0,0 +1,3 @@
+
diff --git a/README.md b/README.md
index 2a6950f6e..e59ffd090 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/package.json b/package.json
index 6ba299592..733230dbd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md
index 38c8b313a..a94b31864 100644
--- a/registry/coder/modules/claude-code/README.md
+++ b/registry/coder/modules/claude-code/README.md
@@ -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" {
@@ -71,7 +71,7 @@ data "coder_parameter" "ai_prompt" {
resource "coder_agent" "main" {
# ...
env = {
- CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter
+ 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
@@ -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
diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf
index 349af17f5..cc7b27e07 100644
--- a/registry/coder/modules/claude-code/main.tf
+++ b/registry/coder/modules/claude-code/main.tf
@@ -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
+ 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
- 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"
+ 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
diff --git a/registry/coder/modules/code-server/README.md b/registry/coder/modules/code-server/README.md
index 30aeff064..9080bddbc 100644
--- a/registry/coder/modules/code-server/README.md
+++ b/registry/coder/modules/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.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
}
diff --git a/registry/coder/modules/code-server/main.tf b/registry/coder/modules/code-server/main.tf
index c80e5378e..ca4ff3afd 100644
--- a/registry/coder/modules/code-server/main.tf
+++ b/registry/coder/modules/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/registry/coder/modules/devcontainers-cli/README.md b/registry/coder/modules/devcontainers-cli/README.md
new file mode 100644
index 000000000..ec7369663
--- /dev/null
+++ b/registry/coder/modules/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/registry/coder/modules/devcontainers-cli/main.test.ts b/registry/coder/modules/devcontainers-cli/main.test.ts
new file mode 100644
index 000000000..6cfe4d04e
--- /dev/null
+++ b/registry/coder/modules/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/registry/coder/modules/devcontainers-cli/main.tf b/registry/coder/modules/devcontainers-cli/main.tf
new file mode 100644
index 000000000..a2aee348b
--- /dev/null
+++ b/registry/coder/modules/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/registry/coder/modules/devcontainers-cli/run.sh b/registry/coder/modules/devcontainers-cli/run.sh
new file mode 100644
index 000000000..f7bf852c6
--- /dev/null
+++ b/registry/coder/modules/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/registry/coder/modules/filebrowser/main.test.ts b/registry/coder/modules/filebrowser/main.test.ts
new file mode 100644
index 000000000..136fa25e2
--- /dev/null
+++ b/registry/coder/modules/filebrowser/main.test.ts
@@ -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);
+ });
+});
diff --git a/registry/coder/modules/filebrowser/run.sh b/registry/coder/modules/filebrowser/run.sh
index 84810e4ed..ffb87f042 100644
--- a/registry/coder/modules/filebrowser/run.sh
+++ b/registry/coder/modules/filebrowser/run.sh
@@ -1,11 +1,13 @@
#!/usr/bin/env bash
+set -euo pipefail
+
BOLD='\033[[0;1m'
printf "$${BOLD}Installing filebrowser \n\n"
# Check if filebrowser is installed
-if ! command -v filebrowser &> /dev/null; then
+if ! command -v filebrowser &>/dev/null; then
curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash
fi
@@ -32,6 +34,6 @@ printf "👷 Starting filebrowser in background... \n\n"
printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n"
-filebrowser >> ${LOG_PATH} 2>&1 &
+filebrowser >>${LOG_PATH} 2>&1 &
printf "📝 Logs at ${LOG_PATH} \n\n"
diff --git a/registry/coder/modules/goose/README.md b/registry/coder/modules/goose/README.md
index e8b844c23..55ce4eb33 100644
--- a/registry/coder/modules/goose/README.md
+++ b/registry/coder/modules/goose/README.md
@@ -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
@@ -72,11 +72,11 @@ 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
@@ -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
diff --git a/registry/coder/modules/goose/main.tf b/registry/coder/modules/goose/main.tf
index fcb6baaa4..0043000ec 100644
--- a/registry/coder/modules/goose/main.tf
+++ b/registry/coder/modules/goose/main.tf
@@ -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
+ # 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
- 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
- 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'
+ # 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
diff --git a/registry/coder/modules/jetbrains-gateway/README.md b/registry/coder/modules/jetbrains-gateway/README.md
index dbf4dba53..e38aae2bb 100644
--- a/registry/coder/modules/jetbrains-gateway/README.md
+++ b/registry/coder/modules/jetbrains-gateway/README.md
@@ -18,7 +18,7 @@ Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prereq
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/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" {
}
```
-
+
## 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"]
diff --git a/registry/coder/modules/jetbrains-gateway/main.tf b/registry/coder/modules/jetbrains-gateway/main.tf
index d197399d9..502469f29 100644
--- a/registry/coder/modules/jetbrains-gateway/main.tf
+++ b/registry/coder/modules/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/registry/coder/modules/jupyterlab/README.md b/registry/coder/modules/jupyterlab/README.md
index 64d9c1c01..c0c4011f8 100644
--- a/registry/coder/modules/jupyterlab/README.md
+++ b/registry/coder/modules/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.30"
+ version = "1.0.31"
agent_id = coder_agent.example.id
}
```
diff --git a/registry/coder/modules/jupyterlab/run.sh b/registry/coder/modules/jupyterlab/run.sh
index 2dd34ace7..e9a45b5ae 100644
--- a/registry/coder/modules/jupyterlab/run.sh
+++ b/registry/coder/modules/jupyterlab/run.sh
@@ -3,13 +3,13 @@ INSTALLER=""
check_available_installer() {
# check if pipx is installed
echo "Checking for a supported installer"
- if command -v pipx > /dev/null 2>&1; then
+ 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
+ if command -v uv >/dev/null 2>&1; then
echo "uv is installed"
INSTALLER="uv"
return
@@ -26,32 +26,33 @@ fi
BOLD='\033[0;1m'
# check if jupyterlab is installed
-if ! command -v jupyter-lab > /dev/null 2>&1; then
+if ! command -v jupyter-lab >/dev/null 2>&1; then
# install jupyterlab
check_available_installer
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/"
- ;;
- pipx)
- pipx install jupyterlab \
- && printf "%s\n" "🥳 jupyterlab has been installed"
- JUPYTERPATH="$HOME/.local/bin"
- ;;
+ 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}"
-$JUPYTERPATH/jupyter-lab --no-browser \
+$JUPYTER --no-browser \
"$BASE_URL_FLAG" \
--ServerApp.ip='*' \
--ServerApp.port="${PORT}" \
--ServerApp.token='' \
--ServerApp.password='' \
- > "${LOG_PATH}" 2>&1 &
+ >"${LOG_PATH}" 2>&1 &
diff --git a/registry/coder/modules/vault-jwt/README.md b/registry/coder/modules/vault-jwt/README.md
index 9837f90dc..409a83525 100644
--- a/registry/coder/modules/vault-jwt/README.md
+++ b/registry/coder/modules/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/registry/coder/modules/vault-jwt/main.tf b/registry/coder/modules/vault-jwt/main.tf
index adcc34d42..17288e008 100644
--- a/registry/coder/modules/vault-jwt/main.tf
+++ b/registry/coder/modules/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/registry/coder/modules/vault-jwt/run.sh b/registry/coder/modules/vault-jwt/run.sh
index ef45884d7..6d4785482 100644
--- a/registry/coder/modules/vault-jwt/run.sh
+++ b/registry/coder/modules/vault-jwt/run.sh
@@ -9,11 +9,11 @@ CODER_OIDC_ACCESS_TOKEN=${CODER_OIDC_ACCESS_TOKEN}
fetch() {
dest="$1"
url="$2"
- if command -v curl > /dev/null 2>&1; then
+ if command -v curl >/dev/null 2>&1; then
curl -sSL --fail "$${url}" -o "$${dest}"
- elif command -v wget > /dev/null 2>&1; then
+ elif command -v wget >/dev/null 2>&1; then
wget -O "$${dest}" "$${url}"
- elif command -v busybox > /dev/null 2>&1; then
+ elif command -v busybox >/dev/null 2>&1; then
busybox wget -O "$${dest}" "$${url}"
else
printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n"
@@ -22,9 +22,9 @@ fetch() {
}
unzip_safe() {
- if command -v unzip > /dev/null 2>&1; then
+ if command -v unzip >/dev/null 2>&1; then
command unzip "$@"
- elif command -v busybox > /dev/null 2>&1; then
+ elif command -v busybox >/dev/null 2>&1; then
busybox unzip "$@"
else
printf "unzip or busybox is not installed. Please install unzip in your image.\n"
@@ -56,7 +56,7 @@ install() {
# Check if the vault CLI is installed and has the correct version
installation_needed=1
- if command -v vault > /dev/null 2>&1; then
+ if command -v vault >/dev/null 2>&1; then
CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
if [ "$${CURRENT_VERSION}" = "$${VAULT_CLI_VERSION}" ]; then
printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}"
@@ -81,7 +81,7 @@ install() {
return 1
fi
rm vault.zip
- if sudo mv vault /usr/local/bin/vault 2> /dev/null; then
+ if sudo mv vault /usr/local/bin/vault 2>/dev/null; then
printf "Vault installed successfully!\n\n"
else
mkdir -p ~/.local/bin
@@ -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/registry/coder/modules/windsurf/README.md b/registry/coder/modules/windsurf/README.md
new file mode 100644
index 000000000..afdb52588
--- /dev/null
+++ b/registry/coder/modules/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/registry/coder/modules/windsurf/main.test.ts b/registry/coder/modules/windsurf/main.test.ts
new file mode 100644
index 000000000..6b520d330
--- /dev/null
+++ b/registry/coder/modules/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/registry/coder/modules/windsurf/main.tf b/registry/coder/modules/windsurf/main.tf
new file mode 100644
index 000000000..1d836d7e3
--- /dev/null
+++ b/registry/coder/modules/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."
+}
diff --git a/registry/whizus/modules/exoscale-instance-type/main.test.ts b/registry/whizus/modules/exoscale-instance-type/main.test.ts
index 8a63cbfae..c155069a9 100644
--- a/registry/whizus/modules/exoscale-instance-type/main.test.ts
+++ b/registry/whizus/modules/exoscale-instance-type/main.test.ts
@@ -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 () => {
diff --git a/test/test.ts b/test/test.ts
index ab3727ee2..4f413180b 100644
--- a/test/test.ts
+++ b/test/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) {
+ 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",
});