mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
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:
+1
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user