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:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
### Added
|
### 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 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)
|
## v0.18.0 (2026-07-10)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
**Added**
|
**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 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)
|
## v0.18.0 (2026-07-10)
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
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):
|
Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ function createContext(): LauncherCommandContext {
|
|||||||
syncForce: false,
|
syncForce: false,
|
||||||
syncJson: false,
|
syncJson: false,
|
||||||
syncCheck: false,
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncUi: false,
|
syncUi: false,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
logRotation: 7,
|
logRotation: 7,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ function makeContext(overrides: Partial<Args>): LauncherCommandContext {
|
|||||||
syncRemoteCmd: '',
|
syncRemoteCmd: '',
|
||||||
syncDbPath: '',
|
syncDbPath: '',
|
||||||
syncForce: false,
|
syncForce: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
logLevel: 'warn',
|
logLevel: 'warn',
|
||||||
...overrides,
|
...overrides,
|
||||||
} as Args,
|
} as Args,
|
||||||
@@ -85,7 +87,7 @@ test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes'
|
|||||||
resolveRemoteSubminerCommand: () => 'subminer',
|
resolveRemoteSubminerCommand: () => 'subminer',
|
||||||
runSsh: (_host: string, command: string) => {
|
runSsh: (_host: string, command: string) => {
|
||||||
calls.push(`ssh:${command}`);
|
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) => {
|
runScp: (from: string, to: string) => {
|
||||||
calls.push(`scp:${from}->${to}`);
|
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,
|
}) as typeof fs.mkdtempSync,
|
||||||
runSsh: (_host: string, command: string) => {
|
runSsh: (_host: string, command: string) => {
|
||||||
calls.push(`ssh:${command}`);
|
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 --snapshot ')) return ok();
|
||||||
if (command.includes(' sync --merge ')) {
|
if (command.includes(' sync --merge ')) {
|
||||||
return { status: 9, stdout: 'remote output', stderr: 'remote merge exploded' };
|
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.equal(calls.filter((call) => call === 'quiescent').length, 3);
|
||||||
assert.ok(calls.indexOf('quiescent') < calls.findIndex((call) => call.startsWith('snapshot:')));
|
assert.ok(calls.indexOf('quiescent') < calls.findIndex((call) => call.startsWith('snapshot:')));
|
||||||
assert.ok(calls.includes('local-merge'));
|
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);
|
assert.equal(fs.existsSync(localTmpDir), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,7 +185,7 @@ test('runHostSync includes remote snapshot stderr in failures', async () => {
|
|||||||
assertSafeSshHost: () => {},
|
assertSafeSshHost: () => {},
|
||||||
resolveRemoteSubminerCommand: () => 'subminer',
|
resolveRemoteSubminerCommand: () => 'subminer',
|
||||||
runSsh: (_host: string, command: string) => {
|
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 ')) {
|
if (command.includes(' sync --snapshot ')) {
|
||||||
return { status: 5, stdout: '', stderr: 'snapshot permission denied' };
|
return { status: 5, stdout: '', stderr: 'snapshot permission denied' };
|
||||||
}
|
}
|
||||||
@@ -218,7 +220,7 @@ function makeDirectionDeps(calls: string[]): Partial<SyncCommandDeps> {
|
|||||||
resolveRemoteSubminerCommand: () => 'subminer',
|
resolveRemoteSubminerCommand: () => 'subminer',
|
||||||
runSsh: (_host: string, command: string) => {
|
runSsh: (_host: string, command: string) => {
|
||||||
calls.push(`ssh:${command}`);
|
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();
|
return ok();
|
||||||
},
|
},
|
||||||
runScp: (from: string, to: string) => {
|
runScp: (from: string, to: string) => {
|
||||||
@@ -278,7 +280,7 @@ test('runSyncCommand --json emits NDJSON progress events for a two-way host sync
|
|||||||
return true;
|
return true;
|
||||||
}) as unknown as typeof process.stdout.write,
|
}) as unknown as typeof process.stdout.write,
|
||||||
runSsh: (_host: string, command: string) => {
|
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');
|
if (command.includes(' sync --merge ')) return ok('remote summary text\n');
|
||||||
return ok();
|
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,
|
writeStdout: (() => true) as unknown as typeof process.stdout.write,
|
||||||
runSsh: (_host: string, command: string) => {
|
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 ')) {
|
if (command.includes(' sync --merge ')) {
|
||||||
return { status: 9, stdout: '', stderr: 'remote merge exploded' };
|
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> = {
|
const failingDeps: Partial<SyncCommandDeps> = {
|
||||||
...deps,
|
...deps,
|
||||||
runSsh: (_host: string, command: string) => {
|
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 ')) {
|
if (command.includes(' sync --merge ')) {
|
||||||
return { status: 9, stdout: '', stderr: 'boom' };
|
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.equal(check.ok, false);
|
||||||
assert.match(check.error, /Could not find a runnable/);
|
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';
|
} from '../sync/sync-db.js';
|
||||||
import {
|
import {
|
||||||
assertSafeSshHost,
|
assertSafeSshHost,
|
||||||
|
detectRemoteShellFlavor,
|
||||||
resolveRemoteSubminerCommand,
|
resolveRemoteSubminerCommand,
|
||||||
runScp,
|
runScp,
|
||||||
runSsh,
|
runSsh,
|
||||||
@@ -40,6 +41,7 @@ const defaultSyncCommandDeps: SyncCommandDeps = {
|
|||||||
formatMergeSummary,
|
formatMergeSummary,
|
||||||
findLiveStatsDaemonPid,
|
findLiveStatsDaemonPid,
|
||||||
assertSafeSshHost,
|
assertSafeSshHost,
|
||||||
|
detectRemoteShellFlavor,
|
||||||
resolveRemoteSubminerCommand,
|
resolveRemoteSubminerCommand,
|
||||||
runScp,
|
runScp,
|
||||||
runSsh,
|
runSsh,
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
syncForce: false,
|
syncForce: false,
|
||||||
syncJson: false,
|
syncJson: false,
|
||||||
syncCheck: false,
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncLogLevel: null,
|
syncLogLevel: null,
|
||||||
syncUiTriggered: false,
|
syncUiTriggered: false,
|
||||||
syncUiLogLevel: null,
|
syncUiLogLevel: null,
|
||||||
@@ -204,6 +206,8 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
|||||||
syncForce: false,
|
syncForce: false,
|
||||||
syncJson: false,
|
syncJson: false,
|
||||||
syncCheck: false,
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncLogLevel: null,
|
syncLogLevel: null,
|
||||||
syncUiTriggered: false,
|
syncUiTriggered: false,
|
||||||
syncUiLogLevel: null,
|
syncUiLogLevel: null,
|
||||||
@@ -256,6 +260,8 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
|||||||
syncForce: false,
|
syncForce: false,
|
||||||
syncJson: false,
|
syncJson: false,
|
||||||
syncCheck: false,
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncLogLevel: null,
|
syncLogLevel: null,
|
||||||
syncUiTriggered: false,
|
syncUiTriggered: false,
|
||||||
syncUiLogLevel: null,
|
syncUiLogLevel: null,
|
||||||
@@ -306,6 +312,8 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
|||||||
syncForce: false,
|
syncForce: false,
|
||||||
syncJson: false,
|
syncJson: false,
|
||||||
syncCheck: false,
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncLogLevel: null,
|
syncLogLevel: null,
|
||||||
syncUiTriggered: false,
|
syncUiTriggered: false,
|
||||||
syncUiLogLevel: null,
|
syncUiLogLevel: null,
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ export function createDefaultArgs(
|
|||||||
syncForce: false,
|
syncForce: false,
|
||||||
syncJson: false,
|
syncJson: false,
|
||||||
syncCheck: false,
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncUi: false,
|
syncUi: false,
|
||||||
logLevel: loggingConfig.level ?? 'warn',
|
logLevel: loggingConfig.level ?? 'warn',
|
||||||
logRotation: loggingConfig.rotation ?? 7,
|
logRotation: loggingConfig.rotation ?? 7,
|
||||||
@@ -286,6 +288,8 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
parsed.syncForce = invocations.syncForce;
|
parsed.syncForce = invocations.syncForce;
|
||||||
parsed.syncJson = invocations.syncJson;
|
parsed.syncJson = invocations.syncJson;
|
||||||
parsed.syncCheck = invocations.syncCheck;
|
parsed.syncCheck = invocations.syncCheck;
|
||||||
|
parsed.syncMakeTemp = invocations.syncMakeTemp;
|
||||||
|
parsed.syncRemoveTempPath = invocations.syncRemoveTempPath ?? '';
|
||||||
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
|
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
|
||||||
}
|
}
|
||||||
if (invocations.syncUiTriggered) {
|
if (invocations.syncUiTriggered) {
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface CliInvocations {
|
|||||||
syncForce: boolean;
|
syncForce: boolean;
|
||||||
syncJson: boolean;
|
syncJson: boolean;
|
||||||
syncCheck: boolean;
|
syncCheck: boolean;
|
||||||
|
syncMakeTemp: boolean;
|
||||||
|
syncRemoveTempPath: string | null;
|
||||||
syncLogLevel: string | null;
|
syncLogLevel: string | null;
|
||||||
syncUiTriggered: boolean;
|
syncUiTriggered: boolean;
|
||||||
syncUiLogLevel: string | null;
|
syncUiLogLevel: string | null;
|
||||||
@@ -188,6 +190,8 @@ export function parseCliPrograms(
|
|||||||
let syncForce = false;
|
let syncForce = false;
|
||||||
let syncJson = false;
|
let syncJson = false;
|
||||||
let syncCheck = false;
|
let syncCheck = false;
|
||||||
|
let syncMakeTemp = false;
|
||||||
|
let syncRemoveTempPath: string | null = null;
|
||||||
let syncLogLevel: string | null = null;
|
let syncLogLevel: string | null = null;
|
||||||
let syncUiTriggered = false;
|
let syncUiTriggered = false;
|
||||||
let syncUiLogLevel: string | null = null;
|
let syncUiLogLevel: string | null = null;
|
||||||
@@ -333,6 +337,8 @@ export function parseCliPrograms(
|
|||||||
.option('-f, --force', 'Skip the running-app safety check')
|
.option('-f, --force', 'Skip the running-app safety check')
|
||||||
.option('--check', 'Test the SSH connection and remote subminer availability')
|
.option('--check', 'Test the SSH connection and remote subminer availability')
|
||||||
.option('--json', 'Emit machine-readable NDJSON progress output')
|
.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('--ui', 'Open the SubMiner sync window')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
|
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
|
||||||
@@ -342,6 +348,8 @@ export function parseCliPrograms(
|
|||||||
const push = options.push === true;
|
const push = options.push === true;
|
||||||
const pull = options.pull === true;
|
const pull = options.pull === true;
|
||||||
const check = options.check === 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 (options.ui === true) {
|
||||||
if (host || snapshot || merge || push || pull || check || options.force === true) {
|
if (host || snapshot || merge || push || pull || check || options.force === true) {
|
||||||
throw new Error('Sync --ui cannot be combined with other sync options.');
|
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.',
|
'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) {
|
if (modes === 0) {
|
||||||
throw new Error('Sync requires a host, --snapshot <file>, or --merge <file>.');
|
throw new Error('Sync requires a host, --snapshot <file>, or --merge <file>.');
|
||||||
}
|
}
|
||||||
if (modes > 1) {
|
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;
|
syncTriggered = true;
|
||||||
syncHost = host || null;
|
syncHost = host || null;
|
||||||
@@ -382,6 +399,8 @@ export function parseCliPrograms(
|
|||||||
syncForce = options.force === true;
|
syncForce = options.force === true;
|
||||||
syncJson = options.json === true;
|
syncJson = options.json === true;
|
||||||
syncCheck = check;
|
syncCheck = check;
|
||||||
|
syncMakeTemp = makeTemp;
|
||||||
|
syncRemoveTempPath = removeTemp || null;
|
||||||
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -506,6 +525,8 @@ export function parseCliPrograms(
|
|||||||
syncForce,
|
syncForce,
|
||||||
syncJson,
|
syncJson,
|
||||||
syncCheck,
|
syncCheck,
|
||||||
|
syncMakeTemp,
|
||||||
|
syncRemoveTempPath,
|
||||||
syncLogLevel,
|
syncLogLevel,
|
||||||
syncUiTriggered,
|
syncUiTriggered,
|
||||||
syncUiLogLevel,
|
syncUiLogLevel,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ function createArgs(): Args {
|
|||||||
syncForce: false,
|
syncForce: false,
|
||||||
syncJson: false,
|
syncJson: false,
|
||||||
syncCheck: false,
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncUi: false,
|
syncUi: false,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
logRotation: 7,
|
logRotation: 7,
|
||||||
|
|||||||
@@ -581,6 +581,8 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
syncForce: false,
|
syncForce: false,
|
||||||
syncJson: false,
|
syncJson: false,
|
||||||
syncCheck: false,
|
syncCheck: false,
|
||||||
|
syncMakeTemp: false,
|
||||||
|
syncRemoveTempPath: '',
|
||||||
syncUi: false,
|
syncUi: false,
|
||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
logRotation: 7,
|
logRotation: 7,
|
||||||
|
|||||||
+92
-19
@@ -1,6 +1,18 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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', () => {
|
test('assertSafeSshHost rejects option-like hosts', () => {
|
||||||
assert.throws(() => assertSafeSshHost('-oProxyCommand=touch pwned'), /looks like an option/);
|
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', () => {
|
test('resolveRemoteSubminerCommand verifies the launcher under the remote runtime PATH', () => {
|
||||||
const calls: Array<{ host: string; remoteCommand: string }> = [];
|
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 });
|
calls.push({ host, remoteCommand });
|
||||||
return { status: 0, stdout: '', stderr: '' };
|
return remoteResult(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(
|
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', () => {
|
test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mode', () => {
|
||||||
const probed: string[] = [];
|
const probed: string[] = [];
|
||||||
const command = resolveRemoteSubminerCommand('media-box', null, (_host, remoteCommand) => {
|
const command = resolveRemoteSubminerCommand('media-box', null, 'posix', (_host, remoteCommand) => {
|
||||||
probed.push(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$/);
|
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', () => {
|
test('resolveRemoteSubminerCommand probes a user override as app first, then launcher', () => {
|
||||||
const probed: string[] = [];
|
const asApp = resolveRemoteSubminerCommand('media-box', '/opt/SubMiner.AppImage', 'posix', (_host, cmd) =>
|
||||||
const asApp = resolveRemoteSubminerCommand('media-box', '/opt/SubMiner.AppImage', (_host, cmd) => {
|
remoteResult(cmd.includes('--sync-cli') ? 0 : 1),
|
||||||
probed.push(cmd);
|
);
|
||||||
return { status: cmd.includes('--sync-cli') ? 0 : 1, stdout: '', stderr: '' };
|
|
||||||
});
|
|
||||||
assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/);
|
assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/);
|
||||||
|
|
||||||
const asLauncher = resolveRemoteSubminerCommand('media-box', '/opt/subminer', (_host, cmd) => {
|
const asLauncher = resolveRemoteSubminerCommand('media-box', '/opt/subminer', 'posix', (_host, cmd) =>
|
||||||
return { status: cmd.includes('--sync-cli') ? 1 : 0, stdout: '', stderr: '' };
|
remoteResult(cmd.includes('--sync-cli') ? 1 : 0),
|
||||||
});
|
);
|
||||||
assert.match(asLauncher, /'\/opt\/subminer'$/);
|
assert.match(asLauncher, /'\/opt\/subminer'$/);
|
||||||
|
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() => resolveRemoteSubminerCommand('media-box', '/missing', 'posix', () => remoteResult(127)),
|
||||||
resolveRemoteSubminerCommand('media-box', '/missing', () => ({
|
|
||||||
status: 127,
|
|
||||||
stdout: '',
|
|
||||||
stderr: '',
|
|
||||||
})),
|
|
||||||
/Remote command not found on media-box: \/missing/,
|
/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.
|
// app's --sync-cli mode resolve remotes and shell out identically.
|
||||||
export {
|
export {
|
||||||
assertSafeSshHost,
|
assertSafeSshHost,
|
||||||
|
detectRemoteShellFlavor,
|
||||||
|
quoteForRemoteShell,
|
||||||
resolveRemoteSubminerCommand,
|
resolveRemoteSubminerCommand,
|
||||||
runScp,
|
runScp,
|
||||||
runSsh,
|
runSsh,
|
||||||
shellQuote,
|
shellQuote,
|
||||||
type RemoteRunResult,
|
type RemoteRunResult,
|
||||||
|
type RemoteShellFlavor,
|
||||||
} from '../../src/core/services/stats-sync/ssh.js';
|
} from '../../src/core/services/stats-sync/ssh.js';
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export interface Args {
|
|||||||
syncForce: boolean;
|
syncForce: boolean;
|
||||||
syncJson: boolean;
|
syncJson: boolean;
|
||||||
syncCheck: boolean;
|
syncCheck: boolean;
|
||||||
|
syncMakeTemp: boolean;
|
||||||
|
syncRemoveTempPath: string;
|
||||||
syncUi: boolean;
|
syncUi: boolean;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
logRotation: LogRotation;
|
logRotation: LogRotation;
|
||||||
|
|||||||
@@ -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', () => {
|
test('parseSyncCliTokens mirrors launcher sync validation', () => {
|
||||||
assert.equal(parseSyncCliTokens([]).kind, 'error');
|
assert.equal(parseSyncCliTokens([]).kind, 'error');
|
||||||
assert.equal(parseSyncCliTokens(['sync']).kind, 'error');
|
assert.equal(parseSyncCliTokens(['sync']).kind, 'error');
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
|
|||||||
let check = false;
|
let check = false;
|
||||||
let force = false;
|
let force = false;
|
||||||
let json = false;
|
let json = false;
|
||||||
|
let makeTemp = false;
|
||||||
|
let removeTemp = '';
|
||||||
let remoteCmd = '';
|
let remoteCmd = '';
|
||||||
let dbPath = '';
|
let dbPath = '';
|
||||||
let logLevel = 'warn';
|
let logLevel = 'warn';
|
||||||
@@ -47,6 +49,7 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
|
|||||||
const valueFlags = new Map<string, (value: string) => void>([
|
const valueFlags = new Map<string, (value: string) => void>([
|
||||||
['--snapshot', (value) => (snapshot = value.trim())],
|
['--snapshot', (value) => (snapshot = value.trim())],
|
||||||
['--merge', (value) => (merge = value.trim())],
|
['--merge', (value) => (merge = value.trim())],
|
||||||
|
['--remove-temp', (value) => (removeTemp = value.trim())],
|
||||||
['--remote-cmd', (value) => (remoteCmd = value.trim())],
|
['--remote-cmd', (value) => (remoteCmd = value.trim())],
|
||||||
['--db', (value) => (dbPath = value.trim())],
|
['--db', (value) => (dbPath = value.trim())],
|
||||||
['--log-level', (value) => (logLevel = value.trim() || 'warn')],
|
['--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 === '--check') check = true;
|
||||||
else if (token === '--force' || token === '-f') force = true;
|
else if (token === '--force' || token === '-f') force = true;
|
||||||
else if (token === '--json') json = true;
|
else if (token === '--json') json = true;
|
||||||
|
else if (token === '--make-temp') makeTemp = true;
|
||||||
else if (token.startsWith('-')) {
|
else if (token.startsWith('-')) {
|
||||||
return { kind: 'error', message: `Unknown sync option: ${token}` };
|
return { kind: 'error', message: `Unknown sync option: ${token}` };
|
||||||
} else if (host) {
|
} 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.',
|
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) {
|
if (modes === 0) {
|
||||||
return { kind: 'error', message: 'Sync requires a host, --snapshot <file>, or --merge <file>.' };
|
return { kind: 'error', message: 'Sync requires a host, --snapshot <file>, or --merge <file>.' };
|
||||||
}
|
}
|
||||||
if (modes > 1) {
|
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 {
|
return {
|
||||||
@@ -114,6 +127,8 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
|
|||||||
syncForce: force,
|
syncForce: force,
|
||||||
syncJson: json,
|
syncJson: json,
|
||||||
syncCheck: check,
|
syncCheck: check,
|
||||||
|
syncMakeTemp: makeTemp,
|
||||||
|
syncRemoveTempPath: removeTemp,
|
||||||
logLevel,
|
logLevel,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -138,6 +153,8 @@ export function syncCliUsage(): string {
|
|||||||
' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host',
|
' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host',
|
||||||
' -f, --force Skip the running-app safety check',
|
' -f, --force Skip the running-app safety check',
|
||||||
' --json Emit machine-readable NDJSON progress output',
|
' --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',
|
' --log-level <level> Log level',
|
||||||
' --help Show this help',
|
' --help Show this help',
|
||||||
' --version Show the SubMiner version',
|
' --version Show the SubMiner version',
|
||||||
|
|||||||
@@ -71,6 +71,48 @@ export function shellQuote(value: string): string {
|
|||||||
return `'${value.replaceAll("'", `'\\''`)}'`;
|
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 =
|
const REMOTE_RUNTIME_PATH =
|
||||||
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$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;
|
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 [
|
return [
|
||||||
// Command-line launcher (bun script) on PATH or in its default install dir.
|
// Command-line launcher (bun script) on PATH or in its default install dir.
|
||||||
{ invocation: 'subminer' },
|
{ 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.
|
* Non-interactive POSIX SSH shells often miss user-installed launchers and
|
||||||
* Probe candidates under the same deterministic PATH used by sync itself.
|
* Bun, so those candidates are probed under the same deterministic PATH sync
|
||||||
* Trusted defaults stay unquoted so the remote shell expands `~`; a
|
* itself uses; Windows shells get their default install locations instead.
|
||||||
* user-supplied override is shell-quoted to prevent command injection and
|
* Trusted defaults stay unquoted so the remote shell expands `~`/%VAR%; a
|
||||||
* probed both as an app binary (--sync-cli) and as a launcher.
|
* 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(
|
export function resolveRemoteSubminerCommand(
|
||||||
host: string,
|
host: string,
|
||||||
preferred: string | null,
|
preferred: string | null,
|
||||||
|
flavor: RemoteShellFlavor = 'posix',
|
||||||
runRemote: typeof runSsh = runSsh,
|
runRemote: typeof runSsh = runSsh,
|
||||||
): string {
|
): string {
|
||||||
const candidates: RemoteCandidate[] = preferred
|
const candidates = preferred
|
||||||
? [
|
? preferredCandidates(flavor, preferred)
|
||||||
// App binaries also answer plain --help by opening the GUI-oriented
|
: defaultRemoteCandidates(flavor);
|
||||||
// help path, so probe the sync-CLI shape first.
|
|
||||||
{ invocation: `${shellQuote(preferred)} ${APP_SYNC_CLI_FLAG}` },
|
|
||||||
{ invocation: shellQuote(preferred) },
|
|
||||||
]
|
|
||||||
: defaultRemoteCandidates();
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const command = `${REMOTE_RUNTIME_PATH} ${candidate.invocation}`;
|
const command =
|
||||||
const probe = runRemote(host, `${command} --help >/dev/null 2>&1`);
|
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) {
|
if (probe.status === 0) {
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
@@ -125,6 +196,6 @@ export function resolveRemoteSubminerCommand(
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
preferred
|
preferred
|
||||||
? `Remote command not found on ${host}: ${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.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { shellQuote } from './ssh';
|
import { quoteForRemoteShell } from './ssh';
|
||||||
import type { RemoteRunResult } from './ssh';
|
import type { RemoteRunResult, RemoteShellFlavor } from './ssh';
|
||||||
import type { SyncMergeSummary, SyncProgressEvent } from '../../../shared/sync/sync-events';
|
import type { SyncMergeSummary, SyncProgressEvent } from '../../../shared/sync/sync-events';
|
||||||
import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store';
|
import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store';
|
||||||
|
|
||||||
@@ -16,6 +16,8 @@ export interface SyncFlowArgs {
|
|||||||
syncForce: boolean;
|
syncForce: boolean;
|
||||||
syncJson: boolean;
|
syncJson: boolean;
|
||||||
syncCheck: boolean;
|
syncCheck: boolean;
|
||||||
|
syncMakeTemp: boolean;
|
||||||
|
syncRemoveTempPath: string;
|
||||||
logLevel: string;
|
logLevel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +37,15 @@ export interface SyncFlowDeps {
|
|||||||
formatMergeSummary: (summary: SyncMergeSummary) => string;
|
formatMergeSummary: (summary: SyncMergeSummary) => string;
|
||||||
findLiveStatsDaemonPid: (dbPath: string) => number | null;
|
findLiveStatsDaemonPid: (dbPath: string) => number | null;
|
||||||
assertSafeSshHost: (host: string) => void;
|
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;
|
runScp: (from: string, to: string) => void;
|
||||||
runSsh: (host: string, remoteCommand: string) => RemoteRunResult;
|
runSsh: (host: string, remoteCommand: string) => RemoteRunResult;
|
||||||
fail: (message: string) => never;
|
fail: (message: string) => never;
|
||||||
@@ -53,6 +63,40 @@ export interface SyncFlowDeps {
|
|||||||
resolvePath: (value: string) => string;
|
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 {
|
export function resolveSyncDbPath(context: SyncFlowContext, deps: SyncFlowDeps): string {
|
||||||
const override = context.args.syncDbPath.trim();
|
const override = context.args.syncDbPath.trim();
|
||||||
return override ? deps.resolvePath(override) : deps.resolveDefaultDbPath();
|
return override ? deps.resolvePath(override) : deps.resolveDefaultDbPath();
|
||||||
@@ -160,7 +204,9 @@ export async function runCheckMode(context: SyncFlowContext, deps: SyncFlowDeps)
|
|||||||
} else {
|
} else {
|
||||||
deps.consoleLog('SSH connection: ok');
|
deps.consoleLog('SSH connection: ok');
|
||||||
try {
|
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`);
|
const version = deps.runSsh(host, `${remoteCommand} --version`);
|
||||||
remoteVersion = version.status === 0 ? version.stdout.trim() || null : null;
|
remoteVersion = version.status === 0 ? version.stdout.trim() || null : null;
|
||||||
deps.consoleLog(
|
deps.consoleLog(
|
||||||
@@ -187,9 +233,32 @@ export async function runCheckMode(context: SyncFlowContext, deps: SyncFlowDeps)
|
|||||||
deps.consoleLog('Check passed.');
|
deps.consoleLog('Check passed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupRemote(host: string, remoteTmpDir: string, deps: SyncFlowDeps): void {
|
// The remote validates --remove-temp against its own tmpdir; this guard only
|
||||||
if (!remoteTmpDir.startsWith('/tmp/')) return;
|
// keeps garbage output from an earlier failure out of the remote command.
|
||||||
deps.runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`);
|
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 {
|
function formatRemoteRunError(message: string, run: RemoteRunResult): string {
|
||||||
@@ -211,20 +280,24 @@ export async function runHostSync(
|
|||||||
|
|
||||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||||
|
|
||||||
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
|
const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh);
|
||||||
deps.log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
|
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 remoteTmpDir = '';
|
||||||
let pulledSummary: SyncMergeSummary | null = null;
|
let pulledSummary: SyncMergeSummary | null = null;
|
||||||
try {
|
try {
|
||||||
// Signal failures by throwing (not fail(), which exits synchronously and
|
// Signal failures by throwing (not fail(), which exits synchronously and
|
||||||
// would skip the finally cleanup, leaking temp dirs holding snapshot data).
|
// would skip the finally cleanup, leaking temp dirs holding snapshot data).
|
||||||
// main().catch() reports the message the same way fail() would.
|
// main().catch() reports the message the same way fail() would.
|
||||||
const mktemp = deps.runSsh(host, 'mktemp -d /tmp/subminer-sync.XXXXXX');
|
const mktemp = deps.runSsh(host, `${remoteCmd} sync --make-temp`);
|
||||||
remoteTmpDir = mktemp.stdout.trim();
|
remoteTmpDir = mktemp.status === 0 ? parseRemoteTempDir(mktemp.stdout) : '';
|
||||||
if (mktemp.status !== 0 || !remoteTmpDir.startsWith('/tmp/')) {
|
if (!remoteTmpDir) {
|
||||||
throw new Error(`Could not create a temporary directory on ${host}.`);
|
throw new Error(
|
||||||
|
formatRemoteRunError(`Could not create a temporary directory on ${host}.`, mktemp),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const forceFlag = args.syncForce ? ' --force' : '';
|
const forceFlag = args.syncForce ? ' --force' : '';
|
||||||
@@ -246,7 +319,7 @@ export async function runHostSync(
|
|||||||
deps.emitEvent({ type: 'stage', stage: 'snapshot-remote', message: `Snapshotting ${host}` });
|
deps.emitEvent({ type: 'stage', stage: 'snapshot-remote', message: `Snapshotting ${host}` });
|
||||||
const snapshotRun = deps.runSsh(
|
const snapshotRun = deps.runSsh(
|
||||||
host,
|
host,
|
||||||
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
|
`${remoteCmd} sync --snapshot ${quote(remoteSnapshot)}${forceFlag}`,
|
||||||
);
|
);
|
||||||
if (snapshotRun.status !== 0) {
|
if (snapshotRun.status !== 0) {
|
||||||
throw new Error(formatRemoteRunError(`Remote snapshot failed on ${host}.`, snapshotRun));
|
throw new Error(formatRemoteRunError(`Remote snapshot failed on ${host}.`, snapshotRun));
|
||||||
@@ -292,7 +365,7 @@ export async function runHostSync(
|
|||||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||||
const mergeRun = deps.runSsh(
|
const mergeRun = deps.runSsh(
|
||||||
host,
|
host,
|
||||||
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
|
`${remoteCmd} sync --merge ${quote(incomingSnapshot)}${forceFlag}`,
|
||||||
);
|
);
|
||||||
deps.writeStdout(mergeRun.stdout);
|
deps.writeStdout(mergeRun.stdout);
|
||||||
if (mergeRun.stdout.trim()) {
|
if (mergeRun.stdout.trim()) {
|
||||||
@@ -328,7 +401,7 @@ export async function runHostSync(
|
|||||||
deps.rmSync(localTmpDir, { recursive: true, force: true });
|
deps.rmSync(localTmpDir, { recursive: true, force: true });
|
||||||
if (remoteTmpDir) {
|
if (remoteTmpDir) {
|
||||||
try {
|
try {
|
||||||
cleanupRemote(host, remoteTmpDir, deps);
|
cleanupRemote(host, remoteCmd, remoteTmpDir, quote, deps);
|
||||||
} catch {
|
} catch {
|
||||||
// best effort
|
// best effort
|
||||||
}
|
}
|
||||||
@@ -342,8 +415,18 @@ export async function runSyncFlow(context: SyncFlowContext, inputDeps: SyncFlowD
|
|||||||
if (!args.sync) return false;
|
if (!args.sync) return false;
|
||||||
if (args.syncJson) deps = withJsonEvents(deps);
|
if (args.syncJson) deps = withJsonEvents(deps);
|
||||||
|
|
||||||
const dbPath = resolveSyncDbPath(context, deps);
|
|
||||||
try {
|
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) {
|
if (args.syncCheck) {
|
||||||
await runCheckMode(context, deps);
|
await runCheckMode(context, deps);
|
||||||
} else if (args.syncSnapshotPath) {
|
} else if (args.syncSnapshotPath) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
|
import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
|
||||||
import {
|
import {
|
||||||
assertSafeSshHost,
|
assertSafeSshHost,
|
||||||
|
detectRemoteShellFlavor,
|
||||||
resolveRemoteSubminerCommand,
|
resolveRemoteSubminerCommand,
|
||||||
runScp,
|
runScp,
|
||||||
runSsh,
|
runSsh,
|
||||||
@@ -122,6 +123,7 @@ function buildSyncCliDeps(): SyncFlowDeps {
|
|||||||
formatMergeSummary,
|
formatMergeSummary,
|
||||||
findLiveStatsDaemonPid,
|
findLiveStatsDaemonPid,
|
||||||
assertSafeSshHost,
|
assertSafeSshHost,
|
||||||
|
detectRemoteShellFlavor,
|
||||||
resolveRemoteSubminerCommand,
|
resolveRemoteSubminerCommand,
|
||||||
runScp,
|
runScp,
|
||||||
runSsh,
|
runSsh,
|
||||||
|
|||||||
@@ -69,9 +69,11 @@
|
|||||||
<code>ssh-copy-id user@hostname</code> (no password prompts).
|
<code>ssh-copy-id user@hostname</code> (no password prompts).
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
The remote machine just needs SubMiner installed. It is found automatically as
|
The remote machine just needs SubMiner installed. It is found automatically in
|
||||||
<code>SubMiner</code> on the remote PATH, a macOS <code>/Applications</code>
|
its default install location (Windows), as <code>SubMiner</code> on the remote
|
||||||
install, or the optional <code>subminer</code> command-line launcher.
|
PATH, a macOS <code>/Applications</code> install, or the optional
|
||||||
|
<code>subminer</code> command-line launcher. Windows remotes need the built-in
|
||||||
|
OpenSSH Server enabled.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Use <em>Test connection</em> to verify both. It checks SSH and finds SubMiner on
|
Use <em>Test connection</em> to verify both. It checks SSH and finds SubMiner on
|
||||||
|
|||||||
Reference in New Issue
Block a user