Skip to content

Commit d66a5d1

Browse files
authored
fix: installer failing on opencode.jsonc files with trailing commas (#49)
* fix: installer failing on opencode.jsonc files with trailing commas The auto-generated opencode.jsonc often contains trailing commas, which is valid JSONC but rejected by JSON.parse. This caused the one-liner installer to fail for many users without a clear path to recovery. The fix adds a string-context-aware trailing comma stripper that runs after comment removal, before JSON.parse. The logic is extracted into a typed module (src/jsonc.ts) so it can be unit tested independently from the self-contained bash script that inlines it. * Fix noUncheckedIndexedAccess typecheck error in jsonc test Array indexing returns number | undefined under strict TS config; use a non-null assertion where the valid index is guaranteed by the test setup.
1 parent 46e196d commit d66a5d1

3 files changed

Lines changed: 375 additions & 1 deletion

File tree

install.sh

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ UPDATED=""
147147
# for plain .json when node is unavailable (jq cannot parse JSONC comments).
148148
if command -v node >/dev/null 2>&1; then
149149
# shellcheck disable=SC2016 # single-quoted block is JS source, not shell
150+
# NOTE: scan() and removeTrailingCommas() below are plain-JS copies of the
151+
# functions in src/jsonc.ts. Keep the two in sync when making changes.
150152
UPDATED="$(node -e '
151153
const fs = require("fs");
152154
const [p, spec, name] = [process.argv[1], process.argv[2], process.argv[3]];
@@ -200,9 +202,32 @@ if command -v node >/dev/null 2>&1; then
200202
return ind;
201203
}
202204
205+
// Remove trailing commas from comment-stripped JSONC so JSON.parse accepts it.
206+
// Tracks string context to avoid touching commas inside string values.
207+
function removeTrailingCommas(s) {
208+
let result = "", inStr = false, i = 0;
209+
while (i < s.length) {
210+
const ch = s[i];
211+
if (inStr) {
212+
result += ch;
213+
if (ch === "\\") { result += (s[i + 1] || ""); i += 2; continue; }
214+
if (ch === "\"") inStr = false;
215+
i++; continue;
216+
}
217+
if (ch === "\"") { inStr = true; result += ch; i++; continue; }
218+
if (ch === ",") {
219+
let j = i + 1;
220+
while (j < s.length && (s[j] === " " || s[j] === "\t" || s[j] === "\n" || s[j] === "\r")) j++;
221+
if (j < s.length && (s[j] === "}" || s[j] === "]")) { i++; continue; }
222+
}
223+
result += ch; i++;
224+
}
225+
return result;
226+
}
227+
203228
const { out: stripped, map } = scan(raw);
204229
let parsed;
205-
try { parsed = JSON.parse(stripped); }
230+
try { parsed = JSON.parse(removeTrailingCommas(stripped)); }
206231
catch (e) { console.error("Failed to parse " + p + ": " + e.message); process.exit(1); }
207232
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
208233
console.error("Expected a JSON object in " + p); process.exit(1);

