"
+ # You may need to remove the 'version' field, it is incompatible with some sources.
}
```
-> **Note:** This is the responsibility of the module author to implement tests for their module. and test the module locally before submitting a PR.
+## Releases
+
+The release process is automated with these steps:
+
+## 1. Create and Merge PR
+
+- Create a PR with your module changes
+- Get your PR reviewed, approved, and merged to `main`
+
+## 2. Prepare Release (Maintainer Task)
+
+After merging to `main`, a maintainer will:
+
+- View all modules and their current versions:
+
+ ```shell
+ ./release.sh --list
+ ```
+
+- Determine the next version number based on changes:
+
+ - **Patch version** (1.2.3 → 1.2.4): Bug fixes
+ - **Minor version** (1.2.3 → 1.3.0): New features, adding inputs, deprecating inputs
+ - **Major version** (1.2.3 → 2.0.0): Breaking changes (removing inputs, changing input types)
+
+- Create and push an annotated tag:
+
+ ```shell
+ # Fetch latest changes
+ git fetch origin
+
+ # Create and push tag
+ ./release.sh module-name 1.2.3 --push
+ ```
+
+ The tag format will be: `release/module-name/v1.2.3`
+
+## 3. Publishing to Registry
+
+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 [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 4b675946c..81d8d3807 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,26 @@
+> [!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
-[Registry](https://registry.coder.com) | [Coder Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Coder Enterprise](https://coder.com/docs/v2/latest/enterprise)
+[Module Registry](https://registry.coder.com) | [Coder Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Coder Enterprise](https://coder.com/docs/v2/latest/enterprise)
[](https://discord.gg/coder)
[](./LICENSE)
+[](https://github.com/coder/modules/actions/workflows/check.yaml)
-Modules extend Templates to create reusable components for your development environment.
+Modules extend Coder Templates to create reusable components for your development environment.
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/aws-region/main.test.ts b/aws-region/main.test.ts
index 0693e65a9..06f8e56e6 100644
--- a/aws-region/main.test.ts
+++ b/aws-region/main.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from "bun:test";
import {
- executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
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/azure-region/main.test.ts b/azure-region/main.test.ts
index bebc0c9f8..8adbb48b1 100644
--- a/azure-region/main.test.ts
+++ b/azure-region/main.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from "bun:test";
import {
- executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
diff --git a/bun.lockb b/bun.lockb
index d3e22141e..7576953c8 100755
Binary files a/bun.lockb and b/bun.lockb differ
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 8132307d9..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.16"
+ 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.16"
+ 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.16"
+ 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.16"
+ 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.16"
+ 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.16"
+ 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.16"
+ 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 8e068b8d9..99b30c0ea 100755
--- a/code-server/run.sh
+++ b/code-server/run.sh
@@ -10,6 +10,7 @@ CODE_SERVER="${INSTALL_PREFIX}/bin/code-server"
EXTENSION_ARG=""
if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
+ mkdir -p "${EXTENSIONS_DIR}"
fi
function run_code_server() {
@@ -41,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}"
@@ -57,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"
@@ -103,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/coder-login/main.test.ts b/coder-login/main.test.ts
index d8fba354a..aca432163 100644
--- a/coder-login/main.test.ts
+++ b/coder-login/main.test.ts
@@ -1,10 +1,5 @@
-import { describe, expect, it } from "bun:test";
-import {
- executeScriptInContainer,
- runTerraformApply,
- runTerraformInit,
- testRequiredVariables,
-} from "../test";
+import { describe } from "bun:test";
+import { runTerraformInit, testRequiredVariables } from "../test";
describe("coder-login", async () => {
await runTerraformInit(import.meta.dir);
diff --git a/cursor/README.md b/cursor/README.md
new file mode 100644
index 000000000..d9a2e17f9
--- /dev/null
+++ b/cursor/README.md
@@ -0,0 +1,37 @@
+---
+display_name: Cursor IDE
+description: Add a one-click button to launch Cursor IDE
+icon: ../.icons/cursor.svg
+maintainer_github: coder
+verified: true
+tags: [ide, cursor, helper]
+---
+
+# Cursor IDE
+
+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/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
+}
+```
+
+## Examples
+
+### Open in a specific directory
+
+```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
+ folder = "/home/coder/project"
+}
+```
diff --git a/cursor/main.test.ts b/cursor/main.test.ts
new file mode 100644
index 000000000..3c1646986
--- /dev/null
+++ b/cursor/main.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "bun:test";
+import {
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("cursor", 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.cursor_url.value).toBe(
+ "cursor://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 === "cursor",
+ );
+
+ 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.cursor_url.value).toBe(
+ "cursor://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.cursor_url.value).toBe(
+ "cursor://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",
+ openRecent: "false",
+ });
+ expect(state.outputs.cursor_url.value).toBe(
+ "cursor://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.cursor_url.value).toBe(
+ "cursor://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 === "cursor",
+ );
+
+ expect(coder_app).not.toBeNull();
+ expect(coder_app?.instances.length).toBe(1);
+ expect(coder_app?.instances[0].attributes.order).toBe(22);
+ });
+});
diff --git a/cursor/main.tf b/cursor/main.tf
new file mode 100644
index 000000000..f350f9428
--- /dev/null
+++ b/cursor/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" "cursor" {
+ agent_id = var.agent_id
+ external = true
+ icon = "/icon/cursor.svg"
+ slug = "cursor"
+ display_name = "Cursor Desktop"
+ order = var.order
+ url = join("", [
+ "cursor://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 "cursor_url" {
+ value = coder_app.cursor.url
+ description = "Cursor IDE Desktop URL."
+}
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 41371ab10..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.15"
+ 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.15"
+ 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.15"
+ 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.15"
+ 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.15"
+ 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.15"
+ version = "1.0.29"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}
diff --git a/dotfiles/main.tf b/dotfiles/main.tf
index bfb67e447..9bc3735e0 100644
--- a/dotfiles/main.tf
+++ b/dotfiles/main.tf
@@ -39,9 +39,14 @@ variable "coder_parameter_order" {
default = null
}
-data "coder_parameter" "dotfiles_uri" {
- count = var.dotfiles_uri == null ? 1 : 0
+variable "manual_update" {
+ type = bool
+ description = "If true, this adds a button to workspace page to refresh dotfiles on demand."
+ default = false
+}
+data "coder_parameter" "dotfiles_uri" {
+ count = var.dotfiles_uri == null ? 1 : 0
type = "string"
name = "dotfiles_uri"
display_name = "Dotfiles URL"
@@ -68,6 +73,18 @@ resource "coder_script" "dotfiles" {
run_on_start = true
}
+resource "coder_app" "dotfiles" {
+ count = var.manual_update ? 1 : 0
+ agent_id = var.agent_id
+ display_name = "Refresh Dotfiles"
+ slug = "dotfiles"
+ icon = "/icon/dotfiles.svg"
+ command = templatefile("${path.module}/run.sh", {
+ DOTFILES_URI : local.dotfiles_uri,
+ DOTFILES_USER : local.user
+ })
+}
+
output "dotfiles_uri" {
description = "Dotfiles URI"
value = local.dotfiles_uri
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/exoscale-zone/main.test.ts b/exoscale-zone/main.test.ts
index ca8eeb727..1751cb148 100644
--- a/exoscale-zone/main.test.ts
+++ b/exoscale-zone/main.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from "bun:test";
import {
- executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
diff --git a/filebrowser/README.md b/filebrowser/README.md
index 288137641..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.8"
+ 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.8"
+ version = "1.0.31"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -38,9 +40,23 @@ module "filebrowser" {
```tf
module "filebrowser" {
+ count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/filebrowser/coder"
- version = "1.0.8"
+ version = "1.0.31"
agent_id = coder_agent.example.id
database_path = ".config/filebrowser.db"
}
```
+
+### Serve from the same domain (no subdomain)
+
+```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 79dd99d06..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,20 +78,28 @@ 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 () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ agent_name: "main",
+ subdomain: false,
+ });
+
+ const output = await await executeScriptInContainer(
+ state,
+ "alpine/curl",
+ "sh",
+ "apk add bash",
+ );
+
+ testBaseLine(output);
});
});
diff --git a/filebrowser/main.tf b/filebrowser/main.tf
index a07072b96..ba83844b0 100644
--- a/filebrowser/main.tf
+++ b/filebrowser/main.tf
@@ -14,6 +14,16 @@ variable "agent_id" {
description = "The ID of a Coder agent."
}
+data "coder_workspace" "me" {}
+
+data "coder_workspace_owner" "me" {}
+
+variable "agent_name" {
+ type = string
+ 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" {
type = string
description = "The path to the filebrowser database."
@@ -58,27 +68,56 @@ variable "order" {
default = null
}
+variable "slug" {
+ type = string
+ description = "The slug of the coder_app resource."
+ default = "filebrowser"
+}
+
+variable "subdomain" {
+ type = bool
+ description = <<-EOT
+ Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
+ If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
+ EOT
+ default = true
+}
+
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,
FOLDER : var.folder,
LOG_PATH : var.log_path,
- DB_PATH : var.database_path
+ DB_PATH : var.database_path,
+ SUBDOMAIN : var.subdomain,
+ 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"
- subdomain = true
+ 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 8744edba4..62f04edf6 100644
--- a/filebrowser/run.sh
+++ b/filebrowser/run.sh
@@ -1,26 +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
-printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n"
+filebrowser config set --baseurl=${SERVER_BASE_PATH} --port=${PORT} --auth.method=noauth --root=$ROOT_DIR 2>&1 | tee -a ${LOG_PATH}
-printf "Running 'filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG}' \n\n"
+printf "👷 Starting filebrowser in background... \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 255b3f1ee..0647f7f93 100644
--- a/git-clone/README.md
+++ b/git-clone/README.md
@@ -13,8 +13,9 @@ 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.12"
+ version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -26,8 +27,9 @@ 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.12"
+ version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
base_dir = "~/projects/coder"
@@ -40,8 +42,9 @@ 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.12"
+ version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -65,31 +68,33 @@ 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.12"
+ version = "1.0.18"
agent_id = coder_agent.example.id
url = data.coder_parameter.git_repo.value
}
# 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.12"
+ 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,8 +102,9 @@ 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.12"
+ version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://github.example.com/coder/coder/tree/feat/example"
git_providers = {
@@ -115,8 +121,9 @@ 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.12"
+ version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://gitlab.com/coder/coder/-/tree/feat/example"
}
@@ -126,8 +133,9 @@ 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.12"
+ version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
git_providers = {
@@ -146,10 +154,29 @@ 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.12"
+ version = "1.0.18"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
branch_name = "feat/example"
}
```
+
+## Git clone with different destination folder
+
+By default, the repository will be cloned into a folder matching the repository name. You can use the `folder_name` attribute to change the name of the destination folder to something else.
+
+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
+ url = "https://github.com/coder/coder"
+ folder_name = "coder-dev"
+ base_dir = "~/projects/coder"
+}
+```
diff --git a/git-clone/main.test.ts b/git-clone/main.test.ts
index 87b0e4a6e..9fbd2022f 100644
--- a/git-clone/main.test.ts
+++ b/git-clone/main.test.ts
@@ -79,6 +79,22 @@ describe("git-clone", async () => {
expect(state.outputs.branch_name.value).toEqual("");
});
+ it("repo_dir should match base_dir/folder_name", async () => {
+ const url = "git@github.com:coder/coder.git";
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ base_dir: "/tmp",
+ folder_name: "foo",
+ url,
+ });
+ expect(state.outputs.repo_dir.value).toEqual("/tmp/foo");
+ expect(state.outputs.folder_name.value).toEqual("foo");
+ expect(state.outputs.clone_url.value).toEqual(url);
+ const https_url = "https://github.com/coder/coder.git";
+ expect(state.outputs.web_url.value).toEqual(https_url);
+ expect(state.outputs.branch_name.value).toEqual("");
+ });
+
it("branch_name should not include query string", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
diff --git a/git-clone/main.tf b/git-clone/main.tf
index 4af5000e5..0295444d8 100644
--- a/git-clone/main.tf
+++ b/git-clone/main.tf
@@ -50,6 +50,12 @@ variable "branch_name" {
default = ""
}
+variable "folder_name" {
+ description = "The destination folder to clone the repository into."
+ type = string
+ default = ""
+}
+
locals {
# Remove query parameters and fragments from the URL
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
@@ -64,7 +70,7 @@ locals {
# Extract the branch name from the URL
branch_name = var.branch_name == "" && local.tree_path != "" ? replace(replace(local.url, local.clone_url, ""), "/.*${local.tree_path}/", "") : var.branch_name
# Extract the folder name from the URL
- folder_name = replace(basename(local.clone_url), ".git", "")
+ folder_name = var.folder_name == "" ? replace(basename(local.clone_url), ".git", "") : var.folder_name
# Construct the path to clone the repository
clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name])
# Construct the web URL
diff --git a/git-commit-signing/README.md b/git-commit-signing/README.md
index 37633a250..1f71cbb3b 100644
--- a/git-commit-signing/README.md
+++ b/git-commit-signing/README.md
@@ -2,13 +2,16 @@
display_name: Git commit signing
description: Configures Git to sign commits using your Coder SSH key
icon: ../.icons/git.svg
-maintainer_github: phorcys420
-verified: false
+maintainer_github: coder
+verified: true
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/github-upload-public-key/main.test.ts b/github-upload-public-key/main.test.ts
index fb1b97767..6ce16d820 100644
--- a/github-upload-public-key/main.test.ts
+++ b/github-upload-public-key/main.test.ts
@@ -1,3 +1,4 @@
+import { type Server, serve } from "bun";
import { describe, expect, it } from "bun:test";
import {
createJSONResponse,
@@ -9,7 +10,6 @@ import {
testRequiredVariables,
writeCoder,
} from "../test";
-import { Server, serve } from "bun";
describe("github-upload-public-key", async () => {
await runTerraformInit(import.meta.dir);
@@ -21,10 +21,12 @@ describe("github-upload-public-key", async () => {
it("creates new key if one does not exist", async () => {
const { instance, id, server } = await setupContainer();
await writeCoder(id, "echo foo");
- let exec = await execContainer(id, [
+
+ const url = server.url.toString().slice(0, -1);
+ const exec = await execContainer(id, [
"env",
- "CODER_ACCESS_URL=" + server.url.toString().slice(0, -1),
- "GITHUB_API_URL=" + server.url.toString().slice(0, -1),
+ `CODER_ACCESS_URL=${url}`,
+ `GITHUB_API_URL=${url}`,
"CODER_OWNER_SESSION_TOKEN=foo",
"CODER_EXTERNAL_AUTH_ID=github",
"bash",
@@ -42,10 +44,12 @@ describe("github-upload-public-key", async () => {
const { instance, id, server } = await setupContainer();
// use keyword to make server return a existing key
await writeCoder(id, "echo findkey");
- let exec = await execContainer(id, [
+
+ const url = server.url.toString().slice(0, -1);
+ const exec = await execContainer(id, [
"env",
- "CODER_ACCESS_URL=" + server.url.toString().slice(0, -1),
- "GITHUB_API_URL=" + server.url.toString().slice(0, -1),
+ `CODER_ACCESS_URL=${url}`,
+ `GITHUB_API_URL=${url}`,
"CODER_OWNER_SESSION_TOKEN=foo",
"CODER_EXTERNAL_AUTH_ID=github",
"bash",
@@ -95,7 +99,7 @@ const setupServer = async (): Promise => {
}
// case: key already exists
- if (req.headers.get("Authorization") == "Bearer findkey") {
+ if (req.headers.get("Authorization") === "Bearer findkey") {
return createJSONResponse([
{
key: "foo",
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 b2c0e0f95..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.13"
+ 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.13"
+ 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.13"
+ 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.13"
+ 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 b327e41a7..ea04a77db 100644
--- a/jetbrains-gateway/main.test.ts
+++ b/jetbrains-gateway/main.test.ts
@@ -10,14 +10,31 @@ describe("jetbrains-gateway", async () => {
await testRequiredVariables(import.meta.dir, {
agent_id: "foo",
- agent_name: "foo",
folder: "/home/foo",
});
+ it("should create a link with the default values", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ // These are all required.
+ agent_id: "foo",
+ folder: "/home/coder",
+ });
+ expect(state.outputs.url.value).toBe(
+ "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(
+ (res) => res.type === "coder_app" && res.name === "gateway",
+ );
+
+ expect(coder_app).not.toBeNull();
+ expect(coder_app?.instances.length).toBe(1);
+ expect(coder_app?.instances[0].attributes.order).toBeNull();
+ });
+
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 c96098c0e..502469f29 100644
--- a/jetbrains-gateway/main.tf
+++ b/jetbrains-gateway/main.tf
@@ -13,14 +13,32 @@ 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."
}
+variable "slug" {
+ type = string
+ description = "The slug for the coder_app. Allows resuing the module with the same template."
+ default = "gateway"
+}
+
variable "agent_name" {
type = string
- description = "Agent name."
+ description = "Agent name. (unused). Will be removed in a future version"
+
+ default = ""
}
variable "folder" {
@@ -74,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 {
@@ -140,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
}
}
@@ -218,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
}
@@ -243,10 +315,11 @@ data "coder_parameter" "jetbrains_ide" {
}
data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
resource "coder_app" "gateway" {
agent_id = var.agent_id
- slug = "gateway"
+ slug = var.slug
display_name = local.display_name
icon = local.icon
external = true
@@ -254,8 +327,8 @@ resource "coder_app" "gateway" {
url = join("", [
"jetbrains-gateway://connect#type=coder&workspace=",
data.coder_workspace.me.name,
- "&agent=",
- var.agent_name,
+ "&owner=",
+ data.coder_workspace_owner.me.name,
"&folder=",
var.folder,
"&url=",
diff --git a/jfrog-oauth/.npmrc.tftpl b/jfrog-oauth/.npmrc.tftpl
new file mode 100644
index 000000000..8bb9fb8f2
--- /dev/null
+++ b/jfrog-oauth/.npmrc.tftpl
@@ -0,0 +1,5 @@
+email=${ARTIFACTORY_EMAIL}
+%{ for REPO in REPOS ~}
+${REPO.SCOPE}registry=${JFROG_URL}/artifactory/api/npm/${REPO.NAME}
+//${JFROG_HOST}/artifactory/api/npm/${REPO.NAME}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}
+%{ endfor ~}
diff --git a/jfrog-oauth/README.md b/jfrog-oauth/README.md
index b7f9d58f9..894e1f325 100644
--- a/jfrog-oauth/README.md
+++ b/jfrog-oauth/README.md
@@ -16,16 +16,18 @@ 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.15"
+ version = "1.0.19"
agent_id = coder_agent.example.id
jfrog_url = "https://example.jfrog.io"
username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username"
package_managers = {
- "npm" : "npm",
- "go" : "go",
- "pypi" : "pypi"
+ npm = ["npm", "@scoped:npm-scoped"]
+ go = ["go", "another-go-repo"]
+ pypi = ["pypi", "extra-index-pypi"]
+ docker = ["example-docker-staging.jfrog.io", "example-docker-production.jfrog.io"]
}
}
```
@@ -43,14 +45,15 @@ 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.15"
+ version = "1.0.19"
agent_id = coder_agent.example.id
jfrog_url = "https://example.jfrog.io"
username_field = "email"
package_managers = {
- "pypi" : "pypi"
+ pypi = ["pypi"]
}
}
```
@@ -71,16 +74,17 @@ 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.15"
+ version = "1.0.19"
agent_id = coder_agent.example.id
jfrog_url = "https://example.jfrog.io"
username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username"
configure_code_server = true # Add JFrog extension configuration for code-server
package_managers = {
- "npm" : "npm",
- "go" : "go",
- "pypi" : "pypi"
+ npm = ["npm"]
+ go = ["go"]
+ pypi = ["pypi"]
}
}
```
@@ -94,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-oauth/main.test.ts b/jfrog-oauth/main.test.ts
index 3397eebba..7b0c1a5f3 100644
--- a/jfrog-oauth/main.test.ts
+++ b/jfrog-oauth/main.test.ts
@@ -1,19 +1,129 @@
-import { serve } from "bun";
-import { describe } from "bun:test";
+import { describe, expect, it } from "bun:test";
import {
- createJSONResponse,
+ findResourceInstance,
runTerraformInit,
+ runTerraformApply,
testRequiredVariables,
} from "../test";
describe("jfrog-oauth", async () => {
+ type TestVariables = {
+ agent_id: string;
+ jfrog_url: string;
+ package_managers: string;
+
+ username_field?: string;
+ jfrog_server_id?: string;
+ external_auth_id?: string;
+ configure_code_server?: boolean;
+ };
+
await runTerraformInit(import.meta.dir);
- testRequiredVariables(import.meta.dir, {
- agent_id: "some-agent-id",
- jfrog_url: "http://localhost:8081",
- package_managers: "{}",
+ const fakeFrogApi = "localhost:8081/artifactory/api";
+ const fakeFrogUrl = "http://localhost:8081";
+ const user = "default";
+
+ it("can run apply with required variables", async () => {
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: fakeFrogUrl,
+ package_managers: "{}",
+ });
});
-});
-//TODO add more tests
+ it("generates an npmrc with scoped repos", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: fakeFrogUrl,
+ package_managers: JSON.stringify({
+ npm: ["global", "@foo:foo", "@bar:bar"],
+ }),
+ });
+ const coderScript = findResourceInstance(state, "coder_script");
+ const npmrcStanza = `cat << EOF > ~/.npmrc
+email=${user}@example.com
+registry=http://${fakeFrogApi}/npm/global
+//${fakeFrogApi}/npm/global/:_authToken=
+@foo:registry=http://${fakeFrogApi}/npm/foo
+//${fakeFrogApi}/npm/foo/:_authToken=
+@bar:registry=http://${fakeFrogApi}/npm/bar
+//${fakeFrogApi}/npm/bar/:_authToken=
+
+EOF`;
+ expect(coderScript.script).toContain(npmrcStanza);
+ expect(coderScript.script).toContain(
+ 'jf npmc --global --repo-resolve "global"',
+ );
+ expect(coderScript.script).toContain(
+ 'if [ -z "YES" ]; then\n not_configured npm',
+ );
+ });
+
+ it("generates a pip config with extra-indexes", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: fakeFrogUrl,
+ package_managers: JSON.stringify({
+ pypi: ["global", "foo", "bar"],
+ }),
+ });
+ const coderScript = findResourceInstance(state, "coder_script");
+ const pipStanza = `cat << EOF > ~/.pip/pip.conf
+[global]
+index-url = https://${user}:@${fakeFrogApi}/pypi/global/simple
+extra-index-url =
+ https://${user}:@${fakeFrogApi}/pypi/foo/simple
+ https://${user}:@${fakeFrogApi}/pypi/bar/simple
+
+EOF`;
+ expect(coderScript.script).toContain(pipStanza);
+ expect(coderScript.script).toContain(
+ 'jf pipc --global --repo-resolve "global"',
+ );
+ expect(coderScript.script).toContain(
+ 'if [ -z "YES" ]; then\n not_configured pypi',
+ );
+ });
+
+ it("registers multiple docker repos", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: fakeFrogUrl,
+ package_managers: JSON.stringify({
+ docker: ["foo.jfrog.io", "bar.jfrog.io", "baz.jfrog.io"],
+ }),
+ });
+ const coderScript = findResourceInstance(state, "coder_script");
+ const dockerStanza = ["foo", "bar", "baz"]
+ .map((r) => `register_docker "${r}.jfrog.io"`)
+ .join("\n");
+ expect(coderScript.script).toContain(dockerStanza);
+ expect(coderScript.script).toContain(
+ 'if [ -z "YES" ]; then\n not_configured docker',
+ );
+ });
+
+ it("sets goproxy with multiple repos", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: fakeFrogUrl,
+ package_managers: JSON.stringify({
+ go: ["foo", "bar", "baz"],
+ }),
+ });
+ const proxyEnv = findResourceInstance(state, "coder_env", "goproxy");
+ const proxies = ["foo", "bar", "baz"]
+ .map((r) => `https://${user}:@${fakeFrogApi}/go/${r}`)
+ .join(",");
+ expect(proxyEnv.value).toEqual(proxies);
+
+ const coderScript = findResourceInstance(state, "coder_script");
+ expect(coderScript.script).toContain(
+ 'jf goc --global --repo-resolve "foo"',
+ );
+ expect(coderScript.script).toContain(
+ 'if [ -z "YES" ]; then\n not_configured go',
+ );
+ });
+});
diff --git a/jfrog-oauth/main.tf b/jfrog-oauth/main.tf
index 767235a14..0bc22568b 100644
--- a/jfrog-oauth/main.tf
+++ b/jfrog-oauth/main.tf
@@ -53,23 +53,51 @@ variable "configure_code_server" {
}
variable "package_managers" {
- type = map(string)
- description = < /dev/null 2>&1; then
echo "✅ JFrog CLI is already installed, skipping installation."
@@ -20,52 +35,47 @@ echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFRO
jf c use "${JFROG_SERVER_ID}"
# Configure npm to use the Artifactory "npm" repository.
-if [ -z "${REPOSITORY_NPM}" ]; then
- echo "🤔 no npm repository is set, skipping npm configuration."
- echo "You can configure an npm repository by providing the a key for 'npm' in the 'package_managers' input."
+if [ -z "${HAS_NPM}" ]; then
+ not_configured npm
else
echo "📦 Configuring npm..."
jf npmc --global --repo-resolve "${REPOSITORY_NPM}"
cat << EOF > ~/.npmrc
-email=${ARTIFACTORY_EMAIL}
-registry=${JFROG_URL}/artifactory/api/npm/${REPOSITORY_NPM}
+${NPMRC}
EOF
- echo "//${JFROG_HOST}/artifactory/api/npm/${REPOSITORY_NPM}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}" >> ~/.npmrc
+ config_complete
fi
# Configure the `pip` to use the Artifactory "python" repository.
-if [ -z "${REPOSITORY_PYPI}" ]; then
- echo "🤔 no pypi repository is set, skipping pip configuration."
- echo "You can configure a pypi repository by providing the a key for 'pypi' in the 'package_managers' input."
+if [ -z "${HAS_PYPI}" ]; then
+ not_configured pypi
else
- echo "📦 Configuring pip..."
+ echo "🐍 Configuring pip..."
jf pipc --global --repo-resolve "${REPOSITORY_PYPI}"
mkdir -p ~/.pip
cat << EOF > ~/.pip/pip.conf
-[global]
-index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPOSITORY_PYPI}/simple
+${PIP_CONF}
EOF
+ config_complete
fi
# Configure Artifactory "go" repository.
-if [ -z "${REPOSITORY_GO}" ]; then
- echo "🤔 no go repository is set, skipping go configuration."
- echo "You can configure a go repository by providing the a key for 'go' in the 'package_managers' input."
+if [ -z "${HAS_GO}" ]; then
+ not_configured go
else
echo "🐹 Configuring go..."
jf goc --global --repo-resolve "${REPOSITORY_GO}"
+ config_complete
fi
-echo "🥳 Configuration complete!"
# Configure the JFrog CLI to use the Artifactory "docker" repository.
-if [ -z "${REPOSITORY_DOCKER}" ]; then
- echo "🤔 no docker repository is set, skipping docker configuration."
- echo "You can configure a docker repository by providing the a key for 'docker' in the 'package_managers' input."
+if [ -z "${HAS_DOCKER}" ]; then
+ not_configured docker
else
if command -v docker > /dev/null 2>&1; then
echo "🔑 Configuring 🐳 docker credentials..."
mkdir -p ~/.docker
- echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login ${JFROG_HOST} --username ${ARTIFACTORY_USERNAME} --password-stdin
+ ${REGISTER_DOCKER}
else
echo "🤔 no docker is installed, skipping docker configuration."
fi
@@ -96,20 +106,19 @@ echo "📦 Configuring JFrog CLI completion..."
SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}')
# Generate the completion script
jf completion $SHELLNAME --install
+begin_stanza="# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)"
# Add the completion script to the user's shell profile
if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then
- if ! grep -q "# jf CLI shell completion" ~/.bashrc; then
- echo "" >> ~/.bashrc
- echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" >> ~/.bashrc
+ if ! grep -q "$begin_stanza" ~/.bashrc; then
+ printf "%s\n" "$begin_stanza" >> ~/.bashrc
echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc
echo "# END: jf CLI shell completion" >> ~/.bashrc
else
echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping."
fi
elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then
- if ! grep -q "# jf CLI shell completion" ~/.zshrc; then
- echo "" >> ~/.zshrc
- echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" >> ~/.zshrc
+ if ! grep -q "$begin_stanza" ~/.zshrc; then
+ printf "\n%s\n" "$begin_stanza" >> ~/.zshrc
echo "autoload -Uz compinit" >> ~/.zshrc
echo "compinit" >> ~/.zshrc
echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc
diff --git a/jfrog-token/.npmrc.tftpl b/jfrog-token/.npmrc.tftpl
new file mode 100644
index 000000000..8bb9fb8f2
--- /dev/null
+++ b/jfrog-token/.npmrc.tftpl
@@ -0,0 +1,5 @@
+email=${ARTIFACTORY_EMAIL}
+%{ for REPO in REPOS ~}
+${REPO.SCOPE}registry=${JFROG_URL}/artifactory/api/npm/${REPO.NAME}
+//${JFROG_HOST}/artifactory/api/npm/${REPO.NAME}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}
+%{ endfor ~}
diff --git a/jfrog-token/README.md b/jfrog-token/README.md
index f903f90d5..ce1652229 100644
--- a/jfrog-token/README.md
+++ b/jfrog-token/README.md
@@ -15,14 +15,15 @@ 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.15"
+ version = "1.0.30"
agent_id = coder_agent.example.id
jfrog_url = "https://XXXX.jfrog.io"
artifactory_access_token = var.artifactory_access_token
package_managers = {
- "npm" : "npm",
- "go" : "go",
- "pypi" : "pypi"
+ npm = ["npm", "@scoped:npm-scoped"]
+ go = ["go", "another-go-repo"]
+ pypi = ["pypi", "extra-index-pypi"]
+ docker = ["example-docker-staging.jfrog.io", "example-docker-production.jfrog.io"]
}
}
```
@@ -41,14 +42,14 @@ 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.15"
+ 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
package_managers = {
- "npm" : "npm-local",
- "go" : "go-local",
- "pypi" : "pypi-local"
+ npm = ["npm-local"]
+ go = ["go-local"]
+ pypi = ["pypi-local"]
}
}
```
@@ -74,15 +75,15 @@ 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.15"
+ version = "1.0.30"
agent_id = coder_agent.example.id
jfrog_url = "https://XXXX.jfrog.io"
artifactory_access_token = var.artifactory_access_token
configure_code_server = true # Add JFrog extension configuration for code-server
package_managers = {
- "npm" : "npm",
- "go" : "go",
- "pypi" : "pypi"
+ npm = ["npm"]
+ go = ["go"]
+ pypi = ["pypi"]
}
}
```
@@ -94,15 +95,13 @@ data "coder_workspace" "me" {}
module "jfrog" {
source = "registry.coder.com/modules/jfrog-token/coder"
- version = "1.0.15"
+ version = "1.0.30"
agent_id = coder_agent.example.id
jfrog_url = "https://XXXX.jfrog.io"
artifactory_access_token = var.artifactory_access_token
token_description = "Token for Coder workspace: ${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}"
package_managers = {
- "npm" : "npm",
- "go" : "go",
- "pypi" : "pypi"
+ npm = ["npm"]
}
}
```
diff --git a/jfrog-token/main.test.ts b/jfrog-token/main.test.ts
index b3b8df982..4ba2f52d3 100644
--- a/jfrog-token/main.test.ts
+++ b/jfrog-token/main.test.ts
@@ -1,12 +1,30 @@
import { serve } from "bun";
-import { describe } from "bun:test";
+import { describe, expect, it } from "bun:test";
import {
createJSONResponse,
+ findResourceInstance,
runTerraformInit,
+ runTerraformApply,
testRequiredVariables,
} from "../test";
describe("jfrog-token", async () => {
+ type TestVariables = {
+ agent_id: string;
+ jfrog_url: string;
+ artifactory_access_token: string;
+ package_managers: string;
+
+ token_description?: string;
+ check_license?: boolean;
+ refreshable?: boolean;
+ expires_in?: number;
+ username_field?: string;
+ username?: string;
+ jfrog_server_id?: string;
+ configure_code_server?: boolean;
+ };
+
await runTerraformInit(import.meta.dir);
// Run a fake JFrog server so the provider can initialize
@@ -32,10 +50,116 @@ describe("jfrog-token", async () => {
port: 0,
});
- testRequiredVariables(import.meta.dir, {
- agent_id: "some-agent-id",
- jfrog_url: "http://" + fakeFrogHost.hostname + ":" + fakeFrogHost.port,
- artifactory_access_token: "XXXX",
- package_managers: "{}",
+ const fakeFrogApi = `${fakeFrogHost.hostname}:${fakeFrogHost.port}/artifactory/api`;
+ const fakeFrogUrl = `http://${fakeFrogHost.hostname}:${fakeFrogHost.port}`;
+ const user = "default";
+ const token = "xxx";
+
+ it("can run apply with required variables", async () => {
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: fakeFrogUrl,
+ artifactory_access_token: "XXXX",
+ package_managers: "{}",
+ });
+ });
+
+ it("generates an npmrc with scoped repos", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: fakeFrogUrl,
+ artifactory_access_token: "XXXX",
+ package_managers: JSON.stringify({
+ npm: ["global", "@foo:foo", "@bar:bar"],
+ }),
+ });
+ const coderScript = findResourceInstance(state, "coder_script");
+ const npmrcStanza = `cat << EOF > ~/.npmrc
+email=${user}@example.com
+registry=http://${fakeFrogApi}/npm/global
+//${fakeFrogApi}/npm/global/:_authToken=xxx
+@foo:registry=http://${fakeFrogApi}/npm/foo
+//${fakeFrogApi}/npm/foo/:_authToken=xxx
+@bar:registry=http://${fakeFrogApi}/npm/bar
+//${fakeFrogApi}/npm/bar/:_authToken=xxx
+
+EOF`;
+ expect(coderScript.script).toContain(npmrcStanza);
+ expect(coderScript.script).toContain(
+ 'jf npmc --global --repo-resolve "global"',
+ );
+ expect(coderScript.script).toContain(
+ 'if [ -z "YES" ]; then\n not_configured npm',
+ );
+ });
+
+ it("generates a pip config with extra-indexes", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: fakeFrogUrl,
+ artifactory_access_token: "XXXX",
+ package_managers: JSON.stringify({
+ pypi: ["global", "foo", "bar"],
+ }),
+ });
+ const coderScript = findResourceInstance(state, "coder_script");
+ const pipStanza = `cat << EOF > ~/.pip/pip.conf
+[global]
+index-url = https://${user}:${token}@${fakeFrogApi}/pypi/global/simple
+extra-index-url =
+ https://${user}:${token}@${fakeFrogApi}/pypi/foo/simple
+ https://${user}:${token}@${fakeFrogApi}/pypi/bar/simple
+
+EOF`;
+ expect(coderScript.script).toContain(pipStanza);
+ expect(coderScript.script).toContain(
+ 'jf pipc --global --repo-resolve "global"',
+ );
+ expect(coderScript.script).toContain(
+ 'if [ -z "YES" ]; then\n not_configured pypi',
+ );
+ });
+
+ it("registers multiple docker repos", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: fakeFrogUrl,
+ artifactory_access_token: "XXXX",
+ package_managers: JSON.stringify({
+ docker: ["foo.jfrog.io", "bar.jfrog.io", "baz.jfrog.io"],
+ }),
+ });
+ const coderScript = findResourceInstance(state, "coder_script");
+ const dockerStanza = ["foo", "bar", "baz"]
+ .map((r) => `register_docker "${r}.jfrog.io"`)
+ .join("\n");
+ expect(coderScript.script).toContain(dockerStanza);
+ expect(coderScript.script).toContain(
+ 'if [ -z "YES" ]; then\n not_configured docker',
+ );
+ });
+
+ it("sets goproxy with multiple repos", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: fakeFrogUrl,
+ artifactory_access_token: "XXXX",
+ package_managers: JSON.stringify({
+ go: ["foo", "bar", "baz"],
+ }),
+ });
+ const proxyEnv = findResourceInstance(state, "coder_env", "goproxy");
+ const proxies = ["foo", "bar", "baz"]
+ .map((r) => `https://${user}:${token}@${fakeFrogApi}/go/${r}`)
+ .join(",");
+ expect(proxyEnv.value).toEqual(proxies);
+
+ const coderScript = findResourceInstance(state, "coder_script");
+ expect(coderScript.script).toContain(
+ 'jf goc --global --repo-resolve "foo"',
+ );
+ expect(coderScript.script).toContain(
+ 'if [ -z "YES" ]; then\n not_configured go',
+ );
});
});
diff --git a/jfrog-token/main.tf b/jfrog-token/main.tf
index 90dad6137..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."
@@ -80,23 +86,51 @@ variable "configure_code_server" {
}
variable "package_managers" {
- type = map(string)
- description = < /dev/null 2>&1; then
echo "✅ JFrog CLI is already installed, skipping installation."
@@ -11,8 +26,7 @@ else
sudo chmod 755 /usr/local/bin/jf
fi
-# The jf CLI checks $CI when determining whether to use interactive
-# flows.
+# The jf CLI checks $CI when determining whether to use interactive flows.
export CI=true
# Authenticate JFrog CLI with Artifactory.
echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFROG_URL}" --overwrite "${JFROG_SERVER_ID}"
@@ -20,52 +34,47 @@ echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFRO
jf c use "${JFROG_SERVER_ID}"
# Configure npm to use the Artifactory "npm" repository.
-if [ -z "${REPOSITORY_NPM}" ]; then
- echo "🤔 no npm repository is set, skipping npm configuration."
- echo "You can configure an npm repository by providing the a key for 'npm' in the 'package_managers' input."
+if [ -z "${HAS_NPM}" ]; then
+ not_configured npm
else
echo "📦 Configuring npm..."
jf npmc --global --repo-resolve "${REPOSITORY_NPM}"
cat << EOF > ~/.npmrc
-email=${ARTIFACTORY_EMAIL}
-registry=${JFROG_URL}/artifactory/api/npm/${REPOSITORY_NPM}
+${NPMRC}
EOF
- echo "//${JFROG_HOST}/artifactory/api/npm/${REPOSITORY_NPM}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}" >> ~/.npmrc
+ config_complete
fi
# Configure the `pip` to use the Artifactory "python" repository.
-if [ -z "${REPOSITORY_PYPI}" ]; then
- echo "🤔 no pypi repository is set, skipping pip configuration."
- echo "You can configure a pypi repository by providing the a key for 'pypi' in the 'package_managers' input."
+if [ -z "${HAS_PYPI}" ]; then
+ not_configured pypi
else
echo "🐍 Configuring pip..."
jf pipc --global --repo-resolve "${REPOSITORY_PYPI}"
mkdir -p ~/.pip
cat << EOF > ~/.pip/pip.conf
-[global]
-index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPOSITORY_PYPI}/simple
+${PIP_CONF}
EOF
+ config_complete
fi
# Configure Artifactory "go" repository.
-if [ -z "${REPOSITORY_GO}" ]; then
- echo "🤔 no go repository is set, skipping go configuration."
- echo "You can configure a go repository by providing the a key for 'go' in the 'package_managers' input."
+if [ -z "${HAS_GO}" ]; then
+ not_configured go
else
echo "🐹 Configuring go..."
jf goc --global --repo-resolve "${REPOSITORY_GO}"
+ config_complete
fi
-echo "🥳 Configuration complete!"
# Configure the JFrog CLI to use the Artifactory "docker" repository.
-if [ -z "${REPOSITORY_DOCKER}" ]; then
- echo "🤔 no docker repository is set, skipping docker configuration."
- echo "You can configure a docker repository by providing the a key for 'docker' in the 'package_managers' input."
+if [ -z "${HAS_DOCKER}" ]; then
+ not_configured docker
else
if command -v docker > /dev/null 2>&1; then
echo "🔑 Configuring 🐳 docker credentials..."
mkdir -p ~/.docker
- echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login ${JFROG_HOST} --username ${ARTIFACTORY_USERNAME} --password-stdin
+ ${REGISTER_DOCKER}
else
echo "🤔 no docker is installed, skipping docker configuration."
fi
@@ -96,20 +105,19 @@ echo "📦 Configuring JFrog CLI completion..."
SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}')
# Generate the completion script
jf completion $SHELLNAME --install
+begin_stanza="# BEGIN: jf CLI shell completion (added by coder module jfrog-token)"
# Add the completion script to the user's shell profile
if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then
- if ! grep -q "# jf CLI shell completion" ~/.bashrc; then
- echo "" >> ~/.bashrc
- echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" >> ~/.bashrc
+ if ! grep -q "$begin_stanza" ~/.bashrc; then
+ printf "%s\n" "$begin_stanza" >> ~/.bashrc
echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc
echo "# END: jf CLI shell completion" >> ~/.bashrc
else
echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping."
fi
elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then
- if ! grep -q "# jf CLI shell completion" ~/.zshrc; then
- echo "" >> ~/.zshrc
- echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" >> ~/.zshrc
+ if ! grep -q "$begin_stanza" ~/.zshrc; then
+ printf "\n%s\n" "$begin_stanza" >> ~/.zshrc
echo "autoload -Uz compinit" >> ~/.zshrc
echo "compinit" >> ~/.zshrc
echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc
diff --git a/jupyter-notebook/README.md b/jupyter-notebook/README.md
index 6338f112a..56f7ff18a 100644
--- a/jupyter-notebook/README.md
+++ b/jupyter-notebook/README.md
@@ -15,8 +15,9 @@ 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.8"
+ version = "1.0.19"
agent_id = coder_agent.example.id
}
```
diff --git a/jupyter-notebook/run.sh b/jupyter-notebook/run.sh
index 4f8c4a203..0c7a9b85f 100755
--- a/jupyter-notebook/run.sh
+++ b/jupyter-notebook/run.sh
@@ -7,14 +7,14 @@ printf "$${BOLD}Installing jupyter-notebook!\n"
# check if jupyter-notebook is installed
if ! command -v jupyter-notebook > /dev/null 2>&1; then
# install jupyter-notebook
- # check if python3 pip is installed
- if ! command -v pip3 > /dev/null 2>&1; then
- echo "pip3 is not installed"
- echo "Please install pip3 in your Dockerfile/VM image before running this script"
+ # 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 using this module"
exit 1
fi
- # install jupyter-notebook
- pip3 install --upgrade --no-cache-dir --no-warn-script-location jupyter
+ # install jupyter notebook
+ pipx install -q notebook
echo "🥳 jupyter-notebook has been installed\n\n"
else
echo "🥳 jupyter-notebook is already installed\n\n"
@@ -22,4 +22,4 @@ fi
echo "👷 Starting jupyter-notebook in background..."
echo "check logs at ${LOG_PATH}"
-$HOME/.local/bin/jupyter notebook --NotebookApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 &
+$HOME/.local/bin/jupyter-notebook --NotebookApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 &
diff --git a/jupyterlab/README.md b/jupyterlab/README.md
index 3d04cf36b..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.8"
+ version = "1.0.31"
agent_id = coder_agent.example.id
}
```
diff --git a/jupyterlab/main.test.ts b/jupyterlab/main.test.ts
index 2597dc21b..a9789c391 100644
--- a/jupyterlab/main.test.ts
+++ b/jupyterlab/main.test.ts
@@ -1,20 +1,20 @@
import { describe, expect, it } from "bun:test";
import {
+ execContainer,
executeScriptInContainer,
+ findResourceInstance,
+ runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
- findResourceInstance,
- runContainer,
- TerraformState,
- execContainer,
+ type TerraformState,
} from "../test";
// executes the coder script after installing pip
const executeScriptInContainerWithPip = async (
state: TerraformState,
image: string,
- shell: string = "sh",
+ shell = "sh",
): Promise<{
exitCode: number;
stdout: string[];
@@ -22,7 +22,34 @@ const executeScriptInContainerWithPip = async (
}> => {
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer(image);
- const respPip = await execContainer(id, [shell, "-c", "apk add py3-pip"]);
+ const respPipx = await execContainer(id, [shell, "-c", "apk add pipx"]);
+ 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,
+ };
+};
+
+// 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");
@@ -40,22 +67,39 @@ describe("jupyterlab", async () => {
agent_id: "foo",
});
- it("fails without pip3", 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!",
- "pip3 is not installed",
- "Please install pip3 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 pip3.
+ // 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 pip3", async () => {
+ // it("runs with pipx", async () => {
// ...
// const output = await executeScriptInContainerWithPip(state, "alpine");
// ...
diff --git a/jupyterlab/main.tf b/jupyterlab/main.tf
index d7928f0d6..d66edb1c1 100644
--- a/jupyterlab/main.tf
+++ b/jupyterlab/main.tf
@@ -9,6 +9,9 @@ terraform {
}
}
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+
# Add required variables for your modules and remove any unneeded variables
variable "agent_id" {
type = string
@@ -36,6 +39,12 @@ variable "share" {
}
}
+variable "subdomain" {
+ type = bool
+ description = "Determines whether JupyterLab will be accessed via its own subdomain or whether it will be accessed via a path on Coder."
+ default = true
+}
+
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)."
@@ -49,17 +58,18 @@ resource "coder_script" "jupyterlab" {
script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path,
PORT : var.port
+ BASE_URL : var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab"
})
run_on_start = true
}
resource "coder_app" "jupyterlab" {
agent_id = var.agent_id
- slug = "jupyterlab"
+ slug = "jupyterlab" # sync with the usage in URL
display_name = "JupyterLab"
- url = "http://localhost:${var.port}"
+ url = var.subdomain ? "http://localhost:${var.port}" : "http://localhost:${var.port}/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab"
icon = "/icon/jupyter.svg"
- subdomain = true
+ subdomain = var.subdomain
share = var.share
order = var.order
}
diff --git a/jupyterlab/run.sh b/jupyterlab/run.sh
index b040cec18..be686e55f 100755
--- a/jupyterlab/run.sh
+++ b/jupyterlab/run.sh
@@ -1,25 +1,58 @@
#!/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
+}
-BOLD='\033[0;1m'
+if [ -n "${BASE_URL}" ]; then
+ BASE_URL_FLAG="--ServerApp.base_url=${BASE_URL}"
+fi
-printf "$${BOLD}Installing jupyterlab!\n"
+BOLD='\033[0;1m'
# check if jupyterlab is installed
-if ! command -v jupyterlab > /dev/null 2>&1; then
- # install jupyterlab
- # check if python3 pip is installed
- if ! command -v pip3 > /dev/null 2>&1; then
- echo "pip3 is not installed"
- echo "Please install pip3 in your Dockerfile/VM image before running this script"
- exit 1
- fi
+if ! command -v jupyter-lab > /dev/null 2>&1; then
# install jupyterlab
- pip3 install --upgrade --no-cache-dir --no-warn-script-location jupyterlab
- echo "🥳 jupyterlab has been installed\n\n"
+ 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
- echo "🥳 jupyterlab is already installed\n\n"
+ printf "%s\n\n" "🥳 jupyterlab is already installed"
+ JUPYTER=$(command -v jupyter-lab)
fi
-echo "👷 Starting jupyterlab in background..."
-echo "check logs at ${LOG_PATH}"
-$HOME/.local/bin/jupyter lab --ServerApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 &
+printf "👷 Starting jupyterlab in background..."
+printf "check logs at ${LOG_PATH}"
+$JUPYTER --no-browser \
+ "$BASE_URL_FLAG" \
+ --ServerApp.ip='*' \
+ --ServerApp.port="${PORT}" \
+ --ServerApp.token='' \
+ --ServerApp.password='' \
+ > "${LOG_PATH}" 2>&1 &
diff --git a/kasmvnc/README.md b/kasmvnc/README.md
new file mode 100644
index 000000000..9c3b28dbf
--- /dev/null
+++ b/kasmvnc/README.md
@@ -0,0 +1,24 @@
+---
+display_name: KasmVNC
+description: A modern open source VNC server
+icon: ../.icons/kasmvnc.svg
+maintainer_github: coder
+verified: true
+tags: [helper, vnc, desktop]
+---
+
+# KasmVNC
+
+Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and create an app to access it via the dashboard.
+
+```tf
+module "kasmvnc" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/kasmvnc/coder"
+ version = "1.0.23"
+ agent_id = coder_agent.example.id
+ desktop_environment = "xfce"
+}
+```
+
+> **Note:** This module only works on workspaces with a pre-installed desktop environment. As an example base image you can use `codercom/enterprise-desktop` image.
diff --git a/kasmvnc/main.test.ts b/kasmvnc/main.test.ts
new file mode 100644
index 000000000..0116d0535
--- /dev/null
+++ b/kasmvnc/main.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "bun:test";
+import {
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+const allowedDesktopEnvs = ["xfce", "kde", "gnome", "lxde", "lxqt"] as const;
+type AllowedDesktopEnv = (typeof allowedDesktopEnvs)[number];
+
+type TestVariables = Readonly<{
+ agent_id: string;
+ desktop_environment: AllowedDesktopEnv;
+ port?: string;
+ kasm_version?: string;
+}>;
+
+describe("Kasm VNC", async () => {
+ await runTerraformInit(import.meta.dir);
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ desktop_environment: "gnome",
+ });
+
+ it("Successfully installs for all expected Kasm desktop versions", async () => {
+ for (const v of allowedDesktopEnvs) {
+ const applyWithEnv = () => {
+ runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ desktop_environment: v,
+ });
+ };
+
+ expect(applyWithEnv).not.toThrow();
+ }
+ });
+});
diff --git a/kasmvnc/main.tf b/kasmvnc/main.tf
new file mode 100644
index 000000000..4265f3c7c
--- /dev/null
+++ b/kasmvnc/main.tf
@@ -0,0 +1,63 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "port" {
+ type = number
+ description = "The port to run KasmVNC on."
+ default = 6800
+}
+
+variable "kasm_version" {
+ type = string
+ description = "Version of KasmVNC to install."
+ default = "1.3.2"
+}
+
+variable "desktop_environment" {
+ type = string
+ description = "Specifies the desktop environment of the workspace. This should be pre-installed on the workspace."
+ validation {
+ condition = contains(["xfce", "kde", "gnome", "lxde", "lxqt"], var.desktop_environment)
+ error_message = "Invalid desktop environment. Please specify a valid desktop environment."
+ }
+}
+
+resource "coder_script" "kasm_vnc" {
+ agent_id = var.agent_id
+ display_name = "KasmVNC"
+ icon = "/icon/kasmvnc.svg"
+ script = templatefile("${path.module}/run.sh", {
+ PORT : var.port,
+ DESKTOP_ENVIRONMENT : var.desktop_environment,
+ KASM_VERSION : var.kasm_version
+ })
+ run_on_start = true
+}
+
+resource "coder_app" "kasm_vnc" {
+ agent_id = var.agent_id
+ slug = "kasm-vnc"
+ display_name = "kasmVNC"
+ url = "http://localhost:${var.port}"
+ icon = "/icon/kasmvnc.svg"
+ subdomain = true
+ share = "owner"
+ healthcheck {
+ url = "http://localhost:${var.port}/app"
+ interval = 5
+ threshold = 5
+ }
+}
diff --git a/kasmvnc/run.sh b/kasmvnc/run.sh
new file mode 100644
index 000000000..c285b0501
--- /dev/null
+++ b/kasmvnc/run.sh
@@ -0,0 +1,235 @@
+#!/usr/bin/env bash
+
+# Exit on error, undefined variables, and pipe failures
+set -euo pipefail
+
+# Function to check if vncserver is already installed
+check_installed() {
+ if command -v vncserver &> /dev/null; then
+ echo "vncserver is already installed."
+ return 0 # Don't exit, just indicate it's installed
+ else
+ return 1 # Indicates not installed
+ fi
+}
+
+# Function to download a file using wget, curl, or busybox as a fallback
+download_file() {
+ 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
+ # shellcheck disable=SC2034
+ download_tool=(busybox wget -O-)
+ else
+ 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
+ local kasmdeb="/tmp/kasmvncserver.deb"
+
+ download_file "$url" "$kasmdeb"
+
+ 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
+ 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
+ local kasmtgz="/tmp/kasmvncserver.tgz"
+
+ download_file "$url" "$kasmtgz"
+
+ tar -xzf "$kasmtgz" -C /usr/local/bin/
+ rm "$kasmtgz"
+}
+
+# Detect system information
+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: $distro_version"
+echo "Detected Codename: $codename"
+echo "Detected Architecture: $arch"
+
+# Map arch to package arch
+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
+ # 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)
+ bin_name="kasmvncserver_$${codename}_${KASM_VERSION}_$${arch}.deb"
+ install_deb "$base_url/$bin_name"
+ ;;
+ oracle | fedora | opensuse)
+ bin_name="kasmvncserver_$${distro}_$${distro_version}_${KASM_VERSION}_$${arch}.rpm"
+ install_rpm "$base_url/$bin_name"
+ ;;
+ alpine)
+ bin_name="kasmvnc.alpine_$${distro_version//./}_$${arch}.tgz"
+ install_alpine "$base_url/$bin_name"
+ ;;
+ *)
+ echo "Unsupported distribution: $distro"
+ exit 1
+ ;;
+ esac
+else
+ echo "vncserver already installed. Skipping installation."
+fi
+
+if command -v sudo &> /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
+
+# 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"
+
+# Start the server
+printf "🚀 Starting KasmVNC server...\n"
+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/lint.ts b/lint.ts
index db1ee9aa3..6652fcb8d 100644
--- a/lint.ts
+++ b/lint.ts
@@ -5,14 +5,15 @@ import grayMatter from "gray-matter";
const files = await readdir(".", { withFileTypes: true });
const dirs = files.filter(
- (f) => f.isDirectory() && !f.name.startsWith(".") && f.name !== "node_modules"
+ (f) =>
+ f.isDirectory() && !f.name.startsWith(".") && f.name !== "node_modules",
);
let badExit = false;
// error reports an error to the console and sets badExit to true
// so that the process will exit with a non-zero exit code.
-const error = (...data: any[]) => {
+const error = (...data: unknown[]) => {
console.error(...data);
badExit = true;
};
@@ -22,7 +23,7 @@ const verifyCodeBlocks = (
res = {
codeIsTF: false,
codeIsHCL: false,
- }
+ },
) => {
for (const token of tokens) {
// Check in-depth.
@@ -30,7 +31,12 @@ const verifyCodeBlocks = (
verifyCodeBlocks(token.items, res);
continue;
}
+
if (token.type === "list_item") {
+ if (token.tokens === undefined) {
+ throw new Error("Tokens are missing for type list_item");
+ }
+
verifyCodeBlocks(token.tokens, res);
continue;
}
@@ -80,8 +86,9 @@ for (const dir of dirs) {
if (!data.maintainer_github) {
error(dir.name, "missing maintainer_github");
}
+
try {
- await stat(path.join(".", dir.name, data.icon));
+ await stat(path.join(".", dir.name, data.icon ?? ""));
} catch (ex) {
error(dir.name, "icon does not exist", data.icon);
}
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/nodejs/main.test.ts b/nodejs/main.test.ts
index 07fc7a540..39e48f49b 100644
--- a/nodejs/main.test.ts
+++ b/nodejs/main.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "bun:test";
+import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "../test";
describe("nodejs", async () => {
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index 101094229..000000000
--- a/package-lock.json
+++ /dev/null
@@ -1,264 +0,0 @@
-{
- "name": "modules",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "modules",
- "devDependencies": {
- "bun-types": "^1.0.18",
- "gray-matter": "^4.0.3",
- "marked": "^12.0.0",
- "prettier": "^3.2.5",
- "prettier-plugin-sh": "^0.13.1",
- "prettier-plugin-terraform-formatter": "^1.2.1"
- },
- "peerDependencies": {
- "typescript": "^5.3.3"
- }
- },
- "node_modules/@types/node": {
- "version": "20.12.14",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz",
- "integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==",
- "dev": true,
- "dependencies": {
- "undici-types": "~5.26.4"
- }
- },
- "node_modules/@types/ws": {
- "version": "8.5.10",
- "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
- "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==",
- "dev": true,
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/argparse": {
- "version": "1.0.10",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
- "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
- "dev": true,
- "dependencies": {
- "sprintf-js": "~1.0.2"
- }
- },
- "node_modules/bun-types": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.16.tgz",
- "integrity": "sha512-LpAh8dQe4NKvhSW390Rkftw0ume0moSkRm575e1JZ1PwI/dXjbXyjpntq+2F0bVW1FV7V6B8EfWx088b+dNurw==",
- "dev": true,
- "dependencies": {
- "@types/node": "~20.12.8",
- "@types/ws": "~8.5.10"
- }
- },
- "node_modules/esprima": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
- "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
- "dev": true,
- "bin": {
- "esparse": "bin/esparse.js",
- "esvalidate": "bin/esvalidate.js"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/extend-shallow": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
- "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
- "dev": true,
- "dependencies": {
- "is-extendable": "^0.1.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/gray-matter": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
- "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
- "dev": true,
- "dependencies": {
- "js-yaml": "^3.13.1",
- "kind-of": "^6.0.2",
- "section-matter": "^1.0.0",
- "strip-bom-string": "^1.0.0"
- },
- "engines": {
- "node": ">=6.0"
- }
- },
- "node_modules/is-extendable": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
- "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
- "dev": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/js-yaml": {
- "version": "3.14.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
- "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
- "dev": true,
- "dependencies": {
- "argparse": "^1.0.7",
- "esprima": "^4.0.0"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
- "node_modules/kind-of": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
- "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
- "dev": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/marked": {
- "version": "12.0.2",
- "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
- "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
- "dev": true,
- "bin": {
- "marked": "bin/marked.js"
- },
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/mvdan-sh": {
- "version": "0.10.1",
- "resolved": "https://registry.npmjs.org/mvdan-sh/-/mvdan-sh-0.10.1.tgz",
- "integrity": "sha512-kMbrH0EObaKmK3nVRKUIIya1dpASHIEusM13S4V1ViHFuxuNxCo+arxoa6j/dbV22YBGjl7UKJm9QQKJ2Crzhg==",
- "dev": true
- },
- "node_modules/prettier": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
- "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
- "dev": true,
- "peer": true,
- "bin": {
- "prettier": "bin/prettier.cjs"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/prettier/prettier?sponsor=1"
- }
- },
- "node_modules/prettier-plugin-sh": {
- "version": "0.13.1",
- "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.13.1.tgz",
- "integrity": "sha512-ytMcl1qK4s4BOFGvsc9b0+k9dYECal7U29bL/ke08FEUsF/JLN0j6Peo0wUkFDG4y2UHLMhvpyd6Sd3zDXe/eg==",
- "dev": true,
- "dependencies": {
- "mvdan-sh": "^0.10.1",
- "sh-syntax": "^0.4.1"
- },
- "engines": {
- "node": ">=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/unts"
- },
- "peerDependencies": {
- "prettier": "^3.0.0"
- }
- },
- "node_modules/prettier-plugin-terraform-formatter": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/prettier-plugin-terraform-formatter/-/prettier-plugin-terraform-formatter-1.2.1.tgz",
- "integrity": "sha512-rdzV61Bs/Ecnn7uAS/vL5usTX8xUWM+nQejNLZxt3I1kJH5WSeLEmq7LYu1wCoEQF+y7Uv1xGvPRfl3lIe6+tA==",
- "dev": true,
- "peerDependencies": {
- "prettier": ">= 1.16.0"
- },
- "peerDependenciesMeta": {
- "prettier": {
- "optional": true
- }
- }
- },
- "node_modules/section-matter": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
- "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
- "dev": true,
- "dependencies": {
- "extend-shallow": "^2.0.1",
- "kind-of": "^6.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/sh-syntax": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.4.2.tgz",
- "integrity": "sha512-/l2UZ5fhGZLVZa16XQM9/Vq/hezGGbdHeVEA01uWjOL1+7Ek/gt6FquW0iKKws4a9AYPYvlz6RyVvjh3JxOteg==",
- "dev": true,
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/unts"
- }
- },
- "node_modules/sprintf-js": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
- "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
- "dev": true
- },
- "node_modules/strip-bom-string": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
- "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
- "dev": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/tslib": {
- "version": "2.6.3",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
- "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
- "dev": true
- },
- "node_modules/typescript": {
- "version": "5.5.2",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz",
- "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==",
- "peer": true,
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/undici-types": {
- "version": "5.26.5",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
- "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
- "dev": true
- }
- }
-}
diff --git a/package.json b/package.json
index f3136b15e..a122f4f2c 100644
--- a/package.json
+++ b/package.json
@@ -2,21 +2,20 @@
"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.0.18",
+ "bun-types": "^1.1.23",
"gray-matter": "^4.0.3",
- "marked": "^12.0.0",
- "prettier": "^3.2.5",
+ "marked": "^12.0.2",
+ "prettier": "^3.3.3",
"prettier-plugin-sh": "^0.13.1",
"prettier-plugin-terraform-formatter": "^1.2.1"
},
"peerDependencies": {
- "typescript": "^5.3.3"
+ "typescript": "^5.5.4"
},
"prettier": {
"plugins": [
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/personalize/main.test.ts b/personalize/main.test.ts
index 9c8134ea2..b499a0b7e 100644
--- a/personalize/main.test.ts
+++ b/personalize/main.test.ts
@@ -1,13 +1,9 @@
-import { readableStreamToText, spawn } from "bun";
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
- runContainer,
- execContainer,
- findResourceInstance,
} from "../test";
describe("personalize", async () => {
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/slackme/main.test.ts b/slackme/main.test.ts
index 402a6907f..d8d062428 100644
--- a/slackme/main.test.ts
+++ b/slackme/main.test.ts
@@ -72,7 +72,7 @@ executed`,
it("formats execution with milliseconds", async () => {
await assertSlackMessage({
command: "echo test",
- format: `$COMMAND took $DURATION`,
+ format: "$COMMAND took $DURATION",
durationMS: 150,
output: "echo test took 150ms",
});
@@ -81,7 +81,7 @@ executed`,
it("formats execution with seconds", async () => {
await assertSlackMessage({
command: "echo test",
- format: `$COMMAND took $DURATION`,
+ format: "$COMMAND took $DURATION",
durationMS: 15000,
output: "echo test took 15.0s",
});
@@ -90,7 +90,7 @@ executed`,
it("formats execution with minutes", async () => {
await assertSlackMessage({
command: "echo test",
- format: `$COMMAND took $DURATION`,
+ format: "$COMMAND took $DURATION",
durationMS: 120000,
output: "echo test took 2m 0.0s",
});
@@ -99,7 +99,7 @@ executed`,
it("formats execution with hours", async () => {
await assertSlackMessage({
command: "echo test",
- format: `$COMMAND took $DURATION`,
+ format: "$COMMAND took $DURATION",
durationMS: 60000 * 60,
output: "echo test took 1hr 0m 0.0s",
});
@@ -126,7 +126,10 @@ const assertSlackMessage = async (opts: {
durationMS?: number;
output: string;
}) => {
- let url: URL;
+ // Have to use non-null assertion because TS can't tell when the fetch
+ // function will run
+ let url!: URL;
+
const fakeSlackHost = serve({
fetch: (req) => {
url = new URL(req.url);
@@ -138,15 +141,16 @@ const assertSlackMessage = async (opts: {
},
port: 0,
});
+
const { instance, id } = await setupContainer(
"alpine/curl",
- opts.format && {
- slack_message: opts.format,
- },
+ opts.format ? { slack_message: opts.format } : undefined,
);
+
await writeCoder(id, "echo 'token'");
let exec = await execContainer(id, ["sh", "-c", instance.script]);
expect(exec.exitCode).toBe(0);
+
exec = await execContainer(id, [
"sh",
"-c",
@@ -154,6 +158,7 @@ const assertSlackMessage = async (opts: {
fakeSlackHost.hostname
}:${fakeSlackHost.port}" slackme ${opts.command}`,
]);
+
expect(exec.stderr.trim()).toBe("");
expect(url.pathname).toEqual("/api/chat.postMessage");
expect(url.searchParams.get("channel")).toEqual("token");
diff --git a/terraform_validate.sh b/terraform_validate.sh
index 292c94c7c..492e65aec 100755
--- a/terraform_validate.sh
+++ b/terraform_validate.sh
@@ -4,25 +4,25 @@ set -euo pipefail
# Function to run terraform init and validate in a directory
run_terraform() {
- local dir="$1"
- echo "Running terraform init and validate in $dir"
- pushd "$dir"
- terraform init -upgrade
- terraform validate
- popd
+ local dir="$1"
+ echo "Running terraform init and validate in $dir"
+ pushd "$dir"
+ terraform init -upgrade
+ terraform validate
+ popd
}
# Main script
main() {
- # Get the directory of the script
- script_dir=$(dirname "$(readlink -f "$0")")
+ # Get the directory of the script
+ script_dir=$(dirname "$(readlink -f "$0")")
- # Get all subdirectories in the repository
- subdirs=$(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -not -name ".*" | sort)
+ # Get all subdirectories in the repository
+ subdirs=$(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -not -name ".*" | sort)
- for dir in $subdirs; do
- run_terraform "$dir"
- done
+ for dir in $subdirs; do
+ run_terraform "$dir"
+ done
}
# Run the main script
diff --git a/test.ts b/test.ts
index b338205a3..e466cb127 100644
--- a/test.ts
+++ b/test.ts
@@ -1,6 +1,6 @@
import { readableStreamToText, spawn } from "bun";
-import { afterEach, expect, it } from "bun:test";
-import { readFile, unlink } from "fs/promises";
+import { expect, it } from "bun:test";
+import { readFile, unlink } from "node:fs/promises";
export const runContainer = async (
image: string,
@@ -21,7 +21,8 @@ export const runContainer = async (
"-c",
init,
]);
- let containerID = await readableStreamToText(proc.stdout);
+
+ const containerID = await readableStreamToText(proc.stdout);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(containerID);
@@ -29,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.
@@ -36,14 +43,16 @@ export const runContainer = async (
export const executeScriptInContainer = async (
state: TerraformState,
image: string,
- shell: string = "sh",
-): Promise<{
- exitCode: number;
- stdout: string[];
- stderr: string[];
-}> => {
+ shell = "sh",
+ 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");
@@ -57,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",
});
@@ -90,26 +100,35 @@ type TerraformStateResource = {
type: string;
name: string;
provider: string;
- instances: [{ attributes: Record }];
+
+ instances: [
+ {
+ attributes: Record;
+ },
+ ];
};
-export interface TerraformState {
- outputs: {
- [key: string]: {
- type: string;
- value: any;
- };
- };
+type TerraformOutput = {
+ type: string;
+ value: JsonValue;
+};
+export interface TerraformState {
+ outputs: Record;
resources: [TerraformStateResource, ...TerraformStateResource[]];
}
+type TerraformVariables = Record;
+
export interface CoderScriptAttributes {
script: string;
agent_id: string;
url: string;
}
+export type ResourceInstance =
+ T extends "coder_script" ? CoderScriptAttributes : Record;
+
/**
* finds the first instance of the given resource type in the given state. If
* name is specified, it will only find the instance with the given name.
@@ -118,10 +137,7 @@ export const findResourceInstance = (
state: TerraformState,
type: T,
name?: string,
- // if type is "coder_script" return CoderScriptAttributes
-): T extends "coder_script"
- ? CoderScriptAttributes
- : Record => {
+): ResourceInstance => {
const resource = state.resources.find(
(resource) =>
resource.type === type && (name ? resource.name === name : true),
@@ -134,34 +150,41 @@ export const findResourceInstance = (
`Resource ${type} has ${resource.instances.length} instances`,
);
}
- return resource.instances[0].attributes as any;
+
+ return resource.instances[0].attributes as ResourceInstance;
};
/**
* Creates a test-case for each variable provided and ensures that the apply
* fails without it.
*/
-export const testRequiredVariables = >(
+export const testRequiredVariables = (
dir: string,
- vars: TVars,
+ vars: Readonly,
) => {
// Ensures that all required variables are provided.
it("required variables", async () => {
await runTerraformApply(dir, vars);
});
+
const varNames = Object.keys(vars);
- varNames.forEach((varName) => {
+ for (const varName of varNames) {
// Ensures that every variable provided is required!
- it("missing variable " + varName, async () => {
- const localVars = {};
- varNames.forEach((otherVarName) => {
+ it(`missing variable: ${varName}`, async () => {
+ const localVars: TerraformVariables = {};
+ for (const otherVarName of varNames) {
if (otherVarName !== varName) {
localVars[otherVarName] = vars[otherVarName];
}
- });
+ }
+
try {
await runTerraformApply(dir, localVars);
} catch (ex) {
+ if (!(ex instanceof Error)) {
+ throw new Error("Unknown error generated");
+ }
+
expect(ex.message).toContain(
`input variable \"${varName}\" is not set`,
);
@@ -169,7 +192,7 @@ export const testRequiredVariables = >(
}
throw new Error(`${varName} is not a required variable!`);
});
- });
+ }
};
/**
@@ -177,18 +200,21 @@ export const testRequiredVariables = >(
* fine to run in parallel with other instances of this function, as it uses a
* random state file.
*/
-export const runTerraformApply = async <
- TVars extends Readonly>,
->(
+export const runTerraformApply = async (
dir: string,
- vars: TVars,
- env?: Record,
+ vars: Readonly,
+ 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(
@@ -204,7 +230,7 @@ export const runTerraformApply = async <
],
{
cwd: dir,
- env: combinedEnv,
+ env: childEnv,
stderr: "pipe",
stdout: "pipe",
},
diff --git a/tsconfig.json b/tsconfig.json
index e7b89cdeb..c7a5d26e6 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,9 +1,14 @@
{
"compilerOptions": {
- "target": "esnext",
- "module": "esnext",
+ // If we were just compiling for the tests, we could safely target ESNext at
+ // all times, but just because we've been starting to add more runtime logic
+ // files to some of the modules, erring on the side of caution by having a
+ // older compilation target
+ "target": "ES6",
+ "module": "ESNext",
+ "strict": true,
"allowSyntheticDefaultImports": true,
- "moduleResolution": "nodenext",
+ "moduleResolution": "node",
"types": ["bun-types"]
}
}
diff --git a/update-version.sh b/update-version.sh
deleted file mode 100755
index 5deb63b21..000000000
--- a/update-version.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env bash
-
-# This script updates the version number in the README.md files of all modules
-# to the latest tag in the repository. 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)
-previous_tag=$(git describe --tags --abbrev=0 $current_tag^)
-mapfile -t changed_dirs < <(git diff --name-only "$previous_tag"..."$current_tag" -- ':!**/README.md' ':!**/*.test.ts' | xargs dirname | grep -v '^\.' | sort -u)
-
-LATEST_TAG=$(git describe --abbrev=0 --tags | sed 's/^v//') || exit $?
-
-for dir in "${changed_dirs[@]}"; do
- if [[ -f "$dir/README.md" ]]; then
- echo "Bumping version in $dir/README.md"
- 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"
- 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
new file mode 100644
index 000000000..1907dbf0a
--- /dev/null
+++ b/vault-jwt/README.md
@@ -0,0 +1,185 @@
+---
+display_name: Hashicorp Vault Integration (JWT)
+description: Authenticates with Vault using a JWT from Coder's OIDC provider
+icon: ../.icons/vault.svg
+maintainer_github: coder
+partner_github: hashicorp
+verified: true
+tags: [helper, integration, vault, jwt, oidc]
+---
+
+# Hashicorp Vault Integration (JWT)
+
+This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces by reusing the [OIDC](https://coder.com/docs/admin/users/oidc-auth) access token from Coder's OIDC authentication method or another source of jwt token. This requires configuring the Vault [JWT/OIDC](https://developer.hashicorp.com/vault/docs/auth/jwt#configuration) auth method.
+
+```tf
+module "vault" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/vault-jwt/coder"
+ version = "1.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
+}
+```
+
+Then you can use the Vault CLI in your workspaces to fetch secrets from Vault:
+
+```shell
+vault kv get -namespace=coder -mount=secrets coder
+```
+
+or using the Vault API:
+
+```shell
+curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/data/coder"
+```
+
+## Examples
+
+### Configure Vault integration with a non standard auth path (default is "jwt")
+
+```tf
+module "vault" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/vault-jwt/coder"
+ version = "1.0.31"
+ agent_id = coder_agent.example.id
+ vault_addr = "https://vault.example.com"
+ vault_jwt_auth_path = "oidc"
+ vault_jwt_role = "coder" # The Vault role to use for authentication
+}
+```
+
+### Map workspace owner's group to a Vault role
+
+```tf
+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.31"
+ agent_id = coder_agent.example.id
+ vault_addr = "https://vault.example.com"
+ vault_jwt_role = data.coder_workspace_owner.me.groups[0]
+}
+```
+
+### Install a specific version of the Vault CLI
+
+```tf
+module "vault" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/vault-jwt/coder"
+ 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.test.ts b/vault-jwt/main.test.ts
new file mode 100644
index 000000000..2fda3d7cf
--- /dev/null
+++ b/vault-jwt/main.test.ts
@@ -0,0 +1,12 @@
+import { describe } from "bun:test";
+import { runTerraformInit, testRequiredVariables } from "../test";
+
+describe("vault-jwt", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ vault_addr: "foo",
+ vault_jwt_role: "foo",
+ });
+});
diff --git a/vault-jwt/main.tf b/vault-jwt/main.tf
new file mode 100644
index 000000000..17288e008
--- /dev/null
+++ b/vault-jwt/main.tf
@@ -0,0 +1,71 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12.4"
+ }
+ }
+}
+
+# Add required variables for your modules and remove any unneeded variables
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "vault_addr" {
+ type = string
+ 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."
+ default = "jwt"
+}
+
+variable "vault_jwt_role" {
+ type = string
+ description = "The name of the Vault role to use for authentication."
+}
+
+variable "vault_cli_version" {
+ type = string
+ description = "The version of Vault to install."
+ default = "latest"
+ validation {
+ condition = can(regex("^(latest|[0-9]+\\.[0-9]+\\.[0-9]+)$", var.vault_cli_version))
+ error_message = "Vault version must be in the format 0.0.0 or latest"
+ }
+}
+
+resource "coder_script" "vault" {
+ agent_id = var.agent_id
+ display_name = "Vault (GitHub)"
+ icon = "/icon/vault.svg"
+ script = templatefile("${path.module}/run.sh", {
+ 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,
+ })
+ run_on_start = true
+ start_blocks_login = true
+}
+
+resource "coder_env" "vault_addr" {
+ agent_id = var.agent_id
+ name = "VAULT_ADDR"
+ value = var.vault_addr
+}
+
+data "coder_workspace_owner" "me" {}
diff --git a/vault-jwt/run.sh b/vault-jwt/run.sh
new file mode 100644
index 000000000..d95b45a27
--- /dev/null
+++ b/vault-jwt/run.sh
@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+
+# Convert all templated variables to shell variables
+VAULT_CLI_VERSION=${VAULT_CLI_VERSION}
+VAULT_JWT_AUTH_PATH=${VAULT_JWT_AUTH_PATH}
+VAULT_JWT_ROLE=${VAULT_JWT_ROLE}
+CODER_OIDC_ACCESS_TOKEN=${CODER_OIDC_ACCESS_TOKEN}
+
+fetch() {
+ dest="$1"
+ url="$2"
+ if command -v curl > /dev/null 2>&1; then
+ curl -sSL --fail "$${url}" -o "$${dest}"
+ elif command -v wget > /dev/null 2>&1; then
+ wget -O "$${dest}" "$${url}"
+ elif command -v busybox > /dev/null 2>&1; then
+ busybox wget -O "$${dest}" "$${url}"
+ else
+ printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n"
+ exit 1
+ fi
+}
+
+unzip_safe() {
+ if command -v unzip > /dev/null 2>&1; then
+ command unzip "$@"
+ elif command -v busybox > /dev/null 2>&1; then
+ busybox unzip "$@"
+ else
+ printf "unzip or busybox is not installed. Please install unzip in your image.\n"
+ exit 1
+ fi
+}
+
+install() {
+ # Get the architecture of the system
+ ARCH=$(uname -m)
+ if [ "$${ARCH}" = "x86_64" ]; then
+ ARCH="amd64"
+ elif [ "$${ARCH}" = "aarch64" ]; then
+ ARCH="arm64"
+ else
+ printf "Unsupported architecture: $${ARCH}\n"
+ return 1
+ fi
+ # Fetch the latest version of Vault if VAULT_CLI_VERSION is 'latest'
+ if [ "$${VAULT_CLI_VERSION}" = "latest" ]; then
+ LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1)
+ printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}"
+ if [ -z "$${LATEST_VERSION}" ]; then
+ printf "Failed to determine the latest Vault version.\n"
+ return 1
+ fi
+ VAULT_CLI_VERSION=$${LATEST_VERSION}
+ fi
+
+ # Check if the vault CLI is installed and has the correct version
+ installation_needed=1
+ if command -v vault > /dev/null 2>&1; then
+ CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
+ if [ "$${CURRENT_VERSION}" = "$${VAULT_CLI_VERSION}" ]; then
+ printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}"
+ installation_needed=0
+ fi
+ fi
+
+ if [ $${installation_needed} -eq 1 ]; then
+ # Download and install Vault
+ if [ -z "$${CURRENT_VERSION}" ]; then
+ printf "Installing Vault CLI ...\n\n"
+ else
+ printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${VAULT_CLI_VERSION}"
+ fi
+ fetch vault.zip "https://releases.hashicorp.com/vault/$${VAULT_CLI_VERSION}/vault_$${VAULT_CLI_VERSION}_linux_$${ARCH}.zip"
+ if [ $? -ne 0 ]; then
+ printf "Failed to download Vault.\n"
+ return 1
+ fi
+ if ! unzip_safe vault.zip; then
+ printf "Failed to unzip Vault.\n"
+ return 1
+ fi
+ rm vault.zip
+ if sudo mv vault /usr/local/bin/vault 2> /dev/null; then
+ printf "Vault installed successfully!\n\n"
+ else
+ mkdir -p ~/.local/bin
+ if ! mv vault ~/.local/bin/vault; then
+ printf "Failed to move Vault to local bin.\n"
+ return 1
+ fi
+ printf "Please add ~/.local/bin to your PATH to use vault CLI.\n"
+ fi
+ fi
+ return 0
+}
+
+TMP=$(mktemp -d)
+if ! (
+ cd "$TMP"
+ install
+); then
+ echo "Failed to install Vault CLI."
+ exit 1
+fi
+rm -rf "$TMP"
+
+# Authenticate with Vault
+printf "🔑 Authenticating with Vault ...\n\n"
+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-desktop/main.test.ts b/vscode-desktop/main.test.ts
index 74c4ffbd1..7aa144ec0 100644
--- a/vscode-desktop/main.test.ts
+++ b/vscode-desktop/main.test.ts
@@ -22,11 +22,12 @@ describe("vscode-desktop", async () => {
);
const coder_app = state.resources.find(
- (res) => res.type == "coder_app" && res.name == "vscode",
+ (res) => res.type === "coder_app" && res.name === "vscode",
);
+
expect(coder_app).not.toBeNull();
- expect(coder_app.instances.length).toBe(1);
- expect(coder_app.instances[0].attributes.order).toBeNull();
+ expect(coder_app?.instances.length).toBe(1);
+ expect(coder_app?.instances[0].attributes.order).toBeNull();
});
it("adds folder", async () => {
@@ -78,10 +79,11 @@ describe("vscode-desktop", async () => {
});
const coder_app = state.resources.find(
- (res) => res.type == "coder_app" && res.name == "vscode",
+ (res) => res.type === "coder_app" && res.name === "vscode",
);
+
expect(coder_app).not.toBeNull();
- expect(coder_app.instances.length).toBe(1);
- expect(coder_app.instances[0].attributes.order).toBe(22);
+ expect(coder_app?.instances.length).toBe(1);
+ expect(coder_app?.instances[0].attributes.order).toBe(22);
});
});
diff --git a/vscode-web/README.md b/vscode-web/README.md
index ba395d0e5..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.14"
+ 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.14"
+ 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.14"
+ 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.14"
+ 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 084f8306b..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 = {}
}
@@ -121,6 +127,18 @@ variable "auto_install_extensions" {
default = false
}
+variable "subdomain" {
+ type = bool
+ description = <<-EOT
+ Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
+ If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
+ EOT
+ default = true
+}
+
+data "coder_workspace_owner" "me" {}
+data "coder_workspace" "me" {}
+
resource "coder_script" "vscode-web" {
agent_id = var.agent_id
display_name = "VS Code Web"
@@ -138,6 +156,8 @@ resource "coder_script" "vscode-web" {
EXTENSIONS_DIR : var.extensions_dir,
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
@@ -158,15 +178,21 @@ resource "coder_app" "vscode-web" {
agent_id = var.agent_id
slug = var.slug
display_name = var.display_name
- url = var.folder == "" ? "http://localhost:${var.port}" : "http://localhost:${var.port}?folder=${var.folder}"
+ url = local.url
icon = "/icon/code.svg"
- subdomain = true
+ subdomain = var.subdomain
share = var.share
order = var.order
healthcheck {
- url = "http://localhost:${var.port}/healthz"
+ url = local.healthcheck_url
interval = 5
threshold = 6
}
}
+
+locals {
+ server_base_path = var.subdomain ? "" : format("/@%s/%s/apps/%s/", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug)
+ url = var.folder == "" ? "http://localhost:${var.port}${local.server_base_path}" : "http://localhost:${var.port}${local.server_base_path}?folder=${var.folder}"
+ healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz"
+}
diff --git a/vscode-web/run.sh b/vscode-web/run.sh
index ce8782f50..588cec56d 100755
--- a/vscode-web/run.sh
+++ b/vscode-web/run.sh
@@ -10,10 +10,16 @@ if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
fi
+# Set extension directory
+SERVER_BASE_PATH_ARG=""
+if [ -n "${SERVER_BASE_PATH}" ]; then
+ SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}"
+fi
+
run_vscode_web() {
- echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
+ echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
echo "Check logs at ${LOG_PATH}!"
- "$VSCODE_WEB" serve-local "$EXTENSION_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
+ "$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
}
# Check if the settings file exists...
@@ -53,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"
@@ -72,27 +85,26 @@ for extension in "$${EXTENSIONLIST[@]}"; do
output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force)
if [ $? -ne 0 ]; then
echo "Failed to install extension: $extension: $output"
- exit 1
fi
done
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
if ! command -v jq > /dev/null; then
echo "jq is required to install extensions from a workspace file."
- exit 0
- fi
-
- WORKSPACE_DIR="$HOME"
- if [ -n "${FOLDER}" ]; then
- WORKSPACE_DIR="${FOLDER}"
- fi
+ else
+ WORKSPACE_DIR="$HOME"
+ if [ -n "${FOLDER}" ]; then
+ WORKSPACE_DIR="${FOLDER}"
+ fi
- 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)
- for extension in $extensions; do
- $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
- done
+ if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
+ printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
+ # 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
+ fi
fi
fi
diff --git a/windows-rdp/README.md b/windows-rdp/README.md
index a124eb0bc..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" {
- source = "registry.coder.com/coder/module/windows-rdp"
- version = "1.0.16"
count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/windows-rdp/coder"
+ version = "1.0.18"
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" {
- source = "registry.coder.com/coder/module/windows-rdp"
- version = "1.0.16"
count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/windows-rdp/coder"
+ version = "1.0.18"
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" {
- source = "registry.coder.com/coder/module/windows-rdp"
- version = "1.0.16"
count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/modules/windows-rdp/coder"
+ version = "1.0.18"
agent_id = resource.coder_agent.main.id
resource_id = resource.google_compute_instance.dev[0].id
}
diff --git a/windows-rdp/main.test.ts b/windows-rdp/main.test.ts
index 24ce1049f..ba5e21a5a 100644
--- a/windows-rdp/main.test.ts
+++ b/windows-rdp/main.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import {
- TerraformState,
+ type TerraformState,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
@@ -9,6 +9,7 @@ import {
type TestVariables = Readonly<{
agent_id: string;
resource_id: string;
+ share?: string;
admin_username?: string;
admin_password?: string;
}>;
@@ -23,7 +24,10 @@ function findWindowsRdpScript(state: TerraformState): string | null {
}
for (const instance of resource.instances) {
- if (instance.attributes.display_name === "windows-rdp") {
+ if (
+ instance.attributes.display_name === "windows-rdp" &&
+ typeof instance.attributes.script === "string"
+ ) {
return instance.attributes.script;
}
}
@@ -99,11 +103,11 @@ describe("Web RDP", async () => {
const defaultRdpScript = findWindowsRdpScript(defaultState);
expect(defaultRdpScript).toBeString();
- const { username: defaultUsername, password: defaultPassword } =
- formEntryValuesRe.exec(defaultRdpScript)?.groups ?? {};
+ const defaultResultsGroup =
+ formEntryValuesRe.exec(defaultRdpScript ?? "")?.groups ?? {};
- expect(defaultUsername).toBe("Administrator");
- expect(defaultPassword).toBe("coderRDP!");
+ expect(defaultResultsGroup.username).toBe("Administrator");
+ expect(defaultResultsGroup.password).toBe("coderRDP!");
// Test that custom usernames/passwords are also forwarded correctly
const customAdminUsername = "crouton";
@@ -121,10 +125,10 @@ describe("Web RDP", async () => {
const customRdpScript = findWindowsRdpScript(customizedState);
expect(customRdpScript).toBeString();
- const { username: customUsername, password: customPassword } =
- formEntryValuesRe.exec(customRdpScript)?.groups ?? {};
+ const customResultsGroup =
+ formEntryValuesRe.exec(customRdpScript ?? "")?.groups ?? {};
- expect(customUsername).toBe(customAdminUsername);
- expect(customPassword).toBe(customAdminPassword);
+ expect(customResultsGroup.username).toBe(customAdminUsername);
+ expect(customResultsGroup.password).toBe(customAdminPassword);
});
});
diff --git a/windows-rdp/main.tf b/windows-rdp/main.tf
index fb09c48f9..10ece09c4 100644
--- a/windows-rdp/main.tf
+++ b/windows-rdp/main.tf
@@ -9,6 +9,15 @@ terraform {
}
}
+variable "share" {
+ type = string
+ default = "owner"
+ validation {
+ condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
+ error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
+ }
+}
+
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
@@ -53,6 +62,7 @@ resource "coder_script" "windows-rdp" {
resource "coder_app" "windows-rdp" {
agent_id = var.agent_id
+ share = var.share
slug = "web-rdp"
display_name = "Web RDP"
url = "http://localhost:7171"
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."
+}