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..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 = 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,8 +221,41 @@ function stripJsonComments(input: string): string { continue; } + if (current === ',') { + let nextIndex = i + 1; + while (nextIndex < content.length && /\s/.test(content[nextIndex])) { + nextIndex += 1; + } + const nextChar = content[nextIndex]; + if (nextChar === '}' || nextChar === ']') { + continue; + } + } + 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); } 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 {