From a418556ae49db5bbcf1d166975115bb9d5dd9600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mu=C3=B1oz?= Date: Sat, 7 Feb 2026 18:06:29 +0000 Subject: [PATCH 1/3] fix: properly parse JSONC in extensions.json and batch extension installs The auto_install_extensions feature uses `sed | jq` to parse .vscode/extensions.json, but this file is JSONC (JSON with Comments), not strict JSON. The current parser fails on: - Block comments (/* */) - Trailing commas (common in VS Code-generated files) - URLs inside string values (sed mangles https:// to https:) Replace the sed|jq pipeline with a proper state-machine JSONC parser that runs on the bundled Node.js binary already shipped with both code-server and vscode-web. This eliminates the jq dependency for this feature. Also batch extension installs into a single invocation using repeated --install-extension flags instead of spawning one process per extension. Co-Authored-By: Claude Opus 4.6 --- registry/coder/modules/code-server/main.tf | 1 + .../code-server/parse_jsonc_extensions.js | 55 +++++++++++++++ .../parse_jsonc_extensions.test.ts | 70 +++++++++++++++++++ registry/coder/modules/code-server/run.sh | 28 ++++---- registry/coder/modules/vscode-web/main.tf | 1 + .../vscode-web/parse_jsonc_extensions.js | 55 +++++++++++++++ registry/coder/modules/vscode-web/run.sh | 57 ++++++++------- 7 files changed, 230 insertions(+), 37 deletions(-) create mode 100644 registry/coder/modules/code-server/parse_jsonc_extensions.js create mode 100644 registry/coder/modules/code-server/parse_jsonc_extensions.test.ts create mode 100644 registry/coder/modules/vscode-web/parse_jsonc_extensions.js 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 From 8ef06c85208ef3de99af708db0c1f42ba2f1a03b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mu=C3=B1oz?= <2280164+rodmk@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:56:12 +0000 Subject: [PATCH 2/3] fix: make trailing-comma removal string-aware in JSONC parser Move trailing-comma handling into the state-machine loop so it never modifies characters inside quoted strings. Adds a regression test for the edge case (though it can't occur in practice since extension IDs are restricted to [a-z0-9-] by vsce's nameRegex). Co-Authored-By: Claude Opus 4.6 --- .../code-server/parse_jsonc_extensions.js | 21 +++++++++++++++---- .../parse_jsonc_extensions.test.ts | 16 ++++++++++++++ .../vscode-web/parse_jsonc_extensions.js | 21 +++++++++++++++---- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/registry/coder/modules/code-server/parse_jsonc_extensions.js b/registry/coder/modules/code-server/parse_jsonc_extensions.js index 2efedbd45..462df402d 100644 --- a/registry/coder/modules/code-server/parse_jsonc_extensions.js +++ b/registry/coder/modules/code-server/parse_jsonc_extensions.js @@ -10,6 +10,7 @@ var fs = require("fs"); var text = fs.readFileSync(process.env.FILE, "utf8"); var result = ""; var inString = false; +var pendingComma = ""; var i = 0; while (i < text.length) { @@ -24,7 +25,8 @@ while (i < text.length) { } else { if (text[i] === '"') { inString = true; - result += text[i++]; + result += pendingComma + text[i++]; + pendingComma = ""; continue; } if (text[i] === "/" && text[i + 1] === "/") { @@ -37,11 +39,22 @@ while (i < text.length) { i += 2; continue; } - result += text[i++]; + if (text[i] === ",") { + pendingComma = ","; + i++; + continue; + } + if (pendingComma && (text[i] === " " || text[i] === "\t" || text[i] === "\n" || text[i] === "\r")) { + pendingComma += text[i++]; + continue; + } + if (text[i] === "]" || text[i] === "}") { + pendingComma = ""; + } + result += pendingComma + text[i++]; + pendingComma = ""; } } - -result = result.replace(/,(\s*[\]}])/g, "$1"); var data = JSON.parse(result); var query = process.env.QUERY || "recommendations"; var recommendations; diff --git a/registry/coder/modules/code-server/parse_jsonc_extensions.test.ts b/registry/coder/modules/code-server/parse_jsonc_extensions.test.ts index 71f86c366..494f16fac 100644 --- a/registry/coder/modules/code-server/parse_jsonc_extensions.test.ts +++ b/registry/coder/modules/code-server/parse_jsonc_extensions.test.ts @@ -54,6 +54,22 @@ describe("parse_jsonc_extensions", () => { ]); }); + // Hardening: extension IDs can only contain [a-z0-9-] per vsce's nameRegex, + // so ",]" / ",}" cannot appear in practice. This test guards against the + // trailing-comma removal corrupting arbitrary string values just in case. + it("does not strip commas inside string values", async () => { + const result = await parseExtensions(`{ + "recommendations": [ + "value-with,]-inside", + "value-with,}-inside", + ] + }`); + expect(result).toEqual([ + "value-with,]-inside", + "value-with,}-inside", + ]); + }); + it("handles .code-workspace format", async () => { const result = await parseExtensions( `{ diff --git a/registry/coder/modules/vscode-web/parse_jsonc_extensions.js b/registry/coder/modules/vscode-web/parse_jsonc_extensions.js index 2efedbd45..462df402d 100644 --- a/registry/coder/modules/vscode-web/parse_jsonc_extensions.js +++ b/registry/coder/modules/vscode-web/parse_jsonc_extensions.js @@ -10,6 +10,7 @@ var fs = require("fs"); var text = fs.readFileSync(process.env.FILE, "utf8"); var result = ""; var inString = false; +var pendingComma = ""; var i = 0; while (i < text.length) { @@ -24,7 +25,8 @@ while (i < text.length) { } else { if (text[i] === '"') { inString = true; - result += text[i++]; + result += pendingComma + text[i++]; + pendingComma = ""; continue; } if (text[i] === "/" && text[i + 1] === "/") { @@ -37,11 +39,22 @@ while (i < text.length) { i += 2; continue; } - result += text[i++]; + if (text[i] === ",") { + pendingComma = ","; + i++; + continue; + } + if (pendingComma && (text[i] === " " || text[i] === "\t" || text[i] === "\n" || text[i] === "\r")) { + pendingComma += text[i++]; + continue; + } + if (text[i] === "]" || text[i] === "}") { + pendingComma = ""; + } + result += pendingComma + text[i++]; + pendingComma = ""; } } - -result = result.replace(/,(\s*[\]}])/g, "$1"); var data = JSON.parse(result); var query = process.env.QUERY || "recommendations"; var recommendations; From dc3759d06d5c2b2b66dc786beda765250c6f8314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mu=C3=B1oz?= <2280164+rodmk@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:58:22 +0000 Subject: [PATCH 3/3] style: run prettier Co-Authored-By: Claude Opus 4.6 --- .../coder/modules/code-server/parse_jsonc_extensions.js | 8 +++++++- .../modules/code-server/parse_jsonc_extensions.test.ts | 5 +---- .../coder/modules/vscode-web/parse_jsonc_extensions.js | 8 +++++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/registry/coder/modules/code-server/parse_jsonc_extensions.js b/registry/coder/modules/code-server/parse_jsonc_extensions.js index 462df402d..e960123e3 100644 --- a/registry/coder/modules/code-server/parse_jsonc_extensions.js +++ b/registry/coder/modules/code-server/parse_jsonc_extensions.js @@ -44,7 +44,13 @@ while (i < text.length) { i++; continue; } - if (pendingComma && (text[i] === " " || text[i] === "\t" || text[i] === "\n" || text[i] === "\r")) { + if ( + pendingComma && + (text[i] === " " || + text[i] === "\t" || + text[i] === "\n" || + text[i] === "\r") + ) { pendingComma += text[i++]; continue; } diff --git a/registry/coder/modules/code-server/parse_jsonc_extensions.test.ts b/registry/coder/modules/code-server/parse_jsonc_extensions.test.ts index 494f16fac..ce5d32ea6 100644 --- a/registry/coder/modules/code-server/parse_jsonc_extensions.test.ts +++ b/registry/coder/modules/code-server/parse_jsonc_extensions.test.ts @@ -64,10 +64,7 @@ describe("parse_jsonc_extensions", () => { "value-with,}-inside", ] }`); - expect(result).toEqual([ - "value-with,]-inside", - "value-with,}-inside", - ]); + expect(result).toEqual(["value-with,]-inside", "value-with,}-inside"]); }); it("handles .code-workspace format", async () => { diff --git a/registry/coder/modules/vscode-web/parse_jsonc_extensions.js b/registry/coder/modules/vscode-web/parse_jsonc_extensions.js index 462df402d..e960123e3 100644 --- a/registry/coder/modules/vscode-web/parse_jsonc_extensions.js +++ b/registry/coder/modules/vscode-web/parse_jsonc_extensions.js @@ -44,7 +44,13 @@ while (i < text.length) { i++; continue; } - if (pendingComma && (text[i] === " " || text[i] === "\t" || text[i] === "\n" || text[i] === "\r")) { + if ( + pendingComma && + (text[i] === " " || + text[i] === "\t" || + text[i] === "\n" || + text[i] === "\r") + ) { pendingComma += text[i++]; continue; }