From 0a3f76c0a8aaf9ca41b032ea077cf1184950f514 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 11 Jul 2026 17:45:27 -0700 Subject: [PATCH] feat(sync): add sync-hosts store, NDJSON --json mode, and --check to subminer sync --- launcher/commands/playback-command.test.ts | 3 + launcher/commands/sync-command.test.ts | 202 +++++++++++++++++++++ launcher/commands/sync-command.ts | 169 +++++++++++++++-- launcher/config/args-normalizer.test.ts | 16 ++ launcher/config/args-normalizer.ts | 9 + launcher/config/cli-parser-builder.test.ts | 33 ++++ launcher/config/cli-parser-builder.ts | 32 ++++ launcher/jellyfin.test.ts | 3 + launcher/mpv.test.ts | 3 + launcher/sync/sync-hosts.ts | 41 +++++ launcher/sync/sync-shared.ts | 18 +- launcher/types.ts | 3 + src/shared/sync/sync-events.test.ts | 29 +++ src/shared/sync/sync-events.ts | 68 +++++++ src/shared/sync/sync-hosts-store.test.ts | 182 +++++++++++++++++++ src/shared/sync/sync-hosts-store.ts | 195 ++++++++++++++++++++ 16 files changed, 979 insertions(+), 27 deletions(-) create mode 100644 launcher/sync/sync-hosts.ts create mode 100644 src/shared/sync/sync-events.test.ts create mode 100644 src/shared/sync/sync-events.ts create mode 100644 src/shared/sync/sync-hosts-store.test.ts create mode 100644 src/shared/sync/sync-hosts-store.ts diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index 050f0b6e..4596f89b 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -44,6 +44,9 @@ function createContext(): LauncherCommandContext { syncRemoteCmd: '', syncDbPath: '', syncForce: false, + syncJson: false, + syncCheck: false, + syncUi: false, logLevel: 'info', logRotation: 7, passwordStore: '', diff --git a/launcher/commands/sync-command.test.ts b/launcher/commands/sync-command.test.ts index 62032c42..ced1a978 100644 --- a/launcher/commands/sync-command.test.ts +++ b/launcher/commands/sync-command.test.ts @@ -36,6 +36,12 @@ function ok(stdout = ''): { status: number; stdout: string; stderr: string } { return { status: 0, stdout, stderr: '' }; } +// Every host-sync test must stub recordHostSyncResult: the default writes the +// real sync-hosts.json in the user's config directory. +const noRecord: Pick = { + recordHostSyncResult: () => {}, +}; + test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', async () => { const context = makeContext({ syncDbPath: '/tmp/local.sqlite' }); context.mpvSocketPath = '/tmp/subminer-socket'; @@ -61,6 +67,7 @@ test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', as test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', async () => { const calls: string[] = []; const deps: Partial = { + ...noRecord, createDbSnapshot: (dbPath: string, outPath: string) => { calls.push(`snapshot:${dbPath}->${outPath}`); }, @@ -120,6 +127,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle const calls: string[] = []; let localTmpDir = ''; const deps: Partial = { + ...noRecord, createDbSnapshot: (_dbPath: string, outPath: string) => { calls.push(`snapshot:${outPath}`); fs.writeFileSync(outPath, 'snapshot'); @@ -167,6 +175,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle test('runHostSync includes remote snapshot stderr in failures', async () => { const deps: Partial = { + ...noRecord, createDbSnapshot: (_dbPath: string, outPath: string) => { fs.writeFileSync(outPath, 'snapshot'); }, @@ -192,6 +201,7 @@ test('runHostSync includes remote snapshot stderr in failures', async () => { function makeDirectionDeps(calls: string[]): Partial { return { + ...noRecord, createDbSnapshot: (_dbPath: string, outPath: string) => { calls.push(`snapshot:${outPath}`); fs.writeFileSync(outPath, 'snapshot'); @@ -255,3 +265,195 @@ test('runHostSync pull only snapshots remotely and merges locally', async () => assert.ok(!calls.some((call) => call.startsWith('snapshot:'))); assert.ok(!calls.some((call) => call.includes(' sync --merge '))); }); + +test('runSyncCommand --json emits NDJSON progress events for a two-way host sync', async () => { + const lines: string[] = []; + const deps: Partial = { + ...makeDirectionDeps([]), + consoleLog: (line: string) => { + lines.push(line); + }, + writeStdout: ((chunk: string) => { + lines.push(`stdout:${chunk}`); + return true; + }) as unknown as typeof process.stdout.write, + runSsh: (_host: string, command: string) => { + if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n'); + if (command.includes(' sync --merge ')) return ok('remote summary text\n'); + return ok(); + }, + recordHostSyncResult: () => {}, + }; + + await runSyncCommand( + makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncJson: true }), + deps, + ); + + const events = lines.map((line) => JSON.parse(line)); + const types = events.map((event) => event.type); + assert.ok(types.includes('stage')); + assert.ok( + events.some((event) => event.type === 'stage' && event.stage === 'snapshot-local'), + ); + assert.ok( + events.some((event) => event.type === 'merge-summary' && event.target === 'local'), + ); + assert.ok( + events.some( + (event) => event.type === 'remote-output' && event.text.includes('remote summary text'), + ), + ); + const last = events[events.length - 1]; + assert.deepEqual(last, { type: 'result', ok: true, error: null }); + assert.ok(!lines.some((line) => line.startsWith('stdout:'))); +}); + +test('runSyncCommand --json emits an error result when the sync fails', async () => { + const lines: string[] = []; + const deps: Partial = { + ...makeDirectionDeps([]), + consoleLog: (line: string) => { + lines.push(line); + }, + writeStdout: (() => true) as unknown as typeof process.stdout.write, + runSsh: (_host: string, command: string) => { + if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n'); + if (command.includes(' sync --merge ')) { + return { status: 9, stdout: '', stderr: 'remote merge exploded' }; + } + return ok(); + }, + recordHostSyncResult: () => {}, + }; + + await assert.rejects(() => + runSyncCommand( + makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncJson: true }), + deps, + ), + ); + + const events = lines.map((line) => JSON.parse(line)); + const last = events[events.length - 1]; + assert.equal(last.type, 'result'); + assert.equal(last.ok, false); + assert.match(last.error, /Remote merge failed/); +}); + +test('runSyncCommand records host sync results for saved-host bookkeeping', async () => { + const recorded: Array<{ host: string; status: string; detail: string | null }> = []; + const baseDeps = makeDirectionDeps([]); + const deps: Partial = { + ...baseDeps, + recordHostSyncResult: (host: string, status: 'success' | 'error', detail: string | null) => { + recorded.push({ host, status, detail }); + }, + }; + + await runSyncCommand( + makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), + deps, + ); + assert.equal(recorded.length, 1); + assert.equal(recorded[0]!.host, 'media-box'); + assert.equal(recorded[0]!.status, 'success'); + + const failingDeps: Partial = { + ...deps, + runSsh: (_host: string, command: string) => { + if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n'); + if (command.includes(' sync --merge ')) { + return { status: 9, stdout: '', stderr: 'boom' }; + } + return ok(); + }, + }; + await assert.rejects(() => + runSyncCommand( + makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), + failingDeps, + ), + ); + assert.equal(recorded.length, 2); + assert.equal(recorded[1]!.status, 'error'); +}); + +test('runSyncCommand --check --json reports ssh and remote launcher status', async () => { + const lines: string[] = []; + const deps: Partial = { + ensureTrackerQuiescent: async () => {}, + assertSafeSshHost: () => {}, + resolveRemoteSubminerCommand: () => 'subminer', + consoleLog: (line: string) => { + lines.push(line); + }, + runSsh: (_host: string, command: string) => { + if (command.includes('--version')) return ok('SubMiner 0.18.0\n'); + return ok('subminer-check-ok\n'); + }, + fail: (message: string): never => { + throw new Error(message); + }, + }; + + assert.equal( + await runSyncCommand( + makeContext({ + syncDbPath: '/tmp/local.sqlite', + syncHost: 'media-box', + syncCheck: true, + syncJson: true, + }), + deps, + ), + true, + ); + + const events = lines.map((line) => JSON.parse(line)); + const check = events.find((event) => event.type === 'check-result'); + assert.ok(check); + assert.equal(check.host, 'media-box'); + assert.equal(check.sshOk, true); + assert.equal(check.remoteCommand, 'subminer'); + assert.equal(check.remoteVersion, 'SubMiner 0.18.0'); + assert.equal(check.ok, true); + assert.equal(check.error, null); +}); + +test('runSyncCommand --check --json reports failures without throwing early', async () => { + const lines: string[] = []; + const deps: Partial = { + ensureTrackerQuiescent: async () => {}, + assertSafeSshHost: () => {}, + resolveRemoteSubminerCommand: () => { + throw new Error('Could not find a runnable "subminer" on media-box.'); + }, + consoleLog: (line: string) => { + lines.push(line); + }, + runSsh: () => ok('subminer-check-ok\n'), + fail: (message: string): never => { + throw new Error(message); + }, + }; + + await assert.rejects(() => + runSyncCommand( + makeContext({ + syncDbPath: '/tmp/local.sqlite', + syncHost: 'media-box', + syncCheck: true, + syncJson: true, + }), + deps, + ), + ); + + const events = lines.map((line) => JSON.parse(line)); + const check = events.find((event) => event.type === 'check-result'); + assert.ok(check); + assert.equal(check.sshOk, true); + assert.equal(check.ok, false); + assert.match(check.error, /Could not find a runnable/); +}); diff --git a/launcher/commands/sync-command.ts b/launcher/commands/sync-command.ts index f6ee81bf..2e6acc46 100644 --- a/launcher/commands/sync-command.ts +++ b/launcher/commands/sync-command.ts @@ -18,8 +18,11 @@ import { } from '../sync/ssh.js'; import { resolvePathMaybe } from '../util.js'; import { canConnectUnixSocket } from '../mpv.js'; +import { recordHostSyncResultToDisk } from '../sync/sync-hosts.js'; import type { LauncherCommandContext } from './context.js'; import type { RemoteRunResult } from '../sync/ssh.js'; +import type { SyncMergeSummary, SyncProgressEvent } from '../../src/shared/sync/sync-events.js'; +import type { SyncResultStatus } from '../../src/shared/sync/sync-hosts-store.js'; export interface SyncCommandDeps { createDbSnapshot: typeof createDbSnapshot; @@ -39,6 +42,8 @@ export interface SyncCommandDeps { consoleLog: typeof console.log; writeStdout: typeof process.stdout.write; ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => Promise; + emitEvent: (event: SyncProgressEvent) => void; + recordHostSyncResult: (host: string, status: SyncResultStatus, detail: string | null) => void; } function resolveDbPath(context: LauncherCommandContext): string { @@ -96,12 +101,26 @@ const defaultSyncCommandDeps: SyncCommandDeps = { consoleLog: console.log, writeStdout: process.stdout.write.bind(process.stdout), ensureTrackerQuiescent: async (context, dbPath) => ensureTrackerQuiescent(context, dbPath), + emitEvent: () => {}, + recordHostSyncResult: recordHostSyncResultToDisk, }; function resolveSyncCommandDeps(inputDeps: Partial = {}): SyncCommandDeps { return { ...defaultSyncCommandDeps, ...inputDeps }; } +// In --json mode every line on stdout is an NDJSON event: human console output +// is silenced and events are written through the original console logger. +function withJsonEvents(deps: SyncCommandDeps): SyncCommandDeps { + const writeLine = deps.consoleLog; + return { + ...deps, + consoleLog: () => {}, + writeStdout: (() => true) as typeof process.stdout.write, + emitEvent: (event) => writeLine(JSON.stringify(event)), + }; +} + export function runSnapshotMode( context: LauncherCommandContext, dbPath: string, @@ -109,7 +128,13 @@ export function runSnapshotMode( ): void { const deps = resolveSyncCommandDeps(inputDeps); const outPath = resolvePathMaybe(context.args.syncSnapshotPath); + deps.emitEvent({ + type: 'stage', + stage: 'snapshot-local', + message: `Snapshotting local database (${dbPath})`, + }); deps.createDbSnapshot(dbPath, outPath); + deps.emitEvent({ type: 'snapshot-created', path: outPath }); deps.consoleLog(outPath); } @@ -121,10 +146,73 @@ export async function runMergeMode( const deps = resolveSyncCommandDeps(inputDeps); await deps.ensureTrackerQuiescent(context, dbPath); const snapshotPath = resolvePathMaybe(context.args.syncMergePath); + deps.emitEvent({ + type: 'stage', + stage: 'merge-local', + message: `Merging ${snapshotPath} into the local database`, + }); const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath); + deps.emitEvent({ type: 'merge-summary', target: 'local', summary }); deps.consoleLog(deps.formatMergeSummary(summary)); } +function formatHostSyncDetail( + direction: 'both' | 'push' | 'pull', + pulledSummary: SyncMergeSummary | null, +): string { + if (!pulledSummary) return direction === 'push' ? 'Pushed local stats' : 'Sync complete'; + const merged = `${pulledSummary.sessionsMerged} session${pulledSummary.sessionsMerged === 1 ? '' : 's'} merged`; + return direction === 'pull' ? merged : `${merged}; pushed local stats`; +} + +export async function runCheckMode( + context: LauncherCommandContext, + inputDeps: Partial = {}, +): Promise { + const deps = resolveSyncCommandDeps(inputDeps); + const { args } = context; + const host = args.syncHost; + deps.assertSafeSshHost(host); + + deps.consoleLog(`Checking SSH connection to ${host}...`); + let remoteCommand: string | null = null; + let remoteVersion: string | null = null; + let error: string | null = null; + + const probe = deps.runSsh(host, 'echo subminer-check-ok'); + const sshOk = probe.status === 0 && probe.stdout.includes('subminer-check-ok'); + if (!sshOk) { + error = formatRemoteRunError(`Could not reach ${host} over SSH.`, probe); + } else { + deps.consoleLog('SSH connection: ok'); + try { + remoteCommand = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null); + const version = deps.runSsh(host, `${remoteCommand} --version`); + remoteVersion = version.status === 0 ? version.stdout.trim() || null : null; + deps.consoleLog( + `Remote subminer: ${remoteCommand}${remoteVersion ? ` (${remoteVersion})` : ''}`, + ); + } catch (resolveError) { + error = resolveError instanceof Error ? resolveError.message : String(resolveError); + } + } + + const ok = sshOk && remoteCommand !== null; + deps.emitEvent({ + type: 'check-result', + host, + sshOk, + remoteCommand, + remoteVersion, + ok, + error, + }); + if (!ok) { + throw new Error(error ?? `Connection check failed for ${host}.`); + } + deps.consoleLog('Check passed.'); +} + function cleanupRemote(host: string, remoteTmpDir: string, deps: SyncCommandDeps): void { if (!remoteTmpDir.startsWith('/tmp/')) return; deps.runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`); @@ -155,6 +243,7 @@ export async function runHostSync( const localTmpDir = deps.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-')); let remoteTmpDir = ''; + let pulledSummary: SyncMergeSummary | null = null; try { // Signal failures by throwing (not fail(), which exits synchronously and // would skip the finally cleanup, leaking temp dirs holding snapshot data). @@ -170,12 +259,18 @@ export async function runHostSync( const localSnapshot = path.join(localTmpDir, 'local.sqlite'); if (shouldPush) { deps.consoleLog(`Snapshotting local database (${dbPath})...`); + deps.emitEvent({ + type: 'stage', + stage: 'snapshot-local', + message: `Snapshotting local database (${dbPath})`, + }); deps.createDbSnapshot(dbPath, localSnapshot); } const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`; if (shouldPull) { deps.consoleLog(`Snapshotting ${host}...`); + deps.emitEvent({ type: 'stage', stage: 'snapshot-remote', message: `Snapshotting ${host}` }); const snapshotRun = deps.runSsh( host, `${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`, @@ -186,25 +281,50 @@ export async function runHostSync( } const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite'); - if (shouldPull) deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot); + if (shouldPull) { + deps.emitEvent({ + type: 'stage', + stage: 'download', + message: `Copying snapshot from ${host}`, + }); + deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot); + } const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`; - if (shouldPush) deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`); + if (shouldPush) { + deps.emitEvent({ type: 'stage', stage: 'upload', message: `Copying snapshot to ${host}` }); + deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`); + } if (shouldPull) { deps.consoleLog(`\nMerging ${host} -> local:`); + deps.emitEvent({ + type: 'stage', + stage: 'merge-local', + message: `Merging ${host} into the local database`, + }); await deps.ensureTrackerQuiescent(context, dbPath); const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot); + pulledSummary = summary; + deps.emitEvent({ type: 'merge-summary', target: 'local', summary }); deps.consoleLog(deps.formatMergeSummary(summary)); } if (shouldPush) { deps.consoleLog(`\nMerging local -> ${host}:`); + deps.emitEvent({ + type: 'stage', + stage: 'merge-remote', + message: `Merging the local database into ${host}`, + }); await deps.ensureTrackerQuiescent(context, dbPath); const mergeRun = deps.runSsh( host, `${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`, ); deps.writeStdout(mergeRun.stdout); + if (mergeRun.stdout.trim()) { + deps.emitEvent({ type: 'remote-output', text: mergeRun.stdout }); + } if (mergeRun.status !== 0) { const retryCommand = direction === 'push' ? `subminer sync ${host} --push` : `subminer sync ${host}`; @@ -219,6 +339,18 @@ export async function runHostSync( } deps.consoleLog('\nSync complete.'); + deps.recordHostSyncResult(host, 'success', formatHostSyncDetail(direction, pulledSummary)); + } catch (error) { + try { + deps.recordHostSyncResult( + host, + 'error', + error instanceof Error ? error.message : String(error), + ); + } catch { + // best effort + } + throw error; } finally { deps.rmSync(localTmpDir, { recursive: true, force: true }); if (remoteTmpDir) { @@ -235,19 +367,34 @@ export async function runSyncCommand( context: LauncherCommandContext, inputDeps: Partial = {}, ): Promise { - const deps = resolveSyncCommandDeps(inputDeps); + let deps = resolveSyncCommandDeps(inputDeps); const { args } = context; if (!args.sync) return false; + if (args.syncJson) deps = withJsonEvents(deps); const dbPath = resolveDbPath(context); - if (args.syncSnapshotPath) { - runSnapshotMode(context, dbPath, deps); - } else if (args.syncMergePath) { - await runMergeMode(context, dbPath, deps); - } else if (args.syncHost) { - await runHostSync(context, dbPath, deps); - } else { - deps.fail('sync requires a host, --snapshot , or --merge .'); + try { + if (args.syncCheck) { + await runCheckMode(context, deps); + } else if (args.syncSnapshotPath) { + runSnapshotMode(context, dbPath, deps); + } else if (args.syncMergePath) { + await runMergeMode(context, dbPath, deps); + } else if (args.syncHost) { + await runHostSync(context, dbPath, deps); + } else { + deps.fail('sync requires a host, --snapshot , or --merge .'); + } + } catch (error) { + if (args.syncJson) { + deps.emitEvent({ + type: 'result', + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + throw error; } + if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null }); return true; } diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts index b02b8ca6..9037a45b 100644 --- a/launcher/config/args-normalizer.test.ts +++ b/launcher/config/args-normalizer.test.ts @@ -143,7 +143,11 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { syncRemoteCmd: null, syncDbPath: null, syncForce: false, + syncJson: false, + syncCheck: false, syncLogLevel: null, + syncUiTriggered: false, + syncUiLogLevel: null, doctorTriggered: false, doctorLogLevel: null, doctorRefreshKnownWords: false, @@ -198,7 +202,11 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () => syncRemoteCmd: null, syncDbPath: null, syncForce: false, + syncJson: false, + syncCheck: false, syncLogLevel: null, + syncUiTriggered: false, + syncUiLogLevel: null, doctorTriggered: false, doctorLogLevel: null, doctorRefreshKnownWords: false, @@ -246,7 +254,11 @@ test('applyInvocationsToArgs fails when config invocation has no action', () => syncRemoteCmd: null, syncDbPath: null, syncForce: false, + syncJson: false, + syncCheck: false, syncLogLevel: null, + syncUiTriggered: false, + syncUiLogLevel: null, doctorTriggered: false, doctorLogLevel: null, doctorRefreshKnownWords: false, @@ -292,7 +304,11 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => { syncRemoteCmd: null, syncDbPath: null, syncForce: false, + syncJson: false, + syncCheck: false, syncLogLevel: null, + syncUiTriggered: false, + syncUiLogLevel: null, doctorTriggered: false, doctorLogLevel: null, doctorRefreshKnownWords: false, diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 395d9544..7d938ec3 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -207,6 +207,9 @@ export function createDefaultArgs( syncRemoteCmd: '', syncDbPath: '', syncForce: false, + syncJson: false, + syncCheck: false, + syncUi: false, logLevel: loggingConfig.level ?? 'warn', logRotation: loggingConfig.rotation ?? 7, passwordStore: '', @@ -281,8 +284,14 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? ''; parsed.syncDbPath = invocations.syncDbPath ?? ''; parsed.syncForce = invocations.syncForce; + parsed.syncJson = invocations.syncJson; + parsed.syncCheck = invocations.syncCheck; if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel); } + if (invocations.syncUiTriggered) { + parsed.syncUi = true; + if (invocations.syncUiLogLevel) parsed.logLevel = parseLogLevel(invocations.syncUiLogLevel); + } if (invocations.doctorTriggered) parsed.doctor = true; if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true; if (invocations.logsTriggered && !invocations.logsExport) { diff --git a/launcher/config/cli-parser-builder.test.ts b/launcher/config/cli-parser-builder.test.ts index 83b632d3..052d4266 100644 --- a/launcher/config/cli-parser-builder.test.ts +++ b/launcher/config/cli-parser-builder.test.ts @@ -61,3 +61,36 @@ test('parseCliPrograms rejects conflicting or hostless one-way sync directions', /--push and --pull require a host/, ); }); + +test('parseCliPrograms captures sync --json and --check flags', () => { + const json = parseCliPrograms(['sync', 'media-box', '--json'], 'subminer'); + assert.equal(json.invocations.syncJson, true); + assert.equal(json.invocations.syncCheck, false); + + const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer'); + assert.equal(check.invocations.syncCheck, true); + assert.equal(check.invocations.syncJson, true); + assert.equal(check.invocations.syncHost, 'media-box'); +}); + +test('parseCliPrograms rejects invalid sync --check combinations', () => { + assert.throws( + () => parseCliPrograms(['sync', '--check'], 'subminer'), + /--check requires a host/, + ); + assert.throws( + () => parseCliPrograms(['sync', 'media-box', '--check', '--push'], 'subminer'), + /--check cannot be combined/, + ); +}); + +test('parseCliPrograms captures sync --ui', () => { + const result = parseCliPrograms(['sync', '--ui'], 'subminer'); + assert.equal(result.invocations.syncUiTriggered, true); + assert.equal(result.invocations.syncTriggered, false); + + assert.throws( + () => parseCliPrograms(['sync', 'media-box', '--ui'], 'subminer'), + /--ui cannot be combined/, + ); +}); diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 66184cf0..da5671ad 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -46,7 +46,11 @@ export interface CliInvocations { syncRemoteCmd: string | null; syncDbPath: string | null; syncForce: boolean; + syncJson: boolean; + syncCheck: boolean; syncLogLevel: string | null; + syncUiTriggered: boolean; + syncUiLogLevel: string | null; doctorTriggered: boolean; doctorLogLevel: string | null; doctorRefreshKnownWords: boolean; @@ -178,7 +182,11 @@ export function parseCliPrograms( let syncRemoteCmd: string | null = null; let syncDbPath: string | null = null; let syncForce = false; + let syncJson = false; + let syncCheck = false; let syncLogLevel: string | null = null; + let syncUiTriggered = false; + let syncUiLogLevel: string | null = null; let doctorLogLevel: string | null = null; let doctorRefreshKnownWords = false; let logsTriggered = false; @@ -319,6 +327,9 @@ export function parseCliPrograms( .option('--db ', 'Override the local stats database path') .option('--remote-cmd ', 'subminer command to run on the remote host') .option('-f, --force', 'Skip the running-app safety check') + .option('--check', 'Test the SSH connection and remote subminer availability') + .option('--json', 'Emit machine-readable NDJSON progress output') + .option('--ui', 'Open the SubMiner sync window') .option('--log-level ', 'Log level') .action((rawHost: string | undefined, options: Record) => { const host = typeof rawHost === 'string' ? rawHost.trim() : ''; @@ -326,12 +337,27 @@ export function parseCliPrograms( const merge = typeof options.merge === 'string' ? options.merge.trim() : ''; const push = options.push === true; const pull = options.pull === true; + const check = options.check === true; + if (options.ui === true) { + if (host || snapshot || merge || push || pull || check || options.force === true) { + throw new Error('Sync --ui cannot be combined with other sync options.'); + } + syncUiTriggered = true; + syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; + return; + } if (push && pull) { throw new Error('Sync --push and --pull cannot be combined.'); } if ((push || pull) && !host) { throw new Error('Sync --push and --pull require a host.'); } + if (check && !host) { + throw new Error('Sync --check requires a host.'); + } + if (check && (push || pull || snapshot || merge)) { + throw new Error('Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.'); + } const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length; if (modes === 0) { throw new Error('Sync requires a host, --snapshot , or --merge .'); @@ -348,6 +374,8 @@ export function parseCliPrograms( typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null; syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null; syncForce = options.force === true; + syncJson = options.json === true; + syncCheck = check; syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; }); @@ -470,7 +498,11 @@ export function parseCliPrograms( syncRemoteCmd, syncDbPath, syncForce, + syncJson, + syncCheck, syncLogLevel, + syncUiTriggered, + syncUiLogLevel, doctorTriggered, doctorLogLevel, doctorRefreshKnownWords, diff --git a/launcher/jellyfin.test.ts b/launcher/jellyfin.test.ts index 86244673..4b52cb69 100644 --- a/launcher/jellyfin.test.ts +++ b/launcher/jellyfin.test.ts @@ -38,6 +38,9 @@ function createArgs(): Args { syncRemoteCmd: '', syncDbPath: '', syncForce: false, + syncJson: false, + syncCheck: false, + syncUi: false, logLevel: 'info', logRotation: 7, passwordStore: '', diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 8cccfc68..2738eee0 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -579,6 +579,9 @@ function makeArgs(overrides: Partial = {}): Args { syncRemoteCmd: '', syncDbPath: '', syncForce: false, + syncJson: false, + syncCheck: false, + syncUi: false, logLevel: 'error', logRotation: 7, passwordStore: '', diff --git a/launcher/sync/sync-hosts.ts b/launcher/sync/sync-hosts.ts new file mode 100644 index 00000000..ab6d41a8 --- /dev/null +++ b/launcher/sync/sync-hosts.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import { resolveConfigDir } from '../../src/config/path-resolution.js'; +import { + getSyncHostsPath, + readSyncHostsState, + recordSyncResult, + writeSyncHostsState, + type SyncResultStatus, +} from '../../src/shared/sync/sync-hosts-store.js'; + +export function resolveSyncHostsFilePath(): string { + const configDir = resolveConfigDir({ + platform: process.platform, + appDataDir: process.env.APPDATA, + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + existsSync: fs.existsSync, + }); + return getSyncHostsPath(configDir); +} + +// Best-effort bookkeeping so hosts synced from the CLI show up in the sync UI; +// a failure to persist must never fail the sync itself. +export function recordHostSyncResultToDisk( + host: string, + status: SyncResultStatus, + detail: string | null, +): void { + try { + const filePath = resolveSyncHostsFilePath(); + const state = recordSyncResult(readSyncHostsState(filePath), host, { + atMs: Date.now(), + status, + detail, + }); + writeSyncHostsState(filePath, state); + } catch { + // best effort + } +} diff --git a/launcher/sync/sync-shared.ts b/launcher/sync/sync-shared.ts index 09fd6bb5..43194b39 100644 --- a/launcher/sync/sync-shared.ts +++ b/launcher/sync/sync-shared.ts @@ -8,22 +8,8 @@ import { resolveConfigDir } from '../../src/config/path-resolution.js'; export { SCHEMA_VERSION }; -export interface SyncMergeSummary { - sessionsMerged: number; - sessionsAlreadyPresent: number; - activeSessionsSkipped: number; - animeAdded: number; - videosAdded: number; - wordsAdded: number; - kanjiAdded: number; - subtitleLinesAdded: number; - telemetryRowsAdded: number; - eventsAdded: number; - excludedWordsAdded: number; - dailyRollupsCopied: number; - monthlyRollupsCopied: number; - rollupGroupsRecomputed: number; -} +export type { SyncMergeSummary } from '../../src/shared/sync/sync-events.js'; +import type { SyncMergeSummary } from '../../src/shared/sync/sync-events.js'; export function createEmptyMergeSummary(): SyncMergeSummary { return { diff --git a/launcher/types.ts b/launcher/types.ts index 374b2599..f4f58be3 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -121,6 +121,9 @@ export interface Args { syncRemoteCmd: string; syncDbPath: string; syncForce: boolean; + syncJson: boolean; + syncCheck: boolean; + syncUi: boolean; logLevel: LogLevel; logRotation: LogRotation; passwordStore: string; diff --git a/src/shared/sync/sync-events.test.ts b/src/shared/sync/sync-events.test.ts new file mode 100644 index 00000000..cf390e37 --- /dev/null +++ b/src/shared/sync/sync-events.test.ts @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSyncProgressLine } from './sync-events'; + +test('parseSyncProgressLine parses known event types', () => { + assert.deepEqual( + parseSyncProgressLine('{"type":"stage","stage":"snapshot-local","message":"Snapshotting"}'), + { type: 'stage', stage: 'snapshot-local', message: 'Snapshotting' }, + ); + const summaryLine = JSON.stringify({ + type: 'merge-summary', + target: 'local', + summary: { sessionsMerged: 2 }, + }); + const parsed = parseSyncProgressLine(summaryLine); + assert.equal(parsed?.type, 'merge-summary'); + assert.deepEqual( + parseSyncProgressLine('{"type":"result","ok":true,"error":null}'), + { type: 'result', ok: true, error: null }, + ); +}); + +test('parseSyncProgressLine rejects non-events and garbage', () => { + assert.equal(parseSyncProgressLine('plain text output'), null); + assert.equal(parseSyncProgressLine('{"no":"type"}'), null); + assert.equal(parseSyncProgressLine('{"type":"unknown-event"}'), null); + assert.equal(parseSyncProgressLine(''), null); + assert.equal(parseSyncProgressLine('42'), null); +}); diff --git a/src/shared/sync/sync-events.ts b/src/shared/sync/sync-events.ts new file mode 100644 index 00000000..30806807 --- /dev/null +++ b/src/shared/sync/sync-events.ts @@ -0,0 +1,68 @@ +// NDJSON progress protocol emitted by `subminer sync --json` and consumed by +// the sync UI's launcher client. Keep this file free of Electron/bun imports — +// it is shared between the launcher (bun) and the app main process (node). + +export interface SyncMergeSummary { + sessionsMerged: number; + sessionsAlreadyPresent: number; + activeSessionsSkipped: number; + animeAdded: number; + videosAdded: number; + wordsAdded: number; + kanjiAdded: number; + subtitleLinesAdded: number; + telemetryRowsAdded: number; + eventsAdded: number; + excludedWordsAdded: number; + dailyRollupsCopied: number; + monthlyRollupsCopied: number; + rollupGroupsRecomputed: number; +} + +export type SyncStage = + | 'snapshot-local' + | 'snapshot-remote' + | 'download' + | 'upload' + | 'merge-local' + | 'merge-remote'; + +export type SyncProgressEvent = + | { type: 'stage'; stage: SyncStage; message: string } + | { type: 'merge-summary'; target: 'local' | 'remote'; summary: SyncMergeSummary } + | { type: 'remote-output'; text: string } + | { type: 'snapshot-created'; path: string } + | { + type: 'check-result'; + host: string; + sshOk: boolean; + remoteCommand: string | null; + remoteVersion: string | null; + ok: boolean; + error: string | null; + } + | { type: 'result'; ok: boolean; error: string | null }; + +const EVENT_TYPES = new Set([ + 'stage', + 'merge-summary', + 'remote-output', + 'snapshot-created', + 'check-result', + 'result', +]); + +export function parseSyncProgressLine(line: string): SyncProgressEvent | null { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const type = (parsed as { type?: unknown }).type; + if (typeof type !== 'string' || !EVENT_TYPES.has(type)) return null; + return parsed as SyncProgressEvent; +} diff --git a/src/shared/sync/sync-hosts-store.test.ts b/src/shared/sync/sync-hosts-store.test.ts new file mode 100644 index 00000000..a515d673 --- /dev/null +++ b/src/shared/sync/sync-hosts-store.test.ts @@ -0,0 +1,182 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + createDefaultSyncHostsState, + getSyncHostsPath, + isValidSyncHost, + normalizeSyncHostsState, + readSyncHostsState, + recordSyncResult, + removeSyncHost, + upsertSyncHost, + writeSyncHostsState, +} from './sync-hosts-store'; + +function withTempDir(fn: (dir: string) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-hosts-test-')); + try { + fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('createDefaultSyncHostsState returns empty v1 state', () => { + assert.deepEqual(createDefaultSyncHostsState(), { + version: 1, + autoSyncIntervalMinutes: 30, + hosts: [], + }); +}); + +test('isValidSyncHost accepts ssh destinations and rejects unsafe values', () => { + assert.equal(isValidSyncHost('desktop'), true); + assert.equal(isValidSyncHost('user@10.0.0.5'), true); + assert.equal(isValidSyncHost('my-alias.local'), true); + assert.equal(isValidSyncHost(''), false); + assert.equal(isValidSyncHost(' '), false); + assert.equal(isValidSyncHost('-oProxyCommand=evil'), false); + assert.equal(isValidSyncHost('host with spaces'), false); +}); + +test('upsertSyncHost adds a new host with defaults and preserves fields on update', () => { + const state = createDefaultSyncHostsState(); + const added = upsertSyncHost(state, { host: 'user@laptop' }, 1000); + assert.deepEqual(added.hosts, [ + { + host: 'user@laptop', + label: null, + direction: 'both', + autoSync: false, + addedAtMs: 1000, + lastSyncAtMs: null, + lastSyncStatus: null, + lastSyncDetail: null, + }, + ]); + // original state untouched + assert.equal(state.hosts.length, 0); + + const synced = recordSyncResult(added, 'user@laptop', { + atMs: 2000, + status: 'success', + detail: 'Sessions merged: 3', + }); + const updated = upsertSyncHost(synced, { host: 'user@laptop', direction: 'push' }, 3000); + assert.equal(updated.hosts.length, 1); + const entry = updated.hosts[0]!; + assert.equal(entry.direction, 'push'); + assert.equal(entry.addedAtMs, 1000); + assert.equal(entry.lastSyncAtMs, 2000); + assert.equal(entry.lastSyncStatus, 'success'); + assert.equal(entry.lastSyncDetail, 'Sessions merged: 3'); +}); + +test('upsertSyncHost trims host and throws on invalid host', () => { + const state = upsertSyncHost(createDefaultSyncHostsState(), { host: ' desktop ' }, 1); + assert.equal(state.hosts[0]!.host, 'desktop'); + assert.throws(() => upsertSyncHost(state, { host: '-bad' }, 2)); +}); + +test('removeSyncHost removes only the matching host', () => { + let state = createDefaultSyncHostsState(); + state = upsertSyncHost(state, { host: 'a' }, 1); + state = upsertSyncHost(state, { host: 'b' }, 2); + const removed = removeSyncHost(state, 'a'); + assert.deepEqual( + removed.hosts.map((entry) => entry.host), + ['b'], + ); + assert.equal(removeSyncHost(removed, 'missing').hosts.length, 1); +}); + +test('recordSyncResult upserts unknown hosts so CLI syncs are remembered', () => { + const state = recordSyncResult(createDefaultSyncHostsState(), 'user@server', { + atMs: 5000, + status: 'error', + detail: 'Remote merge failed', + }); + assert.equal(state.hosts.length, 1); + const entry = state.hosts[0]!; + assert.equal(entry.host, 'user@server'); + assert.equal(entry.addedAtMs, 5000); + assert.equal(entry.lastSyncAtMs, 5000); + assert.equal(entry.lastSyncStatus, 'error'); + assert.equal(entry.lastSyncDetail, 'Remote merge failed'); +}); + +test('normalizeSyncHostsState drops invalid entries and falls back to defaults', () => { + assert.deepEqual(normalizeSyncHostsState(null), createDefaultSyncHostsState()); + assert.deepEqual(normalizeSyncHostsState('junk'), createDefaultSyncHostsState()); + assert.deepEqual(normalizeSyncHostsState({ version: 99 }), createDefaultSyncHostsState()); + + const normalized = normalizeSyncHostsState({ + version: 1, + autoSyncIntervalMinutes: 15, + hosts: [ + { + host: 'desktop', + label: 'Desktop PC', + direction: 'pull', + autoSync: true, + addedAtMs: 10, + lastSyncAtMs: 20, + lastSyncStatus: 'success', + lastSyncDetail: 'ok', + }, + { host: '-bad', direction: 'both', autoSync: false, addedAtMs: 1 }, + { host: 'dupe', direction: 'sideways', autoSync: false, addedAtMs: 1 }, + 'not-an-object', + ], + }); + assert.equal(normalized.autoSyncIntervalMinutes, 15); + assert.deepEqual(normalized.hosts, [ + { + host: 'desktop', + label: 'Desktop PC', + direction: 'pull', + autoSync: true, + addedAtMs: 10, + lastSyncAtMs: 20, + lastSyncStatus: 'success', + lastSyncDetail: 'ok', + }, + { + host: 'dupe', + label: null, + direction: 'both', + autoSync: false, + addedAtMs: 1, + lastSyncAtMs: null, + lastSyncStatus: null, + lastSyncDetail: null, + }, + ]); +}); + +test('read/write round-trips state and tolerates missing or corrupt files', () => { + withTempDir((root) => { + const filePath = getSyncHostsPath(root); + assert.equal(filePath, path.join(root, 'sync-hosts.json')); + assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState()); + + fs.writeFileSync(filePath, '{corrupt'); + assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState()); + + let state = createDefaultSyncHostsState(); + state = upsertSyncHost(state, { host: 'user@laptop', label: 'Laptop' }, 42); + writeSyncHostsState(filePath, state); + assert.deepEqual(readSyncHostsState(filePath), state); + }); +}); + +test('writeSyncHostsState creates parent directories', () => { + withTempDir((root) => { + const filePath = path.join(root, 'nested', 'dir', 'sync-hosts.json'); + writeSyncHostsState(filePath, createDefaultSyncHostsState()); + assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState()); + }); +}); diff --git a/src/shared/sync/sync-hosts-store.ts b/src/shared/sync/sync-hosts-store.ts new file mode 100644 index 00000000..c326ce0c --- /dev/null +++ b/src/shared/sync/sync-hosts-store.ts @@ -0,0 +1,195 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type SyncDirection = 'both' | 'push' | 'pull'; +export type SyncResultStatus = 'success' | 'error'; + +export interface SyncHostEntry { + host: string; + label: string | null; + direction: SyncDirection; + autoSync: boolean; + addedAtMs: number; + lastSyncAtMs: number | null; + lastSyncStatus: SyncResultStatus | null; + lastSyncDetail: string | null; +} + +export interface SyncHostsState { + version: 1; + autoSyncIntervalMinutes: number; + hosts: SyncHostEntry[]; +} + +export interface SyncHostUpdate { + host: string; + label?: string | null; + direction?: SyncDirection; + autoSync?: boolean; +} + +export interface SyncResultUpdate { + atMs: number; + status: SyncResultStatus; + detail: string | null; +} + +const DEFAULT_AUTO_SYNC_INTERVAL_MINUTES = 30; + +export function createDefaultSyncHostsState(): SyncHostsState { + return { + version: 1, + autoSyncIntervalMinutes: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES, + hosts: [], + }; +} + +// Hosts are passed as a single spawn argument to ssh, so the only dangerous +// shapes are option injection (leading "-") and multi-token values. +export function isValidSyncHost(host: string): boolean { + const trimmed = host.trim(); + return trimmed.length > 0 && !trimmed.startsWith('-') && !/\s/.test(trimmed); +} + +function asObject(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function isDirection(value: unknown): value is SyncDirection { + return value === 'both' || value === 'push' || value === 'pull'; +} + +function normalizeHostEntry(value: unknown): SyncHostEntry | null { + const record = asObject(value); + if (!record) return null; + const host = typeof record.host === 'string' ? record.host.trim() : ''; + if (!isValidSyncHost(host)) return null; + return { + host, + label: typeof record.label === 'string' && record.label.trim() ? record.label.trim() : null, + direction: isDirection(record.direction) ? record.direction : 'both', + autoSync: record.autoSync === true, + addedAtMs: + typeof record.addedAtMs === 'number' && Number.isFinite(record.addedAtMs) + ? record.addedAtMs + : 0, + lastSyncAtMs: + typeof record.lastSyncAtMs === 'number' && Number.isFinite(record.lastSyncAtMs) + ? record.lastSyncAtMs + : null, + lastSyncStatus: + record.lastSyncStatus === 'success' || record.lastSyncStatus === 'error' + ? record.lastSyncStatus + : null, + lastSyncDetail: typeof record.lastSyncDetail === 'string' ? record.lastSyncDetail : null, + }; +} + +export function normalizeSyncHostsState(value: unknown): SyncHostsState { + const record = asObject(value); + if (!record || record.version !== 1) return createDefaultSyncHostsState(); + const hosts = Array.isArray(record.hosts) + ? record.hosts.map(normalizeHostEntry).filter((entry): entry is SyncHostEntry => entry !== null) + : []; + const interval = record.autoSyncIntervalMinutes; + return { + version: 1, + autoSyncIntervalMinutes: + typeof interval === 'number' && Number.isFinite(interval) && interval >= 1 + ? Math.floor(interval) + : DEFAULT_AUTO_SYNC_INTERVAL_MINUTES, + hosts, + }; +} + +export function upsertSyncHost( + state: SyncHostsState, + update: SyncHostUpdate, + nowMs: number, +): SyncHostsState { + const host = update.host.trim(); + if (!isValidSyncHost(host)) { + throw new Error(`Invalid sync host: ${JSON.stringify(update.host)}`); + } + const existing = state.hosts.find((entry) => entry.host === host); + const base: SyncHostEntry = existing ?? { + host, + label: null, + direction: 'both', + autoSync: false, + addedAtMs: nowMs, + lastSyncAtMs: null, + lastSyncStatus: null, + lastSyncDetail: null, + }; + const next: SyncHostEntry = { + ...base, + label: update.label !== undefined ? update.label : base.label, + direction: update.direction ?? base.direction, + autoSync: update.autoSync ?? base.autoSync, + }; + const hosts = existing + ? state.hosts.map((entry) => (entry.host === host ? next : entry)) + : [...state.hosts, next]; + return { ...state, hosts }; +} + +export function removeSyncHost(state: SyncHostsState, host: string): SyncHostsState { + return { ...state, hosts: state.hosts.filter((entry) => entry.host !== host.trim()) }; +} + +export function recordSyncResult( + state: SyncHostsState, + host: string, + result: SyncResultUpdate, +): SyncHostsState { + const upserted = upsertSyncHost(state, { host }, result.atMs); + const hosts = upserted.hosts.map((entry) => + entry.host === host.trim() + ? { + ...entry, + lastSyncAtMs: result.atMs, + lastSyncStatus: result.status, + lastSyncDetail: result.detail, + } + : entry, + ); + return { ...upserted, hosts }; +} + +export function getSyncHostsPath(configDir: string): string { + return path.join(configDir, 'sync-hosts.json'); +} + +export function readSyncHostsState( + filePath: string, + deps?: { + existsSync?: (candidate: string) => boolean; + readFileSync?: (candidate: string, encoding: BufferEncoding) => string; + }, +): SyncHostsState { + const existsSync = deps?.existsSync ?? fs.existsSync; + const readFileSync = deps?.readFileSync ?? fs.readFileSync; + if (!existsSync(filePath)) return createDefaultSyncHostsState(); + try { + return normalizeSyncHostsState(JSON.parse(readFileSync(filePath, 'utf8'))); + } catch { + return createDefaultSyncHostsState(); + } +} + +export function writeSyncHostsState( + filePath: string, + state: SyncHostsState, + deps?: { + mkdirSync?: (candidate: string, options: { recursive: true }) => void; + writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void; + }, +): void { + const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; + const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync; + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); +}