Files
SubMiner/launcher/commands/sync-command.ts
T
sudacode 58d54311d8 fix(launcher): address code review findings in stats sync
- merge-catalog: stop dropping anilist_id on every new anime insert
  (bun:sqlite .get() returns null, so the old !== undefined guard always
  fired); the guard was dead anyway since a colliding id is caught earlier
- sync-command: throw instead of fail() inside runHostSync so the finally
  block runs and temp dirs holding snapshot data are cleaned up on failure
- ssh: shell-quote the user-supplied --remote-cmd in the probe, and reject
  option-like (-prefixed) SSH hosts that ssh/scp would parse as flags
- sync-db: close the remote handle if opening the local DB throws
- sync-shared: treat EPERM from process.kill(pid, 0) as alive, not dead
- cli-parser: trim the sync host before the mode-exclusivity check
- tests: cover anilist_id preservation, anilist-based anime matching, and
  the SSH host/shell-quote guards
2026-07-08 23:51:46 -07:00

160 lines
5.4 KiB
TypeScript

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 type { LauncherCommandContext } from './context.js';
function resolveDbPath(context: LauncherCommandContext): string {
const override = context.args.syncDbPath.trim();
return override ? resolvePathMaybe(override) : resolveImmersionDbPath();
}
function isTrackerDb(dbPath: string): boolean {
const trackerDbPath = resolveImmersionDbPath();
try {
return fs.realpathSync(dbPath) === fs.realpathSync(trackerDbPath);
} catch {
return dbPath === trackerDbPath;
}
}
function ensureTrackerQuiescent(context: LauncherCommandContext, dbPath: string): void {
if (context.args.syncForce) return;
// A running SubMiner only holds the tracker's own database; --db pointed
// elsewhere needs no guard.
if (!isTrackerDb(dbPath)) return;
const daemonPid = findLiveStatsDaemonPid(dbPath);
if (daemonPid !== null) {
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 && fs.existsSync(context.mpvSocketPath)) {
fail(
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
);
}
}
function runSnapshotMode(context: LauncherCommandContext, dbPath: string): void {
const outPath = resolvePathMaybe(context.args.syncSnapshotPath);
createDbSnapshot(dbPath, outPath);
console.log(outPath);
}
function runMergeMode(context: LauncherCommandContext, dbPath: string): void {
ensureTrackerQuiescent(context, dbPath);
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
const summary = mergeSnapshotIntoDb(dbPath, snapshotPath);
console.log(formatMergeSummary(summary));
}
function cleanupRemote(host: string, remoteTmpDir: string): void {
if (!remoteTmpDir.startsWith('/tmp/')) return;
runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`);
}
function runHostSync(context: LauncherCommandContext, dbPath: string): void {
const { args } = context;
const host = args.syncHost;
assertSafeSshHost(host);
ensureTrackerQuiescent(context, dbPath);
const remoteCmd = resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
const localTmpDir = fs.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 = 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' : '';
console.log(`Snapshotting local database (${dbPath})...`);
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
createDbSnapshot(dbPath, localSnapshot);
console.log(`Snapshotting ${host}...`);
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
const snapshotRun = runSsh(
host,
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
);
if (snapshotRun.status !== 0) {
throw new Error(`Remote snapshot failed on ${host}.`);
}
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
runScp(localSnapshot, `${host}:${incomingSnapshot}`);
console.log(`\nMerging ${host} -> local:`);
const summary = mergeSnapshotIntoDb(dbPath, pulledSnapshot);
console.log(formatMergeSummary(summary));
console.log(`\nMerging local -> ${host}:`);
const mergeRun = runSsh(
host,
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
);
process.stdout.write(mergeRun.stdout);
if (mergeRun.status !== 0) {
throw new Error(
`Remote merge failed on ${host}. The local database was updated; re-run "subminer sync ${host}" once the remote issue is fixed.`,
);
}
console.log('\nSync complete.');
} finally {
fs.rmSync(localTmpDir, { recursive: true, force: true });
if (remoteTmpDir) {
try {
cleanupRemote(host, remoteTmpDir);
} catch {
// best effort
}
}
}
}
export function runSyncCommand(context: LauncherCommandContext): boolean {
const { args } = context;
if (!args.sync) return false;
const dbPath = resolveDbPath(context);
if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath);
} else if (args.syncMergePath) {
runMergeMode(context, dbPath);
} else if (args.syncHost) {
runHostSync(context, dbPath);
} else {
fail('sync requires a host, --snapshot <file>, or --merge <file>.');
}
return true;
}