feat(launcher): add subminer sync for cross-machine stats/history merge over SSH (#152)

This commit is contained in:
2026-07-11 00:40:15 -07:00
committed by GitHub
parent beeab564b4
commit 321461c50f
27 changed files with 3707 additions and 1 deletions
+36
View File
@@ -135,6 +135,15 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -181,6 +190,15 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -220,6 +238,15 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -257,6 +284,15 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
syncTriggered: false,
syncHost: null,
syncSnapshotPath: null,
syncMergePath: null,
syncDirection: 'both',
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
+19
View File
@@ -199,6 +199,14 @@ export function createDefaultArgs(
texthookerOpenBrowser: false,
useRofi: false,
history: false,
sync: false,
syncHost: '',
syncSnapshotPath: '',
syncMergePath: '',
syncDirection: 'both',
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
passwordStore: '',
@@ -264,6 +272,17 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
) {
fail('Dictionary target path is required.');
}
if (invocations.syncTriggered) {
parsed.sync = true;
parsed.syncHost = invocations.syncHost ?? '';
parsed.syncSnapshotPath = invocations.syncSnapshotPath ?? '';
parsed.syncMergePath = invocations.syncMergePath ?? '';
parsed.syncDirection = invocations.syncDirection;
parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? '';
parsed.syncDbPath = invocations.syncDbPath ?? '';
parsed.syncForce = invocations.syncForce;
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
}
if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.logsTriggered && !invocations.logsExport) {
@@ -42,3 +42,22 @@ test('parseCliPrograms captures texthooker browser-open flag', () => {
assert.equal(result.invocations.texthookerTriggered, true);
assert.equal(result.invocations.texthookerOpenBrowser, true);
});
test('parseCliPrograms captures one-way sync directions', () => {
const push = parseCliPrograms(['sync', 'media-box', '--push'], 'subminer');
const pull = parseCliPrograms(['sync', 'media-box', '--pull'], 'subminer');
assert.equal(push.invocations.syncDirection, 'push');
assert.equal(pull.invocations.syncDirection, 'pull');
});
test('parseCliPrograms rejects conflicting or hostless one-way sync directions', () => {
assert.throws(
() => parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer'),
/--push and --pull cannot be combined/,
);
assert.throws(
() => parseCliPrograms(['sync', '--snapshot', '/tmp/stats.sqlite', '--push'], 'subminer'),
/--push and --pull require a host/,
);
});
+71
View File
@@ -38,6 +38,15 @@ export interface CliInvocations {
statsCleanupVocab: boolean;
statsCleanupLifetime: boolean;
statsLogLevel: string | null;
syncTriggered: boolean;
syncHost: string | null;
syncSnapshotPath: string | null;
syncMergePath: string | null;
syncDirection: 'both' | 'push' | 'pull';
syncRemoteCmd: string | null;
syncDbPath: string | null;
syncForce: boolean;
syncLogLevel: string | null;
doctorTriggered: boolean;
doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean;
@@ -98,6 +107,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'dictionary',
'dict',
'stats',
'sync',
'texthooker',
'app',
'bin',
@@ -160,6 +170,15 @@ export function parseCliPrograms(
let statsCleanupVocab = false;
let statsCleanupLifetime = false;
let statsLogLevel: string | null = null;
let syncTriggered = false;
let syncHost: string | null = null;
let syncSnapshotPath: string | null = null;
let syncMergePath: string | null = null;
let syncDirection: 'both' | 'push' | 'pull' = 'both';
let syncRemoteCmd: string | null = null;
let syncDbPath: string | null = null;
let syncForce = false;
let syncLogLevel: string | null = null;
let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false;
let logsTriggered = false;
@@ -289,6 +308,49 @@ export function parseCliPrograms(
statsLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
commandProgram
.command('sync')
.description('Sync stats and watch history with another machine over SSH')
.argument('[host]', 'SSH destination (user@host or an ssh config alias)')
.option('--snapshot <file>', 'Write a consistent snapshot of the local stats database')
.option('--merge <file>', 'Merge a snapshot database file into the local stats database')
.option('--push', 'Only merge local stats into the SSH host')
.option('--pull', 'Only merge stats from the SSH host into the local database')
.option('--db <file>', 'Override the local stats database path')
.option('--remote-cmd <cmd>', 'subminer command to run on the remote host')
.option('-f, --force', 'Skip the running-app safety check')
.option('--log-level <level>', 'Log level')
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
const host = typeof rawHost === 'string' ? rawHost.trim() : '';
const snapshot = typeof options.snapshot === 'string' ? options.snapshot.trim() : '';
const merge = typeof options.merge === 'string' ? options.merge.trim() : '';
const push = options.push === true;
const pull = options.pull === true;
if (push && pull) {
throw new Error('Sync --push and --pull cannot be combined.');
}
if ((push || pull) && !host) {
throw new Error('Sync --push and --pull require a host.');
}
const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length;
if (modes === 0) {
throw new Error('Sync requires a host, --snapshot <file>, or --merge <file>.');
}
if (modes > 1) {
throw new Error('Sync host, --snapshot, and --merge cannot be combined.');
}
syncTriggered = true;
syncHost = host || null;
syncSnapshotPath = snapshot || null;
syncMergePath = merge || null;
syncDirection = push ? 'push' : pull ? 'pull' : 'both';
syncRemoteCmd =
typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null;
syncForce = options.force === true;
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
commandProgram
.command('doctor')
.description('Run dependency and environment checks')
@@ -400,6 +462,15 @@ export function parseCliPrograms(
statsCleanupVocab,
statsCleanupLifetime,
statsLogLevel,
syncTriggered,
syncHost,
syncSnapshotPath,
syncMergePath,
syncDirection,
syncRemoteCmd,
syncDbPath,
syncForce,
syncLogLevel,
doctorTriggered,
doctorLogLevel,
doctorRefreshKnownWords,