diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e38b5e..db31b3d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs-site/changelog.md b/docs-site/changelog.md index 49aa06af..41d288fd 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -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) diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index 35a1c635..d20d4ec2 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -81,7 +81,7 @@ Series whose directories are not currently accessible (e.g. an unmounted network ## Sync Between Machines -`subminer sync ` 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. `` 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 ` 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. `` 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" diff --git a/launcher/commands/sync-command.test.ts b/launcher/commands/sync-command.test.ts index df307877..f2a2f2f4 100644 --- a/launcher/commands/sync-command.test.ts +++ b/launcher/commands/sync-command.test.ts @@ -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): LauncherCommandContext { +function makeContext( + overrides: Partial, + appPath: string | null = '/opt/SubMiner/subminer-app', +): LauncherCommandContext { return { args: { sync: true, @@ -19,6 +18,8 @@ function makeContext(overrides: Partial): LauncherCommandContext { syncRemoteCmd: '', syncDbPath: '', syncForce: false, + syncJson: false, + syncCheck: false, syncMakeTemp: false, syncRemoveTempPath: '', logLevel: 'warn', @@ -28,522 +29,120 @@ function makeContext(overrides: Partial): 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 = { - 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 = { - 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 = { - ...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 , or --merge /, - ); -}); - -test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => { - const calls: string[] = []; - let localTmpDir = ''; - const deps: Partial = { - ...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 = { - ...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 { +function makeArgs(overrides: Partial[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 = { - ...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 = { - ...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 = { - ...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 = { - ...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 = { - 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 = { - 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 = { - ...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 }); - } }); diff --git a/launcher/commands/sync-command.ts b/launcher/commands/sync-command.ts index 8d89910a..9f80ff4b 100644 --- a/launcher/commands/sync-command.ts +++ b/launcher/commands/sync-command.ts @@ -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 { - return { ...defaultSyncCommandDeps, ...inputDeps }; -} - -export async function ensureTrackerQuiescent( - context: SyncFlowContext, - dbPath: string, - inputDeps: Partial = {}, -): Promise { - await ensureTrackerQuiescentFlow(context, dbPath, resolveSyncCommandDeps(inputDeps)); -} - -export function runSnapshotMode( - context: LauncherCommandContext, - dbPath: string, - inputDeps: Partial = {}, -): void { - runSnapshotModeFlow(context, dbPath, resolveSyncCommandDeps(inputDeps)); -} - -export async function runMergeMode( - context: LauncherCommandContext, - dbPath: string, - inputDeps: Partial = {}, -): Promise { - await runMergeModeFlow(context, dbPath, resolveSyncCommandDeps(inputDeps)); -} - -export async function runCheckMode( - context: LauncherCommandContext, - inputDeps: Partial = {}, -): Promise { - await runCheckModeFlow(context, resolveSyncCommandDeps(inputDeps)); -} - -export async function runHostSync( - context: LauncherCommandContext, - dbPath: string, - inputDeps: Partial = {}, -): Promise { - 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 = {}, ): Promise { - 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; } diff --git a/launcher/mpv.ts b/launcher/mpv.ts index d249b9ff..208887e4 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -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); diff --git a/launcher/sync/bun-driver.ts b/launcher/sync/bun-driver.ts deleted file mode 100644 index 86ab0bb8..00000000 --- a/launcher/sync/bun-driver.ts +++ /dev/null @@ -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(); - }, - }; -}; diff --git a/launcher/sync/engine-drivers.test.ts b/launcher/sync/engine-drivers.test.ts deleted file mode 100644 index 1903306f..00000000 --- a/launcher/sync/engine-drivers.test.ts +++ /dev/null @@ -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 }); - } -}); diff --git a/launcher/sync/sync-db.test.ts b/launcher/sync/engine-merge.test.ts similarity index 96% rename from launcher/sync/sync-db.test.ts rename to launcher/sync/engine-merge.test.ts index 6f72b190..d76b0123 100644 --- a/launcher/sync/sync-db.test.ts +++ b/launcher/sync/engine-merge.test.ts @@ -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, diff --git a/launcher/sync/ssh.ts b/launcher/sync/ssh.ts deleted file mode 100644 index 0bdef08a..00000000 --- a/launcher/sync/ssh.ts +++ /dev/null @@ -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'; diff --git a/launcher/sync/sync-db.ts b/launcher/sync/sync-db.ts deleted file mode 100644 index 0d45f611..00000000 --- a/launcher/sync/sync-db.ts +++ /dev/null @@ -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); -} diff --git a/launcher/sync/sync-hosts.ts b/launcher/sync/sync-hosts.ts deleted file mode 100644 index ab6d41a8..00000000 --- a/launcher/sync/sync-hosts.ts +++ /dev/null @@ -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 - } -} diff --git a/launcher/sync/sync-shared.ts b/launcher/sync/sync-shared.ts deleted file mode 100644 index eeb1c49e..00000000 --- a/launcher/sync/sync-shared.ts +++ /dev/null @@ -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); -} diff --git a/launcher/sync/ssh.test.ts b/src/core/services/stats-sync/ssh.test.ts similarity index 99% rename from launcher/sync/ssh.test.ts rename to src/core/services/stats-sync/ssh.test.ts index 4693a58e..e78e59e4 100644 --- a/launcher/sync/ssh.test.ts +++ b/src/core/services/stats-sync/ssh.test.ts @@ -8,7 +8,7 @@ import { runScp, shellQuote, type RemoteRunResult, -} from './ssh.js'; +} from './ssh'; function remoteResult(status: number, stdout = ''): RemoteRunResult { return { status, stdout, stderr: '' }; diff --git a/src/core/services/stats-sync/sync-flow.test.ts b/src/core/services/stats-sync/sync-flow.test.ts new file mode 100644 index 00000000..e6e8f3fd --- /dev/null +++ b/src/core/services/stats-sync/sync-flow.test.ts @@ -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 { + 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 { + 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 , or --merge /, + ); + + assert.equal(await runSyncFlow(makeContext({ sync: false }), deps), false); +}); + +function makeHostDeps(calls: string[], overrides: Partial = {}): 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 }); + } +});