diff --git a/.icons/devcontainers.svg b/.icons/devcontainers.svg new file mode 100644 index 0000000..fb0443b --- /dev/null +++ b/.icons/devcontainers.svg @@ -0,0 +1,2 @@ + +file_type_devcontainer \ No newline at end of file diff --git a/.icons/windsurf.svg b/.icons/windsurf.svg new file mode 100644 index 0000000..2e4e4e4 --- /dev/null +++ b/.icons/windsurf.svg @@ -0,0 +1,3 @@ + + + diff --git a/README.md b/README.md index 2a6950f..e59ffd0 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 6ba2995..733230d 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 38c8b31..a94b318 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 349af17..cc7b27e 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 30aeff0..9080bdd 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 c80e537..ca4ff3a 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 0000000..ec73696 --- /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 0000000..6cfe4d0 --- /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 0000000..a2aee34 --- /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 0000000..f7bf852 --- /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 0000000..136fa25 --- /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 84810e4..ffb87f0 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 e8b844c..55ce4eb 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 fcb6baa..0043000 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 dbf4dba..e38aae2 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" { } ``` -![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"] diff --git a/registry/coder/modules/jetbrains-gateway/main.tf b/registry/coder/modules/jetbrains-gateway/main.tf index d197399..502469f 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 64d9c1c..c0c4011 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 2dd34ac..e9a45b5 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 9837f90..409a835 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 adcc34d..17288e0 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 ef45884..6d47854 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 0000000..afdb525 --- /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 0000000..6b520d3 --- /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 0000000..1d836d7 --- /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 8a63cbf..c155069 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 ab3727e..4f41318 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", });