mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 01:01:34 -07:00
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:
@@ -46,6 +46,8 @@ function createContext(): LauncherCommandContext {
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <dir>', 'Remove a sync temp directory created by --make-temp')
|
||||
.option('--ui', 'Open the SubMiner sync window')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
|
||||
@@ -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 <file>, or --merge <file>.');
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -40,6 +40,8 @@ function createArgs(): Args {
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
|
||||
@@ -581,6 +581,8 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncUi: false,
|
||||
logLevel: 'error',
|
||||
logRotation: 7,
|
||||
|
||||
+92
-19
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -123,6 +123,8 @@ export interface Args {
|
||||
syncForce: boolean;
|
||||
syncJson: boolean;
|
||||
syncCheck: boolean;
|
||||
syncMakeTemp: boolean;
|
||||
syncRemoveTempPath: string;
|
||||
syncUi: boolean;
|
||||
logLevel: LogLevel;
|
||||
logRotation: LogRotation;
|
||||
|
||||
Reference in New Issue
Block a user