src/jsonc.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* JSONC (JSON with Comments) helpers.
3+
*
4+
* These functions are also inlined verbatim (as plain JS) into install.sh so
5+
* that the installer remains a self-contained bash script with no runtime
6+
* dependencies beyond Node. Keep the two copies in sync when changing this
7+
* file.
8+
*
9+
* Tests live in test/jsonc.test.ts.
10+
*/
11+
12+
/** Result of scanning a JSONC string: comment-stripped text plus index map. */
13+
export interface ScanResult {
14+
/** Comment-stripped output (string literals preserved, comments removed). */
15+
out: string;
16+
/** map[i] is the index in the original raw string that produced out[i]. */
17+
map: number[];
18+
}
19+
20+
/**
21+
* Strip line comments and block comments from a JSONC string while preserving
22+
* string literals verbatim. Returns the stripped text and a map from
23+
* stripped-index back to raw-index so callers can locate and edit the original
24+
* text in place.
25+
*/
26+
export function scan(s: string): ScanResult {
27+
let out = "",
28+
map: number[] = [],
29+
inStr = false,
30+
i = 0;
31+
while (i < s.length) {
32+
const ch = s[i],
33+
nx = s[i + 1];
34+
if (inStr) {
35+
out += ch;
36+
map.push(i);
37+
if (ch === "\\") {
38+
out += s[i + 1] || "";
39+
map.push(i + 1);
40+
i += 2;
41+
continue;
42+
}
43+
if (ch === '"') inStr = false;
44+
i++;
45+
continue;
46+
}
47+
if (ch === '"') {
48+
inStr = true;
49+
out += ch;
50+
map.push(i);
51+
i++;
52+
continue;
53+
}
54+
if (ch === "/" && nx === "/") {
55+
while (i < s.length && s[i] !== "\n") i++;
56+
continue;
57+
}
58+
if (ch === "/" && nx === "*") {
59+
i += 2;
60+
while (i < s.length && !(s[i] === "*" && s[i + 1] === "/")) i++;
61+
i += 2;
62+
continue;
63+
}
64+
out += ch;
65+
map.push(i);
66+
i++;
67+
}
68+
return { out, map };
69+
}
70+
71+
/**
72+
* Remove trailing commas from a comment-stripped JSONC string so that
73+
* `JSON.parse` accepts it. Tracks string context to avoid removing commas
74+
* that are part of a string value.
75+
*
76+
* A trailing comma is a `,` whose next non-whitespace character is `}` or `]`.
77+
*/
78+
export function removeTrailingCommas(s: string): string {
79+
let result = "",
80+
inStr = false,
81+
i = 0;
82+
while (i < s.length) {
83+
const ch = s[i];
84+
if (inStr) {
85+
result += ch;
86+
if (ch === "\\") {
87+
result += s[i + 1] || "";
88+
i += 2;
89+
continue;
90+
}
91+
if (ch === '"') inStr = false;
92+
i++;
93+
continue;
94+
}
95+
if (ch === '"') {
96+
inStr = true;
97+
result += ch;
98+
i++;
99+
continue;
100+
}
101+
if (ch === ",") {
102+
let j = i + 1;
103+
while (
104+
j < s.length &&
105+
(s[j] === " " || s[j] === "\t" || s[j] === "\n" || s[j] === "\r")
106+
)
107+
j++;
108+
if (j < s.length && (s[j] === "}" || s[j] === "]")) {
109+
i++;
110+
continue;
111+
}
112+
}
113+
result += ch;
114+
i++;
115+
}
116+
return result;
117+
}

