@phnx-labs/agents-cli 1.20.70 → 1.20.71
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 +60 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +36 -3
- package/dist/commands/check.js +3 -1
- package/dist/commands/exec.js +12 -0
- package/dist/commands/fleet-capture.d.ts +14 -0
- package/dist/commands/fleet-capture.js +107 -0
- package/dist/commands/fork.js +13 -7
- package/dist/commands/hosts.js +11 -0
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +24 -4
- package/dist/commands/sessions-tail.js +1 -2
- package/dist/commands/sessions.js +6 -6
- package/dist/commands/ssh.js +24 -6
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +50 -28
- package/dist/lib/commands.d.ts +10 -0
- package/dist/lib/commands.js +31 -7
- package/dist/lib/devices/connect.d.ts +13 -0
- package/dist/lib/devices/connect.js +20 -0
- package/dist/lib/devices/fleet.d.ts +36 -3
- package/dist/lib/devices/fleet.js +42 -4
- package/dist/lib/devices/sync.d.ts +30 -0
- package/dist/lib/devices/sync.js +50 -0
- package/dist/lib/doctor-diff.js +38 -16
- package/dist/lib/fleet/apply.d.ts +4 -0
- package/dist/lib/fleet/apply.js +28 -5
- package/dist/lib/fleet/capture.d.ts +35 -0
- package/dist/lib/fleet/capture.js +51 -0
- package/dist/lib/fleet/manifest.d.ts +6 -0
- package/dist/lib/fleet/manifest.js +24 -1
- package/dist/lib/fleet/types.d.ts +24 -1
- package/dist/lib/git.d.ts +14 -0
- package/dist/lib/git.js +39 -1
- package/dist/lib/menubar/install-menubar.d.ts +12 -0
- package/dist/lib/menubar/install-menubar.js +26 -13
- package/dist/lib/secrets/agent.d.ts +5 -0
- package/dist/lib/secrets/agent.js +35 -3
- package/dist/lib/secrets/bundles.js +28 -0
- package/dist/lib/secrets/index.d.ts +14 -0
- package/dist/lib/secrets/index.js +36 -5
- package/dist/lib/secrets/session-store.d.ts +90 -0
- package/dist/lib/secrets/session-store.js +221 -0
- package/dist/lib/session/render.d.ts +9 -1
- package/dist/lib/session/render.js +35 -4
- package/dist/lib/share/capture.d.ts +2 -0
- package/dist/lib/share/capture.js +12 -5
- package/dist/lib/skills.d.ts +8 -1
- package/dist/lib/skills.js +11 -1
- package/dist/lib/types.d.ts +5 -1
- package/package.json +1 -1
package/dist/commands/ssh.js
CHANGED
|
@@ -18,6 +18,7 @@ import ora from 'ora';
|
|
|
18
18
|
import { getCliVersion } from '../lib/version.js';
|
|
19
19
|
import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../lib/secrets/bundles.js';
|
|
20
20
|
import { machineId } from '../lib/session/sync/config.js';
|
|
21
|
+
import { registerFleetCaptureCommand } from './fleet-capture.js';
|
|
21
22
|
import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
|
|
22
23
|
import { addControlToken } from '../lib/serve/token.js';
|
|
23
24
|
import { DEFAULT_SERVE_PORT } from '../lib/serve/server.js';
|
|
@@ -27,7 +28,7 @@ import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-targe
|
|
|
27
28
|
import { clearPendingSentinel } from '../lib/devices/pending.js';
|
|
28
29
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
29
30
|
import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
|
|
30
|
-
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
|
|
31
|
+
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
|
|
31
32
|
import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
|
|
32
33
|
import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
|
|
33
34
|
import { fanOutDevices, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
|
|
@@ -244,10 +245,10 @@ async function probeRemoteHealth(target) {
|
|
|
244
245
|
const isWin = /^win/i.test((target.platform ?? '').trim());
|
|
245
246
|
const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
|
|
246
247
|
const versionCmd = buildRemoteAgentsInvocation(['--version'], undefined, isWin ? 'windows' : undefined, env);
|
|
247
|
-
const versionRes = await sshExecAsync(target.
|
|
248
|
+
const versionRes = await sshExecAsync(target.dialTarget, versionCmd, { timeoutMs: 15000, multiplex: true });
|
|
248
249
|
const version = versionRes.code === 0 ? versionRes.stdout.trim().split(/\s+/)[0] || null : null;
|
|
249
250
|
const doctorCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, env);
|
|
250
|
-
const doctorRes = await sshExecAsync(target.
|
|
251
|
+
const doctorRes = await sshExecAsync(target.dialTarget, doctorCmd, { timeoutMs: 30000, multiplex: true });
|
|
251
252
|
if (doctorRes.code !== 0) {
|
|
252
253
|
throw new Error(doctorRes.timedOut ? 'timed out' : (doctorRes.stderr.trim() || `exit ${doctorRes.code ?? 'unknown'}`));
|
|
253
254
|
}
|
|
@@ -280,6 +281,20 @@ async function runFleetStatus(opts) {
|
|
|
280
281
|
name: t.device.name,
|
|
281
282
|
platform: t.device.platform,
|
|
282
283
|
skip: t.skip,
|
|
284
|
+
dialTarget: fleetDialTarget(t.device),
|
|
285
|
+
// Fail fast: the stats probe already tried this box with the same
|
|
286
|
+
// (registry) address. If it came back unreachable, don't spend another
|
|
287
|
+
// 15s+30s on version+doctor — skip straight to an unreachable row so one
|
|
288
|
+
// genuinely-offline device can't stall the whole matrix.
|
|
289
|
+
//
|
|
290
|
+
// Only trust this on a FRESH probe (`--refresh`/`--live`). loadFleetStats
|
|
291
|
+
// is cache-first (daemon-warmed ~3min), so a box that came back online
|
|
292
|
+
// since the last warm would otherwise be skipped to "unreachable" without
|
|
293
|
+
// a live attempt — a false negative. On a default run we still do the live
|
|
294
|
+
// probe (fast now that it dials the registry address).
|
|
295
|
+
...(forceRefresh && statsMap.get(t.device.name)?.reachable === false && !t.skip
|
|
296
|
+
? { skip: 'unreachable' }
|
|
297
|
+
: {}),
|
|
283
298
|
}));
|
|
284
299
|
const remote = await fanOutDevices(remoteTargets, probeRemoteHealth);
|
|
285
300
|
for (const result of remote) {
|
|
@@ -333,7 +348,7 @@ async function probeRemoteAuth(target) {
|
|
|
333
348
|
const isWin = /^win/i.test((target.platform ?? '').trim());
|
|
334
349
|
const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
|
|
335
350
|
const cmd = buildRemoteAgentsInvocation(['devices', 'ping', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
|
|
336
|
-
const res = await sshExecAsync(target.
|
|
351
|
+
const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 60000, multiplex: true });
|
|
337
352
|
if (res.code !== 0) {
|
|
338
353
|
throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
|
|
339
354
|
}
|
|
@@ -370,6 +385,7 @@ async function runFleetPing(opts) {
|
|
|
370
385
|
name: t.device.name,
|
|
371
386
|
platform: t.device.platform,
|
|
372
387
|
skip: t.skip,
|
|
388
|
+
dialTarget: fleetDialTarget(t.device),
|
|
373
389
|
}));
|
|
374
390
|
const probeable = remoteTargets.filter((t) => !t.skip).length;
|
|
375
391
|
const spinner = isInteractiveTerminal() && !opts.json
|
|
@@ -511,6 +527,8 @@ Typical workflow:
|
|
|
511
527
|
process.exit(1);
|
|
512
528
|
}
|
|
513
529
|
});
|
|
530
|
+
// `agents fleet capture` — snapshot live state into agents.yaml fleet:.
|
|
531
|
+
registerFleetCaptureCommand(devicesCmd);
|
|
514
532
|
devicesCmd
|
|
515
533
|
.command('register <name>')
|
|
516
534
|
.description('Register a discovered (pending) node by name — used by the menu-bar "NEW DEVICES → Register" action.')
|
|
@@ -781,7 +799,7 @@ Typical workflow:
|
|
|
781
799
|
return;
|
|
782
800
|
}
|
|
783
801
|
console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
|
|
784
|
-
const results = runFleet(targets, cmd);
|
|
802
|
+
const results = runFleet(targets, cmd, { self: machineId() });
|
|
785
803
|
printFleetResults(results);
|
|
786
804
|
});
|
|
787
805
|
devicesCmd
|
|
@@ -800,7 +818,7 @@ Typical workflow:
|
|
|
800
818
|
return;
|
|
801
819
|
}
|
|
802
820
|
console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
|
|
803
|
-
const results = runFleet(targets, cmd);
|
|
821
|
+
const results = runFleet(targets, cmd, { self: machineId() });
|
|
804
822
|
printFleetResults(results);
|
|
805
823
|
});
|
|
806
824
|
}
|
package/dist/commands/usage.d.ts
CHANGED
|
@@ -10,4 +10,17 @@
|
|
|
10
10
|
* don't publish per-account usage today)
|
|
11
11
|
*/
|
|
12
12
|
import type { Command } from 'commander';
|
|
13
|
+
import type { AgentId } from '../lib/types.js';
|
|
14
|
+
import { getUsageInfoForIdentity } from '../lib/usage.js';
|
|
15
|
+
/** One agent's usage snapshot — the unit the text and --json renderers share. */
|
|
16
|
+
export interface AgentUsageRecord {
|
|
17
|
+
agent: AgentId;
|
|
18
|
+
/** Plain agent name (no ANSI) — safe to emit in --json; the text table colorizes it. */
|
|
19
|
+
label: string;
|
|
20
|
+
status: 'unsupported' | 'no-version' | 'not-signed-in' | 'ok';
|
|
21
|
+
email?: string;
|
|
22
|
+
usage?: Awaited<ReturnType<typeof getUsageInfoForIdentity>>;
|
|
23
|
+
}
|
|
13
24
|
export declare function registerUsageCommand(program: Command): void;
|
|
25
|
+
/** Render one usage record as the human table section. */
|
|
26
|
+
export declare function formatAgentUsage(rec: AgentUsageRecord): string;
|
package/dist/commands/usage.js
CHANGED
|
@@ -13,13 +13,15 @@ const USAGE_SUPPORTED = new Set(['claude', 'codex', 'kimi', 'droid']);
|
|
|
13
13
|
export function registerUsageCommand(program) {
|
|
14
14
|
addHostOption(program.command('usage [agent]'))
|
|
15
15
|
.description('Show rate-limit / quota usage per agent')
|
|
16
|
+
.option('--json', 'Emit machine-readable JSON (per-agent usage snapshot) instead of the table')
|
|
16
17
|
.addHelpText('after', `
|
|
17
18
|
Examples:
|
|
18
19
|
agents usage Show usage for all installed agents
|
|
19
20
|
agents usage claude Show usage for Claude only
|
|
20
21
|
agents usage codex Show usage for Codex only
|
|
22
|
+
agents usage --json Machine-readable snapshot for scripts
|
|
21
23
|
`)
|
|
22
|
-
.action(async (agentFilter) => {
|
|
24
|
+
.action(async (agentFilter, options) => {
|
|
23
25
|
let filter;
|
|
24
26
|
if (agentFilter) {
|
|
25
27
|
const resolved = resolveAgentName(agentFilter);
|
|
@@ -33,47 +35,67 @@ Examples:
|
|
|
33
35
|
? [filter]
|
|
34
36
|
: ALL_AGENT_IDS.filter((id) => listInstalledVersions(id).length > 0);
|
|
35
37
|
if (targets.length === 0) {
|
|
38
|
+
if (options.json) {
|
|
39
|
+
console.log('[]');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
36
42
|
console.log(chalk.gray('No agents installed. Run `agents add <agent>` first.'));
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
|
-
const
|
|
40
|
-
|
|
45
|
+
const records = await Promise.all(targets.map((agentId) => collectAgentUsage(agentId)));
|
|
46
|
+
if (options.json) {
|
|
47
|
+
console.log(JSON.stringify(records, null, 2));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
console.log(records.map(formatAgentUsage).filter(Boolean).join('\n\n'));
|
|
41
51
|
});
|
|
42
52
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
/** Gather one agent's usage snapshot as structured data (shared by both renderers). */
|
|
54
|
+
async function collectAgentUsage(agentId) {
|
|
55
|
+
// Plain name — color is applied only at text-render time (formatAgentUsage), so
|
|
56
|
+
// `--json` never emits ANSI escapes in `label` (e.g. under FORCE_COLOR=1).
|
|
57
|
+
const label = AGENTS[agentId].name;
|
|
46
58
|
if (!USAGE_SUPPORTED.has(agentId)) {
|
|
47
|
-
return
|
|
48
|
-
`${heading}`,
|
|
49
|
-
` ${chalk.dim(`${cfg.name} CLI does not publish usage data.`)}`,
|
|
50
|
-
].join('\n');
|
|
59
|
+
return { agent: agentId, label, status: 'unsupported' };
|
|
51
60
|
}
|
|
52
61
|
const versions = listInstalledVersions(agentId);
|
|
53
62
|
const version = getGlobalDefault(agentId) || versions[0];
|
|
54
63
|
if (!version) {
|
|
55
|
-
return
|
|
64
|
+
return { agent: agentId, label, status: 'no-version' };
|
|
56
65
|
}
|
|
57
66
|
const home = getVersionHomePath(agentId, version);
|
|
58
67
|
const info = await getAccountInfo(agentId, home);
|
|
59
68
|
if (!info.usageKey && !info.accountKey) {
|
|
60
|
-
return
|
|
61
|
-
}
|
|
62
|
-
const usage = await getUsageInfoForIdentity({
|
|
63
|
-
agentId,
|
|
64
|
-
home,
|
|
65
|
-
info,
|
|
66
|
-
cliVersion: null,
|
|
67
|
-
});
|
|
68
|
-
const lines = [heading];
|
|
69
|
-
if (info.email)
|
|
70
|
-
lines.push(` ${chalk.dim(info.email)}`);
|
|
71
|
-
const section = formatUsageSection(usage);
|
|
72
|
-
if (section.length === 0) {
|
|
73
|
-
lines.push(` ${chalk.dim('No usage data available right now.')}`);
|
|
69
|
+
return { agent: agentId, label, status: 'not-signed-in' };
|
|
74
70
|
}
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
const usage = await getUsageInfoForIdentity({ agentId, home, info, cliVersion: null });
|
|
72
|
+
return { agent: agentId, label, status: 'ok', email: info.email ?? undefined, usage };
|
|
73
|
+
}
|
|
74
|
+
/** Render one usage record as the human table section. */
|
|
75
|
+
export function formatAgentUsage(rec) {
|
|
76
|
+
const cfg = AGENTS[rec.agent];
|
|
77
|
+
// Colorize the heading here (not in the record) so the plain `label` stays
|
|
78
|
+
// clean for --json while the text table keeps its per-agent color.
|
|
79
|
+
const heading = agentLabel(rec.agent);
|
|
80
|
+
switch (rec.status) {
|
|
81
|
+
case 'unsupported':
|
|
82
|
+
return [heading, ` ${chalk.dim(`${cfg.name} CLI does not publish usage data.`)}`].join('\n');
|
|
83
|
+
case 'no-version':
|
|
84
|
+
return [heading, ` ${chalk.dim('No version installed.')}`].join('\n');
|
|
85
|
+
case 'not-signed-in':
|
|
86
|
+
return [heading, ` ${chalk.dim('Not signed in.')}`].join('\n');
|
|
87
|
+
case 'ok': {
|
|
88
|
+
const lines = [heading];
|
|
89
|
+
if (rec.email)
|
|
90
|
+
lines.push(` ${chalk.dim(rec.email)}`);
|
|
91
|
+
const section = formatUsageSection(rec.usage);
|
|
92
|
+
if (section.length === 0) {
|
|
93
|
+
lines.push(` ${chalk.dim('No usage data available right now.')}`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
lines.push(...section);
|
|
97
|
+
}
|
|
98
|
+
return lines.join('\n');
|
|
99
|
+
}
|
|
77
100
|
}
|
|
78
|
-
return lines.join('\n');
|
|
79
101
|
}
|
package/dist/lib/commands.d.ts
CHANGED
|
@@ -89,6 +89,16 @@ export interface VersionCommandDiff {
|
|
|
89
89
|
/**
|
|
90
90
|
* Compare a version home's commands against central. Returns the reconciliation diff.
|
|
91
91
|
*/
|
|
92
|
+
/**
|
|
93
|
+
* Flattened names of plugin-bundled commands (`<plugin>-<command>`), matching
|
|
94
|
+
* exactly how `syncPluginToVersion` installs a plugin's `commands/<cmd>.md` as a
|
|
95
|
+
* command-skill (plugins.ts → `installCommandSkillToVersion(agentDir,
|
|
96
|
+
* `${plugin.name}-${cmd}`, …)`). These are source-managed by their plugin, so
|
|
97
|
+
* the orphan detector must NOT flag them — else `prune cleanup` proposes
|
|
98
|
+
* deleting live plugin commands (swarm-plan, code-review, …). Scans user +
|
|
99
|
+
* system + extra marketplaces (no project layer), matching the trusted sources.
|
|
100
|
+
*/
|
|
101
|
+
export declare function listPluginCommandNames(): Set<string>;
|
|
92
102
|
export declare function diffVersionCommands(agent: AgentId, version: string): VersionCommandDiff;
|
|
93
103
|
/**
|
|
94
104
|
* Install a single command from central into a specific version home.
|
package/dist/lib/commands.js
CHANGED
|
@@ -14,6 +14,7 @@ import { capableAgents, isCapable, supports } from './capabilities.js';
|
|
|
14
14
|
import { markdownToToml } from './convert.js';
|
|
15
15
|
import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
|
|
16
16
|
import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
|
|
17
|
+
import { discoverPlugins } from './plugins.js';
|
|
17
18
|
import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
|
|
18
19
|
import { installGooseCommandToVersion, listGooseCommandsInVersion, gooseCommandMatches, removeGooseCommandFromVersion, } from './goose-commands.js';
|
|
19
20
|
function compareVersions(a, b) {
|
|
@@ -311,8 +312,26 @@ function versionCommandMatches(agent, version, commandName) {
|
|
|
311
312
|
/**
|
|
312
313
|
* Compare a version home's commands against central. Returns the reconciliation diff.
|
|
313
314
|
*/
|
|
315
|
+
/**
|
|
316
|
+
* Flattened names of plugin-bundled commands (`<plugin>-<command>`), matching
|
|
317
|
+
* exactly how `syncPluginToVersion` installs a plugin's `commands/<cmd>.md` as a
|
|
318
|
+
* command-skill (plugins.ts → `installCommandSkillToVersion(agentDir,
|
|
319
|
+
* `${plugin.name}-${cmd}`, …)`). These are source-managed by their plugin, so
|
|
320
|
+
* the orphan detector must NOT flag them — else `prune cleanup` proposes
|
|
321
|
+
* deleting live plugin commands (swarm-plan, code-review, …). Scans user +
|
|
322
|
+
* system + extra marketplaces (no project layer), matching the trusted sources.
|
|
323
|
+
*/
|
|
324
|
+
export function listPluginCommandNames() {
|
|
325
|
+
const names = new Set();
|
|
326
|
+
for (const plugin of discoverPlugins()) {
|
|
327
|
+
for (const cmd of plugin.commands)
|
|
328
|
+
names.add(`${plugin.name}-${cmd}`);
|
|
329
|
+
}
|
|
330
|
+
return names;
|
|
331
|
+
}
|
|
314
332
|
export function diffVersionCommands(agent, version) {
|
|
315
333
|
const central = new Set(listCentralCommands());
|
|
334
|
+
const pluginCommands = listPluginCommandNames();
|
|
316
335
|
const installed = new Set(listCommandsInVersionHome(agent, version));
|
|
317
336
|
const toAdd = [];
|
|
318
337
|
const toUpdate = [];
|
|
@@ -335,15 +354,20 @@ export function diffVersionCommands(agent, version) {
|
|
|
335
354
|
}
|
|
336
355
|
}
|
|
337
356
|
for (const name of installed) {
|
|
338
|
-
if (
|
|
339
|
-
|
|
357
|
+
if (central.has(name)) {
|
|
358
|
+
const sourcePath = path.join(getCommandsDir(), `${name}.md`);
|
|
359
|
+
const metadata = parseCommandMetadata(sourcePath);
|
|
360
|
+
if (!commandAppliesTo(agent, version, metadata).ok) {
|
|
361
|
+
toRemove.push(name);
|
|
362
|
+
}
|
|
340
363
|
continue;
|
|
341
364
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
365
|
+
// A plugin-bundled command (installed as `<plugin>-<cmd>`) is source-managed
|
|
366
|
+
// by its plugin — not an orphan. Only a name with no central AND no plugin
|
|
367
|
+
// source is genuinely unmanaged.
|
|
368
|
+
if (pluginCommands.has(name))
|
|
369
|
+
continue;
|
|
370
|
+
orphans.push(name);
|
|
347
371
|
}
|
|
348
372
|
return {
|
|
349
373
|
agent,
|
|
@@ -8,6 +8,19 @@ export declare const ASKPASS_KEY_ENV = "AGENTS_SSH_KEY";
|
|
|
8
8
|
* it against the shared injection guard. Throws if the device has no address.
|
|
9
9
|
*/
|
|
10
10
|
export declare function sshTargetFor(device: DeviceProfile): string;
|
|
11
|
+
/**
|
|
12
|
+
* The address a fleet fan-out probe should hand to `ssh` for a device: the
|
|
13
|
+
* registry's known-good Tailscale dnsName/IP (via {@link sshTargetFor}) when
|
|
14
|
+
* present, else the bare name (an address-less manual device dials by name as
|
|
15
|
+
* before — never worse than the old behaviour).
|
|
16
|
+
*
|
|
17
|
+
* Prefer the registry address so a stale `~/.ssh/config` alias can never shadow
|
|
18
|
+
* it: dialing the bare device name lets ssh resolve it through the user's config,
|
|
19
|
+
* where a hand-written `Host <name>` block with a DHCP-drifted LAN IP silently
|
|
20
|
+
* shadows the correct entry, times out, and makes a reachable box look dead.
|
|
21
|
+
* Pure/testable.
|
|
22
|
+
*/
|
|
23
|
+
export declare function fleetDialTarget(device: DeviceProfile): string;
|
|
11
24
|
/**
|
|
12
25
|
* Wrap a remote command for the device's shell. Windows devices speak
|
|
13
26
|
* PowerShell, so a bare command is run through `powershell -NoProfile
|
|
@@ -38,6 +38,26 @@ export function sshTargetFor(device) {
|
|
|
38
38
|
assertValidSshTarget(target);
|
|
39
39
|
return target;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The address a fleet fan-out probe should hand to `ssh` for a device: the
|
|
43
|
+
* registry's known-good Tailscale dnsName/IP (via {@link sshTargetFor}) when
|
|
44
|
+
* present, else the bare name (an address-less manual device dials by name as
|
|
45
|
+
* before — never worse than the old behaviour).
|
|
46
|
+
*
|
|
47
|
+
* Prefer the registry address so a stale `~/.ssh/config` alias can never shadow
|
|
48
|
+
* it: dialing the bare device name lets ssh resolve it through the user's config,
|
|
49
|
+
* where a hand-written `Host <name>` block with a DHCP-drifted LAN IP silently
|
|
50
|
+
* shadows the correct entry, times out, and makes a reachable box look dead.
|
|
51
|
+
* Pure/testable.
|
|
52
|
+
*/
|
|
53
|
+
export function fleetDialTarget(device) {
|
|
54
|
+
try {
|
|
55
|
+
return sshTargetFor(device);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return device.user ? `${device.user}@${device.name}` : device.name;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
41
61
|
/**
|
|
42
62
|
* Wrap a remote command for the device's shell. Windows devices speak
|
|
43
63
|
* PowerShell, so a bare command is run through `powershell -NoProfile
|
|
@@ -70,17 +70,50 @@ export declare function runOnDevice(device: DeviceProfile, cmd: string[], opts?:
|
|
|
70
70
|
stdout: string;
|
|
71
71
|
stderr: string;
|
|
72
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* Run `cmd` on THIS machine directly — no ssh. Used by {@link runFleet} for the
|
|
75
|
+
* self target: a box frequently can't ssh to itself (no self-authorized key, as
|
|
76
|
+
* `agents fleet update` hit trying to reach zion from zion) and doesn't need to —
|
|
77
|
+
* `agents upgrade` etc. runs identically as a local process. Mirrors
|
|
78
|
+
* {@link runOnDevice}'s return shape and never throws. The argv is space-joined
|
|
79
|
+
* and evaluated by a shell — matching the POSIX-shell ssh path (so PATH-resolved
|
|
80
|
+
* `agents`, quoting, and `;`/`&&` behave the same). It does NOT replicate the
|
|
81
|
+
* powershell-device encoding runOnDevice uses (`connect.ts` base64 path): a
|
|
82
|
+
* Windows self runs under the default OS shell (cmd.exe), which still resolves
|
|
83
|
+
* `agents` on PATH for the only self commands that matter (`agents upgrade …`).
|
|
84
|
+
*/
|
|
85
|
+
export declare function runLocalCommand(cmd: string[], opts?: {
|
|
86
|
+
timeoutMs?: number;
|
|
87
|
+
}): {
|
|
88
|
+
code: number | null;
|
|
89
|
+
stdout: string;
|
|
90
|
+
stderr: string;
|
|
91
|
+
};
|
|
73
92
|
/**
|
|
74
93
|
* Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
|
|
75
94
|
* Rejects anything that is not a plain npm version/tag token so a version pin
|
|
76
95
|
* cannot inject shell metacharacters into the remote command line.
|
|
77
96
|
*/
|
|
78
97
|
export declare function upgradeCommand(version?: string): string[];
|
|
98
|
+
export interface RunFleetOptions {
|
|
99
|
+
/**
|
|
100
|
+
* Name of THIS machine. Its target runs the command **locally** (no ssh) — a
|
|
101
|
+
* box can't reliably ssh to itself and doesn't need to. Omit to ssh every
|
|
102
|
+
* target (the old behaviour). Callers pass `machineId()`.
|
|
103
|
+
*/
|
|
104
|
+
self?: string;
|
|
105
|
+
/** Injectable ssh runner (tests). */
|
|
106
|
+
runner?: typeof runOnDevice;
|
|
107
|
+
/** Injectable local runner (tests). */
|
|
108
|
+
localRunner?: typeof runLocalCommand;
|
|
109
|
+
}
|
|
79
110
|
/**
|
|
80
111
|
* Execute a command across planned targets. Pure orchestration over
|
|
81
|
-
* {@link runOnDevice}; testable by injecting
|
|
82
|
-
*
|
|
112
|
+
* {@link runOnDevice} / {@link runLocalCommand}; testable by injecting either.
|
|
113
|
+
* The `self` target runs locally so `agents fleet update` upgrades this machine
|
|
114
|
+
* too instead of failing to ssh to itself. Per-device throws from a runner are
|
|
115
|
+
* recorded as `failed` so one bad device never aborts the rest.
|
|
83
116
|
*/
|
|
84
|
-
export declare function runFleet(targets: FleetTarget[], cmd: string[],
|
|
117
|
+
export declare function runFleet(targets: FleetTarget[], cmd: string[], opts?: RunFleetOptions): FleetRunResult[];
|
|
85
118
|
/** Run one async probe per device in parallel, preserving input order. */
|
|
86
119
|
export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>): Promise<FanOutDeviceResult<T>[]>;
|
|
@@ -94,6 +94,39 @@ export function runOnDevice(device, cmd, opts = {}) {
|
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Run `cmd` on THIS machine directly — no ssh. Used by {@link runFleet} for the
|
|
99
|
+
* self target: a box frequently can't ssh to itself (no self-authorized key, as
|
|
100
|
+
* `agents fleet update` hit trying to reach zion from zion) and doesn't need to —
|
|
101
|
+
* `agents upgrade` etc. runs identically as a local process. Mirrors
|
|
102
|
+
* {@link runOnDevice}'s return shape and never throws. The argv is space-joined
|
|
103
|
+
* and evaluated by a shell — matching the POSIX-shell ssh path (so PATH-resolved
|
|
104
|
+
* `agents`, quoting, and `;`/`&&` behave the same). It does NOT replicate the
|
|
105
|
+
* powershell-device encoding runOnDevice uses (`connect.ts` base64 path): a
|
|
106
|
+
* Windows self runs under the default OS shell (cmd.exe), which still resolves
|
|
107
|
+
* `agents` on PATH for the only self commands that matter (`agents upgrade …`).
|
|
108
|
+
*/
|
|
109
|
+
export function runLocalCommand(cmd, opts = {}) {
|
|
110
|
+
try {
|
|
111
|
+
const res = spawnSync(cmd.join(' '), {
|
|
112
|
+
shell: true,
|
|
113
|
+
encoding: 'utf-8',
|
|
114
|
+
timeout: opts.timeoutMs ?? 600_000,
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
code: res.status,
|
|
118
|
+
stdout: res.stdout?.toString() ?? '',
|
|
119
|
+
stderr: (res.stderr?.toString() ?? '') + (res.error ? String(res.error.message) : ''),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
return {
|
|
124
|
+
code: 1,
|
|
125
|
+
stdout: '',
|
|
126
|
+
stderr: err instanceof Error ? err.message : String(err),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
97
130
|
/**
|
|
98
131
|
* Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
|
|
99
132
|
* Rejects anything that is not a plain npm version/tag token so a version pin
|
|
@@ -110,10 +143,14 @@ export function upgradeCommand(version) {
|
|
|
110
143
|
}
|
|
111
144
|
/**
|
|
112
145
|
* Execute a command across planned targets. Pure orchestration over
|
|
113
|
-
* {@link runOnDevice}; testable by injecting
|
|
114
|
-
*
|
|
146
|
+
* {@link runOnDevice} / {@link runLocalCommand}; testable by injecting either.
|
|
147
|
+
* The `self` target runs locally so `agents fleet update` upgrades this machine
|
|
148
|
+
* too instead of failing to ssh to itself. Per-device throws from a runner are
|
|
149
|
+
* recorded as `failed` so one bad device never aborts the rest.
|
|
115
150
|
*/
|
|
116
|
-
export function runFleet(targets, cmd,
|
|
151
|
+
export function runFleet(targets, cmd, opts = {}) {
|
|
152
|
+
const runner = opts.runner ?? runOnDevice;
|
|
153
|
+
const localRunner = opts.localRunner ?? runLocalCommand;
|
|
117
154
|
const results = [];
|
|
118
155
|
for (const t of targets) {
|
|
119
156
|
if (t.skip) {
|
|
@@ -126,7 +163,8 @@ export function runFleet(targets, cmd, runner = runOnDevice) {
|
|
|
126
163
|
continue;
|
|
127
164
|
}
|
|
128
165
|
try {
|
|
129
|
-
const
|
|
166
|
+
const isSelf = opts.self !== undefined && t.device.name === opts.self;
|
|
167
|
+
const res = isSelf ? localRunner(cmd) : runner(t.device, cmd);
|
|
130
168
|
const ok = res.code === 0;
|
|
131
169
|
const detail = (res.stderr || res.stdout).trim().slice(0, 200);
|
|
132
170
|
results.push({
|
|
@@ -69,6 +69,36 @@ export declare function runDeviceSync(opts?: {
|
|
|
69
69
|
soft?: boolean;
|
|
70
70
|
mode?: DeviceSyncMode;
|
|
71
71
|
}): Promise<DeviceSyncResult>;
|
|
72
|
+
export interface EnsureDevicesResult {
|
|
73
|
+
/** Names newly resolved from Tailscale and upserted into the registry. */
|
|
74
|
+
registered: string[];
|
|
75
|
+
/** Names that could not be resolved (not on the tailnet / tailscale absent). */
|
|
76
|
+
unresolved: string[];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Pure decision for `ensureDevicesRegistered`: split the wanted names into those
|
|
80
|
+
* to resolve+register (missing from the registry, present on the tailnet, not
|
|
81
|
+
* ignored) vs. unresolved (missing and either off-tailnet or ignored). Already-
|
|
82
|
+
* registered names need nothing. Pure so the bootstrap's filter matrix is
|
|
83
|
+
* unit-testable without a tailnet or registry writes.
|
|
84
|
+
*/
|
|
85
|
+
export interface WantedPartition {
|
|
86
|
+
toRegister: string[];
|
|
87
|
+
unresolved: string[];
|
|
88
|
+
}
|
|
89
|
+
export declare function partitionWantedDevices(wanted: string[], registered: Set<string>, tailscaleNames: Set<string>, ignored: Set<string>): WantedPartition;
|
|
90
|
+
/**
|
|
91
|
+
* Fresh-machine bootstrap for `agents apply`: given the device names a `fleet:`
|
|
92
|
+
* manifest wants, register any that aren't in the local registry by resolving
|
|
93
|
+
* them LIVE from Tailscale — so a freshly-cloned `agents.yaml` (which carries
|
|
94
|
+
* names only, never IPs/usernames) reconstructs its roster with zero committed
|
|
95
|
+
* connection details. Soft by design: a missing tailscale binary or an offline
|
|
96
|
+
* name yields `unresolved` rather than throwing, so `apply` degrades to "these
|
|
97
|
+
* devices aren't reachable yet" instead of aborting. Reuses the same
|
|
98
|
+
* parse→withDefaultUser→upsert path as `runDeviceSync`, so a bootstrapped device
|
|
99
|
+
* is identical to a synced one.
|
|
100
|
+
*/
|
|
101
|
+
export declare function ensureDevicesRegistered(wantedNames: string[]): Promise<EnsureDevicesResult>;
|
|
72
102
|
/**
|
|
73
103
|
* The register/remove/ignore decision for the interactive curation picker.
|
|
74
104
|
* Pure so the highest-risk reconcile logic is unit-testable without a tailnet
|
package/dist/lib/devices/sync.js
CHANGED
|
@@ -125,6 +125,56 @@ export async function runDeviceSync(opts = {}) {
|
|
|
125
125
|
throw err;
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
+
export function partitionWantedDevices(wanted, registered, tailscaleNames, ignored) {
|
|
129
|
+
const out = { toRegister: [], unresolved: [] };
|
|
130
|
+
for (const name of wanted) {
|
|
131
|
+
if (registered.has(name))
|
|
132
|
+
continue;
|
|
133
|
+
if (tailscaleNames.has(name) && !ignored.has(name))
|
|
134
|
+
out.toRegister.push(name);
|
|
135
|
+
else
|
|
136
|
+
out.unresolved.push(name);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Fresh-machine bootstrap for `agents apply`: given the device names a `fleet:`
|
|
142
|
+
* manifest wants, register any that aren't in the local registry by resolving
|
|
143
|
+
* them LIVE from Tailscale — so a freshly-cloned `agents.yaml` (which carries
|
|
144
|
+
* names only, never IPs/usernames) reconstructs its roster with zero committed
|
|
145
|
+
* connection details. Soft by design: a missing tailscale binary or an offline
|
|
146
|
+
* name yields `unresolved` rather than throwing, so `apply` degrades to "these
|
|
147
|
+
* devices aren't reachable yet" instead of aborting. Reuses the same
|
|
148
|
+
* parse→withDefaultUser→upsert path as `runDeviceSync`, so a bootstrapped device
|
|
149
|
+
* is identical to a synced one.
|
|
150
|
+
*/
|
|
151
|
+
export async function ensureDevicesRegistered(wantedNames) {
|
|
152
|
+
const registryBefore = await loadDevices();
|
|
153
|
+
const registered = new Set(Object.keys(registryBefore));
|
|
154
|
+
const missing = wantedNames.filter((n) => !registered.has(n));
|
|
155
|
+
if (missing.length === 0)
|
|
156
|
+
return { registered: [], unresolved: [] };
|
|
157
|
+
let nodes;
|
|
158
|
+
try {
|
|
159
|
+
nodes = parseTailscaleStatus(tailscaleStatusJson());
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Tailscale absent/unreachable — nothing resolvable; report all missing.
|
|
163
|
+
return { registered: [], unresolved: missing };
|
|
164
|
+
}
|
|
165
|
+
const ignored = await loadIgnored();
|
|
166
|
+
const tailscaleNames = new Set(nodes.map((n) => n.name));
|
|
167
|
+
const { toRegister, unresolved } = partitionWantedDevices(missing, registered, tailscaleNames, ignored);
|
|
168
|
+
const byName = new Map(nodes.map((n) => [n.name, n]));
|
|
169
|
+
const localUser = localLoginUser();
|
|
170
|
+
const done = [];
|
|
171
|
+
for (const name of toRegister) {
|
|
172
|
+
const input = withDefaultUser(nodeToDeviceInput(byName.get(name)), registryBefore[name]?.user, localUser);
|
|
173
|
+
await upsertDevice(name, input);
|
|
174
|
+
done.push(name);
|
|
175
|
+
}
|
|
176
|
+
return { registered: done, unresolved };
|
|
177
|
+
}
|
|
128
178
|
export function planDeviceReconciliation(allNames, keep, registered, ignored) {
|
|
129
179
|
const keepSet = new Set(keep);
|
|
130
180
|
const regSet = new Set(registered);
|