diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5ace4600..600a98c2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,5 @@ updates: directory: "/" schedule: interval: "weekly" + commit-message: + prefix: "chore" diff --git a/.github/typos.toml b/.github/typos.toml new file mode 100644 index 00000000..ec620a44 --- /dev/null +++ b/.github/typos.toml @@ -0,0 +1,2 @@ +[default.extend-words] +muc = "muc" # For Munich location code diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2de2364c..63c6062b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -48,19 +48,8 @@ jobs: - name: Format run: bun fmt:ci - name: typos-action - uses: crate-ci/typos@v1.17.2 + uses: crate-ci/typos@v1.32.0 + with: + config: .github/typos.toml - name: Lint run: bun lint - - name: Check version - shell: bash - run: | - # check for version changes - ./update-version.sh - # Check if any changes were made in README.md files - if [[ -n "$(git status --porcelain -- '**/README.md')" ]]; then - echo "Version mismatch detected. Please run ./update-version.sh and commit the updated README.md files." - git diff -- '**/README.md' - exit 1 - else - echo "No version mismatch detected. All versions are up to date." - fi diff --git a/.github/workflows/deploy-registry.yaml b/.github/workflows/deploy-registry.yaml index 7a67cc83..bc60c06b 100644 --- a/.github/workflows/deploy-registry.yaml +++ b/.github/workflows/deploy-registry.yaml @@ -4,6 +4,8 @@ on: push: branches: - main + tags: + - "release/*/v*" # Matches tags like release/module-name/v1.0.0 jobs: deploy: @@ -20,7 +22,7 @@ jobs: uses: actions/checkout@v4 - name: Authenticate to Google Cloud - uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 + uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 with: workload_identity_provider: projects/309789351055/locations/global/workloadIdentityPools/github-actions/providers/github service_account: registry-v2-github@coder-registry-1.iam.gserviceaccount.com @@ -34,4 +36,8 @@ jobs: - name: Deploy to dev.registry.coder.com run: | gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch dev - \ No newline at end of file + + - name: Deploy to registry.coder.com + run: | + gcloud builds triggers run 106610ff-41fb-4bd0-90a2-7643583fb9c0 --branch main + diff --git a/.icons/claude.svg b/.icons/claude.svg new file mode 100644 index 00000000..998fb0d5 --- /dev/null +++ b/.icons/claude.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.icons/devcontainers.svg b/.icons/devcontainers.svg new file mode 100644 index 00000000..fb0443bd --- /dev/null +++ b/.icons/devcontainers.svg @@ -0,0 +1,2 @@ + +Codestin Search App \ No newline at end of file diff --git a/.icons/goose.svg b/.icons/goose.svg new file mode 100644 index 00000000..cbbe8419 --- /dev/null +++ b/.icons/goose.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.icons/windsurf.svg b/.icons/windsurf.svg new file mode 100644 index 00000000..a7684d4c --- /dev/null +++ b/.icons/windsurf.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60b1260b..2c7ba8bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,8 @@ Follow the instructions to ensure that Bun is available globally. Once Bun has b ## Testing a Module -> **Note:** It is the responsibility of the module author to implement tests for their module. The author must test the module locally before submitting a PR. +> [!NOTE] +> It is the responsibility of the module author to implement tests for their module. The author must test the module locally before submitting a PR. A suite of test-helpers exists to run `terraform apply` on modules with variables, and test script output against containers. @@ -53,23 +54,44 @@ module "example" { ## Releases -> [!WARNING] -> When creating a new release, make sure that your new version number is fully accurate. If a version number is incorrect or does not exist, we may end up serving incorrect/old data for our various tools and providers. +The release process is automated with these steps: + +## 1. Create and Merge PR + +- Create a PR with your module changes +- Get your PR reviewed, approved, and merged to `main` + +## 2. Prepare Release (Maintainer Task) + +After merging to `main`, a maintainer will: + +- View all modules and their current versions: + + ```shell + ./release.sh --list + ``` + +- Determine the next version number based on changes: + + - **Patch version** (1.2.3 → 1.2.4): Bug fixes + - **Minor version** (1.2.3 → 1.3.0): New features, adding inputs, deprecating inputs + - **Major version** (1.2.3 → 2.0.0): Breaking changes (removing inputs, changing input types) -Much of our release process is automated. To cut a new release: +- Create and push an annotated tag: -1. Navigate to [GitHub's Releases page](https://github.com/coder/modules/releases) -2. Click "Draft a new release" -3. Click the "Choose a tag" button and type a new release number in the format `v..` (e.g., `v1.18.0`). Then click "Create new tag". -4. Click the "Generate release notes" button, and clean up the resulting README. Be sure to remove any notes that would not be relevant to end-users (e.g., bumping dependencies). -5. Once everything looks good, click the "Publish release" button. + ```shell + # Fetch latest changes + git fetch origin + + # Create and push tag + ./release.sh module-name 1.2.3 --push + ``` -Once the release has been cut, a script will run to check whether there are any modules that will require that the new release number be published to Terraform. If there are any, a new pull request will automatically be generated. Be sure to approve this PR and merge it into the `main` branch. + The tag format will be: `release/module-name/v1.2.3` -Following that, our automated processes will handle publishing new data for [`registry.coder.com`](https://github.com/coder/registry.coder.com/): +## 3. Publishing to Registry -1. Publishing new versions to Coder's [Terraform Registry](https://registry.terraform.io/providers/coder/coder/latest) -2. Publishing new data to the [Coder Registry](https://registry.coder.com) +Our automated processes will handle publishing new data to [registry.coder.com](https://registry.coder.com). > [!NOTE] -> Some data in `registry.coder.com` is fetched on demand from the Module repo's main branch. This data should be updated almost immediately after a new release, but other changes will take some time to propagate. +> Some data in registry.coder.com is fetched on demand from the [coder/modules](https://github.com/coder/modules) repo's `main` branch. This data should update almost immediately after a release, while other changes will take some time to propagate. diff --git a/README.md b/README.md index acec4e5d..81d8d380 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +> [!CAUTION] +> We are no longer accepting new contributions to this repo. We have moved all modules to https://github.com/coder/registry repo. Please see https://github.com/coder/modules/discussions/469 for more details. +

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