test/jsonc.test.ts

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { describe, expect, it } from "vitest";
2+
import { removeTrailingCommas, scan } from "../src/jsonc.js";
3+
4+
// ---------------------------------------------------------------------------
5+
// removeTrailingCommas
6+
// ---------------------------------------------------------------------------
7+
8+
describe("removeTrailingCommas", () => {
9+
// --- basic cases -----------------------------------------------------------
10+
11+
it("removes a trailing comma from a flat array", () => {
12+
const result = removeTrailingCommas('["a", "b", "c",]');
13+
expect(JSON.parse(result)).toEqual(["a", "b", "c"]);
14+
});
15+
16+
it("removes a trailing comma from a flat object", () => {
17+
const result = removeTrailingCommas('{"x": 1, "y": 2,}');
18+
expect(JSON.parse(result)).toEqual({ x: 1, y: 2 });
19+
});
20+
21+
it("removes trailing commas from both arrays and objects in one pass", () => {
22+
const input = '{"arr": [1, 2, 3,], "obj": {"a": true,},}';
23+
const result = removeTrailingCommas(input);
24+
expect(JSON.parse(result)).toEqual({ arr: [1, 2, 3], obj: { a: true } });
25+
});
26+
27+
it("handles trailing comma with spaces before the closing bracket", () => {
28+
expect(JSON.parse(removeTrailingCommas('["a", "b", ]'))).toEqual([
29+
"a",
30+
"b",
31+
]);
32+
});
33+
34+
it("handles trailing comma with a newline before the closing bracket", () => {
35+
const input = `{
36+
"key": "value",
37+
}`;
38+
expect(JSON.parse(removeTrailingCommas(input))).toEqual({ key: "value" });
39+
});
40+
41+
it("handles trailing comma with mixed whitespace before the closing bracket", () => {
42+
const input = '["x",\t\n ]';
43+
expect(JSON.parse(removeTrailingCommas(input))).toEqual(["x"]);
44+
});
45+
46+
// --- already-valid JSON must pass through unchanged -----------------------
47+
48+
it("leaves already-valid JSON untouched", () => {
49+
const valid = '{"plugin": ["@stablekernel/opencode-cursor@latest"]}';
50+
expect(removeTrailingCommas(valid)).toBe(valid);
51+
});
52+
53+
it("leaves an empty array untouched", () => {
54+
expect(removeTrailingCommas("[]")).toBe("[]");
55+
});
56+
57+
it("leaves an empty object untouched", () => {
58+
expect(removeTrailingCommas("{}")).toBe("{}");
59+
});
60+
61+
it("leaves a nested structure with no trailing commas untouched", () => {
62+
const valid = '{"a": [1, 2], "b": {"c": null}}';
63+
expect(removeTrailingCommas(valid)).toBe(valid);
64+
});
65+
66+
// --- string values that contain comma + bracket must NOT be changed -------
67+
68+
it("does not remove a comma that is inside a string value", () => {
69+
// The comma here is part of the string literal, not a trailing comma.
70+
const input = '{"key": "trailing,"}';
71+
expect(JSON.parse(removeTrailingCommas(input))).toEqual({
72+
key: "trailing,",
73+
});
74+
// The raw text must be identical — nothing was removed.
75+
expect(removeTrailingCommas(input)).toBe(input);
76+
});
77+
78+
it("does not remove a comma followed by ] that is inside a string", () => {
79+
// ",]" appears inside the string — must be preserved.
80+
const input = '{"key": ",]"}';
81+
expect(JSON.parse(removeTrailingCommas(input))).toEqual({ key: ",]" });
82+
expect(removeTrailingCommas(input)).toBe(input);
83+
});
84+
85+
it("does not remove a comma followed by } that is inside a string", () => {
86+
const input = '{"key": ",}"}';
87+
expect(JSON.parse(removeTrailingCommas(input))).toEqual({ key: ",}" });
88+
expect(removeTrailingCommas(input)).toBe(input);
89+
});
90+
91+
it("handles an escaped quote inside a string without confusing string-context tracking", () => {
92+
// The \" inside the string must not end string context prematurely.
93+
const input = '{"msg": "say \\"hi,\\"",}';
94+
const result = removeTrailingCommas(input);
95+
expect(JSON.parse(result)).toEqual({ msg: 'say "hi,"' });
96+
});
97+
98+
// --- deeply nested --------------------------------------------------------
99+
100+
it("removes trailing commas at every nesting level", () => {
101+
const input = `{
102+
"models": [
103+
"cursor/gpt-4o",
104+
"cursor/claude-3-5-sonnet",
105+
],
106+
"settings": {
107+
"theme": "dark",
108+
"fontSize": 14,
109+
},
110+
}`;
111+
const parsed = JSON.parse(removeTrailingCommas(input));
112+
expect(parsed).toEqual({
113+
models: ["cursor/gpt-4o", "cursor/claude-3-5-sonnet"],
114+
settings: { theme: "dark", fontSize: 14 },
115+
});
116+
});
117+
});
118+
119+
// ---------------------------------------------------------------------------
120+
// scan
121+
// ---------------------------------------------------------------------------
122+
123+
describe("scan", () => {
124+
it("passes plain JSON through unchanged", () => {
125+
const s = '{"a": 1}';
126+
expect(scan(s).out).toBe(s);
127+
});
128+
129+
it("removes a single-line comment", () => {
130+
const s = '{"a": 1} // comment';
131+
expect(scan(s).out).toBe('{"a": 1} ');
132+
});
133+
134+
it("removes a single-line comment on its own line", () => {
135+
const s = '// top-level comment\n{"a": 1}';
136+
expect(scan(s).out).toBe('\n{"a": 1}');
137+
});
138+
139+
it("removes a block comment", () => {
140+
const s = '{"a": /* inline */ 1}';
141+
expect(scan(s).out).toBe('{"a": 1}');
142+
});
143+
144+
it("preserves // inside a string literal", () => {
145+
const s = '{"url": "https://example.com"}';
146+
expect(scan(s).out).toBe(s);
147+
});
148+
149+
it("preserves /* */ inside a string literal", () => {
150+
const s = '{"k": "/* not a comment */"}';
151+
expect(scan(s).out).toBe(s);
152+
});
153+
154+
it("strips a multi-line block comment spanning multiple lines", () => {
155+
const s = `{
156+
/* this is a
157+
block comment */
158+
"a": 1
159+
}`;
160+
const stripped = scan(s).out;
161+
expect(JSON.parse(stripped)).toEqual({ a: 1 });
162+
});
163+
164+
it("produces a map with the same length as the output", () => {
165+
const s = '{"a": 1} // tail';
166+
const { out, map } = scan(s);
167+
expect(map).toHaveLength(out.length);
168+
});
169+
170+
it("map entries point to the correct raw indices", () => {
171+
// After stripping "// tail", the last non-whitespace output char is '}'.
172+
// In the raw string that's index 7.
173+
const s = '{"a": 1} // tail';
174+
const { out, map } = scan(s);
175+
const closingBrace = out.indexOf("}");
176+
const rawIdx = map[closingBrace]!;
177+
expect(s[rawIdx]).toBe("}");
178+
});
179+
});
180+
181+
// ---------------------------------------------------------------------------
182+
// Full pipeline: scan → removeTrailingCommas → JSON.parse
183+
// This mirrors what install.sh does when processing a real opencode.jsonc.
184+
// ---------------------------------------------------------------------------
185+
186+
describe("scan + removeTrailingCommas pipeline", () => {
187+
it("parses a realistic opencode.jsonc with comments and trailing commas", () => {
188+
const jsonc = `{
189+
// opencode configuration
190+
"$schema": "https://opencode.ai/config.json",
191+
"plugin": [
192+
// cursor provider plugin
193+
"@stablekernel/opencode-cursor@latest",
194+
],
195+
"model": "cursor/claude-3-5-sonnet", /* default model */
196+
}`;
197+
const { out } = scan(jsonc);
198+
const parsed = JSON.parse(removeTrailingCommas(out));
199+
expect(parsed).toEqual({
200+
$schema: "https://opencode.ai/config.json",
201+
plugin: ["@stablekernel/opencode-cursor@latest"],
202+
model: "cursor/claude-3-5-sonnet",
203+
});
204+
});
205+
206+
it("parses JSONC where a string value contains comment-like text", () => {
207+
const jsonc = `{
208+
"note": "see https://example.com/docs /* not a comment */",
209+
"value": 42, // trailing comma here
210+
}`;
211+
const { out } = scan(jsonc);
212+
const parsed = JSON.parse(removeTrailingCommas(out));
213+
expect(parsed).toEqual({
214+
note: "see https://example.com/docs /* not a comment */",
215+
value: 42,
216+
});
217+
});
218+
219+
it("reproduces the exact error case from the bug report: trailing comma after last array element", () => {
220+
// Simulates the auto-generated opencode.jsonc that caused the install failure.
221+
const jsonc = `{
222+
"$schema": "https://opencode.ai/config.json",
223+
"plugin": [
224+
"atest",
225+
],
226+
"agen": {}
227+
}`;
228+
const { out } = scan(jsonc);
229+
const parsed = JSON.parse(removeTrailingCommas(out));
230+
expect(parsed.plugin).toEqual(["atest"]);
231+
});
232+
});

0 commit comments

Comments
 (0)