From 00ae89c5e291e9dd32777e27aab03712257ad81d Mon Sep 17 00:00:00 2001 From: iHildy Date: Wed, 31 Dec 2025 17:30:23 -0600 Subject: [PATCH 1/2] feat: support trailing commas in config and improve error handling --- .github/workflows/opencode-smoke.yml | 16 +++++++++ opencode.json | 4 +++ package.json | 8 ++--- src/sync/config.test.ts | 30 ++++++++++++++++- src/sync/config.ts | 49 +++++++++++++++++++++++++++- src/sync/service.ts | 14 +++++++- src/sync/utils.ts | 10 ++++-- 7 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 opencode.json diff --git a/.github/workflows/opencode-smoke.yml b/.github/workflows/opencode-smoke.yml index 271076f..8fc16fc 100644 --- a/.github/workflows/opencode-smoke.yml +++ b/.github/workflows/opencode-smoke.yml @@ -69,6 +69,22 @@ jobs: } EOF + SYNC_REPO="$XDG_DATA_HOME/opencode-synced/repo" + mkdir -p "$SYNC_REPO" + git -C "$SYNC_REPO" init -q + + cat > "$XDG_CONFIG_HOME/opencode/opencode-synced.jsonc" < { it('merges nested objects and replaces arrays', () => { @@ -70,3 +76,25 @@ describe('canCommitMcpSecrets', () => { expect(canCommitMcpSecrets({ includeSecrets: true, includeMcpSecrets: true })).toBe(true); }); }); + +describe('parseJsonc', () => { + it('parses JSONC with comments and trailing commas', () => { + const input = `{ + // comment + "repo": { + "owner": "me", + "name": "opencode-config", + }, + "includeSecrets": false, + "extraSecretPaths": [ + "foo", + ], + }`; + + expect(parseJsonc(input)).toEqual({ + repo: { owner: 'me', name: 'opencode-config' }, + includeSecrets: false, + extraSecretPaths: ['foo'], + }); + }); +}); diff --git a/src/sync/config.ts b/src/sync/config.ts index 7cdeb11..b020810 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -161,7 +161,7 @@ export function stripOverrides( } export function parseJsonc(content: string): T { - const stripped = stripJsonComments(content); + const stripped = stripTrailingCommas(stripJsonComments(content)); return JSON.parse(stripped) as T; } @@ -253,3 +253,50 @@ function stripJsonComments(input: string): string { return output; } + +function stripTrailingCommas(input: string): string { + let output = ''; + let inString = false; + let escapeNext = false; + + for (let i = 0; i < input.length; i += 1) { + const current = input[i]; + + if (inString) { + output += current; + if (escapeNext) { + escapeNext = false; + continue; + } + if (current === '\\') { + escapeNext = true; + continue; + } + if (current === '"') { + inString = false; + } + continue; + } + + if (current === '"') { + inString = true; + output += current; + continue; + } + + if (current === ',') { + let nextIndex = i + 1; + while (nextIndex < input.length && /\s/.test(input[nextIndex])) { + nextIndex += 1; + } + const next = input[nextIndex]; + if (next === '}' || next === ']') { + continue; + } + } + + output += current; + } + + return output; +} diff --git a/src/sync/service.ts b/src/sync/service.ts index 2a97593..d4712f6 100644 --- a/src/sync/service.ts +++ b/src/sync/service.ts @@ -79,7 +79,19 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { return { startupSync: async () => { - const config = await loadSyncConfig(locations); + let config: ReturnType | null = null; + try { + config = await loadSyncConfig(locations); + } catch (error) { + const message = `Failed to load opencode-synced config: ${formatError(error)}`; + log.error(message, { path: locations.syncConfigPath }); + await showToast( + ctx.client, + `Failed to load opencode-synced config. Check ${locations.syncConfigPath} for JSON errors.`, + 'error' + ); + return; + } if (!config) { await showToast( ctx.client, diff --git a/src/sync/utils.ts b/src/sync/utils.ts index a6b0a4d..5b1af50 100644 --- a/src/sync/utils.ts +++ b/src/sync/utils.ts @@ -43,9 +43,13 @@ export async function showToast( message: string, variant: 'info' | 'success' | 'warning' | 'error' ): Promise { - await client.tui.showToast({ - body: { title: 'opencode-synced plugin', message, variant }, - }); + try { + await client.tui.showToast({ + body: { title: 'opencode-synced plugin', message, variant }, + }); + } catch { + // Ignore toast failures (e.g. headless mode or early startup). + } } export function unwrapData(response: unknown): T | null { From a87747f7857edff2dc1d0b3cf57d29f2e4669996 Mon Sep 17 00:00:00 2001 From: iHildy Date: Wed, 31 Dec 2025 17:38:51 -0600 Subject: [PATCH 2/2] refactor: combine JSONC comment and trailing comma stripping into a single pass --- opencode.json | 5 +-- src/sync/config.ts | 101 ++++++++++++++------------------------------- 2 files changed, 31 insertions(+), 75 deletions(-) diff --git a/opencode.json b/opencode.json index 56601c0..0967ef4 100644 --- a/opencode.json +++ b/opencode.json @@ -1,4 +1 @@ -{ - "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-synced@file:/Users/ihildy/Documents/Personal/opencode-sync"] -} +{} diff --git a/src/sync/config.ts b/src/sync/config.ts index b020810..7179414 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -161,42 +161,15 @@ export function stripOverrides( } export function parseJsonc(content: string): T { - const stripped = stripTrailingCommas(stripJsonComments(content)); - return JSON.parse(stripped) as T; -} - -export async function writeJsonFile( - filePath: string, - data: unknown, - options: { jsonc: boolean; mode?: number } = { jsonc: false } -): Promise { - const json = JSON.stringify(data, null, 2); - const content = options.jsonc ? `// Generated by opencode-synced\n${json}\n` : `${json}\n`; - await fs.writeFile(filePath, content, 'utf8'); - if (options.mode !== undefined) { - await fs.chmod(filePath, options.mode); - } -} - -export function isPlainObject(value: unknown): value is Record { - if (!value || typeof value !== 'object') return false; - return Object.getPrototypeOf(value) === Object.prototype; -} - -export function hasOwn(target: Record, key: string): boolean { - return Object.hasOwn(target, key); -} - -function stripJsonComments(input: string): string { let output = ''; let inString = false; let inSingleLine = false; let inMultiLine = false; let escapeNext = false; - for (let i = 0; i < input.length; i += 1) { - const current = input[i]; - const next = input[i + 1]; + for (let i = 0; i < content.length; i += 1) { + const current = content[i]; + const next = content[i + 1]; if (inSingleLine) { if (current === '\n') { @@ -230,7 +203,7 @@ function stripJsonComments(input: string): string { continue; } - if (current === '"' && !inString) { + if (current === '"') { inString = true; output += current; continue; @@ -248,49 +221,13 @@ function stripJsonComments(input: string): string { continue; } - output += current; - } - - return output; -} - -function stripTrailingCommas(input: string): string { - let output = ''; - let inString = false; - let escapeNext = false; - - for (let i = 0; i < input.length; i += 1) { - const current = input[i]; - - if (inString) { - output += current; - if (escapeNext) { - escapeNext = false; - continue; - } - if (current === '\\') { - escapeNext = true; - continue; - } - if (current === '"') { - inString = false; - } - continue; - } - - if (current === '"') { - inString = true; - output += current; - continue; - } - if (current === ',') { let nextIndex = i + 1; - while (nextIndex < input.length && /\s/.test(input[nextIndex])) { + while (nextIndex < content.length && /\s/.test(content[nextIndex])) { nextIndex += 1; } - const next = input[nextIndex]; - if (next === '}' || next === ']') { + const nextChar = content[nextIndex]; + if (nextChar === '}' || nextChar === ']') { continue; } } @@ -298,5 +235,27 @@ function stripTrailingCommas(input: string): string { output += current; } - return output; + return JSON.parse(output) as T; +} + +export async function writeJsonFile( + filePath: string, + data: unknown, + options: { jsonc: boolean; mode?: number } = { jsonc: false } +): Promise { + const json = JSON.stringify(data, null, 2); + const content = options.jsonc ? `// Generated by opencode-synced\n${json}\n` : `${json}\n`; + await fs.writeFile(filePath, content, 'utf8'); + if (options.mode !== undefined) { + await fs.chmod(filePath, options.mode); + } +} + +export function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object') return false; + return Object.getPrototypeOf(value) === Object.prototype; +} + +export function hasOwn(target: Record, key: string): boolean { + return Object.hasOwn(target, key); }