Sync Stats & History window, headless --sync-cli, and Windows remote support (#160)

This commit is contained in:
2026-07-13 18:56:51 -07:00
committed by GitHub
parent 66f8ca4f80
commit 49b926e08c
111 changed files with 6983 additions and 1130 deletions
+11
View File
@@ -1,4 +1,5 @@
import {
launchAppCommandDetached,
launchAppBackgroundDetached,
launchTexthookerOnly,
runAppCommandWithInherit,
@@ -7,6 +8,10 @@ import type { LauncherCommandContext } from './context.js';
type AppCommandDeps = {
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
launchSyncUiDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
launchAppBackgroundDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
@@ -15,6 +20,8 @@ type AppCommandDeps = {
const defaultAppCommandDeps: AppCommandDeps = {
runAppCommandWithInherit,
launchSyncUiDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
launchAppBackgroundDetached,
};
@@ -30,6 +37,10 @@ export function runAppPassthroughCommand(
deps.runAppCommandWithInherit(appPath, ['--settings']);
return true;
}
if (args.syncUi) {
deps.launchSyncUiDetached(appPath, args.logLevel);
return true;
}
if (!args.appPassthrough) {
return false;
}
+18
View File
@@ -206,6 +206,7 @@ test('app command starts default macOS background app detached from launcher', (
runAppCommandWithInherit: () => {
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -225,6 +226,7 @@ test('app command starts default Linux background app detached from launcher', (
runAppCommandWithInherit: () => {
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -245,6 +247,7 @@ test('app command keeps explicit passthrough args attached', () => {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
launchSyncUiDetached: () => detached.push('sync-ui'),
launchAppBackgroundDetached: () => {
detached.push('detached');
},
@@ -255,6 +258,21 @@ test('app command keeps explicit passthrough args attached', () => {
assert.deepEqual(detached, []);
});
test('sync UI command launches the app detached from the terminal', () => {
const context = createContext();
context.args.syncUi = true;
const calls: string[] = [];
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => calls.push('piped'),
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
launchAppBackgroundDetached: () => calls.push('detached'),
});
assert.equal(handled, true);
assert.deepEqual(calls, ['sync-ui:/tmp/subminer.app:warn']);
});
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
const context = createContext();
context.args.mpvStatus = true;
+2 -7
View File
@@ -37,13 +37,8 @@ function createContext(): LauncherCommandContext {
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncCliTokens: [],
syncUi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
+65 -214
View File
@@ -1,24 +1,17 @@
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 { runSyncCommand, type SyncCommandDeps } from './sync-command.js';
function makeContext(overrides: Partial<Args>): LauncherCommandContext {
function makeContext(
overrides: Partial<Args>,
appPath: string | null = '/opt/SubMiner/subminer-app',
): LauncherCommandContext {
return {
args: {
sync: true,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncCliTokens: [],
logLevel: 'warn',
...overrides,
} as Args,
@@ -26,232 +19,90 @@ function makeContext(overrides: Partial<Args>): LauncherCommandContext {
scriptName: 'subminer',
mpvSocketPath: '',
pluginRuntimeConfig: {},
appPath: null,
appPath,
launcherJellyfinConfig: {},
processAdapter: process,
processAdapter: { platform: () => 'linux' },
} as unknown as LauncherCommandContext;
}
function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
return { status: 0, stdout, stderr: '' };
}
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;
test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => {
const spawned: Array<{ appPath: string; appArgs: string[] }> = [];
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> = {
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.startsWith('mktemp ') ? 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);
runAppCommand: (appPath, appArgs) => {
spawned.push({ appPath, appArgs });
},
};
assert.equal(
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
deps,
),
await runSyncCommand(makeContext({ syncCliTokens: ['media-box', '--json'] }), deps),
true,
);
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
assert.deepEqual(spawned, [
{
appPath: '/opt/SubMiner/subminer-app',
appArgs: ['--sync-cli', 'sync', 'media-box', '--json', '--log-level', 'warn'],
},
]);
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>/,
);
assert.equal(await runSyncCommand(makeContext({ sync: false }), deps), false);
assert.equal(spawned.length, 1);
});
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => {
const calls: string[] = [];
let localTmpDir = '';
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
const spawned: string[][] = [];
const deps: Partial<SyncCommandDeps> = {
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.startsWith('mktemp ')) 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');
runAppCommand: (_appPath, appArgs) => {
spawned.push(appArgs);
},
};
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.startsWith('ssh:rm -rf ')));
assert.equal(fs.existsSync(localTmpDir), false);
});
test('runHostSync includes remote snapshot stderr in failures', async () => {
const deps: Partial<SyncCommandDeps> = {
createDbSnapshot: (_dbPath: string, outPath: string) => {
fs.writeFileSync(outPath, 'snapshot');
},
ensureTrackerQuiescent: async () => {},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
runSsh: (_host: string, command: string) => {
if (command.startsWith('mktemp ')) 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 {
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.startsWith('mktemp ')) 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 () => {
const calls: string[] = [];
await runSyncCommand(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncDirection: 'push',
syncCliTokens: [
'media-box',
'--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
],
logLevel: 'debug',
}),
makeDirectionDeps(calls),
deps,
);
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'));
assert.deepEqual(spawned, [
[
'--sync-cli',
'sync',
'media-box',
'--pull',
'--remote-cmd',
'/opt/SubMiner.AppImage',
'--db',
'/tmp/db.sqlite',
'--force',
'--json',
'--log-level',
'debug',
],
]);
});
test('runHostSync pull only snapshots remotely and merges locally', async () => {
const calls: string[] = [];
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
const deps: Partial<SyncCommandDeps> = {
runAppCommand: () => {
throw new Error('should not spawn');
},
fail: (message: string): never => {
throw new Error(message);
},
};
await runSyncCommand(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncDirection: 'pull',
}),
makeDirectionDeps(calls),
await assert.rejects(
() => runSyncCommand(makeContext({ syncCliTokens: ['media-box'] }, null), deps),
/SubMiner app binary not found \(sync runs inside the app\)/,
);
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 ')));
});
+29 -237
View File
@@ -1,253 +1,45 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fail, log } from '../log.js';
import { resolveImmersionDbPath } from '../history-db.js';
import {
createDbSnapshot,
findLiveStatsDaemonPid,
formatMergeSummary,
mergeSnapshotIntoDb,
} from '../sync/sync-db.js';
import {
assertSafeSshHost,
resolveRemoteSubminerCommand,
runScp,
runSsh,
shellQuote,
} from '../sync/ssh.js';
import { resolvePathMaybe } from '../util.js';
import { canConnectUnixSocket } from '../mpv.js';
import { SYNC_CLI_FLAG } from '../../src/core/services/stats-sync/cli-args.js';
import { fail } from '../log.js';
import { runAppCommandInteractive } from '../mpv.js';
import type { LauncherCommandContext } from './context.js';
import type { RemoteRunResult } from '../sync/ssh.js';
export interface SyncCommandDeps {
createDbSnapshot: typeof createDbSnapshot;
mergeSnapshotIntoDb: typeof mergeSnapshotIntoDb;
formatMergeSummary: typeof formatMergeSummary;
findLiveStatsDaemonPid: typeof findLiveStatsDaemonPid;
assertSafeSshHost: typeof assertSafeSshHost;
resolveRemoteSubminerCommand: typeof resolveRemoteSubminerCommand;
runScp: typeof runScp;
runSsh: typeof runSsh;
fail: typeof fail;
log: typeof log;
canConnectUnixSocket: typeof canConnectUnixSocket;
realpathSync: typeof fs.realpathSync;
mkdtempSync: typeof fs.mkdtempSync;
rmSync: typeof fs.rmSync;
consoleLog: typeof console.log;
writeStdout: typeof process.stdout.write;
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => Promise<void>;
}
function resolveDbPath(context: LauncherCommandContext): string {
const override = context.args.syncDbPath.trim();
return override ? resolvePathMaybe(override) : resolveImmersionDbPath();
}
function isTrackerDb(dbPath: string, deps: SyncCommandDeps): boolean {
const trackerDbPath = resolveImmersionDbPath();
try {
return deps.realpathSync(dbPath) === deps.realpathSync(trackerDbPath);
} catch {
return dbPath === trackerDbPath;
}
}
export async function ensureTrackerQuiescent(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
if (context.args.syncForce) return;
// A running SubMiner only holds the tracker's own database; --db pointed
// elsewhere needs no guard.
if (!isTrackerDb(dbPath, deps)) return;
const daemonPid = deps.findLiveStatsDaemonPid(dbPath);
if (daemonPid !== null) {
deps.fail(
`The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`,
);
}
if (context.mpvSocketPath && (await deps.canConnectUnixSocket(context.mpvSocketPath))) {
deps.fail(
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
);
}
runAppCommand: (appPath: string, appArgs: string[]) => void;
fail: (message: string) => never;
}
const defaultSyncCommandDeps: SyncCommandDeps = {
createDbSnapshot,
mergeSnapshotIntoDb,
formatMergeSummary,
findLiveStatsDaemonPid,
assertSafeSshHost,
resolveRemoteSubminerCommand,
runScp,
runSsh,
runAppCommand: runAppCommandInteractive,
fail,
log,
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),
};
function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps {
return { ...defaultSyncCommandDeps, ...inputDeps };
}
export function runSnapshotMode(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): void {
const deps = resolveSyncCommandDeps(inputDeps);
const outPath = resolvePathMaybe(context.args.syncSnapshotPath);
deps.createDbSnapshot(dbPath, outPath);
deps.consoleLog(outPath);
}
export async function runMergeMode(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
await deps.ensureTrackerQuiescent(context, dbPath);
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
deps.consoleLog(deps.formatMergeSummary(summary));
}
function cleanupRemote(host: string, remoteTmpDir: string, deps: SyncCommandDeps): void {
if (!remoteTmpDir.startsWith('/tmp/')) return;
deps.runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`);
}
function formatRemoteRunError(message: string, run: RemoteRunResult): string {
const stderr = run.stderr.trim();
return stderr ? `${message}\n${stderr}` : message;
}
export async function runHostSync(
context: LauncherCommandContext,
dbPath: string,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
const host = args.syncHost;
const direction = args.syncDirection ?? 'both';
const shouldPull = direction !== 'push';
const shouldPush = direction !== 'pull';
deps.assertSafeSshHost(host);
await deps.ensureTrackerQuiescent(context, dbPath);
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
deps.log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
const localTmpDir = deps.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
let remoteTmpDir = '';
try {
// Signal failures by throwing (not fail(), which exits synchronously and
// would skip the finally cleanup, leaking temp dirs holding snapshot data).
// main().catch() reports the message the same way fail() would.
const mktemp = deps.runSsh(host, 'mktemp -d /tmp/subminer-sync.XXXXXX');
remoteTmpDir = mktemp.stdout.trim();
if (mktemp.status !== 0 || !remoteTmpDir.startsWith('/tmp/')) {
throw new Error(`Could not create a temporary directory on ${host}.`);
}
const forceFlag = args.syncForce ? ' --force' : '';
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
if (shouldPush) {
deps.consoleLog(`Snapshotting local database (${dbPath})...`);
deps.createDbSnapshot(dbPath, localSnapshot);
}
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
if (shouldPull) {
deps.consoleLog(`Snapshotting ${host}...`);
const snapshotRun = deps.runSsh(
host,
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
);
if (snapshotRun.status !== 0) {
throw new Error(formatRemoteRunError(`Remote snapshot failed on ${host}.`, snapshotRun));
}
}
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
if (shouldPull) deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
if (shouldPush) deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
if (shouldPull) {
deps.consoleLog(`\nMerging ${host} -> local:`);
await deps.ensureTrackerQuiescent(context, dbPath);
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
deps.consoleLog(deps.formatMergeSummary(summary));
}
if (shouldPush) {
deps.consoleLog(`\nMerging local -> ${host}:`);
await deps.ensureTrackerQuiescent(context, dbPath);
const mergeRun = deps.runSsh(
host,
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
);
deps.writeStdout(mergeRun.stdout);
if (mergeRun.status !== 0) {
const retryCommand =
direction === 'push' ? `subminer sync ${host} --push` : `subminer sync ${host}`;
const localUpdate = shouldPull ? ' The local database was updated;' : '';
throw new Error(
formatRemoteRunError(
`Remote merge failed on ${host}.${localUpdate} re-run "${retryCommand}" once the remote issue is fixed.`,
mergeRun,
),
);
}
}
deps.consoleLog('\nSync complete.');
} finally {
deps.rmSync(localTmpDir, { recursive: true, force: true });
if (remoteTmpDir) {
try {
cleanupRemote(host, remoteTmpDir, deps);
} catch {
// best effort
}
}
}
}
/**
* `subminer sync` is a thin proxy: the sync engine only executes inside the
* SubMiner app (--sync-cli mode, libsql). The launcher contributes its
* parser/help and app discovery; the app's parseSyncCliTokens owns validation,
* so its errors reach the terminal through the child's inherited stdio. The
* child owns the terminal and its exit code becomes the launcher's.
*/
export async function runSyncCommand(
context: LauncherCommandContext,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<boolean> {
const deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
if (!args.sync) return false;
const dbPath = resolveDbPath(context);
if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) {
await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) {
await runHostSync(context, dbPath, deps);
} else {
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.');
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, [
SYNC_CLI_FLAG,
'sync',
...context.args.syncCliTokens,
'--log-level',
context.args.logLevel,
]);
return true;
}