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: '',
|
syncRemoteCmd: '',
|
||||||
syncDbPath: '',
|
syncDbPath: '',
|
||||||
syncForce: false,
|
syncForce: false,
|
||||||
|
syncJson: false,
|
||||||
|
syncCheck: false,
|
||||||
|
syncUi: false,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
logRotation: 7,
|
logRotation: 7,
|
||||||
passwordStore: '',
|
passwordStore: '',
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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 { 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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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