refactor(sync): launcher sync command proxies to the app's --sync-cli

The engine now executes only inside the app (libsql): subminer sync
rebuilds the equivalent --sync-cli argv and spawns the discovered app
binary with the terminal attached (stdin for ssh prompts, raw NDJSON
stdout, exit-code passthrough). The bun:sqlite driver binding and the
launcher-side engine shims are gone; flow tests moved to
src/core/services/stats-sync/sync-flow.test.ts, ssh tests to src, and
the merge suite now runs through the libsql driver the app ships.
This commit is contained in:
2026-07-12 00:12:33 -07:00
parent 94260bab16
commit 08419fbc8e
15 changed files with 658 additions and 796 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
## Unreleased
### Added
- Stats Sync Without the Launcher: The stats sync engine now runs inside the app. The sync window no longer needs bun or the `subminer` command-line launcher installed, and a remote machine only needs SubMiner itself — the app answers `SubMiner --sync-cli sync ...` headless (works over SSH with no display), and remote lookup finds the app binary or the launcher automatically.
- Stats Sync Without the Launcher: The stats sync engine now runs only inside the app. The sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher — a remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
- Stats Sync With Windows Remotes: Sync now detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp`/`--remove-temp`) instead of `mktemp`/`rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote; SubMiner is found in its default Windows install location automatically.
## v0.18.0 (2026-07-10)
+1 -1
View File
@@ -3,7 +3,7 @@
## Unreleased
**Added**
- Stats Sync Without the Launcher: The stats sync engine now runs inside the app. The sync window no longer needs bun or the `subminer` command-line launcher installed, and a remote machine only needs SubMiner itself — the app answers `SubMiner --sync-cli sync ...` headless (works over SSH with no display), and remote lookup finds the app binary or the launcher automatically.
- Stats Sync Without the Launcher: The stats sync engine now runs only inside the app. The sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher — a remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
- Stats Sync With Windows Remotes: Sync now detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp`/`--remove-temp`) instead of `mktemp`/`rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote; SubMiner is found in its default Windows install location automatically.
## v0.18.0 (2026-07-10)
+1 -1
View File
@@ -81,7 +81,7 @@ Series whose directories are not currently accessible (e.g. an unmounted network
## Sync Between Machines
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version. The command-line launcher is optional on both sides: the app itself answers sync commands (`SubMiner --sync-cli sync ...`), the sync window runs the engine in-app, and the remote side is found automatically whether it has the launcher or just the app.
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version. The sync engine runs only inside the app (`SubMiner --sync-cli sync ...`): the sync window spawns it that way, `subminer sync` is a thin proxy that forwards to the installed app, and the remote side is found automatically whether it has the launcher or just the app — so the command-line launcher is optional everywhere.
```bash
subminer sync macbook # two-way sync with the host "macbook"
+97 -498
View File
@@ -1,14 +1,13 @@
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 type { Args } from '../types.js';
import { createEmptyMergeSummary } from '../sync/sync-shared.js';
import type { LauncherCommandContext } from './context.js';
import { ensureTrackerQuiescent, runSyncCommand, type SyncCommandDeps } from './sync-command.js';
import { buildSyncCliArgv, runSyncCommand, type SyncCommandDeps } from './sync-command.js';
function makeContext(overrides: Partial<Args>): LauncherCommandContext {
function makeContext(
overrides: Partial<Args>,
appPath: string | null = '/opt/SubMiner/subminer-app',
): LauncherCommandContext {
return {
args: {
sync: true,
@@ -19,6 +18,8 @@ function makeContext(overrides: Partial<Args>): LauncherCommandContext {
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
logLevel: 'warn',
@@ -28,522 +29,120 @@ function makeContext(overrides: Partial<Args>): LauncherCommandContext {
scriptName: 'subminer',
mpvSocketPath: '',
pluginRuntimeConfig: {},
appPath: null,
appPath,
launcherJellyfinConfig: {},
processAdapter: process,
processAdapter: { platform: () => 'linux' },
} as unknown as LauncherCommandContext;
}
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';
let socketConnectable = false;
const deps: Partial<SyncCommandDeps> = {
realpathSync: (() => '/tracker.sqlite') as unknown as typeof fs.realpathSync,
findLiveStatsDaemonPid: () => null,
canConnectUnixSocket: async () => socketConnectable,
fail: (message: string): never => {
throw new Error(message);
},
};
await ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps);
socketConnectable = true;
await assert.rejects(
async () => ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps),
/mpv\/SubMiner session appears to be running/,
);
});
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}`);
},
mergeSnapshotIntoDb: (dbPath: string, snapshotPath: string) => {
calls.push(`merge:${dbPath}<-${snapshotPath}`);
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: (host: string) => {
calls.push(`host:${host}`);
},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
calls.push(`ssh:${command}`);
return command.includes(' sync --make-temp') ? ok('/tmp/subminer-sync-remote\n') : ok();
},
runScp: (from: string, to: string) => {
calls.push(`scp:${from}->${to}`);
},
fail: (message: string): never => {
throw new Error(message);
},
};
assert.equal(
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
deps,
),
true,
);
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
deps,
);
assert.ok(calls.includes('quiescent'));
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
deps,
);
assert.ok(calls.includes('host:media-box'));
await assert.rejects(
() => runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
/sync requires a host, --snapshot <file>, or --merge <file>/,
);
});
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => {
const calls: string[] = [];
let localTmpDir = '';
const deps: Partial<SyncCommandDeps> = {
...noRecord,
createDbSnapshot: (_dbPath: string, outPath: string) => {
calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot');
},
mergeSnapshotIntoDb: () => {
calls.push('local-merge');
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
mkdtempSync: ((prefix: string) => {
localTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), path.basename(prefix)));
return localTmpDir;
}) as typeof fs.mkdtempSync,
runSsh: (_host: string, command: string) => {
calls.push(`ssh:${command}`);
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --snapshot ')) return ok();
if (command.includes(' sync --merge ')) {
return { status: 9, stdout: 'remote output', stderr: 'remote merge exploded' };
}
return ok();
},
runScp: (from: string, to: string) => {
calls.push(`scp:${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
};
await assert.rejects(
() =>
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
);
assert.equal(calls.filter((call) => call === 'quiescent').length, 3);
assert.ok(calls.indexOf('quiescent') < calls.findIndex((call) => call.startsWith('snapshot:')));
assert.ok(calls.includes('local-merge'));
assert.ok(calls.some((call) => call.includes(' sync --remove-temp ')));
assert.equal(fs.existsSync(localTmpDir), false);
});
test('runHostSync includes remote snapshot stderr in failures', async () => {
const deps: Partial<SyncCommandDeps> = {
...noRecord,
createDbSnapshot: (_dbPath: string, outPath: string) => {
fs.writeFileSync(outPath, 'snapshot');
},
ensureTrackerQuiescent: async () => {},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --snapshot ')) {
return { status: 5, stdout: '', stderr: 'snapshot permission denied' };
}
return ok();
},
runScp: () => {},
};
await assert.rejects(
() =>
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
);
});
function makeDirectionDeps(calls: string[]): Partial<SyncCommandDeps> {
function makeArgs(overrides: Partial<Parameters<typeof buildSyncCliArgv>[0]>) {
return {
...noRecord,
createDbSnapshot: (_dbPath: string, outPath: string) => {
calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot');
},
mergeSnapshotIntoDb: () => {
calls.push('local-merge');
return createEmptyMergeSummary();
},
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
calls.push(`ssh:${command}`);
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
return ok();
},
runScp: (from: string, to: string) => {
calls.push(`scp:${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both' as const,
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
logLevel: 'warn' as const,
...overrides,
};
}
test('runHostSync push only snapshots locally and merges remotely', async () => {
const calls: string[] = [];
await runSyncCommand(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncDirection: 'push',
}),
makeDirectionDeps(calls),
);
assert.ok(calls.some((call) => call.startsWith('snapshot:')));
assert.ok(calls.some((call) => call.includes(' sync --merge ')));
assert.ok(calls.some((call) => call.startsWith('scp:') && call.includes('->media-box:')));
assert.ok(!calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(!calls.includes('local-merge'));
});
test('runHostSync pull only snapshots remotely and merges locally', async () => {
const calls: string[] = [];
await runSyncCommand(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncDirection: 'pull',
}),
makeDirectionDeps(calls),
);
assert.ok(calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(calls.some((call) => call.startsWith('scp:media-box:')));
assert.ok(calls.includes('local-merge'));
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[] = [];
test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => {
const spawned: Array<{ appPath: string; appArgs: 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.includes(' sync --make-temp')) 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.includes(' sync --make-temp')) 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.includes(' sync --make-temp')) 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);
runAppCommand: (appPath, appArgs) => {
spawned.push({ appPath, appArgs });
},
};
assert.equal(
await runSyncCommand(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncCheck: true,
syncJson: true,
}),
deps,
),
await runSyncCommand(makeContext({ syncHost: 'media-box', syncJson: true }), deps),
true,
);
assert.deepEqual(spawned, [
{
appPath: '/opt/SubMiner/subminer-app',
appArgs: ['--sync-cli', 'sync', 'media-box', '--json', '--log-level', 'warn'],
},
]);
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);
assert.equal(await runSyncCommand(makeContext({ sync: false }), deps), false);
assert.equal(spawned.length, 1);
});
test('runSyncCommand --check --json reports failures without throwing early', async () => {
const lines: string[] = [];
test('buildSyncCliArgv forwards every sync option', () => {
assert.deepEqual(
buildSyncCliArgv(
makeArgs({
syncHost: 'media-box',
syncDirection: 'pull',
syncRemoteCmd: '/opt/SubMiner.AppImage',
syncDbPath: '/tmp/db.sqlite',
syncForce: true,
syncJson: true,
logLevel: 'debug',
}),
),
[
'--sync-cli',
'sync',
'media-box',
'--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
'--log-level',
'debug',
],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncSnapshotPath: '/tmp/out.sqlite' })),
['--sync-cli', 'sync', '--snapshot', '/tmp/out.sqlite', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncHost: 'media-box', syncCheck: true })),
['--sync-cli', 'sync', 'media-box', '--check', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncMakeTemp: true })),
['--sync-cli', 'sync', '--make-temp', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncRemoveTempPath: '/tmp/subminer-sync-x' })),
['--sync-cli', 'sync', '--remove-temp', '/tmp/subminer-sync-x', '--log-level', 'warn'],
);
assert.deepEqual(
buildSyncCliArgv(makeArgs({ syncMergePath: '/tmp/in.sqlite', syncForce: true })),
['--sync-cli', 'sync', '--merge', '/tmp/in.sqlite', '--force', '--log-level', 'warn'],
);
});
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
const deps: Partial<SyncCommandDeps> = {
ensureTrackerQuiescent: async () => {},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => {
throw new Error('Could not find a runnable "subminer" on media-box.');
runAppCommand: () => {
throw new Error('should not spawn');
},
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,
),
await assert.rejects(
() => runSyncCommand(makeContext({ syncHost: 'media-box' }, null), deps),
/SubMiner app binary not found \(sync runs inside the app\)/,
);
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/);
});
test('runHostSync speaks Windows shells: app command, double quotes, temp protocol', async () => {
const sshCommands: string[] = [];
const scpCalls: string[] = [];
const winTemp = 'C:\\Users\\First Last\\AppData\\Local\\Temp\\subminer-sync-remote';
const deps: Partial<SyncCommandDeps> = {
...noRecord,
createDbSnapshot: (_dbPath: string, outPath: string) => {
fs.writeFileSync(outPath, 'snapshot');
},
mergeSnapshotIntoDb: () => createEmptyMergeSummary(),
formatMergeSummary: () => 'summary',
ensureTrackerQuiescent: async () => {},
assertSafeSshHost: () => {},
detectRemoteShellFlavor: () => 'windows-cmd',
resolveRemoteSubminerCommand: () => '"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli',
runSsh: (_host: string, command: string) => {
sshCommands.push(command);
if (command.includes(' sync --make-temp')) return ok(`${winTemp}\r\n`);
return ok();
},
runScp: (from: string, to: string) => {
scpCalls.push(`${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
};
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'win-box' }),
deps,
);
const expectedDir = 'C:/Users/First Last/AppData/Local/Temp/subminer-sync-remote';
assert.ok(
sshCommands.includes(
`"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli sync --snapshot "${expectedDir}/snapshot.sqlite"`,
),
);
assert.ok(
sshCommands.includes(
`"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli sync --merge "${expectedDir}/incoming.sqlite"`,
),
);
assert.ok(
sshCommands.includes(
`"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli sync --remove-temp "${expectedDir}"`,
),
);
assert.ok(scpCalls.some((call) => call.startsWith(`win-box:${expectedDir}/snapshot.sqlite->`)));
assert.ok(scpCalls.some((call) => call.endsWith(`->win-box:${expectedDir}/incoming.sqlite`)));
// No POSIX shell-isms reach a Windows remote.
assert.ok(!sshCommands.some((command) => command.startsWith('mktemp') || command.startsWith('rm ')));
assert.ok(!sshCommands.some((command) => command.includes('PATH="$HOME')));
});
test('runSyncCommand --make-temp prints a temp dir and --remove-temp only removes sync temp dirs', async () => {
const printed: string[] = [];
const removed: string[] = [];
const madeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
try {
await runSyncCommand(makeContext({ syncMakeTemp: true }), {
...noRecord,
mkdtempSync: (() => madeDir) as unknown as typeof fs.mkdtempSync,
consoleLog: (line: string) => {
printed.push(line);
},
});
assert.deepEqual(printed, [madeDir]);
await runSyncCommand(makeContext({ syncRemoveTempPath: madeDir }), {
...noRecord,
rmSync: ((target: string) => {
removed.push(target);
}) as typeof fs.rmSync,
});
assert.deepEqual(removed, [madeDir]);
await assert.rejects(
() =>
runSyncCommand(makeContext({ syncRemoveTempPath: '/etc' }), {
...noRecord,
rmSync: ((target: string) => {
removed.push(target);
}) as typeof fs.rmSync,
}),
/Refusing to remove a directory outside the sync temp area/,
);
assert.equal(removed.length, 1);
} finally {
fs.rmSync(madeDir, { recursive: true, force: true });
}
});
+59 -97
View File
@@ -1,111 +1,73 @@
import fs from 'node:fs';
import { fail, log } from '../log.js';
import type { LogLevel } from '../types.js';
import { resolveImmersionDbPath } from '../history-db.js';
import {
createDbSnapshot,
findLiveStatsDaemonPid,
formatMergeSummary,
mergeSnapshotIntoDb,
} from '../sync/sync-db.js';
import {
assertSafeSshHost,
detectRemoteShellFlavor,
resolveRemoteSubminerCommand,
runScp,
runSsh,
} from '../sync/ssh.js';
import { resolvePathMaybe } from '../util.js';
import { canConnectUnixSocket } from '../mpv.js';
import { recordHostSyncResultToDisk } from '../sync/sync-hosts.js';
import {
ensureTrackerQuiescentFlow,
runSyncFlow,
runCheckMode as runCheckModeFlow,
runHostSync as runHostSyncFlow,
runMergeMode as runMergeModeFlow,
runSnapshotMode as runSnapshotModeFlow,
type SyncFlowContext,
type SyncFlowDeps,
} from '../../src/core/services/stats-sync/sync-flow.js';
import { fail } from '../log.js';
import { runAppCommandInteractive } from '../mpv.js';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
// The sync flow itself lives in src/core/services/stats-sync (shared with the
// app's --sync-cli mode); this module binds it to the launcher's bun:sqlite
// engine, logger, and config paths.
export type SyncCommandDeps = SyncFlowDeps;
export interface SyncCommandDeps {
runAppCommand: (appPath: string, appArgs: string[]) => void;
fail: (message: string) => never;
}
const defaultSyncCommandDeps: SyncCommandDeps = {
createDbSnapshot,
mergeSnapshotIntoDb,
formatMergeSummary,
findLiveStatsDaemonPid,
assertSafeSshHost,
detectRemoteShellFlavor,
resolveRemoteSubminerCommand,
runScp,
runSsh,
runAppCommand: runAppCommandInteractive,
fail,
log: (level, configured, message) => log(level as LogLevel, configured as LogLevel, message),
canConnectUnixSocket,
realpathSync: fs.realpathSync,
mkdtempSync: fs.mkdtempSync,
rmSync: fs.rmSync,
consoleLog: console.log,
writeStdout: process.stdout.write.bind(process.stdout),
ensureTrackerQuiescent: async (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
emitEvent: () => {},
recordHostSyncResult: recordHostSyncResultToDisk,
resolveDefaultDbPath: resolveImmersionDbPath,
resolvePath: resolvePathMaybe,
};
function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps {
return { ...defaultSyncCommandDeps, ...inputDeps };
}
export async function ensureTrackerQuiescent(
context: SyncFlowContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
await ensureTrackerQuiescentFlow(context, dbPath, resolveSyncCommandDeps(inputDeps));
}
export function runSnapshotMode(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): void {
runSnapshotModeFlow(context, dbPath, resolveSyncCommandDeps(inputDeps));
}
export async function runMergeMode(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
await runMergeModeFlow(context, dbPath, resolveSyncCommandDeps(inputDeps));
}
export async function runCheckMode(
context: LauncherCommandContext,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
await runCheckModeFlow(context, resolveSyncCommandDeps(inputDeps));
}
export async function runHostSync(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
await runHostSyncFlow(context, dbPath, resolveSyncCommandDeps(inputDeps));
type SyncArgs = Pick<
Args,
| 'syncHost'
| 'syncSnapshotPath'
| 'syncMergePath'
| 'syncDirection'
| 'syncRemoteCmd'
| 'syncDbPath'
| 'syncForce'
| 'syncJson'
| 'syncCheck'
| 'syncMakeTemp'
| 'syncRemoveTempPath'
| 'logLevel'
>;
/** Rebuild the app's --sync-cli argv from the launcher's parsed sync args. */
export function buildSyncCliArgv(args: SyncArgs): string[] {
const argv = ['--sync-cli', 'sync'];
if (args.syncHost) argv.push(args.syncHost);
if (args.syncSnapshotPath) argv.push('--snapshot', args.syncSnapshotPath);
if (args.syncMergePath) argv.push('--merge', args.syncMergePath);
if (args.syncMakeTemp) argv.push('--make-temp');
if (args.syncRemoveTempPath) argv.push('--remove-temp', args.syncRemoveTempPath);
if (args.syncDirection === 'push') argv.push('--push');
if (args.syncDirection === 'pull') argv.push('--pull');
if (args.syncCheck) argv.push('--check');
if (args.syncRemoteCmd) argv.push('--remote-cmd', args.syncRemoteCmd);
if (args.syncDbPath) argv.push('--db', args.syncDbPath);
if (args.syncForce) argv.push('--force');
if (args.syncJson) argv.push('--json');
argv.push('--log-level', args.logLevel);
return argv;
}
/**
* `subminer sync` is a thin proxy: the sync engine only executes inside the
* SubMiner app (--sync-cli mode, libsql), so the launcher and the app cannot
* drift apart. The launcher contributes its parser/help and app discovery;
* the child owns the terminal and its exit code becomes the launcher's.
*/
export async function runSyncCommand(
context: LauncherCommandContext,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<boolean> {
return runSyncFlow(context, resolveSyncCommandDeps(inputDeps));
const deps = { ...defaultSyncCommandDeps, ...inputDeps };
if (!context.args.sync) return false;
if (!context.appPath) {
deps.fail(
context.processAdapter.platform() === 'darwin'
? 'SubMiner app binary not found (sync runs inside the app). Install SubMiner.app to /Applications or ~/Applications, or set SUBMINER_APPIMAGE_PATH.'
: 'SubMiner app binary not found (sync runs inside the app). Install the SubMiner app, or set SUBMINER_APPIMAGE_PATH.',
);
return true; // fail() never returns; this only satisfies control-flow analysis
}
deps.runAppCommand(context.appPath, buildSyncCliArgv(context.args));
return true;
}
+24
View File
@@ -1462,6 +1462,30 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
});
}
/**
* Like runAppCommandWithInherit, but with the terminal fully attached: the
* child owns stdin (ssh password/host-key prompts must reach the user) and
* writes stdout/stderr directly (NDJSON --json output must stay unwrapped).
* Used for `subminer sync`, which proxies to the app's --sync-cli mode.
*/
export function runAppCommandInteractive(appPath: string, appArgs: string[]): void {
if (maybeCaptureAppArgs(appArgs)) {
process.exit(0);
}
const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, {
stdio: ['inherit', 'inherit', 'inherit'],
env: buildAppEnv(process.env, target.env),
});
proc.once('error', (error) => {
fail(`Failed to run app command: ${error.message}`);
});
proc.once('close', (code) => {
process.exit(code ?? 0);
});
}
export function runAppCommandSilently(appPath: string, appArgs: string[]): void {
if (maybeCaptureAppArgs(appArgs)) {
process.exit(0);
-23
View File
@@ -1,23 +0,0 @@
import { Database } from 'bun:sqlite';
import type {
OpenSyncDb,
SyncDb,
SyncDbStatement,
} from '../../src/core/services/stats-sync/driver.js';
// bun:sqlite's Database.query() already caches prepared statements per SQL
// string, which is exactly what the SyncDb contract asks for.
export const openBunSyncDb: OpenSyncDb = (dbPath, options): SyncDb => {
const db = new Database(dbPath, options);
return {
query(sql: string): SyncDbStatement {
return db.query(sql);
},
exec(sql: string): void {
db.run(sql);
},
close(): void {
db.close();
},
};
};
-76
View File
@@ -1,76 +0,0 @@
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 {
createImmersionDbFixture,
insertFixtureSession,
} from '../test-support/immersion-db-fixture.js';
import { openLibsqlSyncDb } from '../../src/core/services/stats-sync/libsql-driver.js';
import { createDbSnapshot } from '../../src/core/services/stats-sync/shared.js';
import { mergeSnapshotIntoDb } from '../../src/core/services/stats-sync/merge.js';
const BASE_MS = Date.UTC(2026, 5, 1, 12, 0, 0);
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-libsql-sync-test-'));
}
function countRows(dbPath: string, sql: string): number {
const db = openLibsqlSyncDb(dbPath, { readonly: true });
try {
const row = db.query(sql).get() as { n: number } | undefined;
return Number(row?.n ?? 0);
} finally {
db.close();
}
}
// End-to-end pass of the shared engine over the libsql driver (the binding the
// Electron app's --sync-cli mode uses): snapshot a fixture DB, merge it into
// another, and re-merge to confirm idempotence.
test('libsql driver runs snapshot and merge through the shared engine', () => {
const dir = makeTmpDir();
try {
const localPath = path.join(dir, 'local.sqlite');
const remotePath = path.join(dir, 'remote.sqlite');
createImmersionDbFixture(localPath);
createImmersionDbFixture(remotePath);
insertFixtureSession(localPath, {
uuid: 'local-1',
videoKey: 'showa-e1',
animeTitleKey: 'showa',
startedAtMs: BASE_MS,
applyLifetime: true,
words: [{ headword: '見る', word: '見た', reading: 'みた', count: 2 }],
});
insertFixtureSession(remotePath, {
uuid: 'remote-1',
videoKey: 'showb-e1',
animeTitleKey: 'showb',
startedAtMs: BASE_MS + 86_400_000,
activeWatchedMs: 900_000,
cardsMined: 3,
applyLifetime: true,
words: [{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 }],
});
const snapshotPath = path.join(dir, 'remote-snapshot.sqlite');
createDbSnapshot(openLibsqlSyncDb, remotePath, snapshotPath);
assert.ok(fs.existsSync(snapshotPath));
assert.equal(countRows(snapshotPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 1);
const summary = mergeSnapshotIntoDb(openLibsqlSyncDb, localPath, snapshotPath);
assert.equal(summary.sessionsMerged, 1);
assert.equal(summary.videosAdded, 1);
assert.equal(countRows(localPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 2);
const again = mergeSnapshotIntoDb(openLibsqlSyncDb, localPath, snapshotPath);
assert.equal(again.sessionsMerged, 0);
assert.equal(again.sessionsAlreadyPresent, 1);
assert.equal(countRows(localPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 2);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
@@ -4,7 +4,17 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from 'bun:sqlite';
import { createDbSnapshot, mergeSnapshotIntoDb } from './sync-db.js';
import { openLibsqlSyncDb } from '../../src/core/services/stats-sync/libsql-driver.js';
import { createDbSnapshot as createDbSnapshotWith } from '../../src/core/services/stats-sync/shared.js';
import { mergeSnapshotIntoDb as mergeSnapshotIntoDbWith } from '../../src/core/services/stats-sync/merge.js';
// The engine only executes inside the app (libsql driver) in production, so
// these merge tests run through that same binding; bun:sqlite is used only to
// build fixtures and inspect results.
const createDbSnapshot = (dbPath: string, outPath: string) =>
createDbSnapshotWith(openLibsqlSyncDb, dbPath, outPath);
const mergeSnapshotIntoDb = (localDbPath: string, snapshotPath: string) =>
mergeSnapshotIntoDbWith(openLibsqlSyncDb, localDbPath, snapshotPath);
import {
createImmersionDbFixture,
insertFixtureSession,
-13
View File
@@ -1,13 +0,0 @@
// Re-exported from the shared stats-sync engine so the launcher and the
// app's --sync-cli mode resolve remotes and shell out identically.
export {
assertSafeSshHost,
detectRemoteShellFlavor,
quoteForRemoteShell,
resolveRemoteSubminerCommand,
runScp,
runSsh,
shellQuote,
type RemoteRunResult,
type RemoteShellFlavor,
} from '../../src/core/services/stats-sync/ssh.js';
-12
View File
@@ -1,12 +0,0 @@
import { mergeSnapshotIntoDb as mergeSnapshotIntoDbWith } from '../../src/core/services/stats-sync/merge.js';
import { openBunSyncDb } from './bun-driver.js';
import type { SyncMergeSummary } from './sync-shared.js';
export type { SyncMergeSummary } from './sync-shared.js';
export { createDbSnapshot, findLiveStatsDaemonPid } from './sync-shared.js';
export { formatMergeSummary } from '../../src/core/services/stats-sync/merge.js';
/** bun:sqlite binding of the shared snapshot-merge engine. */
export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string): SyncMergeSummary {
return mergeSnapshotIntoDbWith(openBunSyncDb, localDbPath, snapshotPath);
}
-41
View File
@@ -1,41 +0,0 @@
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
}
}
-31
View File
@@ -1,31 +0,0 @@
// bun:sqlite bindings for the shared stats-sync engine. The engine itself
// lives in src/core/services/stats-sync so the Electron app (libsql) can run
// the exact same snapshot/merge code via its own driver.
import {
createDbSnapshot as createDbSnapshotWith,
createEmptyMergeSummary,
findLiveStatsDaemonPid,
nowDbTimestamp,
readSchemaVersion,
assertMergeableSchema,
insertRow,
tableExists,
SCHEMA_VERSION,
} from '../../src/core/services/stats-sync/shared.js';
import { openBunSyncDb } from './bun-driver.js';
export {
createEmptyMergeSummary,
findLiveStatsDaemonPid,
nowDbTimestamp,
readSchemaVersion,
assertMergeableSchema,
insertRow,
tableExists,
SCHEMA_VERSION,
};
export type { SyncMergeSummary } from '../../src/core/services/stats-sync/shared.js';
export function createDbSnapshot(dbPath: string, outPath: string): void {
createDbSnapshotWith(openBunSyncDb, dbPath, outPath);
}
@@ -8,7 +8,7 @@ import {
runScp,
shellQuote,
type RemoteRunResult,
} from './ssh.js';
} from './ssh';
function remoteResult(status: number, stdout = ''): RemoteRunResult {
return { status, stdout, stderr: '' };
@@ -0,0 +1,463 @@
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 { createEmptyMergeSummary } from './shared';
import {
ensureTrackerQuiescentFlow,
runSyncFlow,
type SyncFlowContext,
type SyncFlowDeps,
} from './sync-flow';
function makeContext(overrides: Partial<SyncFlowContext['args']> = {}): SyncFlowContext {
return {
args: {
sync: true,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
logLevel: 'warn',
...overrides,
},
mpvSocketPath: '',
};
}
function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
return { status: 0, stdout, stderr: '' };
}
// recordHostSyncResult defaults to a no-op here: the real disk-writing
// implementations are bound by the entry points, never by the flow itself.
function makeDeps(overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
return {
createDbSnapshot: () => {},
mergeSnapshotIntoDb: () => createEmptyMergeSummary(),
formatMergeSummary: () => 'summary',
findLiveStatsDaemonPid: () => null,
assertSafeSshHost: () => {},
detectRemoteShellFlavor: () => 'posix',
resolveRemoteSubminerCommand: () => 'subminer',
runScp: () => {},
runSsh: () => ok(),
fail: (message: string): never => {
throw new Error(message);
},
log: () => {},
canConnectUnixSocket: async () => false,
realpathSync: (candidate) => candidate,
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
rmSync: (target, options) => fs.rmSync(target, options),
consoleLog: () => {},
writeStdout: () => true,
ensureTrackerQuiescent: async () => {},
emitEvent: () => {},
recordHostSyncResult: () => {},
resolveDefaultDbPath: () => '/tracker.sqlite',
resolvePath: (value) => value,
...overrides,
};
}
test('ensureTrackerQuiescentFlow ignores stale sockets but rejects live sockets', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
context.mpvSocketPath = '/tmp/subminer-socket';
let socketConnectable = false;
const deps = makeDeps({
realpathSync: () => '/tracker.sqlite',
canConnectUnixSocket: async () => socketConnectable,
});
await ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps);
socketConnectable = true;
await assert.rejects(
async () => ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps),
/mpv\/SubMiner session appears to be running/,
);
});
test('ensureTrackerQuiescentFlow rejects a live stats daemon and honors --force', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
const deps = makeDeps({
realpathSync: () => '/tracker.sqlite',
findLiveStatsDaemonPid: () => 4242,
});
await assert.rejects(
async () => ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps),
/stats server is running \(pid 4242\)/,
);
const forced = makeContext({ syncDbPath: '/tmp/local.sqlite', syncForce: true });
await ensureTrackerQuiescentFlow(forced, '/tmp/local.sqlite', deps);
});
test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', async () => {
const calls: string[] = [];
const deps = makeDeps({
createDbSnapshot: (dbPath, outPath) => {
calls.push(`snapshot:${dbPath}->${outPath}`);
},
mergeSnapshotIntoDb: (dbPath, snapshotPath) => {
calls.push(`merge:${dbPath}<-${snapshotPath}`);
return createEmptyMergeSummary();
},
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
assertSafeSshHost: (host) => {
calls.push(`host:${host}`);
},
runSsh: (_host, command) => {
calls.push(`ssh:${command}`);
return command.includes(' sync --make-temp') ? ok('/tmp/subminer-sync-remote\n') : ok();
},
runScp: (from, to) => {
calls.push(`scp:${from}->${to}`);
},
});
assert.equal(
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
deps,
),
true,
);
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
deps,
);
assert.ok(calls.includes('quiescent'));
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
await runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps);
assert.ok(calls.includes('host:media-box'));
await assert.rejects(
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
/sync requires a host, --snapshot <file>, or --merge <file>/,
);
assert.equal(await runSyncFlow(makeContext({ sync: false }), deps), false);
});
function makeHostDeps(calls: string[], overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
return makeDeps({
createDbSnapshot: (_dbPath, outPath) => {
calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot');
},
mergeSnapshotIntoDb: () => {
calls.push('local-merge');
return createEmptyMergeSummary();
},
ensureTrackerQuiescent: async () => {
calls.push('quiescent');
},
runSsh: (_host, command) => {
calls.push(`ssh:${command}`);
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
return ok();
},
runScp: (from, to) => {
calls.push(`scp:${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
...overrides,
});
}
test('runHostSync keeps tracker quiescent through both merges and cleans up after failure', async () => {
const calls: string[] = [];
let localTmpDir = '';
const deps = makeHostDeps(calls, {
mkdtempSync: (prefix) => {
localTmpDir = fs.mkdtempSync(prefix);
return localTmpDir;
},
runSsh: (_host, command) => {
calls.push(`ssh:${command}`);
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) {
return { status: 9, stdout: 'remote output', stderr: 'remote merge exploded' };
}
return ok();
},
});
await assert.rejects(
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
);
assert.equal(calls.filter((call) => call === 'quiescent').length, 3);
assert.ok(calls.indexOf('quiescent') < calls.findIndex((call) => call.startsWith('snapshot:')));
assert.ok(calls.includes('local-merge'));
assert.ok(calls.some((call) => call.includes(' sync --remove-temp ')));
assert.equal(fs.existsSync(localTmpDir), false);
});
test('runHostSync includes remote snapshot stderr in failures', async () => {
const deps = makeHostDeps([], {
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --snapshot ')) {
return { status: 5, stdout: '', stderr: 'snapshot permission denied' };
}
return ok();
},
});
await assert.rejects(
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
);
});
test('runHostSync push only snapshots locally and merges remotely', async () => {
const calls: string[] = [];
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncDirection: 'push' }),
makeHostDeps(calls),
);
assert.ok(calls.some((call) => call.startsWith('snapshot:')));
assert.ok(calls.some((call) => call.includes(' sync --merge ')));
assert.ok(calls.some((call) => call.startsWith('scp:') && call.includes('->media-box:')));
assert.ok(!calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(!calls.includes('local-merge'));
});
test('runHostSync pull only snapshots remotely and merges locally', async () => {
const calls: string[] = [];
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncDirection: 'pull' }),
makeHostDeps(calls),
);
assert.ok(calls.some((call) => call.includes(' sync --snapshot ')));
assert.ok(calls.some((call) => call.startsWith('scp:media-box:')));
assert.ok(calls.includes('local-merge'));
assert.ok(!calls.some((call) => call.startsWith('snapshot:')));
assert.ok(!calls.some((call) => call.includes(' sync --merge ')));
});
test('runSyncFlow --json emits NDJSON progress events and a final result', async () => {
const lines: string[] = [];
const deps = makeHostDeps([], {
consoleLog: (line) => {
lines.push(line);
},
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) return ok('remote summary text\n');
return ok();
},
});
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncJson: true }),
deps,
);
const events = lines.map((line) => JSON.parse(line));
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'),
),
);
assert.deepEqual(events[events.length - 1], { type: 'result', ok: true, error: null });
});
test('runSyncFlow --json emits an error result when the sync fails', async () => {
const lines: string[] = [];
const deps = makeHostDeps([], {
consoleLog: (line) => {
lines.push(line);
},
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) return { status: 9, stdout: '', stderr: 'boom' };
return ok();
},
});
await assert.rejects(() =>
runSyncFlow(
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('runHostSync records host sync results for saved-host bookkeeping', async () => {
const recorded: Array<{ host: string; status: string; detail: string | null }> = [];
const record: SyncFlowDeps['recordHostSyncResult'] = (host, status, detail) => {
recorded.push({ host, status, detail });
};
await runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
makeHostDeps([], { recordHostSyncResult: record }),
);
assert.deepEqual(recorded[0], {
host: 'media-box',
status: 'success',
detail: '0 sessions merged; pushed local stats',
});
await assert.rejects(() =>
runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
makeHostDeps([], {
recordHostSyncResult: record,
runSsh: (_host, command) => {
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
if (command.includes(' sync --merge ')) return { status: 9, stdout: '', stderr: 'boom' };
return ok();
},
}),
),
);
assert.equal(recorded.length, 2);
assert.equal(recorded[1]!.status, 'error');
});
test('runCheckMode --json reports ssh and remote SubMiner status', async () => {
const lines: string[] = [];
const deps = makeDeps({
consoleLog: (line) => {
lines.push(line);
},
runSsh: (_host, command) => {
if (command.includes('--version')) return ok('SubMiner 0.18.0\n');
return ok('subminer-check-ok\n');
},
});
await runSyncFlow(
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.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);
const failLines: string[] = [];
await assert.rejects(() =>
runSyncFlow(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncCheck: true, syncJson: true }),
makeDeps({
consoleLog: (line) => {
failLines.push(line);
},
resolveRemoteSubminerCommand: () => {
throw new Error('Could not find a runnable "subminer" on media-box.');
},
runSsh: () => ok('subminer-check-ok\n'),
}),
),
);
const failEvents = failLines.map((line) => JSON.parse(line));
const failedCheck = failEvents.find((event) => event.type === 'check-result');
assert.ok(failedCheck);
assert.equal(failedCheck.ok, false);
assert.match(failedCheck.error, /Could not find a runnable/);
});
test('runHostSync speaks Windows shells: app command, double quotes, temp protocol', async () => {
const sshCommands: string[] = [];
const scpCalls: string[] = [];
const winTemp = 'C:\\Users\\First Last\\AppData\\Local\\Temp\\subminer-sync-remote';
const appCmd = '"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli';
const deps = makeHostDeps([], {
detectRemoteShellFlavor: () => 'windows-cmd',
resolveRemoteSubminerCommand: () => appCmd,
createDbSnapshot: (_dbPath, outPath) => {
fs.writeFileSync(outPath, 'snapshot');
},
runSsh: (_host, command) => {
sshCommands.push(command);
if (command.includes(' sync --make-temp')) return ok(`${winTemp}\r\n`);
return ok();
},
runScp: (from, to) => {
scpCalls.push(`${from}->${to}`);
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
},
});
await runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'win-box' }), deps);
const expectedDir = 'C:/Users/First Last/AppData/Local/Temp/subminer-sync-remote';
assert.ok(sshCommands.includes(`${appCmd} sync --snapshot "${expectedDir}/snapshot.sqlite"`));
assert.ok(sshCommands.includes(`${appCmd} sync --merge "${expectedDir}/incoming.sqlite"`));
assert.ok(sshCommands.includes(`${appCmd} sync --remove-temp "${expectedDir}"`));
assert.ok(scpCalls.some((call) => call.startsWith(`win-box:${expectedDir}/snapshot.sqlite->`)));
assert.ok(scpCalls.some((call) => call.endsWith(`->win-box:${expectedDir}/incoming.sqlite`)));
// No POSIX shell-isms reach a Windows remote.
assert.ok(!sshCommands.some((command) => command.startsWith('mktemp') || command.startsWith('rm ')));
assert.ok(!sshCommands.some((command) => command.includes('PATH="$HOME')));
});
test('runSyncFlow --make-temp prints a temp dir and --remove-temp only removes sync temp dirs', async () => {
const printed: string[] = [];
const removed: string[] = [];
const madeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
try {
await runSyncFlow(
makeContext({ syncMakeTemp: true }),
makeDeps({
mkdtempSync: () => madeDir,
consoleLog: (line) => {
printed.push(line);
},
}),
);
assert.deepEqual(printed, [madeDir]);
const removeDeps = makeDeps({
rmSync: (target) => {
removed.push(target);
},
});
await runSyncFlow(makeContext({ syncRemoveTempPath: madeDir }), removeDeps);
assert.deepEqual(removed, [madeDir]);
await assert.rejects(
() => runSyncFlow(makeContext({ syncRemoveTempPath: '/etc' }), removeDeps),
/Refusing to remove a directory outside the sync temp area/,
);
assert.equal(removed.length, 1);
} finally {
fs.rmSync(madeDir, { recursive: true, force: true });
}
});