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
|
## Unreleased
|
||||||
|
|
||||||
### Added
|
### 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.
|
- 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)
|
## v0.18.0 (2026-07-10)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
**Added**
|
**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.
|
- 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)
|
## 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
|
## 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
|
```bash
|
||||||
subminer sync macbook # two-way sync with the host "macbook"
|
subminer sync macbook # two-way sync with the host "macbook"
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 type { Args } from '../types.js';
|
||||||
import { createEmptyMergeSummary } from '../sync/sync-shared.js';
|
|
||||||
import type { LauncherCommandContext } from './context.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 {
|
return {
|
||||||
args: {
|
args: {
|
||||||
sync: true,
|
sync: true,
|
||||||
@@ -19,6 +18,8 @@ function makeContext(overrides: Partial<Args>): LauncherCommandContext {
|
|||||||
syncRemoteCmd: '',
|
syncRemoteCmd: '',
|
||||||
syncDbPath: '',
|
syncDbPath: '',
|
||||||
syncForce: false,
|
syncForce: false,
|
||||||
|
syncJson: false,
|
||||||
|
syncCheck: false,
|
||||||
syncMakeTemp: false,
|
syncMakeTemp: false,
|
||||||
syncRemoveTempPath: '',
|
syncRemoveTempPath: '',
|
||||||
logLevel: 'warn',
|
logLevel: 'warn',
|
||||||
@@ -28,522 +29,120 @@ function makeContext(overrides: Partial<Args>): LauncherCommandContext {
|
|||||||
scriptName: 'subminer',
|
scriptName: 'subminer',
|
||||||
mpvSocketPath: '',
|
mpvSocketPath: '',
|
||||||
pluginRuntimeConfig: {},
|
pluginRuntimeConfig: {},
|
||||||
appPath: null,
|
appPath,
|
||||||
launcherJellyfinConfig: {},
|
launcherJellyfinConfig: {},
|
||||||
processAdapter: process,
|
processAdapter: { platform: () => 'linux' },
|
||||||
} as unknown as LauncherCommandContext;
|
} as unknown as LauncherCommandContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
|
function makeArgs(overrides: Partial<Parameters<typeof buildSyncCliArgv>[0]>) {
|
||||||
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> {
|
|
||||||
return {
|
return {
|
||||||
...noRecord,
|
syncHost: '',
|
||||||
createDbSnapshot: (_dbPath: string, outPath: string) => {
|
syncSnapshotPath: '',
|
||||||
calls.push(`snapshot:${outPath}`);
|
syncMergePath: '',
|
||||||
fs.writeFileSync(outPath, 'snapshot');
|
syncDirection: 'both' as const,
|
||||||
},
|
syncRemoteCmd: '',
|
||||||
mergeSnapshotIntoDb: () => {
|
syncDbPath: '',
|
||||||
calls.push('local-merge');
|
syncForce: false,
|
||||||
return createEmptyMergeSummary();
|
syncJson: false,
|
||||||
},
|
syncCheck: false,
|
||||||
formatMergeSummary: () => 'summary',
|
syncMakeTemp: false,
|
||||||
ensureTrackerQuiescent: async () => {
|
syncRemoveTempPath: '',
|
||||||
calls.push('quiescent');
|
logLevel: 'warn' as const,
|
||||||
},
|
...overrides,
|
||||||
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');
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('runHostSync push only snapshots locally and merges remotely', async () => {
|
test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => {
|
||||||
const calls: string[] = [];
|
const spawned: Array<{ appPath: string; appArgs: 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[] = [];
|
|
||||||
const deps: Partial<SyncCommandDeps> = {
|
const deps: Partial<SyncCommandDeps> = {
|
||||||
...makeDirectionDeps([]),
|
runAppCommand: (appPath, appArgs) => {
|
||||||
consoleLog: (line: string) => {
|
spawned.push({ appPath, appArgs });
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await runSyncCommand(
|
await runSyncCommand(makeContext({ syncHost: 'media-box', syncJson: true }), deps),
|
||||||
makeContext({
|
|
||||||
syncDbPath: '/tmp/local.sqlite',
|
|
||||||
syncHost: 'media-box',
|
|
||||||
syncCheck: true,
|
|
||||||
syncJson: true,
|
|
||||||
}),
|
|
||||||
deps,
|
|
||||||
),
|
|
||||||
true,
|
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));
|
assert.equal(await runSyncCommand(makeContext({ sync: false }), deps), false);
|
||||||
const check = events.find((event) => event.type === 'check-result');
|
assert.equal(spawned.length, 1);
|
||||||
assert.ok(check);
|
|
||||||
assert.equal(check.host, 'media-box');
|
|
||||||
assert.equal(check.sshOk, true);
|
|
||||||
assert.equal(check.remoteCommand, 'subminer');
|
|
||||||
assert.equal(check.remoteVersion, 'SubMiner 0.18.0');
|
|
||||||
assert.equal(check.ok, true);
|
|
||||||
assert.equal(check.error, null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runSyncCommand --check --json reports failures without throwing early', async () => {
|
test('buildSyncCliArgv forwards every sync option', () => {
|
||||||
const lines: string[] = [];
|
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> = {
|
const deps: Partial<SyncCommandDeps> = {
|
||||||
ensureTrackerQuiescent: async () => {},
|
runAppCommand: () => {
|
||||||
assertSafeSshHost: () => {},
|
throw new Error('should not spawn');
|
||||||
resolveRemoteSubminerCommand: () => {
|
|
||||||
throw new Error('Could not find a runnable "subminer" on media-box.');
|
|
||||||
},
|
},
|
||||||
consoleLog: (line: string) => {
|
|
||||||
lines.push(line);
|
|
||||||
},
|
|
||||||
runSsh: () => ok('subminer-check-ok\n'),
|
|
||||||
fail: (message: string): never => {
|
fail: (message: string): never => {
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await assert.rejects(() =>
|
await assert.rejects(
|
||||||
runSyncCommand(
|
() => runSyncCommand(makeContext({ syncHost: 'media-box' }, null), deps),
|
||||||
makeContext({
|
/SubMiner app binary not found \(sync runs inside the app\)/,
|
||||||
syncDbPath: '/tmp/local.sqlite',
|
|
||||||
syncHost: 'media-box',
|
|
||||||
syncCheck: true,
|
|
||||||
syncJson: true,
|
|
||||||
}),
|
|
||||||
deps,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const events = lines.map((line) => JSON.parse(line));
|
|
||||||
const check = events.find((event) => event.type === 'check-result');
|
|
||||||
assert.ok(check);
|
|
||||||
assert.equal(check.sshOk, true);
|
|
||||||
assert.equal(check.ok, false);
|
|
||||||
assert.match(check.error, /Could not find a runnable/);
|
|
||||||
});
|
|
||||||
|
|
||||||
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 } from '../log.js';
|
||||||
import { fail, log } from '../log.js';
|
import { runAppCommandInteractive } from '../mpv.js';
|
||||||
import type { LogLevel } from '../types.js';
|
import type { Args } 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 type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
// The sync flow itself lives in src/core/services/stats-sync (shared with the
|
export interface SyncCommandDeps {
|
||||||
// app's --sync-cli mode); this module binds it to the launcher's bun:sqlite
|
runAppCommand: (appPath: string, appArgs: string[]) => void;
|
||||||
// engine, logger, and config paths.
|
fail: (message: string) => never;
|
||||||
export type SyncCommandDeps = SyncFlowDeps;
|
}
|
||||||
|
|
||||||
const defaultSyncCommandDeps: SyncCommandDeps = {
|
const defaultSyncCommandDeps: SyncCommandDeps = {
|
||||||
createDbSnapshot,
|
runAppCommand: runAppCommandInteractive,
|
||||||
mergeSnapshotIntoDb,
|
|
||||||
formatMergeSummary,
|
|
||||||
findLiveStatsDaemonPid,
|
|
||||||
assertSafeSshHost,
|
|
||||||
detectRemoteShellFlavor,
|
|
||||||
resolveRemoteSubminerCommand,
|
|
||||||
runScp,
|
|
||||||
runSsh,
|
|
||||||
fail,
|
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 {
|
type SyncArgs = Pick<
|
||||||
return { ...defaultSyncCommandDeps, ...inputDeps };
|
Args,
|
||||||
}
|
| 'syncHost'
|
||||||
|
| 'syncSnapshotPath'
|
||||||
export async function ensureTrackerQuiescent(
|
| 'syncMergePath'
|
||||||
context: SyncFlowContext,
|
| 'syncDirection'
|
||||||
dbPath: string,
|
| 'syncRemoteCmd'
|
||||||
inputDeps: Partial<SyncCommandDeps> = {},
|
| 'syncDbPath'
|
||||||
): Promise<void> {
|
| 'syncForce'
|
||||||
await ensureTrackerQuiescentFlow(context, dbPath, resolveSyncCommandDeps(inputDeps));
|
| 'syncJson'
|
||||||
}
|
| 'syncCheck'
|
||||||
|
| 'syncMakeTemp'
|
||||||
export function runSnapshotMode(
|
| 'syncRemoveTempPath'
|
||||||
context: LauncherCommandContext,
|
| 'logLevel'
|
||||||
dbPath: string,
|
>;
|
||||||
inputDeps: Partial<SyncCommandDeps> = {},
|
|
||||||
): void {
|
/** Rebuild the app's --sync-cli argv from the launcher's parsed sync args. */
|
||||||
runSnapshotModeFlow(context, dbPath, resolveSyncCommandDeps(inputDeps));
|
export function buildSyncCliArgv(args: SyncArgs): string[] {
|
||||||
}
|
const argv = ['--sync-cli', 'sync'];
|
||||||
|
if (args.syncHost) argv.push(args.syncHost);
|
||||||
export async function runMergeMode(
|
if (args.syncSnapshotPath) argv.push('--snapshot', args.syncSnapshotPath);
|
||||||
context: LauncherCommandContext,
|
if (args.syncMergePath) argv.push('--merge', args.syncMergePath);
|
||||||
dbPath: string,
|
if (args.syncMakeTemp) argv.push('--make-temp');
|
||||||
inputDeps: Partial<SyncCommandDeps> = {},
|
if (args.syncRemoveTempPath) argv.push('--remove-temp', args.syncRemoveTempPath);
|
||||||
): Promise<void> {
|
if (args.syncDirection === 'push') argv.push('--push');
|
||||||
await runMergeModeFlow(context, dbPath, resolveSyncCommandDeps(inputDeps));
|
if (args.syncDirection === 'pull') argv.push('--pull');
|
||||||
}
|
if (args.syncCheck) argv.push('--check');
|
||||||
|
if (args.syncRemoteCmd) argv.push('--remote-cmd', args.syncRemoteCmd);
|
||||||
export async function runCheckMode(
|
if (args.syncDbPath) argv.push('--db', args.syncDbPath);
|
||||||
context: LauncherCommandContext,
|
if (args.syncForce) argv.push('--force');
|
||||||
inputDeps: Partial<SyncCommandDeps> = {},
|
if (args.syncJson) argv.push('--json');
|
||||||
): Promise<void> {
|
argv.push('--log-level', args.logLevel);
|
||||||
await runCheckModeFlow(context, resolveSyncCommandDeps(inputDeps));
|
return argv;
|
||||||
}
|
|
||||||
|
|
||||||
export async function runHostSync(
|
|
||||||
context: LauncherCommandContext,
|
|
||||||
dbPath: string,
|
|
||||||
inputDeps: Partial<SyncCommandDeps> = {},
|
|
||||||
): Promise<void> {
|
|
||||||
await runHostSyncFlow(context, dbPath, resolveSyncCommandDeps(inputDeps));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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(
|
export async function runSyncCommand(
|
||||||
context: LauncherCommandContext,
|
context: LauncherCommandContext,
|
||||||
inputDeps: Partial<SyncCommandDeps> = {},
|
inputDeps: Partial<SyncCommandDeps> = {},
|
||||||
): Promise<boolean> {
|
): 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 {
|
export function runAppCommandSilently(appPath: string, appArgs: string[]): void {
|
||||||
if (maybeCaptureAppArgs(appArgs)) {
|
if (maybeCaptureAppArgs(appArgs)) {
|
||||||
process.exit(0);
|
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 os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Database } from 'bun:sqlite';
|
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 {
|
import {
|
||||||
createImmersionDbFixture,
|
createImmersionDbFixture,
|
||||||
insertFixtureSession,
|
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,
|
runScp,
|
||||||
shellQuote,
|
shellQuote,
|
||||||
type RemoteRunResult,
|
type RemoteRunResult,
|
||||||
} from './ssh.js';
|
} from './ssh';
|
||||||
|
|
||||||
function remoteResult(status: number, stdout = ''): RemoteRunResult {
|
function remoteResult(status: number, stdout = ''): RemoteRunResult {
|
||||||
return { status, stdout, stderr: '' };
|
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