From 312cdf504a5aa922694c38b8aec7830a677b591a Mon Sep 17 00:00:00 2001 From: Viktor Date: Mon, 15 Jun 2026 23:46:52 +0000 Subject: [PATCH] feat(vscode-desktop): add extensions and settings pre-installation Closes #207 Add the ability to pre-install VS Code extensions and apply machine-level settings on the remote host when the workspace starts. New variables: - extensions: list of extension IDs to pre-install (e.g. ms-python.python) - settings: map of machine-level VS Code settings, merged with existing - extensions_dir: override the extensions storage directory The installation script: - Reuses an existing VS Code CLI if found (from previous connection or vscode-web module), otherwise downloads VS Code Server - Installs extensions to ~/.vscode-server/extensions/ - Writes settings to ~/.vscode-server/data/Machine/settings.json - Merges with existing settings using jq or python3, with fallback No changes to vscode-desktop-core; all new functionality is contained in the wrapper module, following the pattern used by the cursor module for MCP configuration. --- .../coder/modules/vscode-desktop/README.md | 47 ++++- .../vscode-desktop/install-extensions.sh | 160 ++++++++++++++++++ .../coder/modules/vscode-desktop/main.test.ts | 138 +++++++++++++++ registry/coder/modules/vscode-desktop/main.tf | 41 ++++- 4 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 registry/coder/modules/vscode-desktop/install-extensions.sh diff --git a/registry/coder/modules/vscode-desktop/README.md b/registry/coder/modules/vscode-desktop/README.md index 7252361de..3934ec534 100644 --- a/registry/coder/modules/vscode-desktop/README.md +++ b/registry/coder/modules/vscode-desktop/README.md @@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder) module "vscode" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-desktop/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id } ``` @@ -29,8 +29,51 @@ module "vscode" { module "vscode" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-desktop/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" } ``` + +### Pre-install extensions + +Pre-install VS Code extensions so they are ready when the user first connects: + +```tf +module "vscode" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/vscode-desktop/coder" + version = "1.3.0" + agent_id = coder_agent.main.id + folder = "/home/coder/project" + extensions = [ + "ms-python.python", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + ] +} +``` + +### Pre-install extensions with custom settings + +Apply machine-level settings on the remote host. Settings are merged with any existing machine settings: + +```tf +module "vscode" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/vscode-desktop/coder" + version = "1.3.0" + agent_id = coder_agent.main.id + folder = "/home/coder/project" + extensions = [ + "ms-python.python", + "esbenp.prettier-vscode", + ] + settings = { + "editor.fontSize" = 14 + "editor.tabSize" = 2 + "editor.formatOnSave" = true + "python.defaultInterpreterPath" = "/usr/bin/python3" + } +} +``` diff --git a/registry/coder/modules/vscode-desktop/install-extensions.sh b/registry/coder/modules/vscode-desktop/install-extensions.sh new file mode 100644 index 000000000..4b1ef0e3c --- /dev/null +++ b/registry/coder/modules/vscode-desktop/install-extensions.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BOLD='\033[0;1m' +CODE='\033[36;40;1m' +RESET='\033[0m' + +EXTENSIONS="${EXTENSIONS}" +SETTINGS_B64='${SETTINGS_B64}' +CUSTOM_EXTENSIONS_DIR="${EXTENSIONS_DIR}" + +# Default paths for VS Code Remote Development +VSCODE_SERVER_DIR="$HOME/.vscode-server" +SETTINGS_FILE="$VSCODE_SERVER_DIR/data/Machine/settings.json" +EXTENSIONS_TARGET="$${CUSTOM_EXTENSIONS_DIR:-$VSCODE_SERVER_DIR/extensions}" + +# Merge settings from module with existing settings file. +# Uses jq if available, falls back to python3 for deep merge. +merge_settings() { + local new_settings="$1" + local settings_file="$2" + + if [ -z "$new_settings" ] || [ "$new_settings" = "{}" ]; then + return 0 + fi + + if [ ! -f "$settings_file" ]; then + mkdir -p "$(dirname "$settings_file")" + printf '%s\n' "$new_settings" > "$settings_file" + printf "⚙️ Created machine settings.\n" + return 0 + fi + + local tmpfile + tmpfile="$(mktemp)" + + if command -v jq > /dev/null 2>&1; then + if jq -s '.[0] * .[1]' "$settings_file" <(printf '%s\n' "$new_settings") > "$tmpfile" 2> /dev/null; then + mv "$tmpfile" "$settings_file" + printf "⚙️ Merged machine settings.\n" + return 0 + fi + fi + + if command -v python3 > /dev/null 2>&1; then + if python3 -c " +import json, sys +def merge(a, b): + r = {**a} + for k, v in b.items(): + if k in r and isinstance(r[k], dict) and isinstance(v, dict): + r[k] = merge(r[k], v) + else: + r[k] = v + return r +print(json.dumps(merge(json.load(open(sys.argv[1])), json.loads(sys.argv[2])), indent=2)) +" "$settings_file" "$new_settings" > "$tmpfile" 2> /dev/null; then + mv "$tmpfile" "$settings_file" + printf "⚙️ Merged machine settings.\n" + return 0 + fi + fi + + rm -f "$tmpfile" + # Fallback: overwrite + printf '%s\n' "$new_settings" > "$settings_file" + printf "⚙️ Applied machine settings (overwrite, no merge tool found).\n" +} + +# Apply machine settings +if [ -n "$SETTINGS_B64" ]; then + SETTINGS_JSON=$(echo -n "$SETTINGS_B64" | base64 -d 2>/dev/null) || true + if [ -n "$${SETTINGS_JSON:-}" ]; then + merge_settings "$SETTINGS_JSON" "$SETTINGS_FILE" + fi +fi + +# Exit early if no extensions to install +if [ -z "$EXTENSIONS" ]; then + exit 0 +fi + +# Find a usable VS Code CLI for extension installation +find_vscode_cli() { + # 1. Check for existing VS Code Server from a previous Desktop connection + for f in "$HOME"/.vscode-server/bin/*/bin/remote-cli/code; do + if [ -x "$f" 2>/dev/null ]; then + echo "$f" + return 0 + fi + done + + # 2. Check for VS Code Web installation (from vscode-web module) + local web_cli="/tmp/vscode-web/bin/code-server" + if [ -x "$web_cli" ]; then + echo "$web_cli" + return 0 + fi + + return 1 +} + +VSCODE_CLI="" +if VSCODE_CLI=$(find_vscode_cli); then + printf "🔍 Found existing VS Code CLI.\n" +else + # Download VS Code Server for extension installation + printf "$${BOLD}📦 Downloading VS Code Server for extension installation...$${RESET}\n" + + ARCH=$(uname -m) + case "$ARCH" in + x86_64) ARCH="x64" ;; + aarch64) ARCH="arm64" ;; + *) + printf "⚠️ Unsupported architecture: %s. Skipping extension installation.\n" "$ARCH" + exit 0 + ;; + esac + + PLATFORM="linux" + if [ -f /etc/alpine-release ] || grep -qi 'ID=alpine' /etc/os-release 2>/dev/null; then + PLATFORM="alpine" + fi + + INSTALL_DIR="/tmp/vscode-desktop-ext-installer" + mkdir -p "$INSTALL_DIR" + + HASH=$(curl -fsSL "https://update.code.visualstudio.com/api/commits/stable/server-$PLATFORM-$ARCH-web" | cut -d '"' -f 2) + if ! curl -fsSL "https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-$PLATFORM-$ARCH-web.tar.gz" | tar -xz -C "$INSTALL_DIR" --strip-components 1; then + printf "⚠️ Failed to download VS Code Server. Skipping extension installation.\n" + exit 0 + fi + + VSCODE_CLI="$INSTALL_DIR/bin/code-server" + printf "📦 VS Code Server ready.\n" +fi + +# Set extensions directory argument +EXTENSION_ARG="" +if [ -n "$CUSTOM_EXTENSIONS_DIR" ]; then + EXTENSION_ARG="--extensions-dir=$CUSTOM_EXTENSIONS_DIR" + mkdir -p "$CUSTOM_EXTENSIONS_DIR" +else + mkdir -p "$EXTENSIONS_TARGET" +fi + +# Install each extension +IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}" +for extension in "$${EXTENSIONLIST[@]}"; do + if [ -z "$extension" ]; then + continue + fi + printf "🧩 Installing extension $${CODE}%s$${RESET}...\n" "$extension" + if ! output=$("$VSCODE_CLI" $EXTENSION_ARG --install-extension "$extension" --force 2>&1); then + printf "⚠️ Warning: could not install %s: %s\n" "$extension" "$output" + fi +done + +printf "✅ Extension installation complete.\n" diff --git a/registry/coder/modules/vscode-desktop/main.test.ts b/registry/coder/modules/vscode-desktop/main.test.ts index 3c1321b99..6ad3d5690 100644 --- a/registry/coder/modules/vscode-desktop/main.test.ts +++ b/registry/coder/modules/vscode-desktop/main.test.ts @@ -4,6 +4,11 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, + runContainer, + execContainer, + removeContainer, + findResourceInstance, + readFileContainer, } from "~test"; describe("vscode-desktop", async () => { @@ -74,4 +79,137 @@ describe("vscode-desktop", async () => { "vscode://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", ); }); + + it("does not create extensions script when no extensions or settings", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + + const script = state.resources.find( + (res) => + res.type === "coder_script" && + res.name === "vscode-desktop-extensions", + ); + expect(script).toBeUndefined(); + }); + + it("creates extensions script when extensions are specified", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + extensions: '["ms-python.python", "esbenp.prettier-vscode"]', + }); + + const script = findResourceInstance( + state, + "coder_script", + "vscode-desktop-extensions", + ); + expect(script).toBeDefined(); + expect(script.display_name).toBe("VS Code Desktop Extensions"); + expect(script.run_on_start).toBe(true); + expect(script.start_blocks_login).toBe(false); + expect(script.script).toContain("ms-python.python"); + expect(script.script).toContain("esbenp.prettier-vscode"); + }); + + it("creates extensions script when settings are specified", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + settings: JSON.stringify({ + "editor.fontSize": 14, + "editor.tabSize": 2, + }), + }); + + const script = findResourceInstance( + state, + "coder_script", + "vscode-desktop-extensions", + ); + expect(script).toBeDefined(); + expect(script.script).toContain("SETTINGS_B64"); + }); + + it("writes settings to machine settings file", async () => { + const id = await runContainer("alpine/curl"); + + try { + const settings = { + "editor.fontSize": 14, + "editor.tabSize": 2, + }; + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + settings: JSON.stringify(settings), + }); + + const script = findResourceInstance( + state, + "coder_script", + "vscode-desktop-extensions", + ).script; + + const resp = await execContainer(id, ["sh", "-c", script]); + expect(resp.exitCode).toBe(0); + + const content = await readFileContainer( + id, + "/root/.vscode-server/data/Machine/settings.json", + ); + const parsed = JSON.parse(content); + expect(parsed["editor.fontSize"]).toBe(14); + expect(parsed["editor.tabSize"]).toBe(2); + } finally { + await removeContainer(id); + } + }, 15000); + + it("merges settings with existing machine settings", async () => { + const id = await runContainer("alpine/curl"); + + try { + // Pre-populate existing settings + await execContainer(id, [ + "sh", + "-c", + 'mkdir -p /root/.vscode-server/data/Machine && echo \'{"editor.wordWrap":"on","editor.fontSize":12}\' > /root/.vscode-server/data/Machine/settings.json', + ]); + + // Install jq for merge support + await execContainer(id, ["apk", "add", "--no-cache", "jq"]); + + const settings = { + "editor.fontSize": 14, + "editor.tabSize": 2, + }; + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + settings: JSON.stringify(settings), + }); + + const script = findResourceInstance( + state, + "coder_script", + "vscode-desktop-extensions", + ).script; + + const resp = await execContainer(id, ["sh", "-c", script]); + expect(resp.exitCode).toBe(0); + + const content = await readFileContainer( + id, + "/root/.vscode-server/data/Machine/settings.json", + ); + const parsed = JSON.parse(content); + // New settings applied + expect(parsed["editor.fontSize"]).toBe(14); + expect(parsed["editor.tabSize"]).toBe(2); + // Existing settings preserved + expect(parsed["editor.wordWrap"]).toBe("on"); + } finally { + await removeContainer(id); + } + }, 15000); }); diff --git a/registry/coder/modules/vscode-desktop/main.tf b/registry/coder/modules/vscode-desktop/main.tf index 8d98a1a74..259a4266d 100644 --- a/registry/coder/modules/vscode-desktop/main.tf +++ b/registry/coder/modules/vscode-desktop/main.tf @@ -38,6 +38,28 @@ variable "group" { default = null } +variable "extensions" { + type = list(string) + description = "A list of extensions to pre-install when the workspace starts. Use the format 'publisher.extension-name'." + default = [] +} + +variable "settings" { + type = map(any) + description = "Machine-level VS Code settings to apply on the remote host. These are merged with existing machine settings on startup." + default = {} +} + +variable "extensions_dir" { + type = string + description = "Override the directory to store extensions in." + default = "" +} + +locals { + settings_b64 = length(var.settings) > 0 ? base64encode(jsonencode(var.settings)) : "" +} + module "vscode-desktop-core" { source = "registry.coder.com/coder/vscode-desktop-core/coder" version = "1.0.2" @@ -55,7 +77,24 @@ module "vscode-desktop-core" { protocol = "vscode" } +resource "coder_script" "vscode-desktop-extensions" { + count = length(var.extensions) > 0 || length(var.settings) > 0 ? 1 : 0 + agent_id = var.agent_id + + icon = "/icon/code.svg" + display_name = "VS Code Desktop Extensions" + + run_on_start = true + start_blocks_login = false + + script = templatefile("${path.module}/install-extensions.sh", { + EXTENSIONS = join(",", var.extensions) + SETTINGS_B64 = local.settings_b64 + EXTENSIONS_DIR = var.extensions_dir + }) +} + output "vscode_url" { value = module.vscode-desktop-core.ide_uri description = "VS Code Desktop URL." -} \ No newline at end of file +}