feat(sync): add sync-hosts store, NDJSON --json mode, and --check to subminer sync

This commit is contained in:
2026-07-11 17:45:27 -07:00
parent 8797719a09
commit 0a3f76c0a8
16 changed files with 979 additions and 27 deletions
+16
View File
@@ -143,7 +143,11 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -198,7 +202,11 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -246,7 +254,11 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
@@ -292,7 +304,11 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
syncRemoteCmd: null,
syncDbPath: null,
syncForce: false,
syncJson: false,
syncCheck: false,
syncLogLevel: null,
syncUiTriggered: false,
syncUiLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
+9
View File
@@ -207,6 +207,9 @@ export function createDefaultArgs(
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncUi: false,
logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
passwordStore: '',
@@ -281,8 +284,14 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? '';
parsed.syncDbPath = invocations.syncDbPath ?? '';
parsed.syncForce = invocations.syncForce;
parsed.syncJson = invocations.syncJson;
parsed.syncCheck = invocations.syncCheck;
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
}
if (invocations.syncUiTriggered) {
parsed.syncUi = true;
if (invocations.syncUiLogLevel) parsed.logLevel = parseLogLevel(invocations.syncUiLogLevel);
}
if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.logsTriggered && !invocations.logsExport) {
@@ -61,3 +61,36 @@ test('parseCliPrograms rejects conflicting or hostless one-way sync directions',
/--push and --pull require a host/,
);
});
test('parseCliPrograms captures sync --json and --check flags', () => {
const json = parseCliPrograms(['sync', 'media-box', '--json'], 'subminer');
assert.equal(json.invocations.syncJson, true);
assert.equal(json.invocations.syncCheck, false);
const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer');
assert.equal(check.invocations.syncCheck, true);
assert.equal(check.invocations.syncJson, true);
assert.equal(check.invocations.syncHost, 'media-box');
});
test('parseCliPrograms rejects invalid sync --check combinations', () => {
assert.throws(
() => parseCliPrograms(['sync', '--check'], 'subminer'),
/--check requires a host/,
);
assert.throws(
() => parseCliPrograms(['sync', 'media-box', '--check', '--push'], 'subminer'),
/--check cannot be combined/,
);
});
test('parseCliPrograms captures sync --ui', () => {
const result = parseCliPrograms(['sync', '--ui'], 'subminer');
assert.equal(result.invocations.syncUiTriggered, true);
assert.equal(result.invocations.syncTriggered, false);
assert.throws(
() => parseCliPrograms(['sync', 'media-box', '--ui'], 'subminer'),
/--ui cannot be combined/,
);
});
+32
View File
@@ -46,7 +46,11 @@ export interface CliInvocations {
syncRemoteCmd: string | null;
syncDbPath: string | null;
syncForce: boolean;
syncJson: boolean;
syncCheck: boolean;
syncLogLevel: string | null;
syncUiTriggered: boolean;
syncUiLogLevel: string | null;
doctorTriggered: boolean;
doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean;
@@ -178,7 +182,11 @@ export function parseCliPrograms(
let syncRemoteCmd: string | null = null;
let syncDbPath: string | null = null;
let syncForce = false;
let syncJson = false;
let syncCheck = false;
let syncLogLevel: string | null = null;
let syncUiTriggered = false;
let syncUiLogLevel: string | null = null;
let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false;
let logsTriggered = false;
@@ -319,6 +327,9 @@ export function parseCliPrograms(
.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('--check', 'Test the SSH connection and remote subminer availability')
.option('--json', 'Emit machine-readable NDJSON progress output')
.option('--ui', 'Open the SubMiner sync window')
.option('--log-level <level>', 'Log level')
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
const host = typeof rawHost === 'string' ? rawHost.trim() : '';
@@ -326,12 +337,27 @@ export function parseCliPrograms(
const merge = typeof options.merge === 'string' ? options.merge.trim() : '';
const push = options.push === true;
const pull = options.pull === true;
const check = options.check === true;
if (options.ui === true) {
if (host || snapshot || merge || push || pull || check || options.force === true) {
throw new Error('Sync --ui cannot be combined with other sync options.');
}
syncUiTriggered = true;
syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
return;
}
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.');
}
if (check && !host) {
throw new Error('Sync --check requires a host.');
}
if (check && (push || pull || snapshot || merge)) {
throw new Error('Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.');
}
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>.');
@@ -348,6 +374,8 @@ export function parseCliPrograms(
typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null;
syncForce = options.force === true;
syncJson = options.json === true;
syncCheck = check;
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
});
@@ -470,7 +498,11 @@ export function parseCliPrograms(
syncRemoteCmd,
syncDbPath,
syncForce,
syncJson,
syncCheck,
syncLogLevel,
syncUiTriggered,
syncUiLogLevel,
doctorTriggered,
doctorLogLevel,
doctorRefreshKnownWords,