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.
This commit is contained in:
2026-07-11 23:53:40 -07:00
parent 7ed4d4f8e2
commit 94260bab16
20 changed files with 476 additions and 69 deletions
@@ -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');
+19 -2
View File
@@ -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<string, (value: string) => 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 <file>, or --merge <file>.' };
}
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 <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 <dir> Remove a sync temp directory created by --make-temp',
' --log-level <level> Log level',
' --help Show this help',
' --version Show the SubMiner version',
+88 -17
View File
@@ -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 <path> 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 <path> pointing at the SubMiner app or launcher.`,
);
}
+101 -18
View File
@@ -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) {