feat(sync): headless --sync-cli mode so sync only needs the app installed

The Electron app now answers launcher-style sync argv (--sync-cli sync
[host|--snapshot|--merge] ... plus --help/--version) at entry, before any
window or display initialization, backed by a libsql binding of the shared
stats-sync engine. Works over SSH with no display server, so a remote
machine no longer needs the bun launcher. The launcher accepts --sync-cli
as a no-op so both invocation shapes stay equivalent.
This commit is contained in:
2026-07-11 20:23:22 -07:00
parent 04095eebf7
commit ffa183b1a1
16 changed files with 1003 additions and 1 deletions
+4
View File
@@ -79,6 +79,10 @@ ${B}Jellyfin${R}
--jellyfin-audio-stream-index ${D}N${R} Audio stream override
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
${B}Stats sync${R}
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
${D}run --sync-cli --help for details)${R}
${B}Options${R}
--socket ${D}PATH${R} mpv IPC socket path
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos, windows)${R}
+145
View File
@@ -0,0 +1,145 @@
import type { SyncFlowArgs } from './sync-flow';
export const SYNC_CLI_FLAG = '--sync-cli';
export type ParsedSyncCli =
| { kind: 'help' }
| { kind: 'version' }
| { kind: 'run'; args: SyncFlowArgs }
| { kind: 'error'; message: string };
export function extractSyncCliTokens(argv: readonly string[]): string[] | null {
const index = argv.indexOf(SYNC_CLI_FLAG);
if (index === -1) return null;
return argv.slice(index + 1).filter((token) => token !== SYNC_CLI_FLAG);
}
/**
* Parse launcher-style sync argv (`sync [host] [--snapshot f] ...`) for the
* app's --sync-cli mode. Mirrors the launcher's `subminer sync` validation so
* both entry points accept the same command lines and fail the same way.
*/
export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
if (tokens.includes('--help') || tokens.includes('-h')) return { kind: 'help' };
if (tokens.includes('--version') || tokens.includes('-V')) return { kind: 'version' };
const rest = [...tokens];
if (rest[0] !== 'sync') {
return {
kind: 'error',
message: `Expected a "sync" command after ${SYNC_CLI_FLAG} (e.g. ${SYNC_CLI_FLAG} sync --snapshot <file>).`,
};
}
rest.shift();
let host = '';
let snapshot = '';
let merge = '';
let push = false;
let pull = false;
let check = false;
let force = false;
let json = false;
let remoteCmd = '';
let dbPath = '';
let logLevel = 'warn';
const valueFlags = new Map<string, (value: string) => void>([
['--snapshot', (value) => (snapshot = value.trim())],
['--merge', (value) => (merge = value.trim())],
['--remote-cmd', (value) => (remoteCmd = value.trim())],
['--db', (value) => (dbPath = value.trim())],
['--log-level', (value) => (logLevel = value.trim() || 'warn')],
]);
for (let i = 0; i < rest.length; i += 1) {
const token = rest[i]!;
const assignValue = valueFlags.get(token.includes('=') ? token.slice(0, token.indexOf('=')) : token);
if (assignValue) {
if (token.includes('=')) {
assignValue(token.slice(token.indexOf('=') + 1));
continue;
}
const value = rest[i + 1];
if (value === undefined) {
return { kind: 'error', message: `Missing value for ${token}.` };
}
assignValue(value);
i += 1;
continue;
}
if (token === '--push') push = true;
else if (token === '--pull') pull = true;
else if (token === '--check') check = true;
else if (token === '--force' || token === '-f') force = true;
else if (token === '--json') json = true;
else if (token.startsWith('-')) {
return { kind: 'error', message: `Unknown sync option: ${token}` };
} else if (host) {
return { kind: 'error', message: `Unexpected extra argument: ${token}` };
} else {
host = token.trim();
}
}
if (push && pull) return { kind: 'error', message: 'Sync --push and --pull cannot be combined.' };
if ((push || pull) && !host) {
return { kind: 'error', message: 'Sync --push and --pull require a host.' };
}
if (check && !host) return { kind: 'error', message: 'Sync --check requires a host.' };
if (check && (push || pull || snapshot || merge)) {
return {
kind: 'error',
message: '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) {
return { kind: 'error', message: 'Sync requires a host, --snapshot <file>, or --merge <file>.' };
}
if (modes > 1) {
return { kind: 'error', message: 'Sync host, --snapshot, and --merge cannot be combined.' };
}
return {
kind: 'run',
args: {
sync: true,
syncHost: host,
syncSnapshotPath: snapshot,
syncMergePath: merge,
syncDirection: push ? 'push' : pull ? 'pull' : 'both',
syncRemoteCmd: remoteCmd,
syncDbPath: dbPath,
syncForce: force,
syncJson: json,
syncCheck: check,
logLevel,
},
};
}
export function syncCliUsage(): string {
return [
'SubMiner sync CLI',
'',
`Usage: SubMiner ${SYNC_CLI_FLAG} sync [host] [options]`,
'',
'Modes (exactly one):',
' <host> Sync stats with an SSH destination (user@host or ssh alias)',
' --snapshot <file> Write a consistent snapshot of the local stats database',
' --merge <file> Merge a snapshot database file into the local stats database',
'',
'Options:',
' --push Only merge local stats into the SSH host',
' --pull Only merge stats from the SSH host into the local database',
' --check Test the SSH connection and remote SubMiner availability',
' --db <file> Override the local stats database path',
' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host',
' -f, --force Skip the running-app safety check',
' --json Emit machine-readable NDJSON progress output',
' --log-level <level> Log level',
' --help Show this help',
' --version Show the SubMiner version',
].join('\n');
}
@@ -0,0 +1,55 @@
import { Database } from '../immersion-tracker/sqlite';
import type { OpenSyncDb, SyncDb, SyncDbRunResult, SyncDbStatement } from './driver';
interface LibsqlStatement {
run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint };
get(...params: unknown[]): unknown;
all(...params: unknown[]): unknown[];
}
interface LibsqlDatabase {
prepare(sql: string): LibsqlStatement;
exec(sql: string): unknown;
close(): unknown;
}
/**
* libsql (better-sqlite3 API) binding of the SyncDb driver for the Electron
* app. prepare() is not cached by libsql, so query() keeps a per-connection
* statement cache — the merge prepares a handful of statements and runs them
* once per copied row.
*/
export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): SyncDb => {
const db = new Database(dbPath, {
readonly: options.readonly === true,
fileMustExist: options.create !== true,
}) as unknown as LibsqlDatabase;
const statements = new Map<string, SyncDbStatement>();
return {
query(sql: string): SyncDbStatement {
const cached = statements.get(sql);
if (cached) return cached;
const statement = db.prepare(sql);
const wrapped: SyncDbStatement = {
run(...params: unknown[]): SyncDbRunResult {
return statement.run(...params);
},
get(...params: unknown[]): unknown {
return statement.get(...params);
},
all(...params: unknown[]): unknown[] {
return statement.all(...params);
},
};
statements.set(sql, wrapped);
return wrapped;
},
exec(sql: string): void {
db.exec(sql);
},
close(): void {
statements.clear();
db.close();
},
};
};
+2
View File
@@ -158,6 +158,8 @@ export function shouldForwardStartupArgvViaAppControl(
const args = parseCliArgs(argv);
if (args.help || args.appPing || args.launchMpv || args.generateConfig) return false;
if (resolveStatsDaemonCommandAction(argv) !== null) return false;
// Sync CLI argv is handled headless at entry, never forwarded to a running app.
if (argv.includes('--sync-cli')) return false;
return hasExplicitCommand(args);
}
+9
View File
@@ -30,6 +30,7 @@ import {
} from './main/runtime/first-run-setup-plugin';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
import { runSyncCliFromProcess, shouldHandleSyncCliAtEntry } from './main/sync-cli';
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import {
@@ -221,6 +222,14 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
}
async function runEntryProcess(): Promise<void> {
// Headless sync CLI: must run first (its own --help/--version handling) and
// exit before app.whenReady() so it works over SSH with no display server.
if (shouldHandleSyncCliAtEntry(process.argv, process.env)) {
const exitCode = await runSyncCliFromProcess(process.argv, app.getVersion());
app.exit(exitCode);
return;
}
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeHelpEnv(process.env);
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
+188
View File
@@ -0,0 +1,188 @@
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { parse as parseJsonc } from 'jsonc-parser';
import { resolveConfigDir, resolveConfigFilePath } from '../config/path-resolution';
import { getDefaultMpvSocketPath } from '../shared/mpv-socket-path';
import {
extractSyncCliTokens,
parseSyncCliTokens,
syncCliUsage,
} from '../core/services/stats-sync/cli-args';
import {
createDbSnapshot,
findLiveStatsDaemonPid,
} from '../core/services/stats-sync/shared';
import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
import {
assertSafeSshHost,
resolveRemoteSubminerCommand,
runScp,
runSsh,
} from '../core/services/stats-sync/ssh';
import { openLibsqlSyncDb } from '../core/services/stats-sync/libsql-driver';
import {
ensureTrackerQuiescentFlow,
runSyncFlow,
type SyncFlowDeps,
} from '../core/services/stats-sync/sync-flow';
import { recordSyncResult, readSyncHostsState, writeSyncHostsState, getSyncHostsPath } from '../shared/sync/sync-hosts-store';
export function shouldHandleSyncCliAtEntry(argv: readonly string[], env: NodeJS.ProcessEnv): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
return extractSyncCliTokens(argv) !== null;
}
function resolveAppConfigDir(): string {
return resolveConfigDir({
platform: process.platform,
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
}
function resolveTildePath(input: string): string {
return input.startsWith('~') ? path.join(os.homedir(), input.slice(1)) : path.resolve(input);
}
// Same resolution as the launcher: honor a configured immersionTracking.dbPath
// (raw config read, tolerant of comments), else <configDir>/immersion.sqlite.
function resolveDefaultDbPath(): string {
const configPath = resolveConfigFilePath({
appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
let configured = '';
try {
const data = fs.readFileSync(configPath, 'utf8');
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
const tracking =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>).immersionTracking
: null;
if (tracking && typeof tracking === 'object' && !Array.isArray(tracking)) {
const dbPath = (tracking as Record<string, unknown>).dbPath;
if (typeof dbPath === 'string') configured = dbPath.trim();
}
} catch {
// no config or unreadable config → default location
}
if (configured) return resolveTildePath(configured);
return path.join(resolveAppConfigDir(), 'immersion.sqlite');
}
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (value: boolean) => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(value);
};
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.setTimeout(400, () => finish(false));
});
}
function recordHostSyncResultToDisk(
host: string,
status: 'success' | 'error',
detail: string | null,
): void {
try {
const filePath = getSyncHostsPath(resolveAppConfigDir());
const state = recordSyncResult(readSyncHostsState(filePath), host, {
atMs: Date.now(),
status,
detail,
});
writeSyncHostsState(filePath, state);
} catch {
// best effort: bookkeeping must never fail the sync itself
}
}
function buildSyncCliDeps(): SyncFlowDeps {
const deps: SyncFlowDeps = {
createDbSnapshot: (dbPath, outPath) => createDbSnapshot(openLibsqlSyncDb, dbPath, outPath),
mergeSnapshotIntoDb: (localDbPath, snapshotPath) =>
mergeSnapshotIntoDb(openLibsqlSyncDb, localDbPath, snapshotPath),
formatMergeSummary,
findLiveStatsDaemonPid,
assertSafeSshHost,
resolveRemoteSubminerCommand,
runScp,
runSsh,
fail: (message: string): never => {
throw new Error(message);
},
log: (level, configured, message) => {
if (level === 'debug' && configured !== 'debug') return;
console.error(message);
},
canConnectUnixSocket,
realpathSync: (candidate) => fs.realpathSync(candidate),
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
rmSync: (target, options) => fs.rmSync(target, options),
consoleLog: (message) => console.log(message),
writeStdout: (text) => process.stdout.write(text),
ensureTrackerQuiescent: async (context, dbPath) =>
ensureTrackerQuiescentFlow(context, dbPath, deps),
emitEvent: () => {},
recordHostSyncResult: recordHostSyncResultToDisk,
resolveDefaultDbPath,
resolvePath: resolveTildePath,
};
return deps;
}
/**
* Headless sync entry for the Electron app: answers the same launcher-style
* `sync ...` argv as `subminer sync`, so a machine only needs the app
* installed to participate in stats sync (locally or as an SSH remote). Runs
* before any window/display initialization and never touches Electron APIs.
*/
export async function runSyncCliFromProcess(
argv: readonly string[],
appVersion: string,
): Promise<number> {
const tokens = extractSyncCliTokens(argv);
const parsed = parseSyncCliTokens(tokens ?? []);
if (parsed.kind === 'help') {
console.log(syncCliUsage());
return 0;
}
if (parsed.kind === 'version') {
console.log(`SubMiner ${appVersion}`);
return 0;
}
if (parsed.kind === 'error') {
console.error(parsed.message);
console.error(`\n${syncCliUsage()}`);
return 2;
}
const context = {
args: parsed.args,
mpvSocketPath: getDefaultMpvSocketPath(process.platform),
};
try {
await runSyncFlow(context, buildSyncCliDeps());
return 0;
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
return 1;
}
}