@phnx-labs/agents-cli 1.20.69 → 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 +81 -0
- package/README.md +19 -6
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +36 -3
- package/dist/commands/check.js +3 -1
- package/dist/commands/doctor.js +5 -0
- 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 +63 -16
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +50 -28
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +5 -2
- package/dist/lib/auth-health.d.ts +38 -0
- package/dist/lib/auth-health.js +48 -3
- package/dist/lib/commands.d.ts +10 -0
- package/dist/lib/commands.js +31 -7
- package/dist/lib/computer/download.d.ts +3 -0
- package/dist/lib/computer/download.js +13 -12
- package/dist/lib/daemon.js +38 -0
- 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/health-report.d.ts +11 -0
- package/dist/lib/devices/health-report.js +73 -4
- package/dist/lib/devices/stats-cache.d.ts +36 -0
- package/dist/lib/devices/stats-cache.js +115 -0
- 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/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- 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
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* A `.app` is a directory, so the asset is a zip (`ditto -c -k --keepParent`);
|
|
14
14
|
* we extract it with `ditto -x -k` after the checksum passes.
|
|
15
15
|
*/
|
|
16
|
-
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
17
17
|
import * as fs from 'node:fs';
|
|
18
18
|
import * as os from 'node:os';
|
|
19
19
|
import * as path from 'node:path';
|
|
@@ -41,6 +41,11 @@ export function macHelperAssetUrls(version) {
|
|
|
41
41
|
const base = `https://github.com/${HELPER_RELEASE_REPO}/releases/download/v${version}`;
|
|
42
42
|
return { zip: `${base}/${MAC_HELPER_ASSET}`, sha256: `${base}/${MAC_HELPER_ASSET}.sha256` };
|
|
43
43
|
}
|
|
44
|
+
/** Extract `TeamIdentifier=XXXX` from `codesign -dv --verbose=4` output (which is
|
|
45
|
+
* emitted on stderr). Returns null when absent (ad-hoc / unsigned). */
|
|
46
|
+
export function parseTeamId(codesignInfo) {
|
|
47
|
+
return codesignInfo.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1] ?? null;
|
|
48
|
+
}
|
|
44
49
|
/**
|
|
45
50
|
* Verify a helper `.app` bundle is intact, signed by the expected Developer ID
|
|
46
51
|
* Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
|
|
@@ -55,17 +60,13 @@ export function verifyMacHelper(appPath) {
|
|
|
55
60
|
throw new Error(`code signature invalid for ${appPath}: ${e.message}`);
|
|
56
61
|
}
|
|
57
62
|
// 2. Team identity — must be our Developer ID, not some other valid signer.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (!info)
|
|
66
|
-
throw new Error(`could not read code signature of ${appPath}: ${e.message}`);
|
|
67
|
-
}
|
|
68
|
-
const team = info.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1];
|
|
63
|
+
// `codesign -dv` writes its details to STDERR even on success (exit 0), so we
|
|
64
|
+
// must read stderr, not stdout. spawnSync captures both streams regardless of
|
|
65
|
+
// exit code; execFileSync would return only (empty) stdout and the Team check
|
|
66
|
+
// would falsely reject every validly-signed helper.
|
|
67
|
+
const dv = spawnSync('/usr/bin/codesign', ['-dv', '--verbose=4', appPath], { encoding: 'utf8' });
|
|
68
|
+
const info = `${dv.stdout ?? ''}${dv.stderr ?? ''}`;
|
|
69
|
+
const team = parseTeamId(info);
|
|
69
70
|
if (team !== EXPECTED_TEAM_ID) {
|
|
70
71
|
throw new Error(`helper signed by unexpected Team (${team ?? 'none'}), expected ${EXPECTED_TEAM_ID}. Refusing to install.`);
|
|
71
72
|
}
|
package/dist/lib/daemon.js
CHANGED
|
@@ -625,6 +625,42 @@ export async function runDaemon() {
|
|
|
625
625
|
};
|
|
626
626
|
const launchHealthInterval = setInterval(() => { void runLaunchHealthCheck(); }, 6 * 60 * 60_000);
|
|
627
627
|
const launchHealthKickoff = setTimeout(() => { void runLaunchHealthCheck(); }, 90_000);
|
|
628
|
+
// Fleet cache warm: keep the caches that `agents devices list`, `fleet status`,
|
|
629
|
+
// and `agents view` read cache-first actually fresh, so a default read never
|
|
630
|
+
// has to ssh out. Two cheap refreshes: (1) this host's auth-health verdicts
|
|
631
|
+
// (also feeds the `doctor --json` Auth rollup other hosts read), and (2) the
|
|
632
|
+
// fleet resource-stats cache (one bounded parallel probe of the tailnet). Both
|
|
633
|
+
// best-effort + overlap-guarded like the probes above. ~every 3 min, plus once
|
|
634
|
+
// ~60s after startup (staggered off launch).
|
|
635
|
+
let warmingFleetCache = false;
|
|
636
|
+
const runFleetCacheWarm = async () => {
|
|
637
|
+
if (warmingFleetCache)
|
|
638
|
+
return;
|
|
639
|
+
warmingFleetCache = true;
|
|
640
|
+
try {
|
|
641
|
+
const { machineId } = await import('./machine-id.js');
|
|
642
|
+
const self = machineId();
|
|
643
|
+
const { probeLocalFleetAuth, writeFleetAuthRows } = await import('./auth-health.js');
|
|
644
|
+
const { getCliVersion } = await import('./version.js');
|
|
645
|
+
const authRows = await probeLocalFleetAuth({ cliVersion: getCliVersion() });
|
|
646
|
+
writeFleetAuthRows(self, authRows);
|
|
647
|
+
const { loadDevices } = await import('./devices/registry.js');
|
|
648
|
+
const { planFleetTargets } = await import('./devices/fleet.js');
|
|
649
|
+
const { loadFleetStats } = await import('./devices/stats-cache.js');
|
|
650
|
+
const reg = await loadDevices();
|
|
651
|
+
const probeable = planFleetTargets(reg).filter((t) => !t.skip).map((t) => t.device);
|
|
652
|
+
const res = await loadFleetStats(probeable, { forceRefresh: true, selfName: self });
|
|
653
|
+
log('INFO', `fleet cache warm: ${authRows.length} auth row(s), ${res.stats.size} device stat(s)`);
|
|
654
|
+
}
|
|
655
|
+
catch (err) {
|
|
656
|
+
log('ERROR', `fleet cache warm failed: ${err.message}`);
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
warmingFleetCache = false;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
const fleetCacheInterval = setInterval(() => { void runFleetCacheWarm(); }, 3 * 60_000);
|
|
663
|
+
const fleetCacheKickoff = setTimeout(() => { void runFleetCacheWarm(); }, 60_000);
|
|
628
664
|
const handleReload = () => {
|
|
629
665
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
630
666
|
scheduler.reloadAll();
|
|
@@ -657,6 +693,8 @@ export async function runDaemon() {
|
|
|
657
693
|
clearTimeout(tmuxReconcileKickoff);
|
|
658
694
|
clearInterval(launchHealthInterval);
|
|
659
695
|
clearTimeout(launchHealthKickoff);
|
|
696
|
+
clearInterval(fleetCacheInterval);
|
|
697
|
+
clearTimeout(fleetCacheKickoff);
|
|
660
698
|
hostedBroker?.close();
|
|
661
699
|
removeDaemonPid();
|
|
662
700
|
removeHeartbeat();
|
|
@@ -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({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type DeviceStats } from './health.js';
|
|
2
|
+
import { type HostAuthSummary } from '../auth-health.js';
|
|
2
3
|
export interface FleetCliStatus {
|
|
3
4
|
installed: boolean;
|
|
4
5
|
path: string | null;
|
|
@@ -28,6 +29,9 @@ export interface FleetHealthRow {
|
|
|
28
29
|
clis: Record<string, FleetCliStatus>;
|
|
29
30
|
sync: FleetSyncStatus[];
|
|
30
31
|
orphans: FleetOrphanStatus[];
|
|
32
|
+
/** Cached auth-health rollup for this host (the Auth column). Undefined when
|
|
33
|
+
* the host has never been probed (`agents fleet ping`) or the cache is cold. */
|
|
34
|
+
auth?: HostAuthSummary;
|
|
31
35
|
}
|
|
32
36
|
export interface FleetWarning {
|
|
33
37
|
kind: 'unreachable' | 'drift' | 'cli' | 'version-skew';
|
|
@@ -44,3 +48,10 @@ export interface FleetHealthReport {
|
|
|
44
48
|
export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date): FleetHealthReport;
|
|
45
49
|
export declare function renderFleetWarnings(report: FleetHealthReport): string[];
|
|
46
50
|
export declare function renderFleetMatrix(report: FleetHealthReport): string[];
|
|
51
|
+
/**
|
|
52
|
+
* "as of …" line so cache-served output is honest about age and points at the
|
|
53
|
+
* refresh flag. Stats age comes from `stats.fetchedAt`, auth age from the
|
|
54
|
+
* cached rollup's `oldestCheckedAt`; either may be absent. Returns null when the
|
|
55
|
+
* table carries no timestamped data at all (nothing to date).
|
|
56
|
+
*/
|
|
57
|
+
export declare function freshnessFooter(rows: FleetHealthRow[], now?: number): string | null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { padToWidth, terminalWidth, truncateToWidth } from '../session/width.js';
|
|
2
|
+
import { padToWidth, stringWidth, terminalWidth, truncateToWidth } from '../session/width.js';
|
|
3
3
|
import { fmtBytes, headroom } from './health.js';
|
|
4
|
+
import { formatCheckedAge } from '../auth-health.js';
|
|
4
5
|
function driftRows(row) {
|
|
5
6
|
return row.sync.filter((s) => s.status !== 'fresh');
|
|
6
7
|
}
|
|
@@ -121,6 +122,47 @@ function loadLabel(stats) {
|
|
|
121
122
|
const mem = stats.memPercent === undefined ? '-' : `${Math.round(stats.memPercent)}%`;
|
|
122
123
|
return `${load}/${mem}`;
|
|
123
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Compact per-host auth cell. Four buckets, deliberately distinct so the column
|
|
127
|
+
* doesn't cry wolf on healthy accounts:
|
|
128
|
+
* `●{live}` green — live-verified
|
|
129
|
+
* `·{present}` gray — signed in but no live probe (codex/grok/…): benign
|
|
130
|
+
* `◐{degraded}` yellow — soft/self-healing (expired/limited/error)
|
|
131
|
+
* `○{revoked}` red — server rejected the token: re-login now
|
|
132
|
+
* A host with no cached auth rows shows "—"; an unreachable/skipped row shows
|
|
133
|
+
* "-" like the other probe columns.
|
|
134
|
+
*/
|
|
135
|
+
function authLabel(row) {
|
|
136
|
+
if (row.error || row.skipped)
|
|
137
|
+
return chalk.gray('-');
|
|
138
|
+
const s = row.auth;
|
|
139
|
+
if (!s || s.total === 0)
|
|
140
|
+
return chalk.gray('—');
|
|
141
|
+
const parts = [];
|
|
142
|
+
if (s.live > 0)
|
|
143
|
+
parts.push(chalk.green(`●${s.live}`));
|
|
144
|
+
if (s.present > 0)
|
|
145
|
+
parts.push(chalk.gray(`·${s.present}`));
|
|
146
|
+
if (s.degraded > 0)
|
|
147
|
+
parts.push(chalk.yellow(`◐${s.degraded}`));
|
|
148
|
+
if (s.revoked > 0)
|
|
149
|
+
parts.push(chalk.red(`○${s.revoked}`));
|
|
150
|
+
// All-zero can't happen (total > 0); but if only present/degraded exist we
|
|
151
|
+
// still lead with them — never show an empty cell for a probed host.
|
|
152
|
+
return parts.length > 0 ? parts.join(' ') : chalk.gray('—');
|
|
153
|
+
}
|
|
154
|
+
/** Oldest epoch-ms timestamp across rows for a field, or null when none present. */
|
|
155
|
+
function oldestAcross(rows, pick) {
|
|
156
|
+
let oldest = null;
|
|
157
|
+
for (const row of rows) {
|
|
158
|
+
const t = pick(row);
|
|
159
|
+
if (t == null)
|
|
160
|
+
continue;
|
|
161
|
+
if (oldest === null || t < oldest)
|
|
162
|
+
oldest = t;
|
|
163
|
+
}
|
|
164
|
+
return oldest;
|
|
165
|
+
}
|
|
124
166
|
export function renderFleetWarnings(report) {
|
|
125
167
|
if (report.warnings.length === 0)
|
|
126
168
|
return [chalk.green('Fleet warnings: none')];
|
|
@@ -134,14 +176,19 @@ export function renderFleetMatrix(report) {
|
|
|
134
176
|
return [chalk.gray('No registered devices. Run `agents devices` to register some.')];
|
|
135
177
|
const nameW = Math.min(22, Math.max(6, ...report.devices.map((r) => r.name.length)));
|
|
136
178
|
const versionW = Math.min(14, Math.max(7, ...report.devices.map((r) => (r.version ?? '-').length)));
|
|
179
|
+
// Auth cells are variable-length (up to four space-separated buckets, e.g.
|
|
180
|
+
// `●2 ·3 ◐1 ○1`); size the column to the widest so a mixed-auth row can't
|
|
181
|
+
// overflow the fixed slot and shove every later column out of alignment.
|
|
182
|
+
const authW = Math.max(9, ...report.devices.map((r) => stringWidth(authLabel(r))));
|
|
137
183
|
const width = terminalWidth();
|
|
138
184
|
// 4 = leading " " + the per-row status glyph + its trailing space (rows prefix
|
|
139
185
|
// ` ${statusGlyph} `; the header reserves the same 4 cols so every column lines up).
|
|
140
|
-
|
|
186
|
+
// Columns: Device, OS(8), Health(9), Sync(9), CLI(9), Auth(authW), Version, Load/Mem(9), then Note.
|
|
187
|
+
const fixed = 4 + nameW + 2 + 8 + 2 + 9 + 2 + 9 + 2 + 9 + 2 + authW + 2 + versionW + 2 + 9;
|
|
141
188
|
const noteW = Math.max(12, width - fixed);
|
|
142
189
|
const lines = [
|
|
143
190
|
chalk.bold('Fleet status'),
|
|
144
|
-
chalk.gray(` ${padToWidth('Device', nameW)} ${padToWidth('OS', 8)} ${padToWidth('Health', 9)} ${padToWidth('Sync', 9)} ${padToWidth('CLI', 9)} ${padToWidth('Version', versionW)} ${padToWidth('Load/Mem', 9)} Note`),
|
|
191
|
+
chalk.gray(` ${padToWidth('Device', nameW)} ${padToWidth('OS', 8)} ${padToWidth('Health', 9)} ${padToWidth('Sync', 9)} ${padToWidth('CLI', 9)} ${padToWidth('Auth', authW)} ${padToWidth('Version', versionW)} ${padToWidth('Load/Mem', 9)} Note`),
|
|
145
192
|
];
|
|
146
193
|
for (const row of report.devices) {
|
|
147
194
|
const note = row.error ?? row.skipped ?? (row.orphans.length > 0 ? `${row.orphans.length} orphaned version${row.orphans.length === 1 ? '' : 's'}` : '');
|
|
@@ -150,10 +197,32 @@ export function renderFleetMatrix(report) {
|
|
|
150
197
|
`${padToWidth(headroomLabel(row), 9)} ` +
|
|
151
198
|
`${padToWidth(driftLabel(row), 9)} ` +
|
|
152
199
|
`${padToWidth(cliLabel(row), 9)} ` +
|
|
200
|
+
`${padToWidth(authLabel(row), authW)} ` +
|
|
153
201
|
`${padToWidth(truncateToWidth(row.version ?? '-', versionW), versionW)} ` +
|
|
154
202
|
`${padToWidth(loadLabel(row.stats), 9)} ` +
|
|
155
203
|
chalk.gray(truncateToWidth(note || `free ${fmtBytes(row.stats?.memFreeBytes)}`, noteW)));
|
|
156
204
|
}
|
|
157
|
-
lines.push(chalk.gray(' ● fresh · ◐ drift · ○ unreachable/skipped'));
|
|
205
|
+
lines.push(chalk.gray(' ● fresh · ◐ drift · ○ unreachable/skipped · Auth ●live ·present ◐degraded ○revoked'));
|
|
206
|
+
const foot = freshnessFooter(report.devices);
|
|
207
|
+
if (foot)
|
|
208
|
+
lines.push(chalk.gray(foot));
|
|
158
209
|
return lines;
|
|
159
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* "as of …" line so cache-served output is honest about age and points at the
|
|
213
|
+
* refresh flag. Stats age comes from `stats.fetchedAt`, auth age from the
|
|
214
|
+
* cached rollup's `oldestCheckedAt`; either may be absent. Returns null when the
|
|
215
|
+
* table carries no timestamped data at all (nothing to date).
|
|
216
|
+
*/
|
|
217
|
+
export function freshnessFooter(rows, now = Date.now()) {
|
|
218
|
+
const oldestStats = oldestAcross(rows, (r) => r.stats?.fetchedAt);
|
|
219
|
+
const oldestAuth = oldestAcross(rows, (r) => r.auth?.oldestCheckedAt);
|
|
220
|
+
const parts = [];
|
|
221
|
+
if (oldestStats != null)
|
|
222
|
+
parts.push(`stats ${formatCheckedAge(oldestStats, now)}`);
|
|
223
|
+
if (oldestAuth != null)
|
|
224
|
+
parts.push(`auth ${formatCheckedAge(oldestAuth, now)}`);
|
|
225
|
+
if (parts.length === 0)
|
|
226
|
+
return null;
|
|
227
|
+
return ` updated ${parts.join(' · ')} — pass --refresh (--live) for a live probe`;
|
|
228
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { probeFleetStats, probeLocalStats, type DeviceStats } from './health.js';
|
|
2
|
+
import type { DeviceProfile } from './registry.js';
|
|
3
|
+
/** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
|
|
4
|
+
export declare function readStatsCache(): Record<string, DeviceStats>;
|
|
5
|
+
/**
|
|
6
|
+
* Merge freshly-probed rows into the on-disk cache (best-effort write). Rows for
|
|
7
|
+
* devices not in `entries` are preserved, so a partial probe (gap-fill, or a
|
|
8
|
+
* single-device refresh) never drops the rest of the fleet's cached stats.
|
|
9
|
+
*/
|
|
10
|
+
export declare function writeStatsCache(entries: Record<string, DeviceStats>): void;
|
|
11
|
+
export interface FleetStatsResult {
|
|
12
|
+
/** name → stats for every requested device (cache-served + freshly probed). */
|
|
13
|
+
stats: Map<string, DeviceStats>;
|
|
14
|
+
/** Oldest `fetchedAt` among the returned rows, or null when empty. Drives the
|
|
15
|
+
* "as of …" freshness note. */
|
|
16
|
+
oldestFetchedAt: number | null;
|
|
17
|
+
/** True when at least one row was served from cache rather than probed this call. */
|
|
18
|
+
servedFromCache: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface LoadFleetStatsOptions {
|
|
21
|
+
/** Skip the cache and live-probe every device (the `--refresh`/`--live` path). */
|
|
22
|
+
forceRefresh?: boolean;
|
|
23
|
+
/** Device name of THIS machine — always probed locally (no ssh), never cached-served. */
|
|
24
|
+
selfName?: string;
|
|
25
|
+
/** Injectable probes + cache IO for tests (default to the real ssh/local/disk ones). */
|
|
26
|
+
probeFleet?: typeof probeFleetStats;
|
|
27
|
+
probeLocal?: typeof probeLocalStats;
|
|
28
|
+
readCache?: typeof readStatsCache;
|
|
29
|
+
writeCache?: typeof writeStatsCache;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Load fleet stats cache-first. See the module doc for the default vs
|
|
33
|
+
* `--refresh` behaviour. Never throws — an unreachable box degrades to a
|
|
34
|
+
* `reachable: false` row exactly as the live probe does.
|
|
35
|
+
*/
|
|
36
|
+
export declare function loadFleetStats(devices: DeviceProfile[], opts?: LoadFleetStatsOptions): Promise<FleetStatsResult>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk cache for fleet {@link DeviceStats} so `agents devices list` and
|
|
3
|
+
* `agents fleet status` render instantly from the last probe instead of
|
|
4
|
+
* live-SSHing every registered box on every invocation.
|
|
5
|
+
*
|
|
6
|
+
* The old behaviour probed the whole fleet over ssh on every call
|
|
7
|
+
* ({@link probeFleetStats}) — with a dozen devices, a few of them cold or
|
|
8
|
+
* timing out, that turned a status glance into a multi-second hang. This module
|
|
9
|
+
* makes the reads cache-first:
|
|
10
|
+
*
|
|
11
|
+
* - **Default:** serve remote devices from the cache (instant); always probe
|
|
12
|
+
* *this* machine locally (no ssh, sub-ms) so the "this machine" row is live;
|
|
13
|
+
* probe only the remote devices missing from the cache (first run / a
|
|
14
|
+
* newly-added box), then persist them.
|
|
15
|
+
* - **`--refresh` / `--live`:** skip the cache and live-probe every device,
|
|
16
|
+
* rewriting the cache.
|
|
17
|
+
*
|
|
18
|
+
* The daemon warms this cache (~every 3 min, see `lib/daemon.ts
|
|
19
|
+
* runFleetCacheWarm`), so in steady state a default read has zero
|
|
20
|
+
* remote ssh round-trips and returns immediately with data that is at most a
|
|
21
|
+
* few minutes old — surfaced to the user as an "as of …" freshness note.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from 'fs';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import { getCacheDir } from '../state.js';
|
|
26
|
+
import { probeFleetStats, probeLocalStats } from './health.js';
|
|
27
|
+
const CACHE_FILE = '.fleet-stats.json';
|
|
28
|
+
function cacheFilePath() {
|
|
29
|
+
return path.join(getCacheDir(), CACHE_FILE);
|
|
30
|
+
}
|
|
31
|
+
/** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
|
|
32
|
+
export function readStatsCache() {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(fs.readFileSync(cacheFilePath(), 'utf-8'));
|
|
35
|
+
if (parsed && parsed.entries && typeof parsed.entries === 'object')
|
|
36
|
+
return parsed.entries;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// missing or corrupt — treat as empty
|
|
40
|
+
}
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Merge freshly-probed rows into the on-disk cache (best-effort write). Rows for
|
|
45
|
+
* devices not in `entries` are preserved, so a partial probe (gap-fill, or a
|
|
46
|
+
* single-device refresh) never drops the rest of the fleet's cached stats.
|
|
47
|
+
*/
|
|
48
|
+
export function writeStatsCache(entries) {
|
|
49
|
+
try {
|
|
50
|
+
const dir = getCacheDir();
|
|
51
|
+
if (!fs.existsSync(dir))
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
const merged = {
|
|
54
|
+
version: 1,
|
|
55
|
+
entries: { ...readStatsCache(), ...entries },
|
|
56
|
+
};
|
|
57
|
+
fs.writeFileSync(cacheFilePath(), JSON.stringify(merged, null, 2));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// best-effort; a failed write just means the next read falls back to a live probe
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Load fleet stats cache-first. See the module doc for the default vs
|
|
65
|
+
* `--refresh` behaviour. Never throws — an unreachable box degrades to a
|
|
66
|
+
* `reachable: false` row exactly as the live probe does.
|
|
67
|
+
*/
|
|
68
|
+
export async function loadFleetStats(devices, opts = {}) {
|
|
69
|
+
const probeFleet = opts.probeFleet ?? probeFleetStats;
|
|
70
|
+
const probeLocal = opts.probeLocal ?? probeLocalStats;
|
|
71
|
+
const readCache = opts.readCache ?? readStatsCache;
|
|
72
|
+
const writeCache = opts.writeCache ?? writeStatsCache;
|
|
73
|
+
const self = opts.selfName;
|
|
74
|
+
const cache = opts.forceRefresh ? {} : readCache();
|
|
75
|
+
const stats = new Map();
|
|
76
|
+
const toProbe = [];
|
|
77
|
+
let servedFromCache = false;
|
|
78
|
+
for (const d of devices) {
|
|
79
|
+
if (d.name === self) {
|
|
80
|
+
// This machine is always probed locally — cheap, no ssh, always live.
|
|
81
|
+
toProbe.push(d);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const cached = cache[d.name];
|
|
85
|
+
if (cached) {
|
|
86
|
+
stats.set(d.name, cached);
|
|
87
|
+
servedFromCache = true;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
toProbe.push(d);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (toProbe.length > 0) {
|
|
94
|
+
const probed = await probeFleet(toProbe, { selfName: self });
|
|
95
|
+
const fresh = {};
|
|
96
|
+
for (const [name, s] of probed) {
|
|
97
|
+
stats.set(name, s);
|
|
98
|
+
fresh[name] = s;
|
|
99
|
+
}
|
|
100
|
+
if (Object.keys(fresh).length > 0)
|
|
101
|
+
writeCache(fresh);
|
|
102
|
+
}
|
|
103
|
+
// Guarantee a row for this machine even when it isn't in the passed device
|
|
104
|
+
// list (e.g. self not registered as an ssh target) — matches the old callers'
|
|
105
|
+
// explicit local fallback.
|
|
106
|
+
if (self && !stats.has(self)) {
|
|
107
|
+
stats.set(self, await probeLocal(self));
|
|
108
|
+
}
|
|
109
|
+
let oldest = null;
|
|
110
|
+
for (const s of stats.values()) {
|
|
111
|
+
if (oldest === null || s.fetchedAt < oldest)
|
|
112
|
+
oldest = s.fetchedAt;
|
|
113
|
+
}
|
|
114
|
+
return { stats, oldestFetchedAt: oldest, servedFromCache };
|
|
115
|
+
}
|
|
@@ -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);
|