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
@@ -46,6 +46,8 @@ function createContext(): LauncherCommandContext {
syncForce: false,
syncJson: false,
syncCheck: false,
syncMakeTemp: false,
syncRemoveTempPath: '',
syncUi: false,
logLevel: 'info',
logRotation: 7,
+102 -8
View File
@@ -19,6 +19,8 @@ function makeContext(overrides: Partial<Args>): 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<SyncCommandDeps> {
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<SyncCommandDeps> = {
...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<SyncCommandDeps> = {
...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 });
}
});
+2
View File
@@ -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,