diff --git a/registry/coder/modules/code-server/main.tf b/registry/coder/modules/code-server/main.tf index f56513533..4fc7e80e2 100644 --- a/registry/coder/modules/code-server/main.tf +++ b/registry/coder/modules/code-server/main.tf @@ -175,6 +175,7 @@ resource "coder_script" "code-server" { FOLDER : var.folder, AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, ADDITIONAL_ARGS : var.additional_args, + PARSE_JSONC_JS : file("${path.module}/parse_jsonc_extensions.js"), }) run_on_start = true diff --git a/registry/coder/modules/code-server/parse_jsonc_extensions.js b/registry/coder/modules/code-server/parse_jsonc_extensions.js new file mode 100644 index 000000000..2efedbd45 --- /dev/null +++ b/registry/coder/modules/code-server/parse_jsonc_extensions.js @@ -0,0 +1,55 @@ +// Parses a JSONC file and prints extension recommendations, one per line. +// Handles // comments, /* */ block comments (including multi-line), and trailing commas. +// Used by code-server and vscode-web modules to parse .vscode/extensions.json +// and .code-workspace files. +// +// Environment variables: +// FILE - path to the JSONC file +// QUERY - jq-style query: "recommendations" (default) or "extensions.recommendations" +var fs = require("fs"); +var text = fs.readFileSync(process.env.FILE, "utf8"); +var result = ""; +var inString = false; +var i = 0; + +while (i < text.length) { + if (inString) { + if (text[i] === "\\" && i + 1 < text.length) { + result += text.slice(i, i + 2); + i += 2; + continue; + } + if (text[i] === '"') inString = false; + result += text[i++]; + } else { + if (text[i] === '"') { + inString = true; + result += text[i++]; + continue; + } + if (text[i] === "/" && text[i + 1] === "/") { + while (i < text.length && text[i] !== "\n") i++; + continue; + } + if (text[i] === "/" && text[i + 1] === "*") { + i += 2; + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; + i += 2; + continue; + } + result += text[i++]; + } +} + +result = result.replace(/,(\s*[\]}])/g, "$1"); +var data = JSON.parse(result); +var query = process.env.QUERY || "recommendations"; +var recommendations; +if (query === "extensions.recommendations") { + recommendations = (data.extensions && data.extensions.recommendations) || []; +} else { + recommendations = data.recommendations || []; +} +recommendations.forEach(function (e) { + console.log(e); +}); diff --git a/registry/coder/modules/code-server/parse_jsonc_extensions.test.ts b/registry/coder/modules/code-server/parse_jsonc_extensions.test.ts new file mode 100644 index 000000000..71f86c366 --- /dev/null +++ b/registry/coder/modules/code-server/parse_jsonc_extensions.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { spawn, readableStreamToText } from "bun"; +import { unlink } from "node:fs/promises"; +import { join } from "node:path"; + +const PARSER = join(import.meta.dir, "parse_jsonc_extensions.js"); +const TMP = join(import.meta.dir, "tmp_test.json"); + +async function parseExtensions( + json: string, + query?: string, +): Promise { + await Bun.write(TMP, json); + try { + const proc = spawn([process.execPath, PARSER], { + env: { FILE: TMP, QUERY: query ?? "recommendations" }, + stdout: "pipe", + stderr: "pipe", + }); + const out = await readableStreamToText(proc.stdout); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(await readableStreamToText(proc.stderr)); + } + return out.trim().split("\n").filter(Boolean); + } finally { + await unlink(TMP).catch(() => {}); + } +} + +describe("parse_jsonc_extensions", () => { + it("handles comments and trailing commas", async () => { + const result = await parseExtensions(`{ + // line comment + "recommendations": [ + "ms-python.python", + /* block comment */ + "dbaeumer.vscode-eslint", // inline + ], + }`); + expect(result).toEqual(["ms-python.python", "dbaeumer.vscode-eslint"]); + }); + + it("does not mangle URLs in strings", async () => { + const result = await parseExtensions(`{ + "recommendations": [ + "ms-python.python", + "https://example.com/custom.vsix" + ] + }`); + expect(result).toEqual([ + "ms-python.python", + "https://example.com/custom.vsix", + ]); + }); + + it("handles .code-workspace format", async () => { + const result = await parseExtensions( + `{ + "folders": [{"path": "."}], + "extensions": { + // Recommended + "recommendations": ["ms-python.python"], + }, + }`, + "extensions.recommendations", + ); + expect(result).toEqual(["ms-python.python"]); + }); +}); diff --git a/registry/coder/modules/code-server/run.sh b/registry/coder/modules/code-server/run.sh index 33a6972a6..bf6c6271f 100644 --- a/registry/coder/modules/code-server/run.sh +++ b/registry/coder/modules/code-server/run.sh @@ -98,7 +98,8 @@ function extension_installed() { return 1 } -# Install each extension... +# Install extensions... +INSTALL_EXT_ARGS=() IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}" # shellcheck disable=SC2066 for extension in "$${EXTENSIONLIST[@]}"; do @@ -109,19 +110,18 @@ for extension in "$${EXTENSIONLIST[@]}"; do continue fi printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n" - output=$($CODE_SERVER "$EXTENSION_ARG" --force --install-extension "$extension") + INSTALL_EXT_ARGS+=(--install-extension "$extension") +done +# shellcheck disable=SC2170,SC2255 +if [ $${#INSTALL_EXT_ARGS[@]} -gt 0 ]; then + output=$($CODE_SERVER "$EXTENSION_ARG" --force "$${INSTALL_EXT_ARGS[@]}") if [ $? -ne 0 ]; then - echo "Failed to install extension: $extension: $output" + echo "Failed to install extensions: $output" exit 1 fi -done +fi 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}" @@ -129,14 +129,18 @@ 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" - # Use sed to remove single-line comments before parsing with jq - extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]') + extensions=$(FILE="$WORKSPACE_DIR/.vscode/extensions.json" "${INSTALL_PREFIX}/lib/node" -e '${PARSE_JSONC_JS}') + INSTALL_EXT_ARGS=() for extension in $extensions; do if extension_installed "$extension"; then continue fi - $CODE_SERVER "$EXTENSION_ARG" --force --install-extension "$extension" + INSTALL_EXT_ARGS+=(--install-extension "$extension") done + # shellcheck disable=SC2170,SC2255 + if [ $${#INSTALL_EXT_ARGS[@]} -gt 0 ]; then + $CODE_SERVER "$EXTENSION_ARG" --force "$${INSTALL_EXT_ARGS[@]}" + fi fi fi diff --git a/registry/coder/modules/vscode-web/main.tf b/registry/coder/modules/vscode-web/main.tf index 7a2029c87..7af3d0852 100644 --- a/registry/coder/modules/vscode-web/main.tf +++ b/registry/coder/modules/vscode-web/main.tf @@ -186,6 +186,7 @@ resource "coder_script" "vscode-web" { FOLDER : var.folder, WORKSPACE : var.workspace, AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, + PARSE_JSONC_JS : file("${path.module}/parse_jsonc_extensions.js"), SERVER_BASE_PATH : local.server_base_path, COMMIT_ID : var.commit_id, PLATFORM : var.platform, diff --git a/registry/coder/modules/vscode-web/parse_jsonc_extensions.js b/registry/coder/modules/vscode-web/parse_jsonc_extensions.js new file mode 100644 index 000000000..2efedbd45 --- /dev/null +++ b/registry/coder/modules/vscode-web/parse_jsonc_extensions.js @@ -0,0 +1,55 @@ +// Parses a JSONC file and prints extension recommendations, one per line. +// Handles // comments, /* */ block comments (including multi-line), and trailing commas. +// Used by code-server and vscode-web modules to parse .vscode/extensions.json +// and .code-workspace files. +// +// Environment variables: +// FILE - path to the JSONC file +// QUERY - jq-style query: "recommendations" (default) or "extensions.recommendations" +var fs = require("fs"); +var text = fs.readFileSync(process.env.FILE, "utf8"); +var result = ""; +var inString = false; +var i = 0; + +while (i < text.length) { + if (inString) { + if (text[i] === "\\" && i + 1 < text.length) { + result += text.slice(i, i + 2); + i += 2; + continue; + } + if (text[i] === '"') inString = false; + result += text[i++]; + } else { + if (text[i] === '"') { + inString = true; + result += text[i++]; + continue; + } + if (text[i] === "/" && text[i + 1] === "/") { + while (i < text.length && text[i] !== "\n") i++; + continue; + } + if (text[i] === "/" && text[i + 1] === "*") { + i += 2; + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; + i += 2; + continue; + } + result += text[i++]; + } +} + +result = result.replace(/,(\s*[\]}])/g, "$1"); +var data = JSON.parse(result); +var query = process.env.QUERY || "recommendations"; +var recommendations; +if (query === "extensions.recommendations") { + recommendations = (data.extensions && data.extensions.recommendations) || []; +} else { + recommendations = data.recommendations || []; +} +recommendations.forEach(function (e) { + console.log(e); +}); diff --git a/registry/coder/modules/vscode-web/run.sh b/registry/coder/modules/vscode-web/run.sh index 57bb760f9..da60c4932 100644 --- a/registry/coder/modules/vscode-web/run.sh +++ b/registry/coder/modules/vscode-web/run.sh @@ -92,7 +92,8 @@ if [ $? -ne 0 ]; then fi printf "$${BOLD}VS Code Web has been installed.\n" -# Install each extension... +# Install extensions... +INSTALL_EXT_ARGS=() IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}" # shellcheck disable=SC2066 for extension in "$${EXTENSIONLIST[@]}"; do @@ -100,39 +101,45 @@ for extension in "$${EXTENSIONLIST[@]}"; do continue fi printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n" - output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force) + INSTALL_EXT_ARGS+=(--install-extension "$extension") +done +# shellcheck disable=SC2170,SC2255 +if [ $${#INSTALL_EXT_ARGS[@]} -gt 0 ]; then + output=$($VSCODE_WEB "$EXTENSION_ARG" --force "$${INSTALL_EXT_ARGS[@]}") if [ $? -ne 0 ]; then - echo "Failed to install extension: $extension: $output" + echo "Failed to install extensions: $output" fi -done +fi if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then - if ! command -v jq > /dev/null; then - echo "jq is required to install extensions from a workspace file." + INSTALL_EXT_ARGS=() + + # Prefer WORKSPACE if set and points to a .code-workspace file + if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then + printf "🧩 Installing extensions from %s...\n" "${WORKSPACE}" + extensions=$(FILE="${WORKSPACE}" QUERY="extensions.recommendations" "${INSTALL_PREFIX}/node" -e '${PARSE_JSONC_JS}') + for extension in $extensions; do + INSTALL_EXT_ARGS+=(--install-extension "$extension") + done else - # 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 // [])[]') + # Fallback to folder-based .vscode/extensions.json (existing behavior) + 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=$(FILE="$WORKSPACE_DIR/.vscode/extensions.json" "${INSTALL_PREFIX}/node" -e '${PARSE_JSONC_JS}') for extension in $extensions; do - $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force + INSTALL_EXT_ARGS+=(--install-extension "$extension") done - else - # Fallback to folder-based .vscode/extensions.json (existing behavior) - 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=$(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 + + # shellcheck disable=SC2170,SC2255 + if [ $${#INSTALL_EXT_ARGS[@]} -gt 0 ]; then + $VSCODE_WEB "$EXTENSION_ARG" --force "$${INSTALL_EXT_ARGS[@]}" + fi fi run_vscode_web