@phnx-labs/agents-cli 1.20.34 → 1.20.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +113 -0
- package/LICENSE +185 -21
- package/README.md +13 -5
- package/dist/commands/audit.d.ts +14 -0
- package/dist/commands/audit.js +68 -0
- package/dist/commands/browser.js +82 -8
- package/dist/commands/check.d.ts +15 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/cloud.js +143 -3
- package/dist/commands/computer-actions.d.ts +15 -0
- package/dist/commands/computer-actions.js +120 -22
- package/dist/commands/computer.js +81 -0
- package/dist/commands/cost.js +17 -12
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +54 -97
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +173 -0
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +283 -12
- package/dist/commands/hosts.js +37 -29
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +62 -16
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/logs.d.ts +17 -0
- package/dist/commands/logs.js +139 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +27 -8
- package/dist/commands/permissions.js +9 -2
- package/dist/commands/repo.d.ts +34 -0
- package/dist/commands/repo.js +243 -65
- package/dist/commands/resource-view.d.ts +20 -0
- package/dist/commands/resource-view.js +90 -28
- package/dist/commands/routines.js +101 -5
- package/dist/commands/secrets-migrate.js +106 -57
- package/dist/commands/secrets.d.ts +31 -18
- package/dist/commands/secrets.js +196 -91
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +37 -0
- package/dist/commands/sessions-inject.d.ts +14 -0
- package/dist/commands/sessions-inject.js +111 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +24 -3
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +221 -0
- package/dist/commands/sessions-tail.d.ts +10 -0
- package/dist/commands/sessions-tail.js +11 -0
- package/dist/commands/sessions.d.ts +143 -1
- package/dist/commands/sessions.js +628 -80
- package/dist/commands/setup.js +4 -2
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +156 -4
- package/dist/commands/teams.js +217 -0
- package/dist/commands/versions.js +2 -4
- package/dist/commands/view.d.ts +3 -0
- package/dist/commands/view.js +19 -8
- package/dist/commands/wallet.d.ts +6 -0
- package/dist/commands/wallet.js +22 -5
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +82 -32
- package/dist/lib/agents.js +30 -9
- package/dist/lib/audit/log.d.ts +92 -0
- package/dist/lib/audit/log.js +177 -0
- package/dist/lib/auto-pull.js +2 -1
- package/dist/lib/browser/cdp.js +5 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.d.ts +8 -0
- package/dist/lib/browser/drivers/ssh.js +77 -16
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.d.ts +3 -0
- package/dist/lib/browser/ipc.js +37 -12
- package/dist/lib/browser/profiles.d.ts +1 -1
- package/dist/lib/browser/profiles.js +8 -10
- package/dist/lib/browser/refs.d.ts +65 -0
- package/dist/lib/browser/refs.js +73 -1
- package/dist/lib/browser/runtime-state.js +1 -0
- package/dist/lib/browser/service.d.ts +38 -2
- package/dist/lib/browser/service.js +183 -23
- package/dist/lib/browser/types.d.ts +14 -1
- package/dist/lib/budget/live-cloud.d.ts +42 -0
- package/dist/lib/budget/live-cloud.js +79 -0
- package/dist/lib/budget/live-team.d.ts +31 -0
- package/dist/lib/budget/live-team.js +115 -0
- package/dist/lib/cloud/codex.js +4 -0
- package/dist/lib/cloud/rush.d.ts +12 -1
- package/dist/lib/cloud/rush.js +13 -3
- package/dist/lib/cloud/types.d.ts +9 -0
- package/dist/lib/computer/dispatch.d.ts +8 -0
- package/dist/lib/computer/dispatch.js +125 -0
- package/dist/lib/computer/loop.d.ts +62 -0
- package/dist/lib/computer/loop.js +98 -0
- package/dist/lib/computer/model.d.ts +44 -0
- package/dist/lib/computer/model.js +157 -0
- package/dist/lib/concurrency.d.ts +19 -0
- package/dist/lib/concurrency.js +33 -0
- package/dist/lib/daemon.d.ts +92 -0
- package/dist/lib/daemon.js +222 -18
- package/dist/lib/devices/connect.d.ts +3 -2
- package/dist/lib/devices/connect.js +5 -3
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/tailscale.js +1 -1
- package/dist/lib/drift.d.ts +52 -0
- package/dist/lib/drift.js +112 -0
- package/dist/lib/events.d.ts +10 -3
- package/dist/lib/events.js +101 -24
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +100 -12
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +56 -1
- package/dist/lib/hooks/cache.d.ts +6 -0
- package/dist/lib/hooks/cache.js +54 -12
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +135 -10
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +45 -13
- package/dist/lib/hosts/logs.d.ts +16 -0
- package/dist/lib/hosts/logs.js +74 -0
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/progress.d.ts +66 -0
- package/dist/lib/hosts/progress.js +125 -17
- package/dist/lib/hosts/ready.d.ts +51 -7
- package/dist/lib/hosts/ready.js +98 -25
- package/dist/lib/hosts/reconcile.d.ts +53 -0
- package/dist/lib/hosts/reconcile.js +81 -0
- package/dist/lib/hosts/registry.d.ts +19 -2
- package/dist/lib/hosts/registry.js +58 -2
- package/dist/lib/hosts/remote-cmd.d.ts +62 -1
- package/dist/lib/hosts/remote-cmd.js +70 -1
- package/dist/lib/hosts/remote-os.d.ts +17 -0
- package/dist/lib/hosts/remote-os.js +30 -0
- package/dist/lib/hosts/session-index.d.ts +34 -0
- package/dist/lib/hosts/session-index.js +56 -0
- package/dist/lib/hosts/tasks.d.ts +22 -0
- package/dist/lib/hosts/tasks.js +29 -0
- package/dist/lib/lock.d.ts +93 -0
- package/dist/lib/lock.js +207 -0
- package/dist/lib/loop.js +16 -1
- package/dist/lib/machine-id.d.ts +21 -0
- package/dist/lib/machine-id.js +26 -0
- package/dist/lib/mailbox-target.d.ts +36 -0
- package/dist/lib/mailbox-target.js +45 -0
- package/dist/lib/mailbox.d.ts +47 -0
- package/dist/lib/mailbox.js +194 -0
- package/dist/lib/mcp.d.ts +5 -0
- package/dist/lib/mcp.js +24 -8
- package/dist/lib/migrate.d.ts +19 -0
- package/dist/lib/migrate.js +134 -26
- package/dist/lib/overdue.js +3 -0
- package/dist/lib/permissions.d.ts +4 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/picker.d.ts +26 -0
- package/dist/lib/picker.js +127 -0
- package/dist/lib/platform/exec.d.ts +46 -0
- package/dist/lib/platform/exec.js +74 -0
- package/dist/lib/platform/process.d.ts +31 -0
- package/dist/lib/platform/process.js +34 -1
- package/dist/lib/platform/winpath.js +2 -0
- package/dist/lib/plugin-marketplace.d.ts +30 -0
- package/dist/lib/plugin-marketplace.js +215 -2
- package/dist/lib/plugins.d.ts +5 -0
- package/dist/lib/plugins.js +61 -10
- package/dist/lib/profiles.d.ts +25 -0
- package/dist/lib/profiles.js +22 -6
- package/dist/lib/pty-client.js +2 -1
- package/dist/lib/rotate.d.ts +61 -0
- package/dist/lib/rotate.js +52 -0
- package/dist/lib/routines.d.ts +40 -2
- package/dist/lib/routines.js +66 -8
- package/dist/lib/runner.d.ts +11 -2
- package/dist/lib/runner.js +49 -7
- package/dist/lib/scheduler.js +6 -1
- package/dist/lib/secrets/bundles.d.ts +60 -4
- package/dist/lib/secrets/bundles.js +131 -12
- package/dist/lib/secrets/filestore.d.ts +3 -0
- package/dist/lib/secrets/filestore.js +42 -16
- package/dist/lib/secrets/index.d.ts +43 -2
- package/dist/lib/secrets/index.js +102 -3
- package/dist/lib/secrets/mcp.d.ts +93 -0
- package/dist/lib/secrets/mcp.js +205 -0
- package/dist/lib/secrets/remote.js +12 -5
- package/dist/lib/secrets/sync.js +83 -4
- package/dist/lib/secrets/windows.js +14 -3
- package/dist/lib/serve/data.d.ts +81 -0
- package/dist/lib/serve/data.js +91 -0
- package/dist/lib/serve/page.d.ts +7 -0
- package/dist/lib/serve/page.js +140 -0
- package/dist/lib/serve/server.d.ts +46 -0
- package/dist/lib/serve/server.js +115 -0
- package/dist/lib/session/active.d.ts +61 -0
- package/dist/lib/session/active.js +196 -17
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +204 -27
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.d.ts +6 -0
- package/dist/lib/session/parse.js +286 -2
- package/dist/lib/session/pid-registry.d.ts +22 -0
- package/dist/lib/session/pid-registry.js +104 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.d.ts +26 -0
- package/dist/lib/session/remote-active.js +153 -0
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +213 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +18 -8
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/sync/config.d.ts +1 -7
- package/dist/lib/session/sync/config.js +4 -11
- package/dist/lib/session/types.d.ts +18 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +44 -6
- package/dist/lib/shims.js +143 -57
- package/dist/lib/ssh-exec.d.ts +26 -3
- package/dist/lib/ssh-exec.js +47 -3
- package/dist/lib/ssh-tunnel.d.ts +24 -5
- package/dist/lib/ssh-tunnel.js +62 -63
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +8 -0
- package/dist/lib/startup/command-registry.js +17 -1
- package/dist/lib/state.d.ts +13 -0
- package/dist/lib/state.js +103 -9
- package/dist/lib/sync-umbrella.d.ts +14 -7
- package/dist/lib/sync-umbrella.js +17 -9
- package/dist/lib/teams/forEach.d.ts +110 -0
- package/dist/lib/teams/forEach.js +186 -0
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/pr-watch.d.ts +226 -0
- package/dist/lib/teams/pr-watch.js +371 -0
- package/dist/lib/teams/registry.js +25 -9
- package/dist/lib/teams/supervisor.d.ts +14 -1
- package/dist/lib/teams/supervisor.js +19 -0
- package/dist/lib/teams/worktree.d.ts +9 -0
- package/dist/lib/teams/worktree.js +32 -0
- package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
- package/dist/lib/terminal/backends/ghostty.js +69 -0
- package/dist/lib/terminal/backends/index.d.ts +19 -0
- package/dist/lib/terminal/backends/index.js +31 -0
- package/dist/lib/terminal/backends/iterm.d.ts +6 -0
- package/dist/lib/terminal/backends/iterm.js +62 -0
- package/dist/lib/terminal/backends/tmux.d.ts +14 -0
- package/dist/lib/terminal/backends/tmux.js +23 -0
- package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
- package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
- package/dist/lib/terminal/engine.d.ts +39 -0
- package/dist/lib/terminal/engine.js +54 -0
- package/dist/lib/terminal/index.d.ts +17 -0
- package/dist/lib/terminal/index.js +8 -0
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/policy.d.ts +11 -0
- package/dist/lib/terminal/policy.js +11 -0
- package/dist/lib/terminal/quote.d.ts +11 -0
- package/dist/lib/terminal/quote.js +13 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/shell.d.ts +6 -0
- package/dist/lib/terminal/shell.js +23 -0
- package/dist/lib/terminal/transport.d.ts +15 -0
- package/dist/lib/terminal/transport.js +40 -0
- package/dist/lib/terminal/types.d.ts +59 -0
- package/dist/lib/terminal/types.js +13 -0
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/types.d.ts +7 -0
- package/dist/lib/versions.d.ts +30 -3
- package/dist/lib/versions.js +129 -18
- package/dist/lib/watchdog/index.d.ts +3 -0
- package/dist/lib/watchdog/index.js +5 -0
- package/dist/lib/watchdog/read.d.ts +35 -0
- package/dist/lib/watchdog/read.js +149 -0
- package/dist/lib/watchdog/runner.d.ts +127 -0
- package/dist/lib/watchdog/runner.js +322 -0
- package/dist/lib/watchdog/watchdog.d.ts +40 -0
- package/dist/lib/watchdog/watchdog.js +166 -0
- package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
- package/dist/lib/watchdog/watchdogTail.js +154 -0
- package/dist/lib/whats-new.d.ts +9 -0
- package/dist/lib/whats-new.js +35 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- package/package.json +5 -4
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared host-task log viewer — the show-or-follow core behind both
|
|
3
|
+
* `agents hosts logs <id>` and the top-level `agents logs <id>`.
|
|
4
|
+
*
|
|
5
|
+
* A running task with follow re-enters the offset-tail (`followHostTask`);
|
|
6
|
+
* otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
|
|
7
|
+
* place so the two commands can never drift.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
|
|
12
|
+
import { followHostTask } from './progress.js';
|
|
13
|
+
import { reconcileTask } from './reconcile.js';
|
|
14
|
+
import { sshExecRaw } from '../ssh-exec.js';
|
|
15
|
+
/** Show (or follow, when running) a dispatched host task's combined-stdout log. */
|
|
16
|
+
export async function showHostTaskLog(id, follow) {
|
|
17
|
+
const task = loadTask(id);
|
|
18
|
+
if (!task)
|
|
19
|
+
return { found: false };
|
|
20
|
+
if (follow && task.status === 'running') {
|
|
21
|
+
const code = await followHostTask(task.target, {
|
|
22
|
+
remoteLog: task.remoteLog,
|
|
23
|
+
remoteExit: task.remoteExit,
|
|
24
|
+
taskId: id,
|
|
25
|
+
echo: true,
|
|
26
|
+
});
|
|
27
|
+
// -1 = follow window closed; the run continues on the host (not a failure,
|
|
28
|
+
// so exit 0). Any real code is the finished run — persist the terminal
|
|
29
|
+
// status the killed dispatch follower would have written.
|
|
30
|
+
if (code === -1)
|
|
31
|
+
return { found: true, exitCode: 0 };
|
|
32
|
+
updateTask(id, terminalPatch(code));
|
|
33
|
+
return { found: true, exitCode: code };
|
|
34
|
+
}
|
|
35
|
+
// Non-follow view: heal a still-'running' record from the remote `.exit` so a
|
|
36
|
+
// plain `logs <id>` also unsticks a task whose follower was killed. No-op (no
|
|
37
|
+
// ssh) once the record is already terminal.
|
|
38
|
+
reconcileTask(task);
|
|
39
|
+
try {
|
|
40
|
+
process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// No local log — task was dispatched with --no-follow. Fetch from the remote
|
|
44
|
+
// on demand and cache locally so subsequent calls are instant.
|
|
45
|
+
const remote = fetchAndCacheRemoteLog(task);
|
|
46
|
+
if (remote !== null) {
|
|
47
|
+
process.stdout.write(remote);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { found: true, exitCode: 0 };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Fetch a task's remote log over SSH, write it to the local mirror path (for
|
|
57
|
+
* future instant reads), and return its content. Returns null when the host is
|
|
58
|
+
* unreachable or the remote log is empty/absent.
|
|
59
|
+
*
|
|
60
|
+
* `remoteLog` is a $HOME-prefixed path with a safe (hex) basename — intentionally
|
|
61
|
+
* unquoted so the remote shell expands $HOME, matching the contract in reconcile.ts
|
|
62
|
+
* and progress.ts.
|
|
63
|
+
*/
|
|
64
|
+
function fetchAndCacheRemoteLog(task) {
|
|
65
|
+
const res = sshExecRaw(task.target, `cat ${task.remoteLog} 2>/dev/null`, { timeoutMs: 30000, multiplex: true });
|
|
66
|
+
if (res.code !== 0 || res.stdout.length === 0)
|
|
67
|
+
return null;
|
|
68
|
+
// The hosts cache dir already exists (saveTask created it) — write is best-effort.
|
|
69
|
+
try {
|
|
70
|
+
fs.writeFileSync(localLogPath(task.id), res.stdout);
|
|
71
|
+
}
|
|
72
|
+
catch { /* best-effort cache */ }
|
|
73
|
+
return res.stdout;
|
|
74
|
+
}
|
package/dist/lib/hosts/option.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
/** Attach the standard `--host` flag family to a command and return it (chainable). */
|
|
13
13
|
export function addHostOption(cmd) {
|
|
14
14
|
return cmd
|
|
15
|
-
.option('-H, --host <name>', 'Run this command on a registered host
|
|
15
|
+
.option('-H, --host <name>', 'Run this command on another machine over SSH instead of locally — a device, a registered host, or user@host. See `agents devices` / `agents hosts`.')
|
|
16
16
|
.option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
|
|
17
17
|
.option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
|
|
18
18
|
.option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
|
|
@@ -22,6 +22,7 @@ import { resolveHost, resolveHostByCap } from './registry.js';
|
|
|
22
22
|
import { sshTargetFor } from './types.js';
|
|
23
23
|
import { dispatchAgentsCommand } from './dispatch.js';
|
|
24
24
|
import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
|
|
25
|
+
import { resolveRemoteOsSync } from './remote-os.js';
|
|
25
26
|
import { machineId } from '../session/sync/config.js';
|
|
26
27
|
const REMOTE_PASSTHROUGH = {
|
|
27
28
|
view: {},
|
|
@@ -32,6 +33,7 @@ const REMOTE_PASSTHROUGH = {
|
|
|
32
33
|
list: {},
|
|
33
34
|
sync: { nonInteractive: ['--yes'] },
|
|
34
35
|
teams: {},
|
|
36
|
+
message: {},
|
|
35
37
|
};
|
|
36
38
|
/** `--no-tty` is stripped like the routing flags but carries no value. */
|
|
37
39
|
const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
|
|
@@ -130,7 +132,7 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
130
132
|
}
|
|
131
133
|
return true;
|
|
132
134
|
}
|
|
133
|
-
const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd);
|
|
135
|
+
const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, resolveRemoteOsSync(host.name));
|
|
134
136
|
const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
|
|
135
137
|
if (code === 255) {
|
|
136
138
|
console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
|
|
6
6
|
* a dropped connection resumes from the saved offset) and finish when `.exit`
|
|
7
7
|
* appears. Rich transcript-parser rendering is a fast-follow.
|
|
8
|
+
*
|
|
9
|
+
* Efficiency: each cycle is a SINGLE ssh round-trip that returns the new log
|
|
10
|
+
* bytes, a per-task sentinel, then the exit-file contents — half the process +
|
|
11
|
+
* handshake cost of the old tail-then-cat pair. It rides the default control
|
|
12
|
+
* socket (multiplex on) that the launch opened, and eases the poll interval off
|
|
13
|
+
* toward `maxPollMs` while the job is idle, so a quiet long-running follow no
|
|
14
|
+
* longer spawns thousands of ssh processes per hour on the laptop.
|
|
8
15
|
*/
|
|
9
16
|
export interface FollowOptions {
|
|
10
17
|
remoteLog: string;
|
|
@@ -15,7 +22,66 @@ export interface FollowOptions {
|
|
|
15
22
|
echo?: boolean;
|
|
16
23
|
/** Overall wall-clock cap; returns -1 on timeout. */
|
|
17
24
|
timeoutMs?: number;
|
|
25
|
+
/** Fast poll interval while output is flowing (default 1500ms). */
|
|
18
26
|
pollMs?: number;
|
|
27
|
+
/** Idle-backoff ceiling (default 4× the fast interval, min 4000ms). */
|
|
28
|
+
maxPollMs?: number;
|
|
19
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Build the per-task sentinel that separates the log tail from the exit-file
|
|
32
|
+
* contents in one combined fetch. The task id (8 hex chars) makes collision with
|
|
33
|
+
* the agent's own output effectively impossible; callers still split on the LAST
|
|
34
|
+
* occurrence so a token echoed into the log can never be mistaken for the real
|
|
35
|
+
* trailing marker.
|
|
36
|
+
*/
|
|
37
|
+
export declare function exitMarker(taskId: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Split a combined fetch (`<log bytes><marker><exit>`) back into its parts at the
|
|
40
|
+
* BYTE level. Splits on the LAST marker occurrence, so even if the agent's own
|
|
41
|
+
* output happened to echo the token, the real trailing sentinel still wins.
|
|
42
|
+
* Returns null when the marker is absent (a transient fetch miss — the remote
|
|
43
|
+
* shell never ran our printf), telling the caller to retry without advancing.
|
|
44
|
+
*
|
|
45
|
+
* Byte-level (not string) is load-bearing: `logChunk.length` is the EXACT number
|
|
46
|
+
* of log bytes consumed on the wire, which the follow loop adds to its offset. A
|
|
47
|
+
* string split would first UTF-8-decode, turning any multibyte char split at the
|
|
48
|
+
* `tail -c` boundary into a U+FFFD whose re-encoded length ≠ the wire bytes,
|
|
49
|
+
* drifting the offset (see followHostTask). `consumed` is returned explicitly for
|
|
50
|
+
* clarity; it always equals `logChunk.length`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function splitProgressBytes(buf: Buffer, taskId: string): {
|
|
53
|
+
logChunk: Buffer;
|
|
54
|
+
exit: Buffer;
|
|
55
|
+
consumed: number;
|
|
56
|
+
} | null;
|
|
57
|
+
/**
|
|
58
|
+
* One round-trip: new log bytes since `offset`, the sentinel, then the exit
|
|
59
|
+
* file. Returns null on a transient fetch miss (ssh error / marker absent) so
|
|
60
|
+
* the caller simply retries next cycle without advancing the offset.
|
|
61
|
+
*
|
|
62
|
+
* `remoteLog`/`remoteExit` are $HOME-prefixed paths with safe (hex) basenames —
|
|
63
|
+
* intentionally unquoted so the remote shell expands $HOME.
|
|
64
|
+
*/
|
|
65
|
+
export declare function fetchProgress(target: string, opts: {
|
|
66
|
+
remoteLog: string;
|
|
67
|
+
remoteExit: string;
|
|
68
|
+
taskId: string;
|
|
69
|
+
offset: number;
|
|
70
|
+
}): {
|
|
71
|
+
logChunk: Buffer;
|
|
72
|
+
exit: string;
|
|
73
|
+
} | null;
|
|
74
|
+
/**
|
|
75
|
+
* File identity (`dev:ino`) of a path on the remote host, or null if it can't be
|
|
76
|
+
* stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
|
|
77
|
+
*/
|
|
78
|
+
export declare function readRemoteFileId(target: string, remotePath: string): string | null;
|
|
79
|
+
/**
|
|
80
|
+
* True when the local mirror file IS the very file we're tailing — the
|
|
81
|
+
* localhost-as-host case, where `remoteLog` ($HOME-expanded) and `localLogPath`
|
|
82
|
+
* resolve to the same inode. Appending our read bytes back into it would feed the
|
|
83
|
+
* tail and multiply the log, so the caller must skip the mirror write.
|
|
84
|
+
*/
|
|
85
|
+
export declare function mirrorAliasesSource(localId: string | null, remoteId: string | null): boolean;
|
|
20
86
|
/** Tail the remote log to stdout until the run finishes; return its exit code. */
|
|
21
87
|
export declare function followHostTask(target: string, opts: FollowOptions): Promise<number>;
|
|
@@ -5,45 +5,153 @@
|
|
|
5
5
|
* a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
|
|
6
6
|
* a dropped connection resumes from the saved offset) and finish when `.exit`
|
|
7
7
|
* appears. Rich transcript-parser rendering is a fast-follow.
|
|
8
|
+
*
|
|
9
|
+
* Efficiency: each cycle is a SINGLE ssh round-trip that returns the new log
|
|
10
|
+
* bytes, a per-task sentinel, then the exit-file contents — half the process +
|
|
11
|
+
* handshake cost of the old tail-then-cat pair. It rides the default control
|
|
12
|
+
* socket (multiplex on) that the launch opened, and eases the poll interval off
|
|
13
|
+
* toward `maxPollMs` while the job is idle, so a quiet long-running follow no
|
|
14
|
+
* longer spawns thousands of ssh processes per hour on the laptop.
|
|
8
15
|
*/
|
|
9
16
|
import * as fs from 'fs';
|
|
10
|
-
import { sshExec } from '../ssh-exec.js';
|
|
17
|
+
import { sshExec, sshExecRaw } from '../ssh-exec.js';
|
|
11
18
|
import { localLogPath } from './tasks.js';
|
|
12
19
|
function sleep(ms) {
|
|
13
20
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Build the per-task sentinel that separates the log tail from the exit-file
|
|
24
|
+
* contents in one combined fetch. The task id (8 hex chars) makes collision with
|
|
25
|
+
* the agent's own output effectively impossible; callers still split on the LAST
|
|
26
|
+
* occurrence so a token echoed into the log can never be mistaken for the real
|
|
27
|
+
* trailing marker.
|
|
28
|
+
*/
|
|
29
|
+
export function exitMarker(taskId) {
|
|
30
|
+
return `\n@@AGENTS_HOST_EXIT_${taskId}@@\n`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Split a combined fetch (`<log bytes><marker><exit>`) back into its parts at the
|
|
34
|
+
* BYTE level. Splits on the LAST marker occurrence, so even if the agent's own
|
|
35
|
+
* output happened to echo the token, the real trailing sentinel still wins.
|
|
36
|
+
* Returns null when the marker is absent (a transient fetch miss — the remote
|
|
37
|
+
* shell never ran our printf), telling the caller to retry without advancing.
|
|
38
|
+
*
|
|
39
|
+
* Byte-level (not string) is load-bearing: `logChunk.length` is the EXACT number
|
|
40
|
+
* of log bytes consumed on the wire, which the follow loop adds to its offset. A
|
|
41
|
+
* string split would first UTF-8-decode, turning any multibyte char split at the
|
|
42
|
+
* `tail -c` boundary into a U+FFFD whose re-encoded length ≠ the wire bytes,
|
|
43
|
+
* drifting the offset (see followHostTask). `consumed` is returned explicitly for
|
|
44
|
+
* clarity; it always equals `logChunk.length`.
|
|
45
|
+
*/
|
|
46
|
+
export function splitProgressBytes(buf, taskId) {
|
|
47
|
+
const marker = Buffer.from(exitMarker(taskId), 'utf8'); // ASCII-only, unambiguous
|
|
48
|
+
const idx = buf.lastIndexOf(marker);
|
|
49
|
+
if (idx === -1)
|
|
50
|
+
return null;
|
|
51
|
+
return {
|
|
52
|
+
logChunk: buf.subarray(0, idx),
|
|
53
|
+
exit: buf.subarray(idx + marker.length),
|
|
54
|
+
consumed: idx,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* One round-trip: new log bytes since `offset`, the sentinel, then the exit
|
|
59
|
+
* file. Returns null on a transient fetch miss (ssh error / marker absent) so
|
|
60
|
+
* the caller simply retries next cycle without advancing the offset.
|
|
61
|
+
*
|
|
62
|
+
* `remoteLog`/`remoteExit` are $HOME-prefixed paths with safe (hex) basenames —
|
|
63
|
+
* intentionally unquoted so the remote shell expands $HOME.
|
|
64
|
+
*/
|
|
65
|
+
export function fetchProgress(target, opts) {
|
|
66
|
+
// Derive the printf format from the SAME exitMarker the parser splits on, so
|
|
67
|
+
// the emitted sentinel and the one we look for can never desync. The marker's
|
|
68
|
+
// only escape-sensitive bytes are its newlines (→ `\n`); it carries no `%`,
|
|
69
|
+
// single-quote, or other printf/shell-special chars (task id is hex).
|
|
70
|
+
const printfArg = exitMarker(opts.taskId).replace(/\n/g, '\\n');
|
|
71
|
+
const remote = `tail -c +${opts.offset + 1} ${opts.remoteLog} 2>/dev/null; ` +
|
|
72
|
+
`printf '${printfArg}'; ` +
|
|
73
|
+
`cat ${opts.remoteExit} 2>/dev/null`;
|
|
74
|
+
// Raw bytes (no UTF-8 decode): the log tail must be counted and re-emitted
|
|
75
|
+
// byte-for-byte so a multibyte char split at the `tail -c` boundary neither
|
|
76
|
+
// drifts the offset nor renders as U+FFFD. The exit code is pure ASCII → safe
|
|
77
|
+
// to decode to a string for the caller's `.trim()`.
|
|
78
|
+
const res = sshExecRaw(target, remote, { timeoutMs: 20000 });
|
|
79
|
+
const parts = splitProgressBytes(res.stdout, opts.taskId);
|
|
80
|
+
if (!parts)
|
|
81
|
+
return null;
|
|
82
|
+
return { logChunk: parts.logChunk, exit: parts.exit.toString('utf8') };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* File identity (`dev:ino`) of a path on the remote host, or null if it can't be
|
|
86
|
+
* stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
|
|
87
|
+
*/
|
|
88
|
+
export function readRemoteFileId(target, remotePath) {
|
|
89
|
+
const res = sshExec(target, `stat -c '%d:%i' ${remotePath} 2>/dev/null || stat -f '%d:%i' ${remotePath} 2>/dev/null`, { timeoutMs: 8000 });
|
|
90
|
+
const id = res.stdout.trim();
|
|
91
|
+
return id || null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* True when the local mirror file IS the very file we're tailing — the
|
|
95
|
+
* localhost-as-host case, where `remoteLog` ($HOME-expanded) and `localLogPath`
|
|
96
|
+
* resolve to the same inode. Appending our read bytes back into it would feed the
|
|
97
|
+
* tail and multiply the log, so the caller must skip the mirror write.
|
|
98
|
+
*/
|
|
99
|
+
export function mirrorAliasesSource(localId, remoteId) {
|
|
100
|
+
return localId !== null && remoteId !== null && localId === remoteId;
|
|
101
|
+
}
|
|
15
102
|
/** Tail the remote log to stdout until the run finishes; return its exit code. */
|
|
16
103
|
export async function followHostTask(target, opts) {
|
|
17
|
-
const
|
|
104
|
+
const fastMs = opts.pollMs ?? 1500;
|
|
105
|
+
const maxMs = Math.max(opts.maxPollMs ?? fastMs * 4, 4000);
|
|
18
106
|
const deadline = Date.now() + (opts.timeoutMs ?? 3600_000);
|
|
19
107
|
const local = localLogPath(opts.taskId);
|
|
20
108
|
let offset = 0;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
109
|
+
let waitMs = fastMs;
|
|
110
|
+
// localhost-as-host guard: when the local mirror and the remote log are the
|
|
111
|
+
// same physical file, appending our read bytes back would feed the tail and
|
|
112
|
+
// multiply the log (a plain `--host localhost` follow otherwise tripled it).
|
|
113
|
+
// Detect via file identity and echo-only in that case.
|
|
114
|
+
let mirror = true;
|
|
115
|
+
try {
|
|
116
|
+
const s = fs.statSync(local);
|
|
117
|
+
if (mirrorAliasesSource(`${s.dev}:${s.ino}`, readRemoteFileId(target, opts.remoteLog))) {
|
|
118
|
+
mirror = false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch { /* mirror absent or unstattable → distinct file, keep mirroring */ }
|
|
122
|
+
const flush = (logChunk) => {
|
|
123
|
+
if (logChunk.length === 0)
|
|
124
|
+
return false;
|
|
125
|
+
if (opts.echo)
|
|
126
|
+
process.stdout.write(logChunk);
|
|
127
|
+
if (mirror) {
|
|
28
128
|
try {
|
|
29
|
-
fs.appendFileSync(local,
|
|
129
|
+
fs.appendFileSync(local, logChunk);
|
|
30
130
|
}
|
|
31
131
|
catch { /* best-effort */ }
|
|
32
|
-
offset += Buffer.byteLength(chunk.stdout, 'utf8');
|
|
33
132
|
}
|
|
133
|
+
offset += logChunk.length; // exact wire bytes — no re-encode drift
|
|
134
|
+
return true;
|
|
34
135
|
};
|
|
35
136
|
for (;;) {
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
137
|
+
const r = fetchProgress(target, { remoteLog: opts.remoteLog, remoteExit: opts.remoteExit, taskId: opts.taskId, offset });
|
|
138
|
+
const gotOutput = r ? flush(r.logChunk) : false;
|
|
139
|
+
if (r && r.exit.trim() !== '') {
|
|
140
|
+
// Finished — one final fetch catches bytes written between our tail and
|
|
141
|
+
// the exit file appearing.
|
|
142
|
+
const tail = fetchProgress(target, { remoteLog: opts.remoteLog, remoteExit: opts.remoteExit, taskId: opts.taskId, offset });
|
|
143
|
+
if (tail)
|
|
144
|
+
flush(tail.logChunk);
|
|
145
|
+
const code = parseInt(r.exit.trim(), 10);
|
|
41
146
|
return Number.isFinite(code) ? code : 0;
|
|
42
147
|
}
|
|
43
148
|
if (Date.now() > deadline) {
|
|
44
149
|
process.stderr.write('\n[hosts] follow timed out; the run continues on the host. Reattach with: agents hosts logs ' + opts.taskId + ' -f\n');
|
|
45
150
|
return -1;
|
|
46
151
|
}
|
|
47
|
-
|
|
152
|
+
// Fast while output flows; ease toward maxMs when idle so a quiet job isn't
|
|
153
|
+
// polled needlessly. New output snaps the cadence back to fast.
|
|
154
|
+
waitMs = gotOutput ? fastMs : Math.min(maxMs, Math.round(waitMs * 1.5));
|
|
155
|
+
await sleep(waitMs);
|
|
48
156
|
}
|
|
49
157
|
}
|
|
@@ -8,20 +8,62 @@
|
|
|
8
8
|
import type { Host } from './types.js';
|
|
9
9
|
/** Resolve this CLI's own version by walking up to the nearest package.json. */
|
|
10
10
|
export declare function localCliVersion(): string | null;
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/** ssh command that confirms reachability and echoes the OS. POSIX boxes answer
|
|
12
|
+
* `uname -s`; a Windows target has no `uname` (ssh lands in cmd.exe/PowerShell),
|
|
13
|
+
* so it runs a tiny PowerShell probe. Pure/exported so both branches are
|
|
14
|
+
* unit-testable without ssh. */
|
|
15
|
+
export declare function buildProbeCommand(os?: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Reachability + OS probe over ssh. `os` is the caller's hint (device-registry
|
|
18
|
+
* platform / enrolled `HostEntry.os`); when it marks the host Windows we take
|
|
19
|
+
* the PowerShell path and report that known platform, otherwise POSIX + uname.
|
|
20
|
+
*/
|
|
21
|
+
export declare function probeHost(target: string, os?: string): {
|
|
13
22
|
reachable: boolean;
|
|
14
23
|
os?: string;
|
|
15
24
|
};
|
|
16
|
-
/**
|
|
17
|
-
export declare function
|
|
18
|
-
/**
|
|
19
|
-
export declare function
|
|
25
|
+
/** ssh command that prints the remote agents-cli version. Pure/exported. */
|
|
26
|
+
export declare function buildRemoteVersionCommand(os?: string): string;
|
|
27
|
+
/** Remote agents-cli version (PATH-resolved on the remote), or null if not installed. */
|
|
28
|
+
export declare function remoteAgentsVersion(target: string, os?: string): string | null;
|
|
29
|
+
/** ssh command that installs/upgrades agents-cli to `spec` then `agents setup`.
|
|
30
|
+
* PowerShell has no `tail`/`[ -d ]`/`||`, so the Windows branch uses native
|
|
31
|
+
* equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
|
|
32
|
+
export declare function buildBootstrapCommand(spec: string, os?: string): string;
|
|
20
33
|
/** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
|
|
21
|
-
export declare function bootstrapAgentsCli(target: string, version: string | null): {
|
|
34
|
+
export declare function bootstrapAgentsCli(target: string, version: string | null, os?: string): {
|
|
22
35
|
ok: boolean;
|
|
23
36
|
output: string;
|
|
24
37
|
};
|
|
38
|
+
export interface ReadyProbe {
|
|
39
|
+
/** ssh connected and the remote login shell ran (our sentinel came back). */
|
|
40
|
+
reachable: boolean;
|
|
41
|
+
/** Remote agents-cli version (no leading `v`), or null if not installed. */
|
|
42
|
+
version: string | null;
|
|
43
|
+
/** Raw `agents view`/`list` output, for installed-agent checks. */
|
|
44
|
+
view: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Answer every readiness question in ONE ssh round-trip: reachable? (the login
|
|
48
|
+
* shell ran and echoed our sentinel), agents-cli version, and the installed-agent
|
|
49
|
+
* listing. This replaces three sequential probes (`true` + `agents --version` +
|
|
50
|
+
* `agents view`) — 3 handshakes collapse to 1. Reachability keys off the sentinel
|
|
51
|
+
* rather than the exit code, so a command that ran-but-failed is never mistaken
|
|
52
|
+
* for a dead connection (only ssh's own failure drops the sentinel).
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* The one-shot readiness command (version + sentinel + agent listing). The
|
|
56
|
+
* Windows branch emits the sentinel with `Write-Output` and branches on
|
|
57
|
+
* `$LASTEXITCODE` (no `printf`/`||`); `parseReadyProbe` keys off the sentinel
|
|
58
|
+
* substring, so the missing leading newline vs the POSIX `printf '\n…'` form is
|
|
59
|
+
* absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
|
|
60
|
+
*/
|
|
61
|
+
export declare function buildReadyProbeCommand(os?: string): string;
|
|
62
|
+
export declare function readyProbe(target: string, os?: string): ReadyProbe;
|
|
63
|
+
/** Pure parser for `readyProbe` output (unit-tested without ssh). */
|
|
64
|
+
export declare function parseReadyProbe(stdout: string): ReadyProbe;
|
|
65
|
+
/** True if `view` output lists the named agent (word-boundary, case-insensitive). */
|
|
66
|
+
export declare function viewHasAgent(view: string, agent: string): boolean;
|
|
25
67
|
export interface EnsureReadyOptions {
|
|
26
68
|
agent: string;
|
|
27
69
|
/** Throw instead of warn when the agent isn't installed remotely. */
|
|
@@ -31,6 +73,8 @@ export interface EnsureReadyOptions {
|
|
|
31
73
|
* Verify a host can run the agent: reachable + agents-cli present. Throws with an
|
|
32
74
|
* actionable message otherwise. Agent-not-installed is a warning by default (the
|
|
33
75
|
* remote `agents run` will surface it); pass requireAgent to make it fatal.
|
|
76
|
+
*
|
|
77
|
+
* One ssh round-trip (`readyProbe`) covers all three checks.
|
|
34
78
|
*/
|
|
35
79
|
export declare function ensureHostReady(host: Host, opts: EnsureReadyOptions): {
|
|
36
80
|
warnings: string[];
|
package/dist/lib/hosts/ready.js
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
|
-
import { sshExec,
|
|
11
|
+
import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
12
12
|
import { sshTargetFor } from './types.js';
|
|
13
|
+
import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote } from './remote-cmd.js';
|
|
14
|
+
import { resolveRemoteOsSync } from './remote-os.js';
|
|
13
15
|
/** Resolve this CLI's own version by walking up to the nearest package.json. */
|
|
14
16
|
export function localCliVersion() {
|
|
15
17
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -30,55 +32,126 @@ export function localCliVersion() {
|
|
|
30
32
|
}
|
|
31
33
|
return null;
|
|
32
34
|
}
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
/** ssh command that confirms reachability and echoes the OS. POSIX boxes answer
|
|
36
|
+
* `uname -s`; a Windows target has no `uname` (ssh lands in cmd.exe/PowerShell),
|
|
37
|
+
* so it runs a tiny PowerShell probe. Pure/exported so both branches are
|
|
38
|
+
* unit-testable without ssh. */
|
|
39
|
+
export function buildProbeCommand(os) {
|
|
40
|
+
if (remoteShellFor(os) === 'powershell') {
|
|
41
|
+
return `powershell -NoProfile -EncodedCommand ${encodePowershell('[System.Environment]::OSVersion.Platform.ToString()')}`;
|
|
42
|
+
}
|
|
43
|
+
return 'uname -s 2>/dev/null || echo unknown';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reachability + OS probe over ssh. `os` is the caller's hint (device-registry
|
|
47
|
+
* platform / enrolled `HostEntry.os`); when it marks the host Windows we take
|
|
48
|
+
* the PowerShell path and report that known platform, otherwise POSIX + uname.
|
|
49
|
+
*/
|
|
50
|
+
export function probeHost(target, os) {
|
|
51
|
+
const r = sshExec(target, buildProbeCommand(os), { timeoutMs: 12000 });
|
|
36
52
|
if (r.code !== 0)
|
|
37
53
|
return { reachable: false };
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
if (remoteShellFor(os) === 'powershell')
|
|
55
|
+
return { reachable: true, os };
|
|
56
|
+
const uname = r.stdout.trim();
|
|
57
|
+
return { reachable: true, os: uname && uname !== 'unknown' ? uname : undefined };
|
|
58
|
+
}
|
|
59
|
+
/** ssh command that prints the remote agents-cli version. Pure/exported. */
|
|
60
|
+
export function buildRemoteVersionCommand(os) {
|
|
61
|
+
return remoteShellFor(os) === 'powershell'
|
|
62
|
+
? buildWindowsAgentsCommand({ args: ['--version'] })
|
|
63
|
+
: 'bash -lc "agents --version 2>/dev/null"';
|
|
40
64
|
}
|
|
41
|
-
/** Remote agents-cli version (
|
|
42
|
-
export function remoteAgentsVersion(target) {
|
|
43
|
-
const r = sshExec(target,
|
|
65
|
+
/** Remote agents-cli version (PATH-resolved on the remote), or null if not installed. */
|
|
66
|
+
export function remoteAgentsVersion(target, os) {
|
|
67
|
+
const r = sshExec(target, buildRemoteVersionCommand(os), { timeoutMs: 20000 });
|
|
44
68
|
if (r.code !== 0)
|
|
45
69
|
return null;
|
|
46
70
|
const v = r.stdout.trim();
|
|
47
71
|
return v || null;
|
|
48
72
|
}
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function bootstrapAgentsCli(target, version) {
|
|
60
|
-
const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
|
|
73
|
+
/** ssh command that installs/upgrades agents-cli to `spec` then `agents setup`.
|
|
74
|
+
* PowerShell has no `tail`/`[ -d ]`/`||`, so the Windows branch uses native
|
|
75
|
+
* equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
|
|
76
|
+
export function buildBootstrapCommand(spec, os) {
|
|
77
|
+
if (remoteShellFor(os) === 'powershell') {
|
|
78
|
+
const script = `npm install -g ${powershellQuote(spec)} 2>&1 | Select-Object -Last 3; ` +
|
|
79
|
+
`if (-not (Test-Path "$HOME/.agents/.system")) { agents setup 2>&1 | Select-Object -Last 3 }; ` +
|
|
80
|
+
`agents --version`;
|
|
81
|
+
return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
|
|
82
|
+
}
|
|
61
83
|
const script = `npm install -g ${shellQuote(spec)} 2>&1 | tail -3; ` +
|
|
62
84
|
`if [ ! -d ~/.agents/.system ]; then agents setup 2>&1 | tail -3 || true; fi; ` +
|
|
63
85
|
`agents --version`;
|
|
64
|
-
|
|
86
|
+
return `bash -lc ${shellQuote(script)}`;
|
|
87
|
+
}
|
|
88
|
+
/** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
|
|
89
|
+
export function bootstrapAgentsCli(target, version, os) {
|
|
90
|
+
const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
|
|
91
|
+
const r = sshExec(target, buildBootstrapCommand(spec, os), { timeoutMs: 300000 });
|
|
65
92
|
return { ok: r.code === 0, output: (r.stdout + r.stderr).trim() };
|
|
66
93
|
}
|
|
94
|
+
/** Sentinel splitting the version output from the agent listing in one probe. */
|
|
95
|
+
const READY_MARKER = '@@AGENTS_READY@@';
|
|
96
|
+
/**
|
|
97
|
+
* Answer every readiness question in ONE ssh round-trip: reachable? (the login
|
|
98
|
+
* shell ran and echoed our sentinel), agents-cli version, and the installed-agent
|
|
99
|
+
* listing. This replaces three sequential probes (`true` + `agents --version` +
|
|
100
|
+
* `agents view`) — 3 handshakes collapse to 1. Reachability keys off the sentinel
|
|
101
|
+
* rather than the exit code, so a command that ran-but-failed is never mistaken
|
|
102
|
+
* for a dead connection (only ssh's own failure drops the sentinel).
|
|
103
|
+
*/
|
|
104
|
+
/**
|
|
105
|
+
* The one-shot readiness command (version + sentinel + agent listing). The
|
|
106
|
+
* Windows branch emits the sentinel with `Write-Output` and branches on
|
|
107
|
+
* `$LASTEXITCODE` (no `printf`/`||`); `parseReadyProbe` keys off the sentinel
|
|
108
|
+
* substring, so the missing leading newline vs the POSIX `printf '\n…'` form is
|
|
109
|
+
* absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
|
|
110
|
+
*/
|
|
111
|
+
export function buildReadyProbeCommand(os) {
|
|
112
|
+
if (remoteShellFor(os) === 'powershell') {
|
|
113
|
+
const script = `agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
|
|
114
|
+
`agents view 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
|
|
115
|
+
return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
|
|
116
|
+
}
|
|
117
|
+
const script = `agents --version 2>/dev/null; printf '\\n${READY_MARKER}\\n'; ` +
|
|
118
|
+
`agents view 2>/dev/null || agents list 2>/dev/null`;
|
|
119
|
+
return `bash -lc ${shellQuote(script)}`;
|
|
120
|
+
}
|
|
121
|
+
export function readyProbe(target, os) {
|
|
122
|
+
const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000 });
|
|
123
|
+
return parseReadyProbe(r.stdout);
|
|
124
|
+
}
|
|
125
|
+
/** Pure parser for `readyProbe` output (unit-tested without ssh). */
|
|
126
|
+
export function parseReadyProbe(stdout) {
|
|
127
|
+
const idx = stdout.indexOf(READY_MARKER);
|
|
128
|
+
if (idx === -1)
|
|
129
|
+
return { reachable: false, version: null, view: '' };
|
|
130
|
+
const version = stdout.slice(0, idx).trim().replace(/^v/, '') || null;
|
|
131
|
+
return { reachable: true, version, view: stdout.slice(idx + READY_MARKER.length) };
|
|
132
|
+
}
|
|
133
|
+
/** True if `view` output lists the named agent (word-boundary, case-insensitive). */
|
|
134
|
+
export function viewHasAgent(view, agent) {
|
|
135
|
+
return new RegExp(`\\b${agent}\\b`, 'i').test(view);
|
|
136
|
+
}
|
|
67
137
|
/**
|
|
68
138
|
* Verify a host can run the agent: reachable + agents-cli present. Throws with an
|
|
69
139
|
* actionable message otherwise. Agent-not-installed is a warning by default (the
|
|
70
140
|
* remote `agents run` will surface it); pass requireAgent to make it fatal.
|
|
141
|
+
*
|
|
142
|
+
* One ssh round-trip (`readyProbe`) covers all three checks.
|
|
71
143
|
*/
|
|
72
144
|
export function ensureHostReady(host, opts) {
|
|
73
145
|
const target = sshTargetFor(host);
|
|
74
|
-
|
|
146
|
+
const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name));
|
|
147
|
+
if (!probe.reachable) {
|
|
75
148
|
throw new Error(`Host "${host.name}" (${target}) is not reachable over SSH. Check it's online and key auth works.`);
|
|
76
149
|
}
|
|
77
|
-
if (!
|
|
150
|
+
if (!probe.version) {
|
|
78
151
|
throw new Error(`agents-cli is not installed on "${host.name}". Enroll it first: agents hosts add ${host.name} (bootstraps agents-cli).`);
|
|
79
152
|
}
|
|
80
153
|
const warnings = [];
|
|
81
|
-
if (!
|
|
154
|
+
if (!viewHasAgent(probe.view, opts.agent)) {
|
|
82
155
|
const msg = `Agent "${opts.agent}" may not be installed on "${host.name}" (remote \`agents add ${opts.agent}\` to install).`;
|
|
83
156
|
if (opts.requireAgent)
|
|
84
157
|
throw new Error(msg);
|