mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
feat(sync): add sync-hosts store, NDJSON --json mode, and --check to subminer sync
This commit is contained in:
@@ -44,6 +44,9 @@ function createContext(): LauncherCommandContext {
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -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<SyncCommandDeps, 'recordHostSyncResult'> = {
|
||||
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<SyncCommandDeps> = {
|
||||
...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<SyncCommandDeps> = {
|
||||
...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<SyncCommandDeps> = {
|
||||
...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<SyncCommandDeps> {
|
||||
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<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/);
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
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> = {}): 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<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 {
|
||||
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<SyncCommandDeps> = {},
|
||||
): Promise<boolean> {
|
||||
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 <file>, or --merge <file>.');
|
||||
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 <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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 <file>', 'Override the local stats database path')
|
||||
.option('--remote-cmd <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 <level>', 'Log level')
|
||||
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
|
||||
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 <file>, or --merge <file>.');
|
||||
@@ -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,
|
||||
|
||||
@@ -38,6 +38,9 @@ function createArgs(): Args {
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -579,6 +579,9 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncUi: false,
|
||||
logLevel: 'error',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user