From 94260bab16c295bddaba77f7094c4b9c1f501e86 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 11 Jul 2026 23:53:40 -0700 Subject: [PATCH] feat(sync): Windows machines work as sync remotes Remote temp dirs are now created and removed by the remote SubMiner itself (sync --make-temp / --remove-temp, validated against its own tmpdir) instead of mktemp/rm, and the flow detects the remote shell (POSIX, cmd, PowerShell) to pick quoting and SubMiner install-location candidates - %LOCALAPPDATA% app install, launcher shim, or PATH - with no POSIX PATH prefix on Windows. Remote temp paths are normalized to forward slashes for scp and the CLI. Verified end-to-end against a throwaway sshd with the app binary as the resolved remote command. --- CHANGELOG.md | 1 + docs-site/changelog.md | 1 + docs-site/launcher-script.md | 2 + launcher/commands/playback-command.test.ts | 2 + launcher/commands/sync-command.test.ts | 110 ++++++++++++++-- launcher/commands/sync-command.ts | 2 + launcher/config/args-normalizer.test.ts | 8 ++ launcher/config/args-normalizer.ts | 4 + launcher/config/cli-parser-builder.ts | 25 +++- launcher/jellyfin.test.ts | 2 + launcher/mpv.test.ts | 2 + launcher/sync/ssh.test.ts | 111 +++++++++++++--- launcher/sync/ssh.ts | 3 + launcher/types.ts | 2 + src/core/services/stats-sync/cli-args.test.ts | 15 +++ src/core/services/stats-sync/cli-args.ts | 21 +++- src/core/services/stats-sync/ssh.ts | 105 +++++++++++++--- src/core/services/stats-sync/sync-flow.ts | 119 +++++++++++++++--- src/main/sync-cli.ts | 2 + src/syncui/index.html | 8 +- 20 files changed, 476 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1716563d..f6e38b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Stats Sync Without the Launcher: The stats sync engine now runs inside the app. The sync window no longer needs bun or the `subminer` command-line launcher installed, and a remote machine only needs SubMiner itself — the app answers `SubMiner --sync-cli sync ...` headless (works over SSH with no display), and remote lookup finds the app binary or the launcher automatically. +- Stats Sync With Windows Remotes: Sync now detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp`/`--remove-temp`) instead of `mktemp`/`rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote; SubMiner is found in its default Windows install location automatically. ## v0.18.0 (2026-07-10) diff --git a/docs-site/changelog.md b/docs-site/changelog.md index 3c40a124..49aa06af 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -4,6 +4,7 @@ **Added** - Stats Sync Without the Launcher: The stats sync engine now runs inside the app. The sync window no longer needs bun or the `subminer` command-line launcher installed, and a remote machine only needs SubMiner itself — the app answers `SubMiner --sync-cli sync ...` headless (works over SSH with no display), and remote lookup finds the app binary or the launcher automatically. +- Stats Sync With Windows Remotes: Sync now detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp`/`--remove-temp`) instead of `mktemp`/`rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote; SubMiner is found in its default Windows install location automatically. ## v0.18.0 (2026-07-10) diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index c29dbbae..35a1c635 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -101,6 +101,8 @@ Close SubMiner (and stop the background stats daemon, `subminer stats -s`) on bo On the remote, sync looks for the `subminer` launcher first (PATH and `~/.local/bin`), then the app binary in `--sync-cli` mode (`SubMiner` on PATH, then the standard macOS `/Applications` and `~/Applications` installs), checking standard SubMiner and Bun locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, `/usr/local/bin`, `/usr/bin`, and `/bin`) even when the non-interactive SSH shell omits them from `PATH`. An AppImage in a custom location can be addressed with `--remote-cmd /path/to/SubMiner.AppImage` (or symlink it as `SubMiner` somewhere on the remote PATH). +Windows remotes are supported: enable Windows' built-in **OpenSSH Server** and sync detects the remote shell (cmd or PowerShell) automatically, finding SubMiner in its default install location (`%LOCALAPPDATA%\Programs\SubMiner`), the launcher shim (`%LOCALAPPDATA%\SubMiner\bin`), or on PATH. Temp files on the remote are created and removed by SubMiner itself (`sync --make-temp` / `--remove-temp`), so no POSIX tools are required on the remote side. + Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive): ```bash diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index 4596f89b..3c071253 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -46,6 +46,8 @@ function createContext(): LauncherCommandContext { syncForce: false, syncJson: false, syncCheck: false, + syncMakeTemp: false, + syncRemoveTempPath: '', syncUi: false, logLevel: 'info', logRotation: 7, diff --git a/launcher/commands/sync-command.test.ts b/launcher/commands/sync-command.test.ts index 46e1d3b6..df307877 100644 --- a/launcher/commands/sync-command.test.ts +++ b/launcher/commands/sync-command.test.ts @@ -19,6 +19,8 @@ function makeContext(overrides: Partial): LauncherCommandContext { syncRemoteCmd: '', syncDbPath: '', syncForce: false, + syncMakeTemp: false, + syncRemoveTempPath: '', logLevel: 'warn', ...overrides, } as Args, @@ -85,7 +87,7 @@ test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes' resolveRemoteSubminerCommand: () => 'subminer', runSsh: (_host: string, command: string) => { calls.push(`ssh:${command}`); - return command.startsWith('mktemp ') ? ok('/tmp/subminer-sync.remote\n') : ok(); + return command.includes(' sync --make-temp') ? ok('/tmp/subminer-sync-remote\n') : ok(); }, runScp: (from: string, to: string) => { calls.push(`scp:${from}->${to}`); @@ -148,7 +150,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle }) as typeof fs.mkdtempSync, runSsh: (_host: string, command: string) => { calls.push(`ssh:${command}`); - if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n'); + if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n'); if (command.includes(' sync --snapshot ')) return ok(); if (command.includes(' sync --merge ')) { return { status: 9, stdout: 'remote output', stderr: 'remote merge exploded' }; @@ -169,7 +171,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle assert.equal(calls.filter((call) => call === 'quiescent').length, 3); assert.ok(calls.indexOf('quiescent') < calls.findIndex((call) => call.startsWith('snapshot:'))); assert.ok(calls.includes('local-merge')); - assert.ok(calls.some((call) => call.startsWith('ssh:rm -rf '))); + assert.ok(calls.some((call) => call.includes(' sync --remove-temp '))); assert.equal(fs.existsSync(localTmpDir), false); }); @@ -183,7 +185,7 @@ test('runHostSync includes remote snapshot stderr in failures', async () => { assertSafeSshHost: () => {}, resolveRemoteSubminerCommand: () => 'subminer', runSsh: (_host: string, command: string) => { - if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n'); + if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n'); if (command.includes(' sync --snapshot ')) { return { status: 5, stdout: '', stderr: 'snapshot permission denied' }; } @@ -218,7 +220,7 @@ function makeDirectionDeps(calls: string[]): Partial { resolveRemoteSubminerCommand: () => 'subminer', runSsh: (_host: string, command: string) => { calls.push(`ssh:${command}`); - if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n'); + if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n'); return ok(); }, runScp: (from: string, to: string) => { @@ -278,7 +280,7 @@ test('runSyncCommand --json emits NDJSON progress events for a two-way host sync return true; }) as unknown as typeof process.stdout.write, runSsh: (_host: string, command: string) => { - if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n'); + if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n'); if (command.includes(' sync --merge ')) return ok('remote summary text\n'); return ok(); }, @@ -314,7 +316,7 @@ test('runSyncCommand --json emits an error result when the sync fails', async () }, writeStdout: (() => true) as unknown as typeof process.stdout.write, runSsh: (_host: string, command: string) => { - if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n'); + if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n'); if (command.includes(' sync --merge ')) { return { status: 9, stdout: '', stderr: 'remote merge exploded' }; } @@ -358,7 +360,7 @@ test('runSyncCommand records host sync results for saved-host bookkeeping', asyn const failingDeps: Partial = { ...deps, runSsh: (_host: string, command: string) => { - if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n'); + if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n'); if (command.includes(' sync --merge ')) { return { status: 9, stdout: '', stderr: 'boom' }; } @@ -453,3 +455,95 @@ test('runSyncCommand --check --json reports failures without throwing early', as assert.equal(check.ok, false); assert.match(check.error, /Could not find a runnable/); }); + +test('runHostSync speaks Windows shells: app command, double quotes, temp protocol', async () => { + const sshCommands: string[] = []; + const scpCalls: string[] = []; + const winTemp = 'C:\\Users\\First Last\\AppData\\Local\\Temp\\subminer-sync-remote'; + const deps: Partial = { + ...noRecord, + createDbSnapshot: (_dbPath: string, outPath: string) => { + fs.writeFileSync(outPath, 'snapshot'); + }, + mergeSnapshotIntoDb: () => createEmptyMergeSummary(), + formatMergeSummary: () => 'summary', + ensureTrackerQuiescent: async () => {}, + assertSafeSshHost: () => {}, + detectRemoteShellFlavor: () => 'windows-cmd', + resolveRemoteSubminerCommand: () => '"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli', + runSsh: (_host: string, command: string) => { + sshCommands.push(command); + if (command.includes(' sync --make-temp')) return ok(`${winTemp}\r\n`); + return ok(); + }, + runScp: (from: string, to: string) => { + scpCalls.push(`${from}->${to}`); + if (!to.includes(':')) fs.writeFileSync(to, 'pulled'); + }, + }; + + await runSyncCommand( + makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'win-box' }), + deps, + ); + + const expectedDir = 'C:/Users/First Last/AppData/Local/Temp/subminer-sync-remote'; + assert.ok( + sshCommands.includes( + `"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli sync --snapshot "${expectedDir}/snapshot.sqlite"`, + ), + ); + assert.ok( + sshCommands.includes( + `"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli sync --merge "${expectedDir}/incoming.sqlite"`, + ), + ); + assert.ok( + sshCommands.includes( + `"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli sync --remove-temp "${expectedDir}"`, + ), + ); + assert.ok(scpCalls.some((call) => call.startsWith(`win-box:${expectedDir}/snapshot.sqlite->`))); + assert.ok(scpCalls.some((call) => call.endsWith(`->win-box:${expectedDir}/incoming.sqlite`))); + // No POSIX shell-isms reach a Windows remote. + assert.ok(!sshCommands.some((command) => command.startsWith('mktemp') || command.startsWith('rm '))); + assert.ok(!sshCommands.some((command) => command.includes('PATH="$HOME'))); +}); + +test('runSyncCommand --make-temp prints a temp dir and --remove-temp only removes sync temp dirs', async () => { + const printed: string[] = []; + const removed: string[] = []; + const madeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-')); + try { + await runSyncCommand(makeContext({ syncMakeTemp: true }), { + ...noRecord, + mkdtempSync: (() => madeDir) as unknown as typeof fs.mkdtempSync, + consoleLog: (line: string) => { + printed.push(line); + }, + }); + assert.deepEqual(printed, [madeDir]); + + await runSyncCommand(makeContext({ syncRemoveTempPath: madeDir }), { + ...noRecord, + rmSync: ((target: string) => { + removed.push(target); + }) as typeof fs.rmSync, + }); + assert.deepEqual(removed, [madeDir]); + + await assert.rejects( + () => + runSyncCommand(makeContext({ syncRemoveTempPath: '/etc' }), { + ...noRecord, + rmSync: ((target: string) => { + removed.push(target); + }) as typeof fs.rmSync, + }), + /Refusing to remove a directory outside the sync temp area/, + ); + assert.equal(removed.length, 1); + } finally { + fs.rmSync(madeDir, { recursive: true, force: true }); + } +}); diff --git a/launcher/commands/sync-command.ts b/launcher/commands/sync-command.ts index 7320d027..8d89910a 100644 --- a/launcher/commands/sync-command.ts +++ b/launcher/commands/sync-command.ts @@ -10,6 +10,7 @@ import { } from '../sync/sync-db.js'; import { assertSafeSshHost, + detectRemoteShellFlavor, resolveRemoteSubminerCommand, runScp, runSsh, @@ -40,6 +41,7 @@ const defaultSyncCommandDeps: SyncCommandDeps = { formatMergeSummary, findLiveStatsDaemonPid, assertSafeSshHost, + detectRemoteShellFlavor, resolveRemoteSubminerCommand, runScp, runSsh, diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts index 9037a45b..cfaa9b3a 100644 --- a/launcher/config/args-normalizer.test.ts +++ b/launcher/config/args-normalizer.test.ts @@ -145,6 +145,8 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { syncForce: false, syncJson: false, syncCheck: false, + syncMakeTemp: false, + syncRemoveTempPath: '', syncLogLevel: null, syncUiTriggered: false, syncUiLogLevel: null, @@ -204,6 +206,8 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () => syncForce: false, syncJson: false, syncCheck: false, + syncMakeTemp: false, + syncRemoveTempPath: '', syncLogLevel: null, syncUiTriggered: false, syncUiLogLevel: null, @@ -256,6 +260,8 @@ test('applyInvocationsToArgs fails when config invocation has no action', () => syncForce: false, syncJson: false, syncCheck: false, + syncMakeTemp: false, + syncRemoveTempPath: '', syncLogLevel: null, syncUiTriggered: false, syncUiLogLevel: null, @@ -306,6 +312,8 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => { syncForce: false, syncJson: false, syncCheck: false, + syncMakeTemp: false, + syncRemoveTempPath: '', syncLogLevel: null, syncUiTriggered: false, syncUiLogLevel: null, diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 7d938ec3..ed66dcc1 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -209,6 +209,8 @@ export function createDefaultArgs( syncForce: false, syncJson: false, syncCheck: false, + syncMakeTemp: false, + syncRemoveTempPath: '', syncUi: false, logLevel: loggingConfig.level ?? 'warn', logRotation: loggingConfig.rotation ?? 7, @@ -286,6 +288,8 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations parsed.syncForce = invocations.syncForce; parsed.syncJson = invocations.syncJson; parsed.syncCheck = invocations.syncCheck; + parsed.syncMakeTemp = invocations.syncMakeTemp; + parsed.syncRemoveTempPath = invocations.syncRemoveTempPath ?? ''; if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel); } if (invocations.syncUiTriggered) { diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 0997b57c..2d1b1199 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -48,6 +48,8 @@ export interface CliInvocations { syncForce: boolean; syncJson: boolean; syncCheck: boolean; + syncMakeTemp: boolean; + syncRemoveTempPath: string | null; syncLogLevel: string | null; syncUiTriggered: boolean; syncUiLogLevel: string | null; @@ -188,6 +190,8 @@ export function parseCliPrograms( let syncForce = false; let syncJson = false; let syncCheck = false; + let syncMakeTemp = false; + let syncRemoveTempPath: string | null = null; let syncLogLevel: string | null = null; let syncUiTriggered = false; let syncUiLogLevel: string | null = null; @@ -333,6 +337,8 @@ export function parseCliPrograms( .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('--make-temp', 'Create a sync temp directory and print its path (used over SSH)') + .option('--remove-temp ', 'Remove a sync temp directory created by --make-temp') .option('--ui', 'Open the SubMiner sync window') .option('--log-level ', 'Log level') .action((rawHost: string | undefined, options: Record) => { @@ -342,6 +348,8 @@ export function parseCliPrograms( const push = options.push === true; const pull = options.pull === true; const check = options.check === true; + const makeTemp = options.makeTemp === true; + const removeTemp = typeof options.removeTemp === 'string' ? options.removeTemp.trim() : ''; 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.'); @@ -364,12 +372,21 @@ export function parseCliPrograms( 'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.', ); } - const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length; + if ((makeTemp || removeTemp) && (push || pull || check)) { + throw new Error('Sync --make-temp/--remove-temp cannot be combined with other sync options.'); + } + const modes = [ + Boolean(host), + Boolean(snapshot), + Boolean(merge), + makeTemp, + Boolean(removeTemp), + ].filter(Boolean).length; if (modes === 0) { throw new Error('Sync requires a host, --snapshot , or --merge .'); } if (modes > 1) { - throw new Error('Sync host, --snapshot, and --merge cannot be combined.'); + throw new Error('Sync host, --snapshot, --merge, --make-temp, and --remove-temp cannot be combined.'); } syncTriggered = true; syncHost = host || null; @@ -382,6 +399,8 @@ export function parseCliPrograms( syncForce = options.force === true; syncJson = options.json === true; syncCheck = check; + syncMakeTemp = makeTemp; + syncRemoveTempPath = removeTemp || null; syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; }); @@ -506,6 +525,8 @@ export function parseCliPrograms( syncForce, syncJson, syncCheck, + syncMakeTemp, + syncRemoveTempPath, syncLogLevel, syncUiTriggered, syncUiLogLevel, diff --git a/launcher/jellyfin.test.ts b/launcher/jellyfin.test.ts index 4b52cb69..8ba4ef5f 100644 --- a/launcher/jellyfin.test.ts +++ b/launcher/jellyfin.test.ts @@ -40,6 +40,8 @@ function createArgs(): Args { syncForce: false, syncJson: false, syncCheck: false, + syncMakeTemp: false, + syncRemoveTempPath: '', syncUi: false, logLevel: 'info', logRotation: 7, diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 2738eee0..104ce667 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -581,6 +581,8 @@ function makeArgs(overrides: Partial = {}): Args { syncForce: false, syncJson: false, syncCheck: false, + syncMakeTemp: false, + syncRemoveTempPath: '', syncUi: false, logLevel: 'error', logRotation: 7, diff --git a/launcher/sync/ssh.test.ts b/launcher/sync/ssh.test.ts index 208fd49c..4693a58e 100644 --- a/launcher/sync/ssh.test.ts +++ b/launcher/sync/ssh.test.ts @@ -1,6 +1,18 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { assertSafeSshHost, resolveRemoteSubminerCommand, runScp, shellQuote } from './ssh.js'; +import { + assertSafeSshHost, + detectRemoteShellFlavor, + quoteForRemoteShell, + resolveRemoteSubminerCommand, + runScp, + shellQuote, + type RemoteRunResult, +} from './ssh.js'; + +function remoteResult(status: number, stdout = ''): RemoteRunResult { + return { status, stdout, stderr: '' }; +} test('assertSafeSshHost rejects option-like hosts', () => { assert.throws(() => assertSafeSshHost('-oProxyCommand=touch pwned'), /looks like an option/); @@ -32,9 +44,9 @@ test('runScp rejects option-like remote host components', () => { test('resolveRemoteSubminerCommand verifies the launcher under the remote runtime PATH', () => { const calls: Array<{ host: string; remoteCommand: string }> = []; - const command = resolveRemoteSubminerCommand('macbook', null, (host, remoteCommand) => { + const command = resolveRemoteSubminerCommand('macbook', null, 'posix', (host, remoteCommand) => { calls.push({ host, remoteCommand }); - return { status: 0, stdout: '', stderr: '' }; + return remoteResult(0); }); assert.equal( @@ -52,9 +64,9 @@ test('resolveRemoteSubminerCommand verifies the launcher under the remote runtim test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mode', () => { const probed: string[] = []; - const command = resolveRemoteSubminerCommand('media-box', null, (_host, remoteCommand) => { + const command = resolveRemoteSubminerCommand('media-box', null, 'posix', (_host, remoteCommand) => { probed.push(remoteCommand); - return { status: remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1, stdout: '', stderr: '' }; + return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1); }); assert.match(command, / SubMiner --sync-cli$/); @@ -65,25 +77,86 @@ test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mo }); test('resolveRemoteSubminerCommand probes a user override as app first, then launcher', () => { - const probed: string[] = []; - const asApp = resolveRemoteSubminerCommand('media-box', '/opt/SubMiner.AppImage', (_host, cmd) => { - probed.push(cmd); - return { status: cmd.includes('--sync-cli') ? 0 : 1, stdout: '', stderr: '' }; - }); + const asApp = resolveRemoteSubminerCommand('media-box', '/opt/SubMiner.AppImage', 'posix', (_host, cmd) => + remoteResult(cmd.includes('--sync-cli') ? 0 : 1), + ); assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/); - const asLauncher = resolveRemoteSubminerCommand('media-box', '/opt/subminer', (_host, cmd) => { - return { status: cmd.includes('--sync-cli') ? 1 : 0, stdout: '', stderr: '' }; - }); + const asLauncher = resolveRemoteSubminerCommand('media-box', '/opt/subminer', 'posix', (_host, cmd) => + remoteResult(cmd.includes('--sync-cli') ? 1 : 0), + ); assert.match(asLauncher, /'\/opt\/subminer'$/); assert.throws( - () => - resolveRemoteSubminerCommand('media-box', '/missing', () => ({ - status: 127, - stdout: '', - stderr: '', - })), + () => resolveRemoteSubminerCommand('media-box', '/missing', 'posix', () => remoteResult(127)), /Remote command not found on media-box: \/missing/, ); }); + +test('resolveRemoteSubminerCommand probes Windows install locations without a PATH prefix', () => { + const probed: string[] = []; + const command = resolveRemoteSubminerCommand('win-box', null, 'windows-cmd', (_host, cmd) => { + probed.push(cmd); + return remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1); + }); + + assert.equal(command, '"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli'); + assert.equal(probed[0], 'subminer --help'); + assert.equal(probed[1], '"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd" --help'); + + const powershell = resolveRemoteSubminerCommand('win-box', null, 'windows-powershell', (_host, cmd) => + remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1), + ); + assert.equal(powershell, '& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe" --sync-cli'); +}); + +test('resolveRemoteSubminerCommand quotes Windows overrides with double quotes', () => { + const command = resolveRemoteSubminerCommand( + 'win-box', + 'C:/Apps/SubMiner/SubMiner.exe', + 'windows-cmd', + (_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 0 : 1), + ); + assert.equal(command, '"C:/Apps/SubMiner/SubMiner.exe" --sync-cli'); +}); + +test('detectRemoteShellFlavor identifies posix, cmd, and powershell remotes', () => { + assert.equal( + detectRemoteShellFlavor('linux-box', (_host, cmd) => + cmd === 'uname -s' ? remoteResult(0, 'Linux\n') : remoteResult(1), + ), + 'posix', + ); + assert.equal( + detectRemoteShellFlavor('win-box', (_host, cmd) => { + if (cmd === 'uname -s') return remoteResult(1); + if (cmd === 'echo %OS%') return remoteResult(0, 'Windows_NT\r\n'); + return remoteResult(1); + }), + 'windows-cmd', + ); + assert.equal( + detectRemoteShellFlavor('ps-box', (_host, cmd) => { + if (cmd === 'uname -s') return remoteResult(1); + if (cmd === 'echo %OS%') return remoteResult(0, '%OS%\r\n'); + if (cmd === 'echo $env:OS') return remoteResult(0, 'Windows_NT\r\n'); + return remoteResult(1); + }), + 'windows-powershell', + ); + // Unidentifiable remotes keep the pre-detection POSIX behavior. + assert.equal( + detectRemoteShellFlavor('odd-box', () => remoteResult(1)), + 'posix', + ); +}); + +test('quoteForRemoteShell quotes per flavor and rejects unsafe Windows values', () => { + assert.equal(quoteForRemoteShell('posix', "/tmp/it's"), `'/tmp/it'\\''s'`); + assert.equal( + quoteForRemoteShell('windows-cmd', 'C:/Users/First Last/AppData/Local/Temp/subminer-sync-ab'), + '"C:/Users/First Last/AppData/Local/Temp/subminer-sync-ab"', + ); + assert.throws(() => quoteForRemoteShell('windows-cmd', 'a"b'), /Refusing to quote/); + assert.throws(() => quoteForRemoteShell('windows-powershell', 'a\nb'), /Refusing to quote/); +}); diff --git a/launcher/sync/ssh.ts b/launcher/sync/ssh.ts index 4395fbda..0bdef08a 100644 --- a/launcher/sync/ssh.ts +++ b/launcher/sync/ssh.ts @@ -2,9 +2,12 @@ // app's --sync-cli mode resolve remotes and shell out identically. export { assertSafeSshHost, + detectRemoteShellFlavor, + quoteForRemoteShell, resolveRemoteSubminerCommand, runScp, runSsh, shellQuote, type RemoteRunResult, + type RemoteShellFlavor, } from '../../src/core/services/stats-sync/ssh.js'; diff --git a/launcher/types.ts b/launcher/types.ts index f4f58be3..09a743af 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -123,6 +123,8 @@ export interface Args { syncForce: boolean; syncJson: boolean; syncCheck: boolean; + syncMakeTemp: boolean; + syncRemoveTempPath: string; syncUi: boolean; logLevel: LogLevel; logRotation: LogRotation; diff --git a/src/core/services/stats-sync/cli-args.test.ts b/src/core/services/stats-sync/cli-args.test.ts index e2ee8f02..5faf212e 100644 --- a/src/core/services/stats-sync/cli-args.test.ts +++ b/src/core/services/stats-sync/cli-args.test.ts @@ -36,6 +36,21 @@ test('parseSyncCliTokens handles help, version, and run modes', () => { } }); +test('parseSyncCliTokens handles the temp-dir protocol modes', () => { + const makeTemp = parseSyncCliTokens(['sync', '--make-temp']); + assert.equal(makeTemp.kind, 'run'); + if (makeTemp.kind === 'run') assert.equal(makeTemp.args.syncMakeTemp, true); + + const removeTemp = parseSyncCliTokens(['sync', '--remove-temp', '/tmp/subminer-sync-x']); + assert.equal(removeTemp.kind, 'run'); + if (removeTemp.kind === 'run') { + assert.equal(removeTemp.args.syncRemoveTempPath, '/tmp/subminer-sync-x'); + } + + assert.equal(parseSyncCliTokens(['sync', '--make-temp', 'host']).kind, 'error'); + assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error'); +}); + test('parseSyncCliTokens mirrors launcher sync validation', () => { assert.equal(parseSyncCliTokens([]).kind, 'error'); assert.equal(parseSyncCliTokens(['sync']).kind, 'error'); diff --git a/src/core/services/stats-sync/cli-args.ts b/src/core/services/stats-sync/cli-args.ts index f93f9588..eb5554b4 100644 --- a/src/core/services/stats-sync/cli-args.ts +++ b/src/core/services/stats-sync/cli-args.ts @@ -40,6 +40,8 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli { let check = false; let force = false; let json = false; + let makeTemp = false; + let removeTemp = ''; let remoteCmd = ''; let dbPath = ''; let logLevel = 'warn'; @@ -47,6 +49,7 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli { const valueFlags = new Map void>([ ['--snapshot', (value) => (snapshot = value.trim())], ['--merge', (value) => (merge = value.trim())], + ['--remove-temp', (value) => (removeTemp = value.trim())], ['--remote-cmd', (value) => (remoteCmd = value.trim())], ['--db', (value) => (dbPath = value.trim())], ['--log-level', (value) => (logLevel = value.trim() || 'warn')], @@ -73,6 +76,7 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli { else if (token === '--check') check = true; else if (token === '--force' || token === '-f') force = true; else if (token === '--json') json = true; + else if (token === '--make-temp') makeTemp = true; else if (token.startsWith('-')) { return { kind: 'error', message: `Unknown sync option: ${token}` }; } else if (host) { @@ -93,12 +97,21 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli { message: 'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.', }; } - const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length; + const modes = [ + Boolean(host), + Boolean(snapshot), + Boolean(merge), + makeTemp, + Boolean(removeTemp), + ].filter(Boolean).length; if (modes === 0) { return { kind: 'error', message: 'Sync requires a host, --snapshot , or --merge .' }; } if (modes > 1) { - return { kind: 'error', message: 'Sync host, --snapshot, and --merge cannot be combined.' }; + return { + kind: 'error', + message: 'Sync host, --snapshot, --merge, --make-temp, and --remove-temp cannot be combined.', + }; } return { @@ -114,6 +127,8 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli { syncForce: force, syncJson: json, syncCheck: check, + syncMakeTemp: makeTemp, + syncRemoveTempPath: removeTemp, logLevel, }, }; @@ -138,6 +153,8 @@ export function syncCliUsage(): string { ' --remote-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', + ' --make-temp Create a sync temp directory and print its path (used over SSH)', + ' --remove-temp Remove a sync temp directory created by --make-temp', ' --log-level Log level', ' --help Show this help', ' --version Show the SubMiner version', diff --git a/src/core/services/stats-sync/ssh.ts b/src/core/services/stats-sync/ssh.ts index 4bb16198..19567da0 100644 --- a/src/core/services/stats-sync/ssh.ts +++ b/src/core/services/stats-sync/ssh.ts @@ -71,6 +71,48 @@ export function shellQuote(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'`; } +/** + * The shell that Windows OpenSSH hands remote commands to (cmd.exe by + * default, PowerShell when DefaultShell is changed) — it decides quoting, + * environment-variable expansion, and which SubMiner install paths to probe. + */ +export type RemoteShellFlavor = 'posix' | 'windows-cmd' | 'windows-powershell'; + +/** + * Identify the remote shell with probes that are harmless everywhere: + * `uname -s` only succeeds on a POSIX shell, `echo %OS%` only expands under + * cmd.exe, and `echo $env:OS` only expands under PowerShell. Defaults to + * posix so unreachable/odd hosts fail with the familiar POSIX errors. + */ +export function detectRemoteShellFlavor( + host: string, + runRemote: typeof runSsh = runSsh, +): RemoteShellFlavor { + const posixProbe = runRemote(host, 'uname -s'); + if (posixProbe.status === 0 && posixProbe.stdout.trim().length > 0) return 'posix'; + const cmdProbe = runRemote(host, 'echo %OS%'); + if (cmdProbe.status === 0 && cmdProbe.stdout.includes('Windows_NT')) return 'windows-cmd'; + const powershellProbe = runRemote(host, 'echo $env:OS'); + if (powershellProbe.status === 0 && powershellProbe.stdout.includes('Windows_NT')) { + return 'windows-powershell'; + } + return 'posix'; +} + +/** + * Quote one argument for the detected remote shell. Windows shells have no + * safe single-quote escaping (cmd treats ' literally; PowerShell and cmd + * disagree on embedded double quotes), so quote-containing values are + * rejected instead of escaped — sync only ever quotes paths it composed. + */ +export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): string { + if (flavor === 'posix') return shellQuote(value); + if (value.includes('"') || /[\r\n]/.test(value)) { + throw new Error(`Refusing to quote a value with quotes or newlines for a Windows shell: ${value}`); + } + return `"${value}"`; +} + const REMOTE_RUNTIME_PATH = 'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"'; @@ -83,7 +125,27 @@ interface RemoteCandidate { invocation: string; } -function defaultRemoteCandidates(): RemoteCandidate[] { +function defaultRemoteCandidates(flavor: RemoteShellFlavor): RemoteCandidate[] { + if (flavor === 'windows-cmd' || flavor === 'windows-powershell') { + // cmd.exe expands %VAR% inside double quotes; PowerShell needs $env: and + // the & call operator to run a quoted path. + const launcherShim = + flavor === 'windows-cmd' + ? `"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd"` + : `& "$env:LOCALAPPDATA\\SubMiner\\bin\\subminer.cmd"`; + const appInstall = + flavor === 'windows-cmd' + ? `"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe"` + : `& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe"`; + return [ + // Command-line launcher shim on PATH or in its default install dir. + { invocation: 'subminer' }, + { invocation: launcherShim }, + // The app binary itself in sync-CLI mode (default NSIS install dir). + { invocation: `${appInstall} ${APP_SYNC_CLI_FLAG}` }, + { invocation: `SubMiner ${APP_SYNC_CLI_FLAG}` }, + ]; + } return [ // Command-line launcher (bun script) on PATH or in its default install dir. { invocation: 'subminer' }, @@ -95,29 +157,38 @@ function defaultRemoteCandidates(): RemoteCandidate[] { ]; } +function preferredCandidates(flavor: RemoteShellFlavor, preferred: string): RemoteCandidate[] { + const quoted = quoteForRemoteShell(flavor, preferred); + const invocation = flavor === 'windows-powershell' ? `& ${quoted}` : quoted; + // App binaries also answer plain --help by opening the GUI-oriented help + // path, so probe the sync-CLI shape first. + return [{ invocation: `${invocation} ${APP_SYNC_CLI_FLAG}` }, { invocation }]; +} + /** - * Non-interactive SSH shells often miss user-installed launchers and Bun. - * Probe candidates under the same deterministic PATH used by sync itself. - * Trusted defaults stay unquoted so the remote shell expands `~`; a - * user-supplied override is shell-quoted to prevent command injection and - * probed both as an app binary (--sync-cli) and as a launcher. + * Non-interactive POSIX SSH shells often miss user-installed launchers and + * Bun, so those candidates are probed under the same deterministic PATH sync + * itself uses; Windows shells get their default install locations instead. + * Trusted defaults stay unquoted so the remote shell expands `~`/%VAR%; a + * user-supplied override is quoted to prevent command injection and probed + * both as an app binary (--sync-cli) and as a launcher. */ export function resolveRemoteSubminerCommand( host: string, preferred: string | null, + flavor: RemoteShellFlavor = 'posix', runRemote: typeof runSsh = runSsh, ): string { - const candidates: RemoteCandidate[] = preferred - ? [ - // App binaries also answer plain --help by opening the GUI-oriented - // help path, so probe the sync-CLI shape first. - { invocation: `${shellQuote(preferred)} ${APP_SYNC_CLI_FLAG}` }, - { invocation: shellQuote(preferred) }, - ] - : defaultRemoteCandidates(); + const candidates = preferred + ? preferredCandidates(flavor, preferred) + : defaultRemoteCandidates(flavor); for (const candidate of candidates) { - const command = `${REMOTE_RUNTIME_PATH} ${candidate.invocation}`; - const probe = runRemote(host, `${command} --help >/dev/null 2>&1`); + const command = + flavor === 'posix' ? `${REMOTE_RUNTIME_PATH} ${candidate.invocation}` : candidate.invocation; + const probe = runRemote( + host, + flavor === 'posix' ? `${command} --help >/dev/null 2>&1` : `${command} --help`, + ); if (probe.status === 0) { return command; } @@ -125,6 +196,6 @@ export function resolveRemoteSubminerCommand( throw new Error( preferred ? `Remote command not found on ${host}: ${preferred}` - : `SubMiner not found on ${host} (tried the subminer launcher on PATH and ~/.local/bin, and the SubMiner app binary). Pass --remote-cmd pointing at the SubMiner app or launcher.`, + : `SubMiner not found on ${host} (tried the subminer launcher and the SubMiner app binary in their default install locations). Pass --remote-cmd pointing at the SubMiner app or launcher.`, ); } diff --git a/src/core/services/stats-sync/sync-flow.ts b/src/core/services/stats-sync/sync-flow.ts index 40d64e62..cb69101a 100644 --- a/src/core/services/stats-sync/sync-flow.ts +++ b/src/core/services/stats-sync/sync-flow.ts @@ -1,7 +1,7 @@ import os from 'node:os'; import path from 'node:path'; -import { shellQuote } from './ssh'; -import type { RemoteRunResult } from './ssh'; +import { quoteForRemoteShell } from './ssh'; +import type { RemoteRunResult, RemoteShellFlavor } from './ssh'; import type { SyncMergeSummary, SyncProgressEvent } from '../../../shared/sync/sync-events'; import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store'; @@ -16,6 +16,8 @@ export interface SyncFlowArgs { syncForce: boolean; syncJson: boolean; syncCheck: boolean; + syncMakeTemp: boolean; + syncRemoveTempPath: string; logLevel: string; } @@ -35,7 +37,15 @@ export interface SyncFlowDeps { formatMergeSummary: (summary: SyncMergeSummary) => string; findLiveStatsDaemonPid: (dbPath: string) => number | null; assertSafeSshHost: (host: string) => void; - resolveRemoteSubminerCommand: (host: string, preferred: string | null) => string; + detectRemoteShellFlavor: ( + host: string, + runRemote: (host: string, remoteCommand: string) => RemoteRunResult, + ) => RemoteShellFlavor; + resolveRemoteSubminerCommand: ( + host: string, + preferred: string | null, + flavor: RemoteShellFlavor, + ) => string; runScp: (from: string, to: string) => void; runSsh: (host: string, remoteCommand: string) => RemoteRunResult; fail: (message: string) => never; @@ -53,6 +63,40 @@ export interface SyncFlowDeps { resolvePath: (value: string) => string; } +/** + * Prefix shared by every sync temp dir, local or remote. Remote temp dirs are + * created and removed by the remote SubMiner itself (sync --make-temp / + * --remove-temp) so the flow never depends on mktemp/rm existing in the + * remote shell — that is what makes Windows remotes work. + */ +export const SYNC_TEMP_PREFIX = 'subminer-sync-'; + +export function makeSyncTempDir(mkdtempSync: SyncFlowDeps['mkdtempSync']): string { + return mkdtempSync(path.join(os.tmpdir(), SYNC_TEMP_PREFIX)); +} + +/** Only dirs directly under os.tmpdir() with the sync prefix may be removed. */ +export function assertRemovableSyncTempDir(target: string): string { + const resolved = path.resolve(target.trim()); + const normalizeCase = (value: string) => + process.platform === 'win32' ? value.toLowerCase() : value; + const insideTmp = + normalizeCase(path.dirname(resolved)) === normalizeCase(path.resolve(os.tmpdir())); + if (!insideTmp || !path.basename(resolved).startsWith(SYNC_TEMP_PREFIX)) { + throw new Error(`Refusing to remove a directory outside the sync temp area: ${target}`); + } + return resolved; +} + +export function runMakeTempMode(deps: SyncFlowDeps): void { + deps.consoleLog(makeSyncTempDir(deps.mkdtempSync)); +} + +export function runRemoveTempMode(context: SyncFlowContext, deps: SyncFlowDeps): void { + const target = assertRemovableSyncTempDir(context.args.syncRemoveTempPath); + deps.rmSync(target, { recursive: true, force: true }); +} + export function resolveSyncDbPath(context: SyncFlowContext, deps: SyncFlowDeps): string { const override = context.args.syncDbPath.trim(); return override ? deps.resolvePath(override) : deps.resolveDefaultDbPath(); @@ -160,7 +204,9 @@ export async function runCheckMode(context: SyncFlowContext, deps: SyncFlowDeps) } else { deps.consoleLog('SSH connection: ok'); try { - remoteCommand = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null); + const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh); + if (flavor !== 'posix') deps.consoleLog(`Remote platform: Windows (${flavor})`); + remoteCommand = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor); const version = deps.runSsh(host, `${remoteCommand} --version`); remoteVersion = version.status === 0 ? version.stdout.trim() || null : null; deps.consoleLog( @@ -187,9 +233,32 @@ export async function runCheckMode(context: SyncFlowContext, deps: SyncFlowDeps) deps.consoleLog('Check passed.'); } -function cleanupRemote(host: string, remoteTmpDir: string, deps: SyncFlowDeps): void { - if (!remoteTmpDir.startsWith('/tmp/')) return; - deps.runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`); +// The remote validates --remove-temp against its own tmpdir; this guard only +// keeps garbage output from an earlier failure out of the remote command. +function cleanupRemote( + host: string, + remoteCmd: string, + remoteTmpDir: string, + quote: (value: string) => string, + deps: SyncFlowDeps, +): void { + if (!path.posix.basename(remoteTmpDir).startsWith(SYNC_TEMP_PREFIX)) return; + deps.runSsh(host, `${remoteCmd} sync --remove-temp ${quote(remoteTmpDir)}`); +} + +/** + * `sync --make-temp` prints the created dir as its last stdout line (a + * launcher wrapper may log above it). Backslashes are normalized to forward + * slashes: scp, the remote SubMiner, and Windows itself all accept them, and + * it keeps the later `${dir}/file` compositions valid on every platform. + */ +function parseRemoteTempDir(stdout: string): string { + const lines = stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const candidate = (lines[lines.length - 1] ?? '').replaceAll('\\', '/'); + return path.posix.basename(candidate).startsWith(SYNC_TEMP_PREFIX) ? candidate : ''; } function formatRemoteRunError(message: string, run: RemoteRunResult): string { @@ -211,20 +280,24 @@ export async function runHostSync( await deps.ensureTrackerQuiescent(context, dbPath); - const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null); - deps.log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`); + const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh); + const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor); + const quote = (value: string) => quoteForRemoteShell(flavor, value); + deps.log('debug', args.logLevel, `Remote subminer command (${flavor}): ${remoteCmd}`); - const localTmpDir = deps.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-')); + const localTmpDir = makeSyncTempDir(deps.mkdtempSync); let remoteTmpDir = ''; let pulledSummary: SyncMergeSummary | null = null; 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 = deps.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 mktemp = deps.runSsh(host, `${remoteCmd} sync --make-temp`); + remoteTmpDir = mktemp.status === 0 ? parseRemoteTempDir(mktemp.stdout) : ''; + if (!remoteTmpDir) { + throw new Error( + formatRemoteRunError(`Could not create a temporary directory on ${host}.`, mktemp), + ); } const forceFlag = args.syncForce ? ' --force' : ''; @@ -246,7 +319,7 @@ export async function runHostSync( deps.emitEvent({ type: 'stage', stage: 'snapshot-remote', message: `Snapshotting ${host}` }); const snapshotRun = deps.runSsh( host, - `${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`, + `${remoteCmd} sync --snapshot ${quote(remoteSnapshot)}${forceFlag}`, ); if (snapshotRun.status !== 0) { throw new Error(formatRemoteRunError(`Remote snapshot failed on ${host}.`, snapshotRun)); @@ -292,7 +365,7 @@ export async function runHostSync( await deps.ensureTrackerQuiescent(context, dbPath); const mergeRun = deps.runSsh( host, - `${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`, + `${remoteCmd} sync --merge ${quote(incomingSnapshot)}${forceFlag}`, ); deps.writeStdout(mergeRun.stdout); if (mergeRun.stdout.trim()) { @@ -328,7 +401,7 @@ export async function runHostSync( deps.rmSync(localTmpDir, { recursive: true, force: true }); if (remoteTmpDir) { try { - cleanupRemote(host, remoteTmpDir, deps); + cleanupRemote(host, remoteCmd, remoteTmpDir, quote, deps); } catch { // best effort } @@ -342,8 +415,18 @@ export async function runSyncFlow(context: SyncFlowContext, inputDeps: SyncFlowD if (!args.sync) return false; if (args.syncJson) deps = withJsonEvents(deps); - const dbPath = resolveSyncDbPath(context, deps); try { + if (args.syncMakeTemp) { + runMakeTempMode(deps); + if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null }); + return true; + } + if (args.syncRemoveTempPath) { + runRemoveTempMode(context, deps); + if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null }); + return true; + } + const dbPath = resolveSyncDbPath(context, deps); if (args.syncCheck) { await runCheckMode(context, deps); } else if (args.syncSnapshotPath) { diff --git a/src/main/sync-cli.ts b/src/main/sync-cli.ts index cdcbc08d..9e65d1be 100644 --- a/src/main/sync-cli.ts +++ b/src/main/sync-cli.ts @@ -17,6 +17,7 @@ import { import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge'; import { assertSafeSshHost, + detectRemoteShellFlavor, resolveRemoteSubminerCommand, runScp, runSsh, @@ -122,6 +123,7 @@ function buildSyncCliDeps(): SyncFlowDeps { formatMergeSummary, findLiveStatsDaemonPid, assertSafeSshHost, + detectRemoteShellFlavor, resolveRemoteSubminerCommand, runScp, runSsh, diff --git a/src/syncui/index.html b/src/syncui/index.html index aa77551e..01ff4328 100644 --- a/src/syncui/index.html +++ b/src/syncui/index.html @@ -69,9 +69,11 @@ ssh-copy-id user@hostname (no password prompts).
  • - The remote machine just needs SubMiner installed. It is found automatically as - SubMiner on the remote PATH, a macOS /Applications - install, or the optional subminer command-line launcher. + The remote machine just needs SubMiner installed. It is found automatically in + its default install location (Windows), as SubMiner on the remote + PATH, a macOS /Applications install, or the optional + subminer command-line launcher. Windows remotes need the built-in + OpenSSH Server enabled.
  • Use Test connection to verify both. It checks SSH and finds SubMiner on