feat(sync): add sync-hosts store, NDJSON --json mode, and --check to subminer sync

This commit is contained in:
2026-07-11 17:45:27 -07:00
parent 8797719a09
commit 0a3f76c0a8
16 changed files with 979 additions and 27 deletions
@@ -44,6 +44,9 @@ function createContext(): LauncherCommandContext {
syncRemoteCmd: '', syncRemoteCmd: '',
syncDbPath: '', syncDbPath: '',
syncForce: false, syncForce: false,
syncJson: false,
syncCheck: false,
syncUi: false,
logLevel: 'info', logLevel: 'info',
logRotation: 7, logRotation: 7,
passwordStore: '', passwordStore: '',
+202
View File
@@ -36,6 +36,12 @@ function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
return { status: 0, stdout, stderr: '' }; 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<SyncCommandDeps, 'recordHostSyncResult'> = {
recordHostSyncResult: () => {},
};
test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', async () => { test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' }); const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
context.mpvSocketPath = '/tmp/subminer-socket'; 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 () => { test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', async () => {
const calls: string[] = []; const calls: string[] = [];
const deps: Partial<SyncCommandDeps> = { const deps: Partial<SyncCommandDeps> = {
...noRecord,
createDbSnapshot: (dbPath: string, outPath: string) => { createDbSnapshot: (dbPath: string, outPath: string) => {
calls.push(`snapshot:${dbPath}->${outPath}`); calls.push(`snapshot:${dbPath}->${outPath}`);
}, },
@@ -120,6 +127,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
const calls: string[] = []; const calls: string[] = [];
let localTmpDir = ''; let localTmpDir = '';
const deps: Partial<SyncCommandDeps> = { const deps: Partial<SyncCommandDeps> = {
...noRecord,
createDbSnapshot: (_dbPath: string, outPath: string) => { createDbSnapshot: (_dbPath: string, outPath: string) => {
calls.push(`snapshot:${outPath}`); calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot'); 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 () => { test('runHostSync includes remote snapshot stderr in failures', async () => {
const deps: Partial<SyncCommandDeps> = { const deps: Partial<SyncCommandDeps> = {
...noRecord,
createDbSnapshot: (_dbPath: string, outPath: string) => { createDbSnapshot: (_dbPath: string, outPath: string) => {
fs.writeFileSync(outPath, 'snapshot'); fs.writeFileSync(outPath, 'snapshot');
}, },
@@ -192,6 +201,7 @@ test('runHostSync includes remote snapshot stderr in failures', async () => {
function makeDirectionDeps(calls: string[]): Partial<SyncCommandDeps> { function makeDirectionDeps(calls: string[]): Partial<SyncCommandDeps> {
return { return {
...noRecord,
createDbSnapshot: (_dbPath: string, outPath: string) => { createDbSnapshot: (_dbPath: string, outPath: string) => {
calls.push(`snapshot:${outPath}`); calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot'); 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.startsWith('snapshot:')));
assert.ok(!calls.some((call) => call.includes(' sync --merge '))); 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<SyncCommandDeps> = {
...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<SyncCommandDeps> = {
...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<SyncCommandDeps> = {
...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<SyncCommandDeps> = {
...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<SyncCommandDeps> = {
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<SyncCommandDeps> = {
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/);
});
+158 -11
View File
@@ -18,8 +18,11 @@ import {
} from '../sync/ssh.js'; } from '../sync/ssh.js';
import { resolvePathMaybe } from '../util.js'; import { resolvePathMaybe } from '../util.js';
import { canConnectUnixSocket } from '../mpv.js'; import { canConnectUnixSocket } from '../mpv.js';
import { recordHostSyncResultToDisk } from '../sync/sync-hosts.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
import type { RemoteRunResult } from '../sync/ssh.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 { export interface SyncCommandDeps {
createDbSnapshot: typeof createDbSnapshot; createDbSnapshot: typeof createDbSnapshot;
@@ -39,6 +42,8 @@ export interface SyncCommandDeps {
consoleLog: typeof console.log; consoleLog: typeof console.log;
writeStdout: typeof process.stdout.write; writeStdout: typeof process.stdout.write;
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => Promise<void>; ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => Promise<void>;
emitEvent: (event: SyncProgressEvent) => void;
recordHostSyncResult: (host: string, status: SyncResultStatus, detail: string | null) => void;
} }
function resolveDbPath(context: LauncherCommandContext): string { function resolveDbPath(context: LauncherCommandContext): string {
@@ -96,12 +101,26 @@ const defaultSyncCommandDeps: SyncCommandDeps = {
consoleLog: console.log, consoleLog: console.log,
writeStdout: process.stdout.write.bind(process.stdout), writeStdout: process.stdout.write.bind(process.stdout),
ensureTrackerQuiescent: async (context, dbPath) => ensureTrackerQuiescent(context, dbPath), ensureTrackerQuiescent: async (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
emitEvent: () => {},
recordHostSyncResult: recordHostSyncResultToDisk,
}; };
function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps { function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps {
return { ...defaultSyncCommandDeps, ...inputDeps }; 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( export function runSnapshotMode(
context: LauncherCommandContext, context: LauncherCommandContext,
dbPath: string, dbPath: string,
@@ -109,7 +128,13 @@ export function runSnapshotMode(
): void { ): void {
const deps = resolveSyncCommandDeps(inputDeps); const deps = resolveSyncCommandDeps(inputDeps);
const outPath = resolvePathMaybe(context.args.syncSnapshotPath); const outPath = resolvePathMaybe(context.args.syncSnapshotPath);
deps.emitEvent({
type: 'stage',
stage: 'snapshot-local',
message: `Snapshotting local database (${dbPath})`,
});
deps.createDbSnapshot(dbPath, outPath); deps.createDbSnapshot(dbPath, outPath);
deps.emitEvent({ type: 'snapshot-created', path: outPath });
deps.consoleLog(outPath); deps.consoleLog(outPath);
} }
@@ -121,10 +146,73 @@ export async function runMergeMode(
const deps = resolveSyncCommandDeps(inputDeps); const deps = resolveSyncCommandDeps(inputDeps);
await deps.ensureTrackerQuiescent(context, dbPath); await deps.ensureTrackerQuiescent(context, dbPath);
const snapshotPath = resolvePathMaybe(context.args.syncMergePath); 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); const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(deps.formatMergeSummary(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<SyncCommandDeps> = {},
): Promise<void> {
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 { function cleanupRemote(host: string, remoteTmpDir: string, deps: SyncCommandDeps): void {
if (!remoteTmpDir.startsWith('/tmp/')) return; if (!remoteTmpDir.startsWith('/tmp/')) return;
deps.runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`); 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-')); const localTmpDir = deps.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
let remoteTmpDir = ''; let remoteTmpDir = '';
let pulledSummary: SyncMergeSummary | null = null;
try { try {
// Signal failures by throwing (not fail(), which exits synchronously and // Signal failures by throwing (not fail(), which exits synchronously and
// would skip the finally cleanup, leaking temp dirs holding snapshot data). // 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'); const localSnapshot = path.join(localTmpDir, 'local.sqlite');
if (shouldPush) { if (shouldPush) {
deps.consoleLog(`Snapshotting local database (${dbPath})...`); deps.consoleLog(`Snapshotting local database (${dbPath})...`);
deps.emitEvent({
type: 'stage',
stage: 'snapshot-local',
message: `Snapshotting local database (${dbPath})`,
});
deps.createDbSnapshot(dbPath, localSnapshot); deps.createDbSnapshot(dbPath, localSnapshot);
} }
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`; const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
if (shouldPull) { if (shouldPull) {
deps.consoleLog(`Snapshotting ${host}...`); deps.consoleLog(`Snapshotting ${host}...`);
deps.emitEvent({ type: 'stage', stage: 'snapshot-remote', message: `Snapshotting ${host}` });
const snapshotRun = deps.runSsh( const snapshotRun = deps.runSsh(
host, host,
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`, `${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
@@ -186,25 +281,50 @@ export async function runHostSync(
} }
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite'); 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`; 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) { if (shouldPull) {
deps.consoleLog(`\nMerging ${host} -> local:`); deps.consoleLog(`\nMerging ${host} -> local:`);
deps.emitEvent({
type: 'stage',
stage: 'merge-local',
message: `Merging ${host} into the local database`,
});
await deps.ensureTrackerQuiescent(context, dbPath); await deps.ensureTrackerQuiescent(context, dbPath);
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot); const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
pulledSummary = summary;
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(deps.formatMergeSummary(summary)); deps.consoleLog(deps.formatMergeSummary(summary));
} }
if (shouldPush) { if (shouldPush) {
deps.consoleLog(`\nMerging local -> ${host}:`); deps.consoleLog(`\nMerging local -> ${host}:`);
deps.emitEvent({
type: 'stage',
stage: 'merge-remote',
message: `Merging the local database into ${host}`,
});
await deps.ensureTrackerQuiescent(context, dbPath); await deps.ensureTrackerQuiescent(context, dbPath);
const mergeRun = deps.runSsh( const mergeRun = deps.runSsh(
host, host,
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`, `${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
); );
deps.writeStdout(mergeRun.stdout); deps.writeStdout(mergeRun.stdout);
if (mergeRun.stdout.trim()) {
deps.emitEvent({ type: 'remote-output', text: mergeRun.stdout });
}
if (mergeRun.status !== 0) { if (mergeRun.status !== 0) {
const retryCommand = const retryCommand =
direction === 'push' ? `subminer sync ${host} --push` : `subminer sync ${host}`; direction === 'push' ? `subminer sync ${host} --push` : `subminer sync ${host}`;
@@ -219,6 +339,18 @@ export async function runHostSync(
} }
deps.consoleLog('\nSync complete.'); 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 { } finally {
deps.rmSync(localTmpDir, { recursive: true, force: true }); deps.rmSync(localTmpDir, { recursive: true, force: true });
if (remoteTmpDir) { if (remoteTmpDir) {
@@ -235,19 +367,34 @@ export async function runSyncCommand(
context: LauncherCommandContext, context: LauncherCommandContext,
inputDeps: Partial<SyncCommandDeps> = {}, inputDeps: Partial<SyncCommandDeps> = {},
): Promise<boolean> { ): Promise<boolean> {
const deps = resolveSyncCommandDeps(inputDeps); let deps = resolveSyncCommandDeps(inputDeps);
const { args } = context; const { args } = context;
if (!args.sync) return false; if (!args.sync) return false;
if (args.syncJson) deps = withJsonEvents(deps);
const dbPath = resolveDbPath(context); const dbPath = resolveDbPath(context);
if (args.syncSnapshotPath) { try {
runSnapshotMode(context, dbPath, deps); if (args.syncCheck) {
} else if (args.syncMergePath) { await runCheckMode(context, deps);
await runMergeMode(context, dbPath, deps); } else if (args.syncSnapshotPath) {
} else if (args.syncHost) { runSnapshotMode(context, dbPath, deps);
await runHostSync(context, dbPath, deps); } else if (args.syncMergePath) {
} else { await runMergeMode(context, dbPath, deps);
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.'); } else if (args.syncHost) {
await runHostSync(context, dbPath, deps);
} else {
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.');
}
} 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; return true;
} }
+16
View File
@@ -143,7 +143,11 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
syncRemoteCmd: null, syncRemoteCmd: null,
syncDbPath: null, syncDbPath: null,
syncForce: false, syncForce: false,
syncJson: false,
syncCheck: false,
syncLogLevel: null, syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
@@ -198,7 +202,11 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
syncRemoteCmd: null, syncRemoteCmd: null,
syncDbPath: null, syncDbPath: null,
syncForce: false, syncForce: false,
syncJson: false,
syncCheck: false,
syncLogLevel: null, syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
@@ -246,7 +254,11 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
syncRemoteCmd: null, syncRemoteCmd: null,
syncDbPath: null, syncDbPath: null,
syncForce: false, syncForce: false,
syncJson: false,
syncCheck: false,
syncLogLevel: null, syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
@@ -292,7 +304,11 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
syncRemoteCmd: null, syncRemoteCmd: null,
syncDbPath: null, syncDbPath: null,
syncForce: false, syncForce: false,
syncJson: false,
syncCheck: false,
syncLogLevel: null, syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false, doctorTriggered: false,
doctorLogLevel: null, doctorLogLevel: null,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
+9
View File
@@ -207,6 +207,9 @@ export function createDefaultArgs(
syncRemoteCmd: '', syncRemoteCmd: '',
syncDbPath: '', syncDbPath: '',
syncForce: false, syncForce: false,
syncJson: false,
syncCheck: false,
syncUi: false,
logLevel: loggingConfig.level ?? 'warn', logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7, logRotation: loggingConfig.rotation ?? 7,
passwordStore: '', passwordStore: '',
@@ -281,8 +284,14 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? ''; parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? '';
parsed.syncDbPath = invocations.syncDbPath ?? ''; parsed.syncDbPath = invocations.syncDbPath ?? '';
parsed.syncForce = invocations.syncForce; parsed.syncForce = invocations.syncForce;
parsed.syncJson = invocations.syncJson;
parsed.syncCheck = invocations.syncCheck;
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel); 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.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true; if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.logsTriggered && !invocations.logsExport) { if (invocations.logsTriggered && !invocations.logsExport) {
@@ -61,3 +61,36 @@ test('parseCliPrograms rejects conflicting or hostless one-way sync directions',
/--push and --pull require a host/, /--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/,
);
});
+32
View File
@@ -46,7 +46,11 @@ export interface CliInvocations {
syncRemoteCmd: string | null; syncRemoteCmd: string | null;
syncDbPath: string | null; syncDbPath: string | null;
syncForce: boolean; syncForce: boolean;
syncJson: boolean;
syncCheck: boolean;
syncLogLevel: string | null; syncLogLevel: string | null;
syncUiTriggered: boolean;
syncUiLogLevel: string | null;
doctorTriggered: boolean; doctorTriggered: boolean;
doctorLogLevel: string | null; doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean; doctorRefreshKnownWords: boolean;
@@ -178,7 +182,11 @@ export function parseCliPrograms(
let syncRemoteCmd: string | null = null; let syncRemoteCmd: string | null = null;
let syncDbPath: string | null = null; let syncDbPath: string | null = null;
let syncForce = false; let syncForce = false;
let syncJson = false;
let syncCheck = false;
let syncLogLevel: string | null = null; let syncLogLevel: string | null = null;
let syncUiTriggered = false;
let syncUiLogLevel: string | null = null;
let doctorLogLevel: string | null = null; let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false; let doctorRefreshKnownWords = false;
let logsTriggered = false; let logsTriggered = false;
@@ -319,6 +327,9 @@ export function parseCliPrograms(
.option('--db <file>', 'Override the local stats database path') .option('--db <file>', 'Override the local stats database path')
.option('--remote-cmd <cmd>', 'subminer command to run on the remote host') .option('--remote-cmd <cmd>', 'subminer command to run on the remote host')
.option('-f, --force', 'Skip the running-app safety check') .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 <level>', 'Log level') .option('--log-level <level>', 'Log level')
.action((rawHost: string | undefined, options: Record<string, unknown>) => { .action((rawHost: string | undefined, options: Record<string, unknown>) => {
const host = typeof rawHost === 'string' ? rawHost.trim() : ''; const host = typeof rawHost === 'string' ? rawHost.trim() : '';
@@ -326,12 +337,27 @@ export function parseCliPrograms(
const merge = typeof options.merge === 'string' ? options.merge.trim() : ''; const merge = typeof options.merge === 'string' ? options.merge.trim() : '';
const push = options.push === true; const push = options.push === true;
const pull = options.pull === 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) { if (push && pull) {
throw new Error('Sync --push and --pull cannot be combined.'); throw new Error('Sync --push and --pull cannot be combined.');
} }
if ((push || pull) && !host) { if ((push || pull) && !host) {
throw new Error('Sync --push and --pull require a 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; const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length;
if (modes === 0) { if (modes === 0) {
throw new Error('Sync requires a host, --snapshot <file>, or --merge <file>.'); throw new Error('Sync requires a host, --snapshot <file>, or --merge <file>.');
@@ -348,6 +374,8 @@ export function parseCliPrograms(
typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null; typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null; syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null;
syncForce = options.force === true; syncForce = options.force === true;
syncJson = options.json === true;
syncCheck = check;
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
}); });
@@ -470,7 +498,11 @@ export function parseCliPrograms(
syncRemoteCmd, syncRemoteCmd,
syncDbPath, syncDbPath,
syncForce, syncForce,
syncJson,
syncCheck,
syncLogLevel, syncLogLevel,
syncUiTriggered,
syncUiLogLevel,
doctorTriggered, doctorTriggered,
doctorLogLevel, doctorLogLevel,
doctorRefreshKnownWords, doctorRefreshKnownWords,
+3
View File
@@ -38,6 +38,9 @@ function createArgs(): Args {
syncRemoteCmd: '', syncRemoteCmd: '',
syncDbPath: '', syncDbPath: '',
syncForce: false, syncForce: false,
syncJson: false,
syncCheck: false,
syncUi: false,
logLevel: 'info', logLevel: 'info',
logRotation: 7, logRotation: 7,
passwordStore: '', passwordStore: '',
+3
View File
@@ -579,6 +579,9 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
syncRemoteCmd: '', syncRemoteCmd: '',
syncDbPath: '', syncDbPath: '',
syncForce: false, syncForce: false,
syncJson: false,
syncCheck: false,
syncUi: false,
logLevel: 'error', logLevel: 'error',
logRotation: 7, logRotation: 7,
passwordStore: '', passwordStore: '',
+41
View File
@@ -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
}
}
+2 -16
View File
@@ -8,22 +8,8 @@ import { resolveConfigDir } from '../../src/config/path-resolution.js';
export { SCHEMA_VERSION }; export { SCHEMA_VERSION };
export interface SyncMergeSummary { export type { SyncMergeSummary } from '../../src/shared/sync/sync-events.js';
sessionsMerged: number; import type { SyncMergeSummary } from '../../src/shared/sync/sync-events.js';
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 function createEmptyMergeSummary(): SyncMergeSummary { export function createEmptyMergeSummary(): SyncMergeSummary {
return { return {
+3
View File
@@ -121,6 +121,9 @@ export interface Args {
syncRemoteCmd: string; syncRemoteCmd: string;
syncDbPath: string; syncDbPath: string;
syncForce: boolean; syncForce: boolean;
syncJson: boolean;
syncCheck: boolean;
syncUi: boolean;
logLevel: LogLevel; logLevel: LogLevel;
logRotation: LogRotation; logRotation: LogRotation;
passwordStore: string; passwordStore: string;
+29
View File
@@ -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);
});
+68
View File
@@ -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;
}
+182
View File
@@ -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());
});
});
+195
View File
@@ -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<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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');
}