diff --git a/.github/typos.toml b/.github/typos.toml index d0b1f2863..73b31c30b 100644 --- a/.github/typos.toml +++ b/.github/typos.toml @@ -8,6 +8,7 @@ mavrickrishi = "mavrickrishi" # Username mavrick = "mavrick" # Username inh = "inh" # Option in setpriv command exportfs = "exportfs" # nfs related binary +ba = "ba" # sed branch command in :a;N;$!ba pattern [files] extend-exclude = ["registry/coder/templates/aws-devcontainer/architecture.svg"] #False positive \ No newline at end of file diff --git a/registry/coder/modules/code-server/README.md b/registry/coder/modules/code-server/README.md index d6dcd6bd7..af359d5f1 100644 --- a/registry/coder/modules/code-server/README.md +++ b/registry/coder/modules/code-server/README.md @@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.5.1" + version = "1.5.2" agent_id = coder_agent.example.id } ``` @@ -29,7 +29,7 @@ module "code-server" { module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.5.1" + version = "1.5.2" agent_id = coder_agent.example.id install_version = "4.106.3" } @@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/): module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.5.1" + version = "1.5.2" agent_id = coder_agent.example.id extensions = [ "dracula-theme.theme-dracula" @@ -61,7 +61,7 @@ Configure VS Code's [User settings.json](https://code.visualstudio.com/docs/gets module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.5.1" + version = "1.5.2" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -81,7 +81,7 @@ Install multiple extensions from [OpenVSX](https://open-vsx.org/) by adding them module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.5.1" + version = "1.5.2" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] } @@ -95,7 +95,7 @@ Open a [`.code-workspace`](https://coder.com/docs/code-server/FAQ#how-does-code- module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.5.1" + version = "1.5.2" agent_id = coder_agent.example.id workspace = "/home/coder/project/my.code-workspace" } @@ -109,7 +109,7 @@ You can pass additional command-line arguments to code-server using the `additio module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.5.1" + version = "1.5.2" agent_id = coder_agent.example.id additional_args = "--disable-workspace-trust" } @@ -125,7 +125,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub: module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.5.1" + version = "1.5.2" agent_id = coder_agent.example.id use_cached = true extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] @@ -138,7 +138,7 @@ Just run code-server in the background, don't fetch it from GitHub: module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.5.1" + version = "1.5.2" agent_id = coder_agent.example.id offline = true } diff --git a/registry/coder/modules/code-server/main.test.ts b/registry/coder/modules/code-server/main.test.ts index 2686fa1e9..087d4d8df 100644 --- a/registry/coder/modules/code-server/main.test.ts +++ b/registry/coder/modules/code-server/main.test.ts @@ -18,6 +18,39 @@ import { setDefaultTimeout(2 * 60 * 1000); +// Mock code-server CLI that records every `--install-extension ` call as +// "INSTALLED:" on stdout so tests can assert which extensions the script +// tried to install. `--list-extensions` returns nothing (no cached extensions). +const MOCK_CODE_SERVER = `#!/bin/bash +if [ "$1" = "--list-extensions" ]; then + exit 0 +fi +prev="" +for arg in "$@"; do + if [ "$prev" = "--install-extension" ]; then + echo "INSTALLED:$arg" + fi + prev="$arg" +done +exit 0`; + +// A .vscode/extensions.json exercising every JSONC feature the stripper must +// handle: a standalone line comment, an end-of-line comment, a block comment +// containing a URL (the case the previous sed pipeline corrupted), a multi-line +// block comment, and a trailing comma. +const JSONC_EXTENSIONS_JSON = `{ + // Recommended extensions for this workspace + "recommendations": [ + "ms-python.python", // Python language support + /* linting - see https://open-vsx.org for the registry */ + "dbaeumer.vscode-eslint", + /* + * Formatting tools + */ + "esbenp.prettier-vscode", // trailing comma below is intentional + ] +}`; + let cleanupContainers: string[] = []; afterEach(async () => { @@ -214,6 +247,97 @@ chmod +x /tmp/code-server/bin/code-server`, expect(settingsResult.stdout).toContain("template_value"); }); + it("auto-installs recommended extensions from a JSONC extensions.json", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + use_cached: true, + auto_install_extensions: true, + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + await execContainer(containerId, ["apt-get", "update", "-qq"]); + await execContainer(containerId, ["apt-get", "install", "-y", "-qq", "jq"]); + + // use_cached + a pre-existing binary skips the real install so the script + // reaches the auto-install path. + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/code-server/bin && cat > /tmp/code-server/bin/code-server << 'MOCKEOF' +${MOCK_CODE_SERVER} +MOCKEOF +chmod +x /tmp/code-server/bin/code-server`, + ]); + + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /root/.vscode && cat > /root/.vscode/extensions.json << 'JSONCEOF' +${JSONC_EXTENSIONS_JSON} +JSONCEOF`, + ]); + + const script = findResourceInstance(state, "coder_script"); + const result = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("INSTALLED:ms-python.python"); + expect(result.stdout).toContain("INSTALLED:dbaeumer.vscode-eslint"); + expect(result.stdout).toContain("INSTALLED:esbenp.prettier-vscode"); + }); + + it("does not error on an extensions.json without a recommendations key", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + use_cached: true, + auto_install_extensions: true, + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + await execContainer(containerId, ["apt-get", "update", "-qq"]); + await execContainer(containerId, ["apt-get", "install", "-y", "-qq", "jq"]); + + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/code-server/bin && cat > /tmp/code-server/bin/code-server << 'MOCKEOF' +${MOCK_CODE_SERVER} +MOCKEOF +chmod +x /tmp/code-server/bin/code-server`, + ]); + + // Valid JSON, but no `recommendations` key. The null-safe query + // `(.recommendations // [])[]` must not make jq error on the missing key. + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /root/.vscode && cat > /root/.vscode/extensions.json << 'JSONCEOF' +{ + "unwantedRecommendations": ["ms-python.python"] +} +JSONCEOF`, + ]); + + const script = findResourceInstance(state, "coder_script"); + const result = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain("INSTALLED:"); + expect(result.stderr).not.toContain("Cannot iterate over null"); + }); + it("installs and runs code-server", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", diff --git a/registry/coder/modules/code-server/run.sh b/registry/coder/modules/code-server/run.sh index 5ac2d12a2..8ea7fa6a8 100644 --- a/registry/coder/modules/code-server/run.sh +++ b/registry/coder/modules/code-server/run.sh @@ -156,6 +156,39 @@ for extension in "$${EXTENSIONLIST[@]}"; do fi done +# Strip JSONC features (block/line comments, trailing commas) so jq can parse +# .vscode/extensions.json and .code-workspace files. Portable across GNU, BSD, +# and BusyBox sed (Coder workspaces run on Linux, macOS and Alpine). +# +# Three passes, because each concern needs a different scope and order: +# 1. Block comments - slurps the whole file so /* ... */ can span lines. +# Runs first so a URL such as /* see https://example */ is removed as a +# unit and its // is never seen by the line-comment pass. +# 2. Line comments - per line, so // ... stops at end of line without the +# non-portable [^\n] class (BSD sed reads \n inside a bracket as a literal +# backslash and n, silently corrupting IDs containing "n"). A // preceded +# by ':' is preserved so URLs in string values (e.g. proxy settings in a +# .code-workspace) survive. +# 3. Trailing commas - slurps the whole file so a comma and its closing +# bracket may sit on different lines. +# The ':a;$!{N;ba}' slurp is single-line safe (it falls through on the last or +# only line), unlike ':a;N;$!ba', which drops single-line input entirely. +strip_jsonc_for_extensions() { + sed -E ':a +$!{ +N +ba +} +s#/[*]([^*]|[*]+[^*/])*[*]+/##g' "$1" \ + | sed -E 's#^[[:space:]]*//.*##; s#([^:])//.*#\1#' \ + | sed -E ':a +$!{ +N +ba +} +s/,[^]}"]*([]}])/\1/g' +} + if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then if ! command -v jq > /dev/null; then echo "jq is required to install extensions from a workspace file." @@ -163,11 +196,11 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then fi RECOMMENDATIONS_FILE="" - RECOMMENDATIONS_QUERY=".recommendations[]" + RECOMMENDATIONS_QUERY='(.recommendations // [])[]' if [ -n "${WORKSPACE}" ]; then if [ -f "${WORKSPACE}" ]; then RECOMMENDATIONS_FILE="${WORKSPACE}" - RECOMMENDATIONS_QUERY=".extensions.recommendations[]?" + RECOMMENDATIONS_QUERY='(.extensions.recommendations // [])[]' else echo "⚠️ Workspace file ${WORKSPACE} not found, skipping extension recommendations." fi @@ -183,8 +216,8 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then if [ -n "$RECOMMENDATIONS_FILE" ]; then printf "🧩 Installing extensions from %s...\n" "$RECOMMENDATIONS_FILE" - # Use sed to remove single-line comments before parsing with jq - extensions=$(sed 's|//.*||g' "$RECOMMENDATIONS_FILE" | jq -r "$RECOMMENDATIONS_QUERY") + # Strip JSONC comments and trailing commas before parsing with jq + extensions=$(strip_jsonc_for_extensions "$RECOMMENDATIONS_FILE" | jq -r "$RECOMMENDATIONS_QUERY") for extension in $extensions; do if extension_installed "$extension"; then continue diff --git a/registry/coder/modules/vscode-web/README.md b/registry/coder/modules/vscode-web/README.md index 90dd23e1a..e3a1681ea 100644 --- a/registry/coder/modules/vscode-web/README.md +++ b/registry/coder/modules/vscode-web/README.md @@ -14,7 +14,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/ module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.6.0" + version = "1.6.1" agent_id = coder_agent.example.id accept_license = true } @@ -30,7 +30,7 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.6.0" + version = "1.6.1" agent_id = coder_agent.example.id install_prefix = "/home/coder/.vscode-web" folder = "/home/coder" @@ -44,7 +44,7 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.6.0" + version = "1.6.1" agent_id = coder_agent.example.id extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] accept_license = true @@ -59,7 +59,7 @@ Configure VS Code's [Machine settings.json](https://code.visualstudio.com/docs/g module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.6.0" + version = "1.6.1" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -80,7 +80,7 @@ By default, this module installs the latest. To pin a specific version, retrieve module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.6.0" + version = "1.6.1" agent_id = coder_agent.example.id commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" accept_license = true @@ -96,7 +96,7 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.6.0" + version = "1.6.1" agent_id = coder_agent.example.id workspace = "/home/coder/coder.code-workspace" } diff --git a/registry/coder/modules/vscode-web/main.test.ts b/registry/coder/modules/vscode-web/main.test.ts index 96c787c86..5e140d92e 100644 --- a/registry/coder/modules/vscode-web/main.test.ts +++ b/registry/coder/modules/vscode-web/main.test.ts @@ -18,6 +18,51 @@ import { // Set timeout to 2 minutes for tests that install packages setDefaultTimeout(2 * 60 * 1000); +// Mock vscode-web CLI that records every `--install-extension ` call as +// "INSTALLED:" on stdout so tests can assert which extensions the script +// tried to install. +const MOCK_VSCODE_WEB = `#!/bin/bash +prev="" +for arg in "$@"; do + if [ "$prev" = "--install-extension" ]; then + echo "INSTALLED:$arg" + fi + prev="$arg" +done +exit 0`; + +// Stub curl/tar so the download is a no-op: the script only reaches the +// extension-install path when neither use_cached nor offline is set (both exit +// early), so the real download must be short-circuited while the pre-placed +// mock binary survives. +const STUB_DOWNLOAD = `cat > /usr/local/bin/curl << 'CURLEOF' +#!/bin/bash +echo '"stub-commit"' +CURLEOF +cat > /usr/local/bin/tar << 'TAREOF' +#!/bin/bash +cat > /dev/null 2>&1 || true +exit 0 +TAREOF +chmod +x /usr/local/bin/curl /usr/local/bin/tar`; + +// A .vscode/extensions.json exercising every JSONC feature the stripper must +// handle: a standalone line comment, an end-of-line comment, a block comment +// containing a URL (the case the previous sed pipeline corrupted), a multi-line +// block comment, and a trailing comma. +const JSONC_EXTENSIONS_JSON = `{ + // Recommended extensions for this workspace + "recommendations": [ + "ms-python.python", // Python language support + /* linting - see https://open-vsx.org for the registry */ + "dbaeumer.vscode-eslint", + /* + * Formatting tools + */ + "esbenp.prettier-vscode", // trailing comma below is intentional + ] +}`; + let cleanupContainers: string[] = []; afterEach(async () => { @@ -295,4 +340,108 @@ chmod +x /tmp/vscode-web/bin/code-server`, expect(settingsResult.stdout).not.toContain("new.setting"); expect(settingsResult.stdout).not.toContain("new_value"); }); + + it("auto-installs recommended extensions from a JSONC extensions.json", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + auto_install_extensions: true, + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + await execContainer(containerId, ["apt-get", "update", "-qq"]); + await execContainer(containerId, ["apt-get", "install", "-y", "-qq", "jq"]); + + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF' +${MOCK_VSCODE_WEB} +MOCKEOF +chmod +x /tmp/vscode-web/bin/code-server +${STUB_DOWNLOAD}`, + ]); + + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /root/.vscode && cat > /root/.vscode/extensions.json << 'JSONCEOF' +${JSONC_EXTENSIONS_JSON} +JSONCEOF`, + ]); + + const script = findResourceInstance(state, "coder_script"); + const result = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("INSTALLED:ms-python.python"); + expect(result.stdout).toContain("INSTALLED:dbaeumer.vscode-eslint"); + expect(result.stdout).toContain("INSTALLED:esbenp.prettier-vscode"); + }); + + it("auto-installs extensions from a JSONC .code-workspace with URL-valued settings", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + auto_install_extensions: true, + workspace: "/root/team.code-workspace", + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + await execContainer(containerId, ["apt-get", "update", "-qq"]); + await execContainer(containerId, ["apt-get", "install", "-y", "-qq", "jq"]); + + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF' +${MOCK_VSCODE_WEB} +MOCKEOF +chmod +x /tmp/vscode-web/bin/code-server +${STUB_DOWNLOAD}`, + ]); + + // A .code-workspace whose settings hold a URL. The `://` in the URL must + // survive JSONC stripping (it is not a comment) so jq can parse the file + // and read .extensions.recommendations. + await execContainer(containerId, [ + "bash", + "-c", + `cat > /root/team.code-workspace << 'JSONCEOF' +{ + // Team workspace configuration + "folders": [{ "path": "." }], + "settings": { + "http.proxy": "https://proxy.corp.example:8080", // corporate proxy URL + }, + "extensions": { + "recommendations": [ + "ms-python.python", + /* linting - https://open-vsx.org */ + "dbaeumer.vscode-eslint", + ] + } +} +JSONCEOF`, + ]); + + const script = findResourceInstance(state, "coder_script"); + const result = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("INSTALLED:ms-python.python"); + expect(result.stdout).toContain("INSTALLED:dbaeumer.vscode-eslint"); + }); }); diff --git a/registry/coder/modules/vscode-web/run.sh b/registry/coder/modules/vscode-web/run.sh index dea8e5853..0204cafa2 100644 --- a/registry/coder/modules/vscode-web/run.sh +++ b/registry/coder/modules/vscode-web/run.sh @@ -150,6 +150,39 @@ for extension in "$${EXTENSIONLIST[@]}"; do fi done +# Strip JSONC features (block/line comments, trailing commas) so jq can parse +# .vscode/extensions.json and .code-workspace files. Portable across GNU, BSD, +# and BusyBox sed (Coder workspaces run on Linux, macOS and Alpine). +# +# Three passes, because each concern needs a different scope and order: +# 1. Block comments - slurps the whole file so /* ... */ can span lines. +# Runs first so a URL such as /* see https://example */ is removed as a +# unit and its // is never seen by the line-comment pass. +# 2. Line comments - per line, so // ... stops at end of line without the +# non-portable [^\n] class (BSD sed reads \n inside a bracket as a literal +# backslash and n, silently corrupting IDs containing "n"). A // preceded +# by ':' is preserved so URLs in string values (e.g. proxy settings in a +# .code-workspace) survive. +# 3. Trailing commas - slurps the whole file so a comma and its closing +# bracket may sit on different lines. +# The ':a;$!{N;ba}' slurp is single-line safe (it falls through on the last or +# only line), unlike ':a;N;$!ba', which drops single-line input entirely. +strip_jsonc_for_extensions() { + sed -E ':a +$!{ +N +ba +} +s#/[*]([^*]|[*]+[^*/])*[*]+/##g' "$1" \ + | sed -E 's#^[[:space:]]*//.*##; s#([^:])//.*#\1#' \ + | sed -E ':a +$!{ +N +ba +} +s/,[^]}"]*([]}])/\1/g' +} + if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then if ! command -v jq > /dev/null; then echo "jq is required to install extensions from a workspace file." @@ -157,8 +190,8 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then # Prefer WORKSPACE if set and points to a file if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then printf "🧩 Installing extensions from %s...\n" "${WORKSPACE}" - # Strip single-line comments then parse .extensions.recommendations[] - extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]') + extensions=$(strip_jsonc_for_extensions "${WORKSPACE}" \ + | jq -r '(.extensions.recommendations // [])[]') for extension in $extensions; do $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force done @@ -170,7 +203,8 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then fi if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR" - extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]') + extensions=$(strip_jsonc_for_extensions "$WORKSPACE_DIR/.vscode/extensions.json" \ + | jq -r '(.recommendations // [])[]') for extension in $extensions; do $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force done