Files
SubMiner/launcher/sync/ssh.ts
T
sudacode be31f96f02 feat(launcher): add sync command to merge stats and history between machines over SSH
subminer sync <host> exchanges VACUUM INTO snapshots over ssh/scp and each
side merges the other's data as an insert-only union keyed on session UUIDs,
video keys, series title keys, and word/kanji identity. Lifetime totals and
daily/monthly rollups are applied incrementally so pre-retention history
survives, remote-only historical rollups are copied, and re-syncing is
idempotent. sync --snapshot/--merge expose the underlying steps for manual
transfers; a pid-file/mpv-socket guard refuses to run while SubMiner may be
writing the database and schema-version mismatches abort the merge.
2026-07-08 23:28:10 -07:00

59 lines
1.9 KiB
TypeScript

import { spawnSync } from 'node:child_process';
export interface RemoteRunResult {
status: number;
stdout: string;
}
/**
* Run a command on the SSH host. stdin/stderr stay attached to the terminal
* so key passphrase / password prompts and remote progress output work;
* stdout is captured for the caller.
*/
export function runSsh(host: string, remoteCommand: string): RemoteRunResult {
const result = spawnSync('ssh', [host, remoteCommand], {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'inherit'],
});
if (result.error) {
throw new Error(`Failed to run ssh: ${(result.error as Error).message}`);
}
return { status: result.status ?? 1, stdout: result.stdout ?? '' };
}
export function runScp(from: string, to: string): void {
const result = spawnSync('scp', ['-q', from, to], {
encoding: 'utf8',
stdio: ['inherit', 'inherit', 'inherit'],
});
if (result.error) {
throw new Error(`Failed to run scp: ${(result.error as Error).message}`);
}
if ((result.status ?? 1) !== 0) {
throw new Error(`scp failed copying ${from} -> ${to}`);
}
}
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
/**
* Non-interactive SSH shells often miss ~/.local/bin in PATH, so probe the
* configured command first and fall back to the default install location.
*/
export function resolveRemoteSubminerCommand(host: string, preferred: string | null): string {
const candidates = preferred ? [preferred] : ['subminer', '~/.local/bin/subminer'];
for (const candidate of candidates) {
const probe = runSsh(host, `command -v ${candidate} >/dev/null 2>&1`);
if (probe.status === 0) {
return candidate;
}
}
throw new Error(
preferred
? `Remote command not found on ${host}: ${preferred}`
: `subminer not found on ${host} (tried PATH and ~/.local/bin/subminer). Pass --remote-cmd <path>.`,
);
}