` (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 48a96a3ae..81d8d3807 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
@@ -7,6 +10,7 @@
[](https://discord.gg/coder)
[](./LICENSE)
+[](https://github.com/coder/modules/actions/workflows/check.yaml)
@@ -16,6 +20,7 @@ e.g.
```tf
module "code-server" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
diff --git a/amazon-dcv-windows/README.md b/amazon-dcv-windows/README.md
new file mode 100644
index 000000000..91fdc9ef3
--- /dev/null
+++ b/amazon-dcv-windows/README.md
@@ -0,0 +1,49 @@
+---
+display_name: Amazon DCV Windows
+description: Amazon DCV Server and Web Client for Windows
+icon: ../.icons/dcv.svg
+maintainer_github: coder
+verified: true
+tags: [windows, amazon, dcv, web, desktop]
+---
+
+# Amazon DCV Windows
+
+Amazon DCV is high performance remote display protocol that provides a secure way to deliver remote desktop and application streaming from any cloud or data center to any device, over varying network conditions.
+
+
+
+Enable DCV Server and Web Client on Windows workspaces.
+
+```tf
+module "dcv" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/amazon-dcv-windows/coder"
+ version = "1.0.24"
+ agent_id = resource.coder_agent.main.id
+}
+
+
+resource "coder_metadata" "dcv" {
+ count = data.coder_workspace.me.start_count
+ resource_id = aws_instance.dev.id # id of the instance resource
+
+ item {
+ key = "DCV client instructions"
+ value = "Run `coder port-forward ${data.coder_workspace.me.name} -p ${module.dcv[count.index].port}` and connect to **localhost:${module.dcv[count.index].port}${module.dcv[count.index].web_url_path}**"
+ }
+ item {
+ key = "username"
+ value = module.dcv[count.index].username
+ }
+ item {
+ key = "password"
+ value = module.dcv[count.index].password
+ sensitive = true
+ }
+}
+```
+
+## License
+
+Amazon DCV is free to use on AWS EC2 instances but requires a license for other cloud providers. Please see the instructions [here](https://docs.aws.amazon.com/dcv/latest/adminguide/setting-up-license.html#setting-up-license-ec2) for more information.
diff --git a/amazon-dcv-windows/install-dcv.ps1 b/amazon-dcv-windows/install-dcv.ps1
new file mode 100644
index 000000000..2b1c9f4b2
--- /dev/null
+++ b/amazon-dcv-windows/install-dcv.ps1
@@ -0,0 +1,170 @@
+# Terraform variables
+$adminPassword = "${admin_password}"
+$port = "${port}"
+$webURLPath = "${web_url_path}"
+
+function Set-LocalAdminUser {
+ Write-Output "[INFO] Starting Set-LocalAdminUser function"
+ $securePassword = ConvertTo-SecureString $adminPassword -AsPlainText -Force
+ Write-Output "[DEBUG] Secure password created"
+ Get-LocalUser -Name Administrator | Set-LocalUser -Password $securePassword
+ Write-Output "[INFO] Administrator password set"
+ Get-LocalUser -Name Administrator | Enable-LocalUser
+ Write-Output "[INFO] User Administrator enabled successfully"
+ Read-Host "[DEBUG] Press Enter to proceed to the next step"
+}
+
+function Get-VirtualDisplayDriverRequired {
+ Write-Output "[INFO] Starting Get-VirtualDisplayDriverRequired function"
+ $token = Invoke-RestMethod -Headers @{'X-aws-ec2-metadata-token-ttl-seconds' = '21600'} -Method PUT -Uri http://169.254.169.254/latest/api/token
+ Write-Output "[DEBUG] Token acquired: $token"
+ $instanceType = Invoke-RestMethod -Headers @{'X-aws-ec2-metadata-token' = $token} -Method GET -Uri http://169.254.169.254/latest/meta-data/instance-type
+ Write-Output "[DEBUG] Instance type: $instanceType"
+ $OSVersion = ((Get-ItemProperty -Path "Microsoft.PowerShell.Core\Registry::\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name ProductName).ProductName) -replace "[^0-9]", ''
+ Write-Output "[DEBUG] OS version: $OSVersion"
+
+ # Force boolean result
+ $result = (($OSVersion -ne "2019") -and ($OSVersion -ne "2022") -and ($OSVersion -ne "2025")) -and (($instanceType[0] -ne 'g') -and ($instanceType[0] -ne 'p'))
+ Write-Output "[INFO] VirtualDisplayDriverRequired result: $result"
+ Read-Host "[DEBUG] Press Enter to proceed to the next step"
+ return [bool]$result
+}
+
+function Download-DCV {
+ param (
+ [bool]$VirtualDisplayDriverRequired
+ )
+ Write-Output "[INFO] Starting Download-DCV function"
+
+ $downloads = @(
+ @{
+ Name = "DCV Display Driver"
+ Required = $VirtualDisplayDriverRequired
+ Path = "C:\Windows\Temp\DCVDisplayDriver.msi"
+ Uri = "https://d1uj6qtbmh3dt5.cloudfront.net/nice-dcv-virtual-display-x64-Release.msi"
+ },
+ @{
+ Name = "DCV Server"
+ Required = $true
+ Path = "C:\Windows\Temp\DCVServer.msi"
+ Uri = "https://d1uj6qtbmh3dt5.cloudfront.net/nice-dcv-server-x64-Release.msi"
+ }
+ )
+
+ foreach ($download in $downloads) {
+ if ($download.Required -and -not (Test-Path $download.Path)) {
+ try {
+ Write-Output "[INFO] Downloading $($download.Name)"
+
+ # Display progress manually (no events)
+ $progressActivity = "Downloading $($download.Name)"
+ $progressStatus = "Starting download..."
+ Write-Progress -Activity $progressActivity -Status $progressStatus -PercentComplete 0
+
+ # Synchronously download the file
+ $webClient = New-Object System.Net.WebClient
+ $webClient.DownloadFile($download.Uri, $download.Path)
+
+ # Update progress
+ Write-Progress -Activity $progressActivity -Status "Completed" -PercentComplete 100
+
+ Write-Output "[INFO] $($download.Name) downloaded successfully."
+ } catch {
+ Write-Output "[ERROR] Failed to download $($download.Name): $_"
+ throw
+ }
+ } else {
+ Write-Output "[INFO] $($download.Name) already exists. Skipping download."
+ }
+ }
+
+ Write-Output "[INFO] All downloads completed"
+ Read-Host "[DEBUG] Press Enter to proceed to the next step"
+}
+
+function Install-DCV {
+ param (
+ [bool]$VirtualDisplayDriverRequired
+ )
+ Write-Output "[INFO] Starting Install-DCV function"
+
+ if (-not (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue)) {
+ if ($VirtualDisplayDriverRequired) {
+ Write-Output "[INFO] Installing DCV Display Driver"
+ Start-Process "C:\Windows\System32\msiexec.exe" -ArgumentList "/I C:\Windows\Temp\DCVDisplayDriver.msi /quiet /norestart" -Wait
+ } else {
+ Write-Output "[INFO] DCV Display Driver installation skipped (not required)."
+ }
+ Write-Output "[INFO] Installing DCV Server"
+ Start-Process "C:\Windows\System32\msiexec.exe" -ArgumentList "/I C:\Windows\Temp\DCVServer.msi ADDLOCAL=ALL /quiet /norestart /l*v C:\Windows\Temp\dcv_install_msi.log" -Wait
+ } else {
+ Write-Output "[INFO] DCV Server already installed, skipping installation."
+ }
+
+ # Wait for the service to appear with a timeout
+ $timeout = 10 # seconds
+ $elapsed = 0
+ while (-not (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) {
+ Start-Sleep -Seconds 1
+ $elapsed++
+ }
+
+ if ($elapsed -ge $timeout) {
+ Write-Output "[WARNING] Timeout waiting for dcvserver service. A restart is required to complete installation."
+ Restart-SystemForDCV
+ } else {
+ Write-Output "[INFO] dcvserver service detected successfully."
+ }
+}
+
+function Restart-SystemForDCV {
+ Write-Output "[INFO] The system will restart in 10 seconds to finalize DCV installation."
+ Start-Sleep -Seconds 10
+
+ # Initiate restart
+ Restart-Computer -Force
+
+ # Exit the script after initiating restart
+ Write-Output "[INFO] Please wait for the system to restart..."
+
+ Exit 1
+}
+
+
+function Configure-DCV {
+ Write-Output "[INFO] Starting Configure-DCV function"
+ $dcvPath = "Microsoft.PowerShell.Core\Registry::\HKEY_USERS\S-1-5-18\Software\GSettings\com\nicesoftware\dcv"
+
+ # Create the required paths
+ @("$dcvPath\connectivity", "$dcvPath\session-management", "$dcvPath\session-management\automatic-console-session", "$dcvPath\display") | ForEach-Object {
+ if (-not (Test-Path $_)) {
+ New-Item -Path $_ -Force | Out-Null
+ }
+ }
+
+ # Set registry keys
+ New-ItemProperty -Path "$dcvPath\session-management" -Name create-session -PropertyType DWORD -Value 1 -Force
+ New-ItemProperty -Path "$dcvPath\session-management\automatic-console-session" -Name owner -Value Administrator -Force
+ New-ItemProperty -Path "$dcvPath\connectivity" -Name quic-port -PropertyType DWORD -Value $port -Force
+ New-ItemProperty -Path "$dcvPath\connectivity" -Name web-port -PropertyType DWORD -Value $port -Force
+ New-ItemProperty -Path "$dcvPath\connectivity" -Name web-url-path -PropertyType String -Value $webURLPath -Force
+
+ # Attempt to restart service
+ if (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue) {
+ Restart-Service -Name "dcvserver"
+ } else {
+ Write-Output "[WARNING] dcvserver service not found. Ensure the system was restarted properly."
+ }
+
+ Write-Output "[INFO] DCV configuration completed"
+ Read-Host "[DEBUG] Press Enter to proceed to the next step"
+}
+
+# Main Script Execution
+Write-Output "[INFO] Starting script"
+$VirtualDisplayDriverRequired = [bool](Get-VirtualDisplayDriverRequired)
+Set-LocalAdminUser
+Download-DCV -VirtualDisplayDriverRequired $VirtualDisplayDriverRequired
+Install-DCV -VirtualDisplayDriverRequired $VirtualDisplayDriverRequired
+Configure-DCV
+Write-Output "[INFO] Script completed"
diff --git a/amazon-dcv-windows/main.tf b/amazon-dcv-windows/main.tf
new file mode 100644
index 000000000..90058af3a
--- /dev/null
+++ b/amazon-dcv-windows/main.tf
@@ -0,0 +1,85 @@
+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."
+}
+
+variable "admin_password" {
+ type = string
+ default = "coderDCV!"
+ sensitive = true
+}
+
+variable "port" {
+ type = number
+ description = "The port number for the DCV server."
+ default = 8443
+}
+
+variable "subdomain" {
+ type = bool
+ description = "Whether to use a subdomain for the DCV server."
+ default = true
+}
+
+variable "slug" {
+ type = string
+ description = "The slug of the web-dcv coder_app resource."
+ default = "web-dcv"
+}
+
+resource "coder_app" "web-dcv" {
+ agent_id = var.agent_id
+ slug = var.slug
+ display_name = "Web DCV"
+ url = "https://localhost:${var.port}${local.web_url_path}?username=${local.admin_username}&password=${var.admin_password}"
+ icon = "/icon/dcv.svg"
+ subdomain = var.subdomain
+}
+
+resource "coder_script" "install-dcv" {
+ agent_id = var.agent_id
+ display_name = "Install DCV"
+ icon = "/icon/dcv.svg"
+ run_on_start = true
+ script = templatefile("${path.module}/install-dcv.ps1", {
+ admin_password : var.admin_password,
+ port : var.port,
+ web_url_path : local.web_url_path
+ })
+}
+
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+
+locals {
+ web_url_path = var.subdomain ? "/" : format("/@%s/%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug)
+ admin_username = "Administrator"
+}
+
+output "web_url_path" {
+ value = local.web_url_path
+}
+
+output "username" {
+ value = local.admin_username
+}
+
+output "password" {
+ value = var.admin_password
+ sensitive = true
+}
+
+output "port" {
+ value = var.port
+}
diff --git a/apache-airflow/README.md b/apache-airflow/README.md
index 194cceb8e..72361a0bc 100644
--- a/apache-airflow/README.md
+++ b/apache-airflow/README.md
@@ -14,6 +14,7 @@ A module that adds Apache Airflow in your Coder template.
```tf
module "airflow" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/apache-airflow/coder"
version = "1.0.13"
agent_id = coder_agent.main.id
diff --git a/aws-region/README.md b/aws-region/README.md
index 4d363c3e8..c190ffd81 100644
--- a/aws-region/README.md
+++ b/aws-region/README.md
@@ -16,6 +16,7 @@ Customize the preselected parameter value:
```tf
module "aws-region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/aws-region/coder"
version = "1.0.12"
default = "us-east-1"
@@ -36,6 +37,7 @@ Change the display name and icon for a region using the corresponding maps:
```tf
module "aws-region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/aws-region/coder"
version = "1.0.12"
default = "ap-south-1"
@@ -62,6 +64,7 @@ Hide the Asia Pacific regions Seoul and Osaka:
```tf
module "aws-region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/aws-region/coder"
version = "1.0.12"
exclude = ["ap-northeast-2", "ap-northeast-3"]
diff --git a/azure-region/README.md b/azure-region/README.md
index cd0efd332..2ac9597e1 100644
--- a/azure-region/README.md
+++ b/azure-region/README.md
@@ -13,6 +13,7 @@ This module adds a parameter with all Azure regions, allowing developers to sele
```tf
module "azure_region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/azure-region/coder"
version = "1.0.12"
default = "eastus"
@@ -33,6 +34,7 @@ Change the display name and icon for a region using the corresponding maps:
```tf
module "azure-region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/azure-region/coder"
version = "1.0.12"
custom_names = {
@@ -56,6 +58,7 @@ Hide all regions in Australia except australiacentral:
```tf
module "azure-region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/azure-region/coder"
version = "1.0.12"
exclude = [
diff --git a/claude-code/README.md b/claude-code/README.md
new file mode 100644
index 000000000..7ff2ae873
--- /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 000000000..cc7b27e07
--- /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 330661c26..dc44237f4 100644
--- a/code-server/README.md
+++ b/code-server/README.md
@@ -13,8 +13,9 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
```tf
module "code-server" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.18"
+ version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -27,8 +28,9 @@ module "code-server" {
```tf
module "code-server" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.18"
+ version = "1.1.0"
agent_id = coder_agent.example.id
install_version = "4.8.3"
}
@@ -40,8 +42,9 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
```tf
module "code-server" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.18"
+ version = "1.1.0"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -53,12 +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.18"
+ version = "1.1.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -73,8 +77,9 @@ Just run code-server in the background, don't fetch it from GitHub:
```tf
module "code-server" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.18"
+ version = "1.1.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
@@ -88,8 +93,9 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
```tf
module "code-server" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.18"
+ version = "1.1.0"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@@ -100,8 +106,9 @@ Just run code-server in the background, don't fetch it from GitHub:
```tf
module "code-server" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.18"
+ 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 996169340..ca4ff3afd 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"
}
}
}
@@ -39,7 +39,7 @@ variable "slug" {
}
variable "settings" {
- type = map(string)
+ type = any
description = "A map of settings to apply to code-server."
default = {}
}
@@ -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 9af391e04..99b30c0ea 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"
@@ -104,7 +114,8 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
- extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json)
+ # Use sed to remove single-line comments before parsing with jq
+ extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]')
for extension in $extensions; do
if extension_installed "$extension"; then
continue
diff --git a/coder-login/README.md b/coder-login/README.md
index c9bb333f3..589266bfb 100644
--- a/coder-login/README.md
+++ b/coder-login/README.md
@@ -13,6 +13,7 @@ Automatically logs the user into Coder when creating their workspace.
```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
diff --git a/cursor/README.md b/cursor/README.md
index c2997bee9..d9a2e17f9 100644
--- a/cursor/README.md
+++ b/cursor/README.md
@@ -11,10 +11,11 @@ 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" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/cursor/coder"
version = "1.0.19"
agent_id = coder_agent.example.id
@@ -27,6 +28,7 @@ module "cursor" {
```tf
module "cursor" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/cursor/coder"
version = "1.0.19"
agent_id = coder_agent.example.id
diff --git a/devcontainers-cli/README.md b/devcontainers-cli/README.md
new file mode 100644
index 000000000..4b4450730
--- /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 000000000..892d6430b
--- /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 000000000..a2aee348b
--- /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 000000000..bd3c1b1dc
--- /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/dotfiles/README.md b/dotfiles/README.md
index 6d7673cc8..4a911f87d 100644
--- a/dotfiles/README.md
+++ b/dotfiles/README.md
@@ -17,8 +17,9 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
```tf
module "dotfiles" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
- version = "1.0.18"
+ version = "1.0.29"
agent_id = coder_agent.example.id
}
```
@@ -29,8 +30,9 @@ module "dotfiles" {
```tf
module "dotfiles" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
- version = "1.0.18"
+ version = "1.0.29"
agent_id = coder_agent.example.id
}
```
@@ -39,8 +41,9 @@ module "dotfiles" {
```tf
module "dotfiles" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
- version = "1.0.18"
+ version = "1.0.29"
agent_id = coder_agent.example.id
user = "root"
}
@@ -50,14 +53,16 @@ module "dotfiles" {
```tf
module "dotfiles" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
- version = "1.0.18"
+ version = "1.0.29"
agent_id = coder_agent.example.id
}
module "dotfiles-root" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
- version = "1.0.18"
+ version = "1.0.29"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
@@ -70,8 +75,9 @@ You can set a default dotfiles repository for all users by setting the `default_
```tf
module "dotfiles" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
- version = "1.0.18"
+ version = "1.0.29"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}
diff --git a/dotfiles/run.sh b/dotfiles/run.sh
index 946343920..e0599418c 100644
--- a/dotfiles/run.sh
+++ b/dotfiles/run.sh
@@ -1,4 +1,7 @@
#!/usr/bin/env bash
+
+set -euo pipefail
+
DOTFILES_URI="${DOTFILES_URI}"
DOTFILES_USER="${DOTFILES_USER}"
diff --git a/exoscale-instance-type/README.md b/exoscale-instance-type/README.md
index 4296121c4..19083c3a0 100644
--- a/exoscale-instance-type/README.md
+++ b/exoscale-instance-type/README.md
@@ -16,6 +16,7 @@ Customize the preselected parameter value:
```tf
module "exoscale-instance-type" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/exoscale-instance-type/coder"
version = "1.0.12"
default = "standard.medium"
@@ -44,6 +45,7 @@ Change the display name a type using the corresponding maps:
```tf
module "exoscale-instance-type" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/exoscale-instance-type/coder"
version = "1.0.12"
default = "standard.medium"
@@ -78,6 +80,7 @@ Show only gpu1 types
```tf
module "exoscale-instance-type" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/exoscale-instance-type/coder"
version = "1.0.12"
default = "gpu.large"
diff --git a/exoscale-zone/README.md b/exoscale-zone/README.md
index 0f4353e52..611aee5b0 100644
--- a/exoscale-zone/README.md
+++ b/exoscale-zone/README.md
@@ -16,6 +16,7 @@ Customize the preselected parameter value:
```tf
module "exoscale-zone" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/exoscale-zone/coder"
version = "1.0.12"
default = "ch-dk-2"
@@ -43,6 +44,7 @@ Change the display name and icon for a zone using the corresponding maps:
```tf
module "exoscale-zone" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/exoscale-zone/coder"
version = "1.0.12"
default = "at-vie-1"
diff --git a/filebrowser/README.md b/filebrowser/README.md
index 1b53ffaa6..3a0e56bda 100644
--- a/filebrowser/README.md
+++ b/filebrowser/README.md
@@ -13,8 +13,9 @@ A file browser for your workspace.
```tf
module "filebrowser" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/filebrowser/coder"
- version = "1.0.22"
+ version = "1.0.31"
agent_id = coder_agent.example.id
}
```
@@ -27,8 +28,9 @@ module "filebrowser" {
```tf
module "filebrowser" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/filebrowser/coder"
- version = "1.0.22"
+ version = "1.0.31"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -38,8 +40,9 @@ module "filebrowser" {
```tf
module "filebrowser" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/filebrowser/coder"
- version = "1.0.22"
+ version = "1.0.31"
agent_id = coder_agent.example.id
database_path = ".config/filebrowser.db"
}
@@ -49,7 +52,9 @@ module "filebrowser" {
```tf
module "filebrowser" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/filebrowser/coder"
+ 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 7dd49724e..346808808 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' ",
- "",
- "📝 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' ",
- "",
- "📝 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' ",
- "",
- "📝 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' ",
- "",
- "📝 Logs at /tmp/filebrowser.log",
- ]);
+
+ const output = await await executeScriptInContainer(
+ state,
+ "alpine/curl",
+ "sh",
+ "apk add bash",
+ );
+
+ testBaseLine(output);
});
});
diff --git a/filebrowser/main.tf b/filebrowser/main.tf
index 4fd74598b..ba83844b0 100644
--- a/filebrowser/main.tf
+++ b/filebrowser/main.tf
@@ -20,13 +20,8 @@ data "coder_workspace_owner" "me" {}
variable "agent_name" {
type = string
- description = "The name of the main deployment. (Used to build the subpath for coder_app.)"
- default = ""
- validation {
- # If subdomain is false, then agent_name must be set.
- condition = var.subdomain || var.agent_name != ""
- error_message = "The agent_name must be set."
- }
+ description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)"
+ default = null
}
variable "database_path" {
@@ -73,6 +68,12 @@ variable "order" {
default = null
}
+variable "slug" {
+ type = string
+ description = "The slug of the coder_app resource."
+ default = "filebrowser"
+}
+
variable "subdomain" {
type = bool
description = <<-EOT
@@ -85,7 +86,7 @@ variable "subdomain" {
resource "coder_script" "filebrowser" {
agent_id = var.agent_id
display_name = "File Browser"
- icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg"
+ icon = "/icon/filebrowser.svg"
script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path,
PORT : var.port,
@@ -93,18 +94,30 @@ resource "coder_script" "filebrowser" {
LOG_PATH : var.log_path,
DB_PATH : var.database_path,
SUBDOMAIN : var.subdomain,
- SERVER_BASE_PATH : var.subdomain ? "" : format("/@%s/%s.%s/apps/filebrowser", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name),
+ SERVER_BASE_PATH : local.server_base_path
})
run_on_start = true
}
resource "coder_app" "filebrowser" {
agent_id = var.agent_id
- slug = "filebrowser"
+ slug = var.slug
display_name = "File Browser"
- url = "http://localhost:${var.port}"
- icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg"
+ url = local.url
+ icon = "/icon/filebrowser.svg"
subdomain = var.subdomain
share = var.share
order = var.order
+
+ healthcheck {
+ url = local.healthcheck_url
+ interval = 5
+ threshold = 6
+ }
}
+
+locals {
+ server_base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug)
+ url = "http://localhost:${var.port}${local.server_base_path}"
+ healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/health"
+}
\ No newline at end of file
diff --git a/filebrowser/run.sh b/filebrowser/run.sh
index 8a31d4dbd..62f04edf6 100644
--- a/filebrowser/run.sh
+++ b/filebrowser/run.sh
@@ -1,29 +1,39 @@
#!/usr/bin/env bash
-BOLD='\033[0;1m'
+set -euo pipefail
+
+BOLD='\033[[0;1m'
+
printf "$${BOLD}Installing filebrowser \n\n"
-curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash
+# Check if filebrowser is installed
+if ! command -v filebrowser &> /dev/null; then
+ curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash
+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
-# set baseurl to be able to run if sudomain=false; if subdomain=true the SERVER_BASE_PATH value will be ""
-filebrowser config set --baseurl "${SERVER_BASE_PATH}"$${DB_FLAG} > ${LOG_PATH} 2>&1
+filebrowser config set --baseurl=${SERVER_BASE_PATH} --port=${PORT} --auth.method=noauth --root=$ROOT_DIR 2>&1 | tee -a ${LOG_PATH}
-printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n"
+printf "👷 Starting filebrowser in background... \n\n"
-printf "Running 'filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG}' \n\n"
+printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n"
-filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG} > ${LOG_PATH} 2>&1 &
+filebrowser >> ${LOG_PATH} 2>&1 &
printf "📝 Logs at ${LOG_PATH} \n\n"
diff --git a/fly-region/README.md b/fly-region/README.md
index e5f446ef4..30bcb136a 100644
--- a/fly-region/README.md
+++ b/fly-region/README.md
@@ -15,6 +15,7 @@ We can use the simplest format here, only adding a default selection as the `atl
```tf
module "fly-region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/fly-region/coder"
version = "1.0.2"
default = "atl"
@@ -31,6 +32,7 @@ The regions argument can be used to display only the desired regions in the Code
```tf
module "fly-region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/fly-region/coder"
version = "1.0.2"
default = "ams"
@@ -46,6 +48,7 @@ Set custom icons and names with their respective maps.
```tf
module "fly-region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/fly-region/coder"
version = "1.0.2"
default = "ams"
diff --git a/gcp-region/README.md b/gcp-region/README.md
index 776d638cd..a74807f39 100644
--- a/gcp-region/README.md
+++ b/gcp-region/README.md
@@ -13,6 +13,7 @@ This module adds Google Cloud Platform regions to your Coder template.
```tf
module "gcp_region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/gcp-region/coder"
version = "1.0.12"
regions = ["us", "europe"]
@@ -33,6 +34,7 @@ Note: setting `gpu_only = true` and using a default region without GPU support,
```tf
module "gcp_region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/gcp-region/coder"
version = "1.0.12"
default = ["us-west1-a"]
@@ -49,6 +51,7 @@ resource "google_compute_instance" "example" {
```tf
module "gcp_region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/gcp-region/coder"
version = "1.0.12"
regions = ["europe-west"]
@@ -64,6 +67,7 @@ resource "google_compute_instance" "example" {
```tf
module "gcp_region" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/gcp-region/coder"
version = "1.0.12"
regions = ["us", "europe"]
diff --git a/git-clone/README.md b/git-clone/README.md
index 6b8871eb2..0647f7f93 100644
--- a/git-clone/README.md
+++ b/git-clone/README.md
@@ -13,6 +13,7 @@ This module allows you to automatically clone a repository by URL and skip if it
```tf
module "git-clone" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
agent_id = coder_agent.example.id
@@ -26,6 +27,7 @@ module "git-clone" {
```tf
module "git-clone" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
agent_id = coder_agent.example.id
@@ -40,6 +42,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov
```tf
module "git-clone" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
agent_id = coder_agent.example.id
@@ -65,6 +68,7 @@ data "coder_parameter" "git_repo" {
# Clone the repository for branch `feat/example`
module "git_clone" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
agent_id = coder_agent.example.id
@@ -73,23 +77,24 @@ module "git_clone" {
# Create a code-server instance for the cloned repository
module "code-server" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.18"
agent_id = coder_agent.example.id
order = 1
- folder = "/home/${local.username}/${module.git_clone.folder_name}"
+ folder = "/home/${local.username}/${module.git_clone[count.index].folder_name}"
}
# Create a Coder app for the website
resource "coder_app" "website" {
+ count = data.coder_workspace.me.start_count
agent_id = coder_agent.example.id
order = 2
slug = "website"
external = true
- display_name = module.git_clone.folder_name
- url = module.git_clone.web_url
- icon = module.git_clone.git_provider != "" ? "/icon/${module.git_clone.git_provider}.svg" : "/icon/git.svg"
- count = module.git_clone.web_url != "" ? 1 : 0
+ display_name = module.git_clone[count.index].folder_name
+ url = module.git_clone[count.index].web_url
+ icon = module.git_clone[count.index].git_provider != "" ? "/icon/${module.git_clone[count.index].git_provider}.svg" : "/icon/git.svg"
}
```
@@ -97,6 +102,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g
```tf
module "git-clone" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
agent_id = coder_agent.example.id
@@ -115,6 +121,7 @@ To GitLab clone with a specific branch like `feat/example`
```tf
module "git-clone" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
agent_id = coder_agent.example.id
@@ -126,6 +133,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
```tf
module "git-clone" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
agent_id = coder_agent.example.id
@@ -146,6 +154,7 @@ For example, to clone the `feat/example` branch:
```tf
module "git-clone" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
agent_id = coder_agent.example.id
@@ -162,6 +171,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder:
```tf
module "git-clone" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
agent_id = coder_agent.example.id
diff --git a/git-commit-signing/README.md b/git-commit-signing/README.md
index 942f2f35e..1f71cbb3b 100644
--- a/git-commit-signing/README.md
+++ b/git-commit-signing/README.md
@@ -9,6 +9,9 @@ tags: [helper, git]
# git-commit-signing
+> [!IMPORTANT]
+> This module will only work with Git versions >=2.34, prior versions [do not support signing commits via SSH keys](https://lore.kernel.org/git/xmqq8rxpgwki.fsf@gitster.g/).
+
This module downloads your SSH key from Coder and uses it to sign commits with Git.
It requires `curl` and `jq` to be installed inside your workspace.
@@ -18,6 +21,7 @@ This module has a chance of conflicting with the user's dotfiles / the personali
```tf
module "git-commit-signing" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-commit-signing/coder"
version = "1.0.11"
agent_id = coder_agent.example.id
diff --git a/git-config/README.md b/git-config/README.md
index 90e8442c2..5ba0806be 100644
--- a/git-config/README.md
+++ b/git-config/README.md
@@ -13,6 +13,7 @@ Runs a script that updates git credentials in the workspace to match the user's
```tf
module "git-config" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-config/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
@@ -27,6 +28,7 @@ TODO: Add screenshot
```tf
module "git-config" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-config/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
@@ -40,6 +42,7 @@ TODO: Add screenshot
```tf
module "git-config" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-config/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
diff --git a/github-upload-public-key/README.md b/github-upload-public-key/README.md
index 17464f35d..192db7ebb 100644
--- a/github-upload-public-key/README.md
+++ b/github-upload-public-key/README.md
@@ -13,6 +13,7 @@ Templates that utilize Github External Auth can automatically ensure that the Co
```tf
module "github-upload-public-key" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/github-upload-public-key/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
@@ -45,6 +46,7 @@ data "coder_external_auth" "github" {
}
module "github-upload-public-key" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/github-upload-public-key/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
diff --git a/goose/README.md b/goose/README.md
new file mode 100644
index 000000000..89014891d
--- /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 000000000..0043000ec
--- /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 0745fa79c..f3fc33f77 100644
--- a/jetbrains-gateway/README.md
+++ b/jetbrains-gateway/README.md
@@ -11,12 +11,15 @@ tags: [ide, jetbrains, helper, parameter]
This module adds a JetBrains Gateway Button to open any workspace with a single click.
+JetBrains recommends a minimum of 4 CPU cores and 8GB of RAM.
+Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prerequisites.html#min_requirements) to confirm other system requirements.
+
```tf
module "jetbrains_gateway" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
- version = "1.0.21"
+ version = "1.1.0"
agent_id = coder_agent.example.id
- agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"]
default = "GO"
@@ -31,39 +34,64 @@ module "jetbrains_gateway" {
```tf
module "jetbrains_gateway" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
- version = "1.0.21"
+ version = "1.1.0"
agent_id = coder_agent.example.id
- agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
default = "GO"
}
```
-### Use the latest release version
+### Use the latest version of each IDE
```tf
module "jetbrains_gateway" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
- version = "1.0.21"
+ version = "1.1.0"
agent_id = coder_agent.example.id
- agent_name = "example"
folder = "/home/coder/example"
- jetbrains_ides = ["GO", "WS"]
- default = "GO"
+ jetbrains_ides = ["IU", "PY"]
+ default = "IU"
latest = true
}
```
+### Use fixed versions set by `jetbrains_ide_versions`
+
+```tf
+module "jetbrains_gateway" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/jetbrains-gateway/coder"
+ version = "1.1.0"
+ agent_id = coder_agent.example.id
+ folder = "/home/coder/example"
+ jetbrains_ides = ["IU", "PY"]
+ default = "IU"
+ latest = false
+ jetbrains_ide_versions = {
+ "IU" = {
+ build_number = "243.21565.193"
+ version = "2024.3"
+ }
+ "PY" = {
+ build_number = "243.21565.199"
+ version = "2024.3"
+ }
+ }
+}
+```
+
### Use the latest EAP version
```tf
module "jetbrains_gateway" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
- version = "1.0.21"
+ version = "1.1.0"
agent_id = coder_agent.example.id
- agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
default = "GO"
@@ -72,15 +100,34 @@ module "jetbrains_gateway" {
}
```
+### Custom base link
+
+Due to the highest priority of the `ide_download_link` parameter in the `(jetbrains-gateway://...` within IDEA, the pre-configured download address will be overridden when using [IDEA's offline mode](https://www.jetbrains.com/help/idea/fully-offline-mode.html). Therefore, it is necessary to configure the `download_base_link` parameter for the `jetbrains_gateway` module to change the value of `ide_download_link`.
+
+```tf
+module "jetbrains_gateway" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/jetbrains-gateway/coder"
+ version = "1.1.0"
+ agent_id = coder_agent.example.id
+ folder = "/home/coder/example"
+ jetbrains_ides = ["GO", "WS"]
+ releases_base_link = "https://releases.internal.site/"
+ download_base_link = "https://download.internal.site/"
+ default = "GO"
+}
+```
+
## Supported IDEs
This module and JetBrains Gateway support the following JetBrains IDEs:
-- GoLand (`GO`)
-- WebStorm (`WS`)
-- IntelliJ IDEA Ultimate (`IU`)
-- PyCharm Professional (`PY`)
-- PhpStorm (`PS`)
-- CLion (`CL`)
-- RubyMine (`RM`)
-- Rider (`RD`)
+- [GoLand (`GO`)](https://www.jetbrains.com/go/)
+- [WebStorm (`WS`)](https://www.jetbrains.com/webstorm/)
+- [IntelliJ IDEA Ultimate (`IU`)](https://www.jetbrains.com/idea/)
+- [PyCharm Professional (`PY`)](https://www.jetbrains.com/pycharm/)
+- [PhpStorm (`PS`)](https://www.jetbrains.com/phpstorm/)
+- [CLion (`CL`)](https://www.jetbrains.com/clion/)
+- [RubyMine (`RM`)](https://www.jetbrains.com/ruby/)
+- [Rider (`RD`)](https://www.jetbrains.com/rider/)
+- [RustRover (`RR`)](https://www.jetbrains.com/rust/)
diff --git a/jetbrains-gateway/main.test.ts b/jetbrains-gateway/main.test.ts
index 0a5b3bc32..ea04a77db 100644
--- a/jetbrains-gateway/main.test.ts
+++ b/jetbrains-gateway/main.test.ts
@@ -10,7 +10,6 @@ describe("jetbrains-gateway", async () => {
await testRequiredVariables(import.meta.dir, {
agent_id: "foo",
- agent_name: "foo",
folder: "/home/foo",
});
@@ -18,11 +17,10 @@ describe("jetbrains-gateway", async () => {
const state = await runTerraformApply(import.meta.dir, {
// These are all required.
agent_id: "foo",
- agent_name: "foo",
folder: "/home/coder",
});
expect(state.outputs.url.value).toBe(
- "jetbrains-gateway://connect#type=coder&workspace=default&owner=default&agent=foo&folder=/home/coder&url=https://mydeployment.coder.com&token=$SESSION_TOKEN&ide_product_code=IU&ide_build_number=241.14494.240&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2024.1.tar.gz",
+ "jetbrains-gateway://connect#type=coder&workspace=default&owner=default&folder=/home/coder&url=https://mydeployment.coder.com&token=$SESSION_TOKEN&ide_product_code=IU&ide_build_number=243.21565.193&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2024.3.tar.gz",
);
const coder_app = state.resources.find(
@@ -37,7 +35,6 @@ describe("jetbrains-gateway", async () => {
it("default to first ide", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
- agent_name: "foo",
folder: "/home/foo",
jetbrains_ides: '["IU", "GO", "PY"]',
});
diff --git a/jetbrains-gateway/main.tf b/jetbrains-gateway/main.tf
index 2bc00d397..502469f29 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."
@@ -26,7 +36,9 @@ variable "slug" {
variable "agent_name" {
type = string
- description = "Agent name."
+ description = "Agent name. (unused). Will be removed in a future version"
+
+ default = ""
}
variable "folder" {
@@ -80,59 +92,63 @@ variable "jetbrains_ide_versions" {
description = "The set of versions for each jetbrains IDE"
default = {
"IU" = {
- build_number = "241.14494.240"
- version = "2024.1"
+ build_number = "243.21565.193"
+ version = "2024.3"
}
"PS" = {
- build_number = "241.14494.237"
- version = "2024.1"
+ build_number = "243.21565.202"
+ version = "2024.3"
}
"WS" = {
- build_number = "241.14494.235"
- version = "2024.1"
+ build_number = "243.21565.180"
+ version = "2024.3"
}
"PY" = {
- build_number = "241.14494.241"
- version = "2024.1"
+ build_number = "243.21565.199"
+ version = "2024.3"
}
"CL" = {
- build_number = "241.14494.288"
+ build_number = "243.21565.238"
version = "2024.1"
}
"GO" = {
- build_number = "241.14494.238"
- version = "2024.1"
+ build_number = "243.21565.208"
+ version = "2024.3"
}
"RM" = {
- build_number = "241.14494.234"
- version = "2024.1"
+ build_number = "243.21565.197"
+ version = "2024.3"
}
"RD" = {
- build_number = "241.14494.307"
- version = "2024.1"
+ build_number = "243.21565.191"
+ version = "2024.3"
+ }
+ "RR" = {
+ build_number = "243.22562.230"
+ version = "2024.3"
}
}
validation {
condition = (
alltrue([
- for code in keys(var.jetbrains_ide_versions) : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"], code)
+ for code in keys(var.jetbrains_ide_versions) : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code)
])
)
- error_message = "The jetbrains_ide_versions must contain a map of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"])}."
+ error_message = "The jetbrains_ide_versions must contain a map of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"])}."
}
}
variable "jetbrains_ides" {
type = list(string)
description = "The list of IDE product codes."
- default = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"]
+ default = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"]
validation {
condition = (
alltrue([
- for code in var.jetbrains_ides : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"], code)
+ for code in var.jetbrains_ides : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code)
])
)
- error_message = "The jetbrains_ides must be a list of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"])}."
+ error_message = "The jetbrains_ides must be a list of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"])}."
}
# check if the list is empty
validation {
@@ -146,76 +162,126 @@ variable "jetbrains_ides" {
}
}
+variable "releases_base_link" {
+ type = string
+ description = ""
+ default = "https://data.services.jetbrains.com"
+ validation {
+ condition = can(regex("^https?://.+$", var.releases_base_link))
+ error_message = "The releases_base_link must be a valid HTTP/S address."
+ }
+}
+
+variable "download_base_link" {
+ type = string
+ description = ""
+ default = "https://download.jetbrains.com"
+ validation {
+ condition = can(regex("^https?://.+$", var.download_base_link))
+ error_message = "The download_base_link must be a valid HTTP/S address."
+ }
+}
+
data "http" "jetbrains_ide_versions" {
for_each = var.latest ? toset(var.jetbrains_ides) : toset([])
- url = "https://data.services.jetbrains.com/products/releases?code=${each.key}&latest=true&type=${var.channel}"
+ url = "${var.releases_base_link}/products/releases?code=${each.key}&latest=true&type=${var.channel}"
}
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 = "https://download.jetbrains.com/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 = "https://download.jetbrains.com/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 = "https://download.jetbrains.com/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 = "https://download.jetbrains.com/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 = "https://download.jetbrains.com/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 = "https://download.jetbrains.com/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 = "https://download.jetbrains.com/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 = "https://download.jetbrains.com/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 = 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
}
}
@@ -224,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
}
@@ -263,8 +329,6 @@ resource "coder_app" "gateway" {
data.coder_workspace.me.name,
"&owner=",
data.coder_workspace_owner.me.name,
- "&agent=",
- var.agent_name,
"&folder=",
var.folder,
"&url=",
diff --git a/jfrog-oauth/README.md b/jfrog-oauth/README.md
index 4423a740c..894e1f325 100644
--- a/jfrog-oauth/README.md
+++ b/jfrog-oauth/README.md
@@ -16,6 +16,7 @@ Install the JF CLI and authenticate package managers with Artifactory using OAut
```tf
module "jfrog" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jfrog-oauth/coder"
version = "1.0.19"
agent_id = coder_agent.example.id
@@ -44,6 +45,7 @@ Configure the Python pip package manager to fetch packages from Artifactory whil
```tf
module "jfrog" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jfrog-oauth/coder"
version = "1.0.19"
agent_id = coder_agent.example.id
@@ -72,6 +74,7 @@ The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extensio
```tf
module "jfrog" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jfrog-oauth/coder"
version = "1.0.19"
agent_id = coder_agent.example.id
@@ -95,8 +98,8 @@ provider "docker" {
# ...
registry_auth {
address = "https://example.jfrog.io/artifactory/api/docker/REPO-KEY"
- username = module.jfrog.username
- password = module.jfrog.access_token
+ username = try(module.jfrog[0].username, "")
+ password = try(module.jfrog[0].access_token, "")
}
}
```
diff --git a/jfrog-token/README.md b/jfrog-token/README.md
index 146dc7f7a..ce1652229 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 2c856720c..4ba2f52d3 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 f6f5f5b08..720e2d8c1 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/jupyter-notebook/README.md b/jupyter-notebook/README.md
index 83d36cb98..56f7ff18a 100644
--- a/jupyter-notebook/README.md
+++ b/jupyter-notebook/README.md
@@ -15,6 +15,7 @@ A module that adds Jupyter Notebook in your Coder template.
```tf
module "jupyter-notebook" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jupyter-notebook/coder"
version = "1.0.19"
agent_id = coder_agent.example.id
diff --git a/jupyterlab/README.md b/jupyterlab/README.md
index 52d5a502e..abebdc826 100644
--- a/jupyterlab/README.md
+++ b/jupyterlab/README.md
@@ -15,8 +15,9 @@ A module that adds JupyterLab in your Coder template.
```tf
module "jupyterlab" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jupyterlab/coder"
- version = "1.0.22"
+ version = "1.0.31"
agent_id = coder_agent.example.id
}
```
diff --git a/jupyterlab/main.test.ts b/jupyterlab/main.test.ts
index cf9ac1f0b..a9789c391 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 aff21b726..be686e55f 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 jupyterlab > /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
+if ! command -v jupyter-lab > /dev/null 2>&1; then
# 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/kasmvnc/README.md b/kasmvnc/README.md
index 3b7fe5078..9c3b28dbf 100644
--- a/kasmvnc/README.md
+++ b/kasmvnc/README.md
@@ -13,8 +13,9 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
```tf
module "kasmvnc" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/kasmvnc/coder"
- version = "1.0.22"
+ version = "1.0.23"
agent_id = coder_agent.example.id
desktop_environment = "xfce"
}
diff --git a/kasmvnc/main.tf b/kasmvnc/main.tf
index 3a730ff58..4265f3c7c 100644
--- a/kasmvnc/main.tf
+++ b/kasmvnc/main.tf
@@ -42,7 +42,7 @@ resource "coder_script" "kasm_vnc" {
script = templatefile("${path.module}/run.sh", {
PORT : var.port,
DESKTOP_ENVIRONMENT : var.desktop_environment,
- VERSION : var.kasm_version
+ KASM_VERSION : var.kasm_version
})
run_on_start = true
}
diff --git a/kasmvnc/run.sh b/kasmvnc/run.sh
index b83153762..c285b0501 100644
--- a/kasmvnc/run.sh
+++ b/kasmvnc/run.sh
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
-#!/bin/bash
+# Exit on error, undefined variables, and pipe failures
+set -euo pipefail
# Function to check if vncserver is already installed
check_installed() {
@@ -14,143 +15,167 @@ check_installed() {
# Function to download a file using wget, curl, or busybox as a fallback
download_file() {
- local url=$1
- local output=$2
- if command -v wget &> /dev/null; then
- wget $url -O $output
- elif command -v curl &> /dev/null; then
- curl -fsSL $url -o $output
+ local url="$1"
+ local output="$2"
+ local download_tool
+
+ if command -v curl &> /dev/null; then
+ # shellcheck disable=SC2034
+ download_tool=(curl -fsSL)
+ elif command -v wget &> /dev/null; then
+ # shellcheck disable=SC2034
+ download_tool=(wget -q -O-)
elif command -v busybox &> /dev/null; then
- busybox wget -O $output $url
+ # shellcheck disable=SC2034
+ download_tool=(busybox wget -O-)
else
- echo "Neither wget, curl, nor busybox is installed. Please install one of them to proceed."
+ echo "ERROR: No download tool available (curl, wget, or busybox required)"
exit 1
fi
+
+ # shellcheck disable=SC2288
+ "$${download_tool[@]}" "$url" > "$output" || {
+ echo "ERROR: Failed to download $url"
+ exit 1
+ }
}
# Function to install kasmvncserver for debian-based distros
install_deb() {
local url=$1
- download_file $url /tmp/kasmvncserver.deb
- sudo apt-get update
- DEBIAN_FRONTEND=noninteractive sudo apt-get install --yes -qq --no-install-recommends --no-install-suggests /tmp/kasmvncserver.deb
- sudo adduser $USER ssl-cert
- rm /tmp/kasmvncserver.deb
-}
+ local kasmdeb="/tmp/kasmvncserver.deb"
-# Function to install kasmvncserver for Oracle 8
-install_rpm_oracle8() {
- local url=$1
- download_file $url /tmp/kasmvncserver.rpm
- sudo dnf config-manager --set-enabled ol8_codeready_builder
- sudo dnf install oracle-epel-release-el8 -y
- sudo dnf localinstall /tmp/kasmvncserver.rpm -y
- sudo usermod -aG kasmvnc-cert $USER
- rm /tmp/kasmvncserver.rpm
-}
+ download_file "$url" "$kasmdeb"
-# Function to install kasmvncserver for CentOS 7
-install_rpm_centos7() {
- local url=$1
- download_file $url /tmp/kasmvncserver.rpm
- sudo yum install epel-release -y
- sudo yum install /tmp/kasmvncserver.rpm -y
- sudo usermod -aG kasmvnc-cert $USER
- rm /tmp/kasmvncserver.rpm
+ CACHE_DIR="/var/lib/apt/lists/partial"
+ # Check if the directory exists and was modified in the last 60 minutes
+ if [[ ! -d "$CACHE_DIR" ]] || ! find "$CACHE_DIR" -mmin -60 -print -quit &> /dev/null; then
+ echo "Stale package cache, updating..."
+ # Update package cache with a 300-second timeout for dpkg lock
+ sudo apt-get -o DPkg::Lock::Timeout=300 -qq update
+ fi
+
+ DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests "$kasmdeb"
+ rm "$kasmdeb"
}
# Function to install kasmvncserver for rpm-based distros
install_rpm() {
local url=$1
- download_file $url /tmp/kasmvncserver.rpm
- sudo rpm -i /tmp/kasmvncserver.rpm
- rm /tmp/kasmvncserver.rpm
+ local kasmrpm="/tmp/kasmvncserver.rpm"
+ local package_manager
+
+ if command -v dnf &> /dev/null; then
+ # shellcheck disable=SC2034
+ package_manager=(dnf localinstall -y)
+ elif command -v zypper &> /dev/null; then
+ # shellcheck disable=SC2034
+ package_manager=(zypper install -y)
+ elif command -v yum &> /dev/null; then
+ # shellcheck disable=SC2034
+ package_manager=(yum localinstall -y)
+ elif command -v rpm &> /dev/null; then
+ # Do we need to manually handle missing dependencies?
+ # shellcheck disable=SC2034
+ package_manager=(rpm -i)
+ else
+ echo "ERROR: No supported package manager available (dnf, zypper, yum, or rpm required)"
+ exit 1
+ fi
+
+ download_file "$url" "$kasmrpm"
+
+ # shellcheck disable=SC2288
+ sudo "$${package_manager[@]}" "$kasmrpm" || {
+ echo "ERROR: Failed to install $kasmrpm"
+ exit 1
+ }
+
+ rm "$kasmrpm"
}
# Function to install kasmvncserver for Alpine Linux
install_alpine() {
local url=$1
- download_file $url /tmp/kasmvncserver.tgz
- tar -xzf /tmp/kasmvncserver.tgz -C /usr/local/bin/
- rm /tmp/kasmvncserver.tgz
+ local kasmtgz="/tmp/kasmvncserver.tgz"
+
+ download_file "$url" "$kasmtgz"
+
+ tar -xzf "$kasmtgz" -C /usr/local/bin/
+ rm "$kasmtgz"
}
# Detect system information
-distro=$(grep "^ID=" /etc/os-release | awk -F= '{print $2}')
-version=$(grep "^VERSION_ID=" /etc/os-release | awk -F= '{print $2}' | tr -d '"')
-arch=$(uname -m)
+if [[ ! -f /etc/os-release ]]; then
+ echo "ERROR: Cannot detect OS: /etc/os-release not found"
+ exit 1
+fi
+
+# shellcheck disable=SC1091
+source /etc/os-release
+distro="$ID"
+distro_version="$VERSION_ID"
+codename="$VERSION_CODENAME"
+arch="$(uname -m)"
+if [[ "$ID" == "ol" ]]; then
+ distro="oracle"
+ distro_version="$${distro_version%%.*}"
+elif [[ "$ID" == "fedora" ]]; then
+ distro_version="$(grep -oP '\(\K[\w ]+' /etc/fedora-release | tr '[:upper:]' '[:lower:]' | tr -d ' ')"
+fi
echo "Detected Distribution: $distro"
-echo "Detected Version: $version"
+echo "Detected Version: $distro_version"
+echo "Detected Codename: $codename"
echo "Detected Architecture: $arch"
# Map arch to package arch
-if [[ "$arch" == "x86_64" ]]; then
- if [[ "$distro" == "ubuntu" || "$distro" == "debian" || "$distro" == "kali" ]]; then
- arch="amd64"
- else
- arch="x86_64"
- fi
-elif [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then
- if [[ "$distro" == "ubuntu" || "$distro" == "debian" || "$distro" == "kali" ]]; then
- arch="arm64"
- else
- arch="aarch64"
- fi
-else
- echo "Unsupported architecture: $arch"
- exit 1
-fi
+case "$arch" in
+ x86_64)
+ if [[ "$distro" =~ ^(ubuntu|debian|kali)$ ]]; then
+ arch="amd64"
+ fi
+ ;;
+ aarch64)
+ if [[ "$distro" =~ ^(ubuntu|debian|kali)$ ]]; then
+ arch="arm64"
+ fi
+ ;;
+ arm64)
+ : # This is effectively a noop
+ ;;
+ *)
+ echo "ERROR: Unsupported architecture: $arch"
+ exit 1
+ ;;
+esac
# Check if vncserver is installed, and install if not
if ! check_installed; then
- echo "Installing KASM version: ${VERSION}"
+ # Check for NOPASSWD sudo (required)
+ if ! command -v sudo &> /dev/null || ! sudo -n true 2> /dev/null; then
+ echo "ERROR: sudo NOPASSWD access required!"
+ exit 1
+ fi
+
+ base_url="https://github.com/kasmtech/KasmVNC/releases/download/v${KASM_VERSION}"
+
+ echo "Installing KASM version: ${KASM_VERSION}"
case $distro in
ubuntu | debian | kali)
- case $version in
- "20.04")
- install_deb "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_focal_${VERSION}_$${arch}.deb"
- ;;
- "22.04")
- install_deb "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_jammy_${VERSION}_$${arch}.deb"
- ;;
- "24.04")
- install_deb "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_noble_${VERSION}_$${arch}.deb"
- ;;
- *)
- echo "Unsupported Ubuntu/Debian/Kali version: $${version}"
- exit 1
- ;;
- esac
+ bin_name="kasmvncserver_$${codename}_${KASM_VERSION}_$${arch}.deb"
+ install_deb "$base_url/$bin_name"
;;
- oracle)
- if [[ "$version" == "8" ]]; then
- install_rpm_oracle8 "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_oracle_8_${VERSION}_$${arch}.rpm"
- else
- echo "Unsupported Oracle version: $${version}"
- exit 1
- fi
- ;;
- centos)
- if [[ "$version" == "7" ]]; then
- install_rpm_centos7 "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_centos_core_${VERSION}_$${arch}.rpm"
- else
- install_rpm "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_centos_core_${VERSION}_$${arch}.rpm"
- fi
+ oracle | fedora | opensuse)
+ bin_name="kasmvncserver_$${distro}_$${distro_version}_${KASM_VERSION}_$${arch}.rpm"
+ install_rpm "$base_url/$bin_name"
;;
alpine)
- if [[ "$version" == "3.17" || "$version" == "3.18" || "$version" == "3.19" || "$version" == "3.20" ]]; then
- install_alpine "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvnc.alpine_$${version}_$${arch}.tgz"
- else
- echo "Unsupported Alpine version: $${version}"
- exit 1
- fi
- ;;
- fedora | opensuse)
- install_rpm "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_$${distro}_$${version}_${VERSION}_$${arch}.rpm"
+ bin_name="kasmvnc.alpine_$${distro_version//./}_$${arch}.tgz"
+ install_alpine "$base_url/$bin_name"
;;
*)
- echo "Unsupported distribution: $${distro}"
+ echo "Unsupported distribution: $distro"
exit 1
;;
esac
@@ -158,22 +183,53 @@ else
echo "vncserver already installed. Skipping installation."
fi
-# Coder port-forwarding from dashboard only supports HTTP
-sudo bash -c "cat > /etc/kasmvnc/kasmvnc.yaml < /dev/null && sudo -n true 2> /dev/null; then
+ kasm_config_file="/etc/kasmvnc/kasmvnc.yaml"
+ SUDO=sudo
+else
+ kasm_config_file="$HOME/.vnc/kasmvnc.yaml"
+ SUDO=
+
+ echo "WARNING: Sudo access not available, using user config dir!"
+
+ if [[ -f "$kasm_config_file" ]]; then
+ echo "WARNING: Custom user KasmVNC config exists, not overwriting!"
+ echo "WARNING: Ensure that you manually configure the appropriate settings."
+ kasm_config_file="/dev/stderr"
+ else
+ echo "WARNING: This may prevent custom user KasmVNC settings from applying!"
+ mkdir -p "$HOME/.vnc"
+ fi
+fi
+
+echo "Writing KasmVNC config to $kasm_config_file"
+$SUDO tee "$kasm_config_file" > /dev/null << EOF
network:
protocol: http
websocket_port: ${PORT}
ssl:
require_ssl: false
+ pem_certificate:
+ pem_key:
udp:
public_ip: 127.0.0.1
-EOF"
+EOF
# This password is not used since we start the server without auth.
# The server is protected via the Coder session token / tunnel
# and does not listen publicly
-echo -e "password\npassword\n" | vncpasswd -wo -u $USER
+echo -e "password\npassword\n" | vncpasswd -wo -u "$USER"
# Start the server
printf "🚀 Starting KasmVNC server...\n"
-sudo -u $USER bash -c "vncserver -select-de ${DESKTOP_ENVIRONMENT} -disableBasicAuth" > /tmp/kasmvncserver.log 2>&1 &
+vncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > /tmp/kasmvncserver.log 2>&1 &
+pid=$!
+
+# Wait for server to start
+sleep 5
+grep -v '^[[:space:]]*$' /tmp/kasmvncserver.log | tail -n 10
+if ps -p $pid | grep -q "^$pid"; then
+ echo "ERROR: Failed to start KasmVNC server. Check full logs at /tmp/kasmvncserver.log"
+ exit 1
+fi
+printf "🚀 KasmVNC server started successfully!\n"
diff --git a/nodejs/README.md b/nodejs/README.md
index 25714aadf..b4420c1da 100644
--- a/nodejs/README.md
+++ b/nodejs/README.md
@@ -13,6 +13,7 @@ Automatically installs [Node.js](https://github.com/nodejs/node) via [nvm](https
```tf
module "nodejs" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/nodejs/coder"
version = "1.0.10"
agent_id = coder_agent.example.id
@@ -25,6 +26,7 @@ This installs multiple versions of Node.js:
```tf
module "nodejs" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/nodejs/coder"
version = "1.0.10"
agent_id = coder_agent.example.id
@@ -43,6 +45,7 @@ A example with all available options:
```tf
module "nodejs" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/nodejs/coder"
version = "1.0.10"
agent_id = coder_agent.example.id
diff --git a/package.json b/package.json
index eea421d81..a122f4f2c 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/personalize/README.md b/personalize/README.md
index 24d19a98c..af307f1b9 100644
--- a/personalize/README.md
+++ b/personalize/README.md
@@ -13,6 +13,7 @@ Run a script on workspace start that allows developers to run custom commands to
```tf
module "personalize" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/personalize/coder"
version = "1.0.2"
agent_id = coder_agent.example.id
diff --git a/release.sh b/release.sh
new file mode 100755
index 000000000..b91639181
--- /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/setup.ts b/setup.ts
index 3cfb871e8..a867c7581 100644
--- a/setup.ts
+++ b/setup.ts
@@ -25,7 +25,7 @@ const removeOldContainers = async () => {
"-a",
"-q",
"--filter",
- `label=modules-test`,
+ "label=modules-test",
]);
let containerIDsRaw = await readableStreamToText(proc.stdout);
let exitCode = await proc.exited;
diff --git a/slackme/README.md b/slackme/README.md
index 0858c3dd2..f686b8667 100644
--- a/slackme/README.md
+++ b/slackme/README.md
@@ -56,6 +56,7 @@ slackme npm run long-build
```tf
module "slackme" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/slackme/coder"
version = "1.0.2"
agent_id = coder_agent.example.id
@@ -72,6 +73,7 @@ slackme npm run long-build
```tf
module "slackme" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/slackme/coder"
version = "1.0.2"
agent_id = coder_agent.example.id
diff --git a/test.ts b/test.ts
index 5437374f9..e466cb127 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",
});
@@ -194,13 +203,18 @@ export const testRequiredVariables = (
export const runTerraformApply = async (
dir: string,
vars: Readonly,
- env?: Record,
+ customEnv?: Record,
): Promise => {
const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`;
- const combinedEnv = env === undefined ? {} : { ...env };
- for (const [key, value] of Object.entries(vars)) {
- combinedEnv[`TF_VAR_${key}`] = String(value);
+ const childEnv: Record = {
+ ...process.env,
+ ...(customEnv ?? {}),
+ };
+ for (const [key, value] of Object.entries(vars) as [string, JsonValue][]) {
+ if (value !== null) {
+ childEnv[`TF_VAR_${key}`] = String(value);
+ }
}
const proc = spawn(
@@ -216,7 +230,7 @@ export const runTerraformApply = async (
],
{
cwd: dir,
- env: combinedEnv,
+ env: childEnv,
stderr: "pipe",
stdout: "pipe",
},
diff --git a/update-version.sh b/update-version.sh
deleted file mode 100755
index b062736f5..000000000
--- a/update-version.sh
+++ /dev/null
@@ -1,40 +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" '{
- if ($1 == "version" && $2 == "=") {
- sub(/"[^"]*"/, "\"" tag "\"")
- print
- } else {
- 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 000000000..39430cddf
--- /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-github/README.md b/vault-github/README.md
index ac73972b2..f801c1935 100644
--- a/vault-github/README.md
+++ b/vault-github/README.md
@@ -14,6 +14,7 @@ This module lets you authenticate with [Hashicorp Vault](https://www.vaultprojec
```tf
module "vault" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vault-github/coder"
version = "1.0.7"
agent_id = coder_agent.example.id
@@ -45,6 +46,7 @@ To configure the Vault module, you must set up a Vault GitHub auth method. See t
```tf
module "vault" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vault-github/coder"
version = "1.0.7"
agent_id = coder_agent.example.id
@@ -57,6 +59,7 @@ module "vault" {
```tf
module "vault" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vault-github/coder"
version = "1.0.7"
agent_id = coder_agent.example.id
@@ -70,6 +73,7 @@ module "vault" {
```tf
module "vault" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vault-github/coder"
version = "1.0.7"
agent_id = coder_agent.example.id
diff --git a/vault-jwt/README.md b/vault-jwt/README.md
index 939bed279..1907dbf0a 100644
--- a/vault-jwt/README.md
+++ b/vault-jwt/README.md
@@ -10,15 +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/auth#openid-connect) 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" {
- 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
}
```
@@ -40,8 +42,9 @@ curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/d
```tf
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"
@@ -55,8 +58,9 @@ module "vault" {
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]
@@ -67,11 +71,115 @@ module "vault" {
```tf
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 adcc34d42..17288e008 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 ef45884d7..d95b45a27 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-desktop/README.md b/vscode-desktop/README.md
index bc8920d4b..e32fd9bf1 100644
--- a/vscode-desktop/README.md
+++ b/vscode-desktop/README.md
@@ -15,6 +15,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
```tf
module "vscode" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-desktop/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
@@ -27,6 +28,7 @@ module "vscode" {
```tf
module "vscode" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-desktop/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
diff --git a/vscode-web/README.md b/vscode-web/README.md
index 8b5fa6888..5846c04c7 100644
--- a/vscode-web/README.md
+++ b/vscode-web/README.md
@@ -13,8 +13,9 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/
```tf
module "vscode-web" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-web/coder"
- version = "1.0.22"
+ version = "1.0.30"
agent_id = coder_agent.example.id
accept_license = true
}
@@ -28,8 +29,9 @@ module "vscode-web" {
```tf
module "vscode-web" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-web/coder"
- version = "1.0.22"
+ version = "1.0.30"
agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder"
@@ -41,8 +43,9 @@ module "vscode-web" {
```tf
module "vscode-web" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-web/coder"
- version = "1.0.22"
+ version = "1.0.30"
agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true
@@ -51,12 +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.22"
+ version = "1.0.30"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -65,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 207450e54..11e220cd2 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."
@@ -92,7 +98,7 @@ variable "order" {
}
variable "settings" {
- type = map(string)
+ type = any
description = "A map of settings to apply to VS Code web."
default = {}
}
@@ -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 1738364eb..588cec56d 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"
@@ -92,7 +99,8 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
- extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json)
+ # Use sed to remove single-line comments before parsing with jq
+ extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]')
for extension in $extensions; do
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
done
diff --git a/windows-rdp/README.md b/windows-rdp/README.md
index c4d35fdf8..b069f5e31 100644
--- a/windows-rdp/README.md
+++ b/windows-rdp/README.md
@@ -14,9 +14,9 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de
```tf
# AWS example. See below for examples of using this module with other providers
module "windows_rdp" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18"
- count = data.coder_workspace.me.start_count
agent_id = resource.coder_agent.main.id
resource_id = resource.aws_instance.dev.id
}
@@ -32,9 +32,9 @@ module "windows_rdp" {
```tf
module "windows_rdp" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18"
- count = data.coder_workspace.me.start_count
agent_id = resource.coder_agent.main.id
resource_id = resource.aws_instance.dev.id
}
@@ -44,9 +44,9 @@ module "windows_rdp" {
```tf
module "windows_rdp" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18"
- count = data.coder_workspace.me.start_count
agent_id = resource.coder_agent.main.id
resource_id = resource.google_compute_instance.dev[0].id
}
diff --git a/windsurf/README.md b/windsurf/README.md
new file mode 100644
index 000000000..93f25ebb0
--- /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 000000000..a158962a7
--- /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 000000000..1d836d7e3
--- /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."
+}