@phnx-labs/agents-cli 1.20.87 → 1.20.89
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 +323 -0
- package/README.md +12 -4
- package/dist/bin/agents +0 -0
- package/dist/commands/commands.js +7 -7
- package/dist/commands/doctor.d.ts +0 -19
- package/dist/commands/doctor.js +219 -305
- package/dist/commands/exec.js +7 -19
- package/dist/commands/factory.js +26 -2
- package/dist/commands/funnel.js +16 -1
- package/dist/commands/inspect.js +3 -5
- package/dist/commands/menubar.js +117 -34
- package/dist/commands/routines.js +25 -3
- package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
- package/dist/commands/secrets-rotate-passphrase.js +96 -0
- package/dist/commands/secrets.js +2 -0
- package/dist/commands/sessions.d.ts +7 -1
- package/dist/commands/sessions.js +40 -12
- package/dist/commands/ssh.js +3 -3
- package/dist/commands/usage.d.ts +3 -2
- package/dist/commands/usage.js +2 -9
- package/dist/commands/webhook.js +7 -2
- package/dist/lib/agents.d.ts +31 -1
- package/dist/lib/agents.js +55 -0
- package/dist/lib/command-skills.d.ts +10 -0
- package/dist/lib/command-skills.js +14 -0
- package/dist/lib/commands.js +28 -2
- package/dist/lib/daemon.d.ts +29 -0
- package/dist/lib/daemon.js +75 -6
- package/dist/lib/devices/doctor-findings.d.ts +167 -0
- package/dist/lib/devices/doctor-findings.js +893 -0
- package/dist/lib/devices/fleet-divergence.d.ts +22 -0
- package/dist/lib/devices/fleet-divergence.js +34 -10
- package/dist/lib/devices/fleet-inventory.d.ts +17 -6
- package/dist/lib/devices/fleet-inventory.js +56 -8
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +14 -3
- package/dist/lib/exec.js +41 -8
- package/dist/lib/factory/snapshot.d.ts +78 -0
- package/dist/lib/factory/snapshot.js +209 -0
- package/dist/lib/fs-atomic.d.ts +14 -1
- package/dist/lib/fs-atomic.js +35 -3
- package/dist/lib/funnel.d.ts +1 -0
- package/dist/lib/funnel.js +8 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
- package/dist/lib/menubar/install-menubar.d.ts +53 -2
- package/dist/lib/menubar/install-menubar.js +183 -28
- package/dist/lib/platform/process.d.ts +2 -0
- package/dist/lib/platform/process.js +5 -3
- package/dist/lib/project-resources.js +34 -20
- package/dist/lib/resources.d.ts +8 -0
- package/dist/lib/resources.js +34 -1
- package/dist/lib/routines-placement.d.ts +2 -1
- package/dist/lib/routines-placement.js +8 -4
- package/dist/lib/routines.d.ts +57 -1
- package/dist/lib/routines.js +74 -1
- package/dist/lib/runner.d.ts +16 -1
- package/dist/lib/runner.js +58 -16
- package/dist/lib/sandbox.d.ts +2 -0
- package/dist/lib/sandbox.js +38 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +9 -34
- package/dist/lib/secrets/filestore.d.ts +152 -34
- package/dist/lib/secrets/filestore.js +676 -123
- package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
- package/dist/lib/secrets/rc-hygiene.js +0 -24
- package/dist/lib/session/active.d.ts +6 -6
- package/dist/lib/session/active.js +6 -6
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +137 -1
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +76 -37
- package/dist/lib/session/remote-active.d.ts +4 -1
- package/dist/lib/session/remote-active.js +8 -2
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/viewing-in.d.ts +31 -0
- package/dist/lib/session/viewing-in.js +47 -0
- package/dist/lib/staleness/detectors/commands.js +14 -5
- package/dist/lib/staleness/types.d.ts +2 -0
- package/dist/lib/staleness/writers/commands.js +13 -7
- package/dist/lib/state.d.ts +17 -0
- package/dist/lib/state.js +30 -2
- package/dist/lib/triggers/handlers.d.ts +95 -0
- package/dist/lib/triggers/handlers.js +384 -0
- package/dist/lib/triggers/webhook.d.ts +10 -2
- package/dist/lib/triggers/webhook.js +65 -11
- package/dist/lib/usage.d.ts +72 -1
- package/dist/lib/usage.js +21 -27
- package/dist/lib/versions.js +30 -13
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -7,6 +7,8 @@ import { fanOutDevices, planFleetTargets, remoteFleetTargets } from '../lib/devi
|
|
|
7
7
|
import { fleetDialTarget } from '../lib/devices/connect.js';
|
|
8
8
|
import { compareFleetInventories } from '../lib/devices/fleet-divergence.js';
|
|
9
9
|
import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
|
|
10
|
+
import { buildLocalFindings, fleetDivergenceToFindings, signInToFindings, renderFindings, } from '../lib/devices/doctor-findings.js';
|
|
11
|
+
import { getCliVersion } from '../lib/version.js';
|
|
10
12
|
import { resolveHost } from '../lib/hosts/registry.js';
|
|
11
13
|
import { sshExecAsync } from '../lib/ssh-exec.js';
|
|
12
14
|
import { sshTargetFor } from '../lib/hosts/types.js';
|
|
@@ -14,7 +16,6 @@ import { machineId } from '../lib/session/sync/config.js';
|
|
|
14
16
|
import chalk from 'chalk';
|
|
15
17
|
import { checkAllClis, collectTeamsDoctorData } from '../lib/teams/agents.js';
|
|
16
18
|
import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError, getAccountInfo } from '../lib/agents.js';
|
|
17
|
-
import { formatSignInBadge } from '../lib/signin-badge.js';
|
|
18
19
|
import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalledVersions, parseAgentSpec, } from '../lib/versions.js';
|
|
19
20
|
import { loadManifest, isStale } from '../lib/staleness/index.js';
|
|
20
21
|
import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
|
|
@@ -26,8 +27,8 @@ import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
|
|
|
26
27
|
import { listCliStatus } from '../lib/cli-resources.js';
|
|
27
28
|
import { setHelpSections } from '../lib/help.js';
|
|
28
29
|
import { heal, healChangedAnything } from '../lib/heal.js';
|
|
29
|
-
import {
|
|
30
|
-
import { scanUserRcFiles
|
|
30
|
+
import { getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
|
|
31
|
+
import { scanUserRcFiles } from '../lib/secrets/rc-hygiene.js';
|
|
31
32
|
import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
|
|
32
33
|
import { readRepoBehindMarkers } from '../lib/auto-pull.js';
|
|
33
34
|
import * as fs from 'fs';
|
|
@@ -63,239 +64,19 @@ export function wrapLine(prefix, text, width = terminalWidth()) {
|
|
|
63
64
|
lines.push(line);
|
|
64
65
|
return lines;
|
|
65
66
|
}
|
|
67
|
+
/** Reshape into the pure finding-builder's input: install state
|
|
68
|
+
* per declared CLI, plus the manifests the loader rejected (a bad manifest
|
|
69
|
+
* declares a CLI that can never install, so it is a finding, not silence). */
|
|
70
|
+
function toHostCliInput(status) {
|
|
71
|
+
return {
|
|
72
|
+
statuses: status.statuses.map((c) => ({ name: c.manifest.name, installed: c.installed })),
|
|
73
|
+
errors: status.errors.map((e) => ({ file: e.file, reason: e.reason })),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
66
76
|
function printWrappedLine(prefix, text) {
|
|
67
77
|
for (const line of wrapLine(prefix, text))
|
|
68
78
|
console.log(chalk.gray(line));
|
|
69
79
|
}
|
|
70
|
-
function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers, duplicateHooks) {
|
|
71
|
-
// Triaged health banner FIRST, so a user running bare `agents doctor` sees what
|
|
72
|
-
// is unhealthy, why it matters, and the exact fix before scrolling the detail
|
|
73
|
-
// sections below. Same triage model as target mode, aggregated across versions.
|
|
74
|
-
const overviewHealth = computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, duplicateHooks);
|
|
75
|
-
console.log(chalk.bold('Health'));
|
|
76
|
-
renderHealthBlock(overviewHealth, {
|
|
77
|
-
healthySummary: syncRows.length
|
|
78
|
-
? `${syncRows.length} version${syncRows.length === 1 ? '' : 's'} reconciled · hooks wired · sources current`
|
|
79
|
-
: 'no installed versions to check',
|
|
80
|
-
healFix: verdictIsAutoFixable(overviewHealth) ? 'agents doctor --fix' : undefined,
|
|
81
|
-
});
|
|
82
|
-
console.log();
|
|
83
|
-
console.log(chalk.bold('Agent CLIs'));
|
|
84
|
-
// Show the fleet you actually run — agents that are ready in PATH, plus any
|
|
85
|
-
// you MANAGE (have installed versions) whose binary isn't resolving (a real
|
|
86
|
-
// problem). The other supported-but-unadopted agents collapse to one hint line
|
|
87
|
-
// instead of a column of red "not installed" nags for tools you never wanted.
|
|
88
|
-
const managed = new Set(ALL_AGENT_IDS.filter((a) => listInstalledVersions(a).length > 0));
|
|
89
|
-
const entries = Object.entries(clis);
|
|
90
|
-
const shown = entries.filter(([name, e]) => e.installed || managed.has(name));
|
|
91
|
-
const hidden = entries.filter(([name, e]) => !e.installed && !managed.has(name)).map(([name]) => name);
|
|
92
|
-
if (shown.length === 0) {
|
|
93
|
-
console.log(chalk.gray(' (none installed — `agents add <name>` to start)'));
|
|
94
|
-
}
|
|
95
|
-
else {
|
|
96
|
-
for (const [name, entry] of shown) {
|
|
97
|
-
const pretty = (AGENT_NAMES[name] || name).padEnd(11);
|
|
98
|
-
if (entry.installed) {
|
|
99
|
-
const badge = formatSignInBadge(signIn[name]);
|
|
100
|
-
const pathHint = entry.path ? chalk.gray(` ${entry.path}`) : '';
|
|
101
|
-
console.log(` ${chalk.green('ready')} ${pretty} ${badge}${pathHint}`);
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
console.log(` ${chalk.red('no ')} ${pretty} ${chalk.gray(entry.error || 'not installed')}`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (hidden.length > 0) {
|
|
109
|
-
printWrappedLine(' ', `+${hidden.length} more supported (${hidden.join(', ')}) — \`agents add <name>\` to manage`);
|
|
110
|
-
}
|
|
111
|
-
console.log();
|
|
112
|
-
console.log(chalk.bold('Sync status (installed versions)'));
|
|
113
|
-
if (syncRows.length === 0) {
|
|
114
|
-
console.log(chalk.gray(' (no versions installed; add one with `agents add <agent>@<version>`)'));
|
|
115
|
-
}
|
|
116
|
-
else {
|
|
117
|
-
let anyOutOfSync = false;
|
|
118
|
-
for (const row of syncRows) {
|
|
119
|
-
const tag = row.isDefault ? chalk.gray(' (default)') : '';
|
|
120
|
-
const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}${tag}`;
|
|
121
|
-
const unwired = (row.unwiredHooks ?? 0) > 0;
|
|
122
|
-
if (row.status === 'fresh' && !unwired) {
|
|
123
|
-
console.log(` ${chalk.green('fresh')} ${label}`);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
anyOutOfSync = true;
|
|
127
|
-
if (row.status === 'stale') {
|
|
128
|
-
console.log(` ${chalk.yellow('stale')} ${label} ${chalk.gray('— sources changed since last sync')}`);
|
|
129
|
-
}
|
|
130
|
-
else if (row.status === 'never-synced') {
|
|
131
|
-
console.log(` ${chalk.gray('cold ')} ${label} ${chalk.gray('— never synced')}`);
|
|
132
|
-
}
|
|
133
|
-
else {
|
|
134
|
-
// Manifest-fresh but a declared hook is present-on-disk yet not wired into
|
|
135
|
-
// settings.json — it never fires (the yosemite-s1 blind spot).
|
|
136
|
-
console.log(` ${chalk.red('unwired')} ${label} ${chalk.gray('— hooks present but not wired into settings.json')}`);
|
|
137
|
-
}
|
|
138
|
-
for (const line of row.divergence ?? []) {
|
|
139
|
-
console.log(chalk.gray(` ${line}`));
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
// Launching does NOT reconcile a version home — the shim hot path only
|
|
143
|
-
// resolves a version and compiles project-scoped resources (shims.ts v15/v16).
|
|
144
|
-
// Version homes are reconciled only by management commands, so point at one
|
|
145
|
-
// rather than promising an auto-sync that never happens.
|
|
146
|
-
if (anyOutOfSync) {
|
|
147
|
-
printWrappedLine(' ', 'Reconcile with `agents doctor <agent>@<version> --fix` or `agents sync <agent>@<version>` (not applied on launch).');
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
console.log();
|
|
151
|
-
console.log(chalk.bold('Orphans (installed versions)'));
|
|
152
|
-
if (orphanRows.length === 0) {
|
|
153
|
-
console.log(chalk.gray(' (none — version homes match central sources)'));
|
|
154
|
-
}
|
|
155
|
-
else {
|
|
156
|
-
for (const row of orphanRows) {
|
|
157
|
-
const parts = [];
|
|
158
|
-
if (row.commands > 0)
|
|
159
|
-
parts.push(`${row.commands} command${row.commands === 1 ? '' : 's'}`);
|
|
160
|
-
if (row.skills > 0)
|
|
161
|
-
parts.push(`${row.skills} skill${row.skills === 1 ? '' : 's'}`);
|
|
162
|
-
if (row.hooks > 0)
|
|
163
|
-
parts.push(`${row.hooks} hook${row.hooks === 1 ? '' : 's'}`);
|
|
164
|
-
const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}`;
|
|
165
|
-
console.log(` ${chalk.yellow('warn ')} ${label} ${chalk.gray(parts.join(', '))}`);
|
|
166
|
-
}
|
|
167
|
-
console.log(chalk.gray(' Run `agents prune cleanup` to remove.'));
|
|
168
|
-
}
|
|
169
|
-
console.log();
|
|
170
|
-
// Host CLIs are host-global (declared in any DotAgents repo's cli/, installed
|
|
171
|
-
// to PATH — not synced into version homes), so they live in the overview, not
|
|
172
|
-
// the per-version resource diff. Source tag shows which repo layer declared
|
|
173
|
-
// each, including user-level and extra repos.
|
|
174
|
-
console.log(chalk.bold('Host CLIs'));
|
|
175
|
-
if (hostClis.statuses.length === 0) {
|
|
176
|
-
console.log(chalk.gray(' (none declared — add one with `agents cli add <name>`)'));
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
const nameWidth = Math.max(...hostClis.statuses.map((s) => s.manifest.name.length));
|
|
180
|
-
for (const { manifest, installed } of hostClis.statuses) {
|
|
181
|
-
const label = manifest.name.padEnd(nameWidth);
|
|
182
|
-
const src = chalk.gray(`[${manifest.source}]`);
|
|
183
|
-
if (installed) {
|
|
184
|
-
const prefix = ` ${chalk.green('ready')} ${label} ${src}`;
|
|
185
|
-
const desc = manifest.description
|
|
186
|
-
? ` ${truncateToWidth(collapseWhitespace(manifest.description), Math.max(1, terminalWidth() - stringWidth(prefix) - 2))}`
|
|
187
|
-
: '';
|
|
188
|
-
console.log(prefix + chalk.gray(desc));
|
|
189
|
-
}
|
|
190
|
-
else {
|
|
191
|
-
const prefix = ` ${chalk.red('miss ')} ${label} ${src}`;
|
|
192
|
-
const msg = `not installed — run \`agents cli install ${manifest.name}\``;
|
|
193
|
-
const budget = Math.max(1, terminalWidth() - stringWidth(prefix) - 2);
|
|
194
|
-
console.log(prefix + chalk.gray(` ${truncateToWidth(msg, budget)}`));
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
for (const err of hostClis.errors) {
|
|
199
|
-
console.log(` ${chalk.red('err ')} ${chalk.gray(err.file)}: ${chalk.gray(err.reason)}`);
|
|
200
|
-
}
|
|
201
|
-
// On Windows a Restricted/AllSigned execution policy silently breaks the
|
|
202
|
-
// generated `agents.ps1` launcher — postinstall diagnoses it interactively,
|
|
203
|
-
// but a non-interactive install never sees that. Surface it here too.
|
|
204
|
-
renderExecPolicyAdvisory();
|
|
205
|
-
// Credentials exported from a shell rc file leak into every process's
|
|
206
|
-
// environment (readable via /proc/<pid>/environ) — the RUSH-1968 class. Flag
|
|
207
|
-
// them here so the master passphrase never silently lives in `~/.zshenv` again.
|
|
208
|
-
renderRcHygieneAdvisory();
|
|
209
|
-
// Repos that are behind upstream — surfaced here instead of on stderr during
|
|
210
|
-
// every command. Markers are written by the background fetch worker and persist
|
|
211
|
-
// until the user runs `agents repo pull <alias>`.
|
|
212
|
-
renderRepoBehindAdvisory(repoBehindMarkers);
|
|
213
|
-
}
|
|
214
|
-
// ─── windows execution-policy advisory ─────────────────────────────────────────
|
|
215
|
-
/**
|
|
216
|
-
* Windows-only advisory lines. When the effective PowerShell execution policy
|
|
217
|
-
* blocks unsigned local `.ps1` scripts (`Restricted`/`AllSigned`), the generated
|
|
218
|
-
* `agents.ps1` launcher fails in PowerShell even when it is on PATH. Surface the
|
|
219
|
-
* remediation; the `.cmd` companion still works, so this is a warning, not an
|
|
220
|
-
* error, and doctor never auto-changes the policy. Pure — returns `[]` on
|
|
221
|
-
* non-Windows or a permissive policy, so it is testable without invoking
|
|
222
|
-
* PowerShell.
|
|
223
|
-
*/
|
|
224
|
-
export function execPolicyWarningLines(platform, policy) {
|
|
225
|
-
if (platform !== 'win32')
|
|
226
|
-
return [];
|
|
227
|
-
if (!blocksLocalScripts(policy))
|
|
228
|
-
return [];
|
|
229
|
-
return [
|
|
230
|
-
`PowerShell execution policy is ${policy} — it blocks the generated agents.ps1 launcher.`,
|
|
231
|
-
'Fix: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned',
|
|
232
|
-
'The agents.cmd shim still works regardless of the policy.',
|
|
233
|
-
];
|
|
234
|
-
}
|
|
235
|
-
function renderRcHygieneAdvisory() {
|
|
236
|
-
const findings = scanUserRcFiles();
|
|
237
|
-
const lines = rcSecretWarningLines(findings);
|
|
238
|
-
if (lines.length === 0)
|
|
239
|
-
return;
|
|
240
|
-
console.log();
|
|
241
|
-
console.log(chalk.bold('Secrets in shell config'));
|
|
242
|
-
const [headline, ...rest] = lines;
|
|
243
|
-
console.log(` ${chalk.yellow('warn ')} ${headline}`);
|
|
244
|
-
for (const line of rest) {
|
|
245
|
-
console.log(chalk.gray(` ${line}`));
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
// ─── repo-behind advisory ─────────────────────────────────────────────────────
|
|
249
|
-
/**
|
|
250
|
-
* Render repo-behind notices from background fetch markers as a "Repo updates"
|
|
251
|
-
* section in `agents doctor`. Reads without consuming the markers so repeated
|
|
252
|
-
* invocations keep showing the notice until the user runs `agents repo pull`.
|
|
253
|
-
*/
|
|
254
|
-
function renderRepoBehindAdvisory(markers) {
|
|
255
|
-
const behind = markers.filter((m) => m.behind > 0);
|
|
256
|
-
if (behind.length === 0)
|
|
257
|
-
return;
|
|
258
|
-
console.log();
|
|
259
|
-
console.log(chalk.bold('Repo updates'));
|
|
260
|
-
for (const m of behind) {
|
|
261
|
-
const label = m.alias === 'user' ? '~/.agents/' : m.alias;
|
|
262
|
-
const commits = `${m.behind} commit${m.behind === 1 ? '' : 's'}`;
|
|
263
|
-
console.log(` ${chalk.yellow('warn ')} ${label} is ${commits} behind ${m.branch}`);
|
|
264
|
-
console.log(chalk.gray(` run \`agents repo pull ${m.alias}\` to update`));
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
function renderExecPolicyAdvisory() {
|
|
268
|
-
// Only probe the policy on Windows — getEffectiveExecutionPolicy() spawns
|
|
269
|
-
// powershell, which is a wasted (doomed) process on POSIX where the advisory
|
|
270
|
-
// never applies.
|
|
271
|
-
if (process.platform !== 'win32')
|
|
272
|
-
return;
|
|
273
|
-
const lines = execPolicyWarningLines(process.platform, getEffectiveExecutionPolicy());
|
|
274
|
-
if (lines.length === 0)
|
|
275
|
-
return;
|
|
276
|
-
console.log();
|
|
277
|
-
console.log(chalk.bold('Execution policy (Windows)'));
|
|
278
|
-
const [headline, ...rest] = lines;
|
|
279
|
-
console.log(` ${chalk.yellow('warn ')} ${headline}`);
|
|
280
|
-
for (const line of rest) {
|
|
281
|
-
console.log(chalk.gray(` ${line}`));
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
const AGENT_ORDER = ['claude', 'codex', 'kimi', 'grok', 'antigravity', 'opencode', 'cursor', 'droid'];
|
|
285
|
-
function shortAgentHeader(name) {
|
|
286
|
-
return name.slice(0, 4).padEnd(4);
|
|
287
|
-
}
|
|
288
|
-
function agentCell(entry) {
|
|
289
|
-
if (!entry)
|
|
290
|
-
return chalk.gray('- ');
|
|
291
|
-
if (entry.installed) {
|
|
292
|
-
const signedInHint = entry.signedIn ? '*' : ' ';
|
|
293
|
-
return chalk.green(`rdy${signedInHint}`.padEnd(4));
|
|
294
|
-
}
|
|
295
|
-
if (entry.error)
|
|
296
|
-
return chalk.red('err '.padEnd(4));
|
|
297
|
-
return chalk.gray('no '.padEnd(4));
|
|
298
|
-
}
|
|
299
80
|
async function resolveFleetTargets(opts) {
|
|
300
81
|
const singleName = opts.host || opts.device;
|
|
301
82
|
if (singleName) {
|
|
@@ -377,12 +158,73 @@ async function probeFleetInventory(target) {
|
|
|
377
158
|
return null;
|
|
378
159
|
try {
|
|
379
160
|
const parsed = JSON.parse(res.stdout);
|
|
380
|
-
return parsed.fleet
|
|
161
|
+
return asFleetInventory(parsed.fleet);
|
|
381
162
|
}
|
|
382
163
|
catch {
|
|
383
164
|
return null;
|
|
384
165
|
}
|
|
385
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Narrow a remote `doctor --json` `.fleet` payload to a usable inventory, or null.
|
|
169
|
+
*
|
|
170
|
+
* The remote runs ITS OWN agents-cli, whose version we do not control, so the
|
|
171
|
+
* payload is untrusted input rather than a typed value — a cast alone let a
|
|
172
|
+
* partial object (`{"fleet":{}}` from a skewed or truncated remote) reach
|
|
173
|
+
* `compareFleetInventories`, which indexes `inv.resources[kind]` and
|
|
174
|
+
* `Object.keys(inv.agentVersions)` unconditionally and throws. Returning null
|
|
175
|
+
* routes that device into the existing "older agents-cli / no inventory" path,
|
|
176
|
+
* which is honest and already rendered, instead of aborting the whole fan-out.
|
|
177
|
+
*
|
|
178
|
+
* This validates EVERY field of {@link FleetInventory} that anything downstream
|
|
179
|
+
* reads — `resources`, `agentVersions`, `repos` and each optional `signIn` row.
|
|
180
|
+
* It was tightened four times during review, each round finding the next
|
|
181
|
+
* unvalidated layer, so treat partial validation here as a bug: a field added to
|
|
182
|
+
* the inventory must be checked here in the same change.
|
|
183
|
+
*/
|
|
184
|
+
function asFleetInventory(value) {
|
|
185
|
+
// `typeof [] === 'object'`, so a shallow object check is not enough: an array
|
|
186
|
+
// passes it, every `inv.resources[kind] ?? []` then yields empty, and the
|
|
187
|
+
// comparison reports EVERY baseline resource as missing on that device — a
|
|
188
|
+
// screenful of false findings, worse than the crash this guard replaced.
|
|
189
|
+
const isMap = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
|
|
190
|
+
const isStringArray = (x) => Array.isArray(x) && x.every((e) => typeof e === 'string');
|
|
191
|
+
if (!isMap(value))
|
|
192
|
+
return null;
|
|
193
|
+
const v = value;
|
|
194
|
+
if (!isMap(v.resources) || !Object.values(v.resources).every(isStringArray))
|
|
195
|
+
return null;
|
|
196
|
+
if (!isMap(v.agentVersions) || !Object.values(v.agentVersions).every(isStringArray))
|
|
197
|
+
return null;
|
|
198
|
+
// `repos.agents` / `repos.system` must each be a RepoState or null. Checking
|
|
199
|
+
// only that `repos` is a record let `{agents: [], system: []}` through: `[]` is
|
|
200
|
+
// truthy, so `describeRepoDrift` read `.head`/`.branch`/`.dirty` off an array,
|
|
201
|
+
// got undefined for each, and could emit a bogus repo-drift row.
|
|
202
|
+
const isRepoState = (x) => x === null
|
|
203
|
+
|| (isMap(x)
|
|
204
|
+
&& (x.branch === null || typeof x.branch === 'string')
|
|
205
|
+
&& (x.head === null || typeof x.head === 'string')
|
|
206
|
+
&& typeof x.dirty === 'boolean');
|
|
207
|
+
if (!isMap(v.repos) || !Object.values(v.repos).every(isRepoState))
|
|
208
|
+
return null;
|
|
209
|
+
// `signIn` is optional (older remotes omit it) but every FIELD must be
|
|
210
|
+
// well-formed if sent — not just `version`. `provable` and `signedIn` are read
|
|
211
|
+
// as booleans to decide a CRITICAL vs a hedged warning, so a remote sending
|
|
212
|
+
// `provable: "yes"` would print a logged-out critical for a signed-in version,
|
|
213
|
+
// and a non-string `account` would render straight into the accounts line.
|
|
214
|
+
if (v.signIn !== undefined) {
|
|
215
|
+
if (!isMap(v.signIn))
|
|
216
|
+
return null;
|
|
217
|
+
const isSignInRow = (r) => isMap(r)
|
|
218
|
+
&& typeof r.version === 'string'
|
|
219
|
+
&& typeof r.signedIn === 'boolean'
|
|
220
|
+
&& typeof r.provable === 'boolean'
|
|
221
|
+
&& (r.account === null || typeof r.account === 'string');
|
|
222
|
+
const rowsOk = Object.values(v.signIn).every((rows) => Array.isArray(rows) && rows.every(isSignInRow));
|
|
223
|
+
if (!rowsOk)
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
return v;
|
|
227
|
+
}
|
|
386
228
|
async function runDevicesDoctor(opts) {
|
|
387
229
|
const singleName = opts.host || opts.device;
|
|
388
230
|
const targets = await resolveFleetTargets(opts);
|
|
@@ -395,7 +237,7 @@ async function runDevicesDoctor(opts) {
|
|
|
395
237
|
name: localName,
|
|
396
238
|
online: true,
|
|
397
239
|
agents: await collectTeamsDoctorData(),
|
|
398
|
-
inventory: collectLocalFleetInventory(opts.cwd ?? process.cwd()),
|
|
240
|
+
inventory: await collectLocalFleetInventory(opts.cwd ?? process.cwd()),
|
|
399
241
|
});
|
|
400
242
|
}
|
|
401
243
|
// Remote targets in parallel: agent readiness (teams doctor) and the harness
|
|
@@ -427,74 +269,99 @@ async function runDevicesDoctor(opts) {
|
|
|
427
269
|
const divergence = singleName
|
|
428
270
|
? null
|
|
429
271
|
: compareFleetInventories(results.map((r) => ({ name: r.name, inventory: r.inventory ?? null })), localName);
|
|
430
|
-
if (opts.json) {
|
|
431
|
-
console.log(JSON.stringify({ devices: results, fleet: divergence }, null, 2));
|
|
272
|
+
if (opts.json && results.length === 0) {
|
|
273
|
+
console.log(JSON.stringify({ devices: results, fleet: divergence, findings: [] }, null, 2));
|
|
432
274
|
return;
|
|
433
275
|
}
|
|
434
276
|
if (results.length === 0) {
|
|
435
277
|
console.log(chalk.gray('No registered devices. Run `agents devices` to register some.'));
|
|
436
278
|
return;
|
|
437
279
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
280
|
+
// ── Hybrid fleet view (RUSH-2069): CRITICAL section across all boxes, then a
|
|
281
|
+
// per-computer block for each. Findings come from:
|
|
282
|
+
// - LOCAL: per-version resource reports + sync/orphan/repo-behind + sign-in.
|
|
283
|
+
// - REMOTE: the box's self-reported inventory (per-version sign-in) folded to
|
|
284
|
+
// logged-out findings, plus cross-device divergence (version-skew, repo
|
|
285
|
+
// drift, missing resources). A remote on an older CLI with no `signIn` in
|
|
286
|
+
// its inventory emits the "older agents-cli — can't report per-version
|
|
287
|
+
// sign-in → upgrade" warning so the readout stays honest.
|
|
288
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
289
|
+
const findings = [];
|
|
290
|
+
const accounts = {};
|
|
291
|
+
for (const r of results) {
|
|
292
|
+
if (r.name === localName) {
|
|
293
|
+
// Local findings from the real reports (not just the inventory summary).
|
|
294
|
+
const localReports = [];
|
|
295
|
+
for (const agent of ALL_AGENT_IDS) {
|
|
296
|
+
for (const version of listInstalledVersions(agent)) {
|
|
297
|
+
localReports.push(diffVersionResources(agent, version, { cwd, excludeProject: true }));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const localCliMissing = ALL_AGENT_IDS.filter((a) => listInstalledVersions(a).length > 0 && !checkAllClis()[a]?.installed);
|
|
301
|
+
findings.push(...buildLocalFindings({
|
|
302
|
+
device: localName,
|
|
303
|
+
syncRows: checkSyncStatus(cwd),
|
|
304
|
+
orphanRows: countOrphans(),
|
|
305
|
+
repoBehind: readRepoBehindMarkers(),
|
|
306
|
+
reports: localReports,
|
|
307
|
+
signIn: r.inventory?.signIn ?? {},
|
|
308
|
+
cliMissing: localCliMissing,
|
|
309
|
+
duplicateHooks: inspectDuplicateVersionHooks(cwd),
|
|
310
|
+
hostClis: toHostCliInput(listCliStatus(cwd)),
|
|
311
|
+
rcSecrets: scanUserRcFiles(),
|
|
312
|
+
execPolicy: process.platform === 'win32'
|
|
313
|
+
? { platform: process.platform, policy: getEffectiveExecutionPolicy() }
|
|
314
|
+
: undefined,
|
|
315
|
+
isolatedVersions: localReports
|
|
316
|
+
.filter((rep) => isVersionIsolated(rep.agent, rep.version))
|
|
317
|
+
.map((rep) => `${rep.agent}@${rep.version}`),
|
|
318
|
+
}));
|
|
319
|
+
accounts[localName] = r.inventory?.signIn ?? {};
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
// Remote device.
|
|
323
|
+
if (!r.online) {
|
|
324
|
+
// An offline / unreachable box: surface it as a warning so it isn't
|
|
325
|
+
// silently dropped from the per-computer section.
|
|
326
|
+
findings.push({
|
|
327
|
+
severity: 'warning', kind: 'stale-cli', device: r.name,
|
|
328
|
+
message: r.error ? `unreachable — ${r.error}` : 'unreachable',
|
|
329
|
+
remediation: 'check the device',
|
|
330
|
+
});
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (r.inventory?.signIn) {
|
|
334
|
+
findings.push(...signInToFindings(r.name, r.inventory.signIn));
|
|
335
|
+
accounts[r.name] = r.inventory.signIn;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
// Reachable, but an older CLI that can't report per-version sign-in.
|
|
339
|
+
findings.push({
|
|
340
|
+
severity: 'warning', kind: 'stale-cli', device: r.name,
|
|
341
|
+
message: "older agents-cli — can't report per-version sign-in",
|
|
342
|
+
remediation: 'upgrade',
|
|
343
|
+
});
|
|
344
|
+
accounts[r.name] = {};
|
|
345
|
+
}
|
|
452
346
|
}
|
|
453
|
-
|
|
454
|
-
|
|
347
|
+
// Cross-device divergence → version-skew / repo-drift / missing-resource
|
|
348
|
+
// warnings, attributed to the lagging box.
|
|
455
349
|
if (divergence) {
|
|
456
|
-
|
|
457
|
-
for (const line of renderFleetDivergence(divergence))
|
|
458
|
-
console.log(line);
|
|
350
|
+
findings.push(...fleetDivergenceToFindings(divergence.divergences, divergence.baseline));
|
|
459
351
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
: null;
|
|
473
|
-
if (!report.hasDivergence) {
|
|
474
|
-
lines.push(chalk.green(` Fleet is consistent — every compared device matches ${report.baseline}.`));
|
|
475
|
-
if (skippedNote)
|
|
476
|
-
lines.push(skippedNote);
|
|
477
|
-
return lines;
|
|
478
|
-
}
|
|
479
|
-
// Group findings by the LAGGING device so a box with several gaps reads as one
|
|
480
|
-
// block AND the remediation points at the right box. For a `*-missing-local`
|
|
481
|
-
// finding the resource is absent on the local baseline (present on `d.device`),
|
|
482
|
-
// so the box that's behind is the baseline — not the remote that has it.
|
|
483
|
-
const byDevice = new Map();
|
|
484
|
-
for (const d of report.divergences) {
|
|
485
|
-
const lagging = d.kind.endsWith('-missing-local') ? report.baseline : d.device;
|
|
486
|
-
(byDevice.get(lagging) ?? byDevice.set(lagging, []).get(lagging)).push(d);
|
|
487
|
-
}
|
|
488
|
-
for (const device of Array.from(byDevice.keys()).sort()) {
|
|
489
|
-
lines.push(` ${chalk.yellow('⚠')} ${chalk.bold(device)}`);
|
|
490
|
-
for (const d of byDevice.get(device)) {
|
|
491
|
-
lines.push(chalk.gray(` ${d.message}`));
|
|
492
|
-
}
|
|
352
|
+
// `--json` emits HERE, not before the findings are built: a consumer that reads
|
|
353
|
+
// the JSON must see the same finding set the text renders — same membership,
|
|
354
|
+
// same severities, so the CRITICAL `(N)` and each device's `✗ N critical`
|
|
355
|
+
// reconcile. Emitting early left `--devices --json` with no `findings` at all
|
|
356
|
+
// while the bare `--json` had one.
|
|
357
|
+
if (opts.json) {
|
|
358
|
+
console.log(JSON.stringify({ devices: results, fleet: divergence, findings }, null, 2));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const header = `${chalk.gray('agents doctor ·')} ${chalk.hex('#a3e635')(String(results.length))} ${chalk.gray('devices · baseline')} ${chalk.hex('#a3e635')(localName)}`;
|
|
362
|
+
for (const line of renderFindings(findings, accounts, { fleet: true, baseline: localName, header })) {
|
|
363
|
+
console.log(line);
|
|
493
364
|
}
|
|
494
|
-
if (skippedNote)
|
|
495
|
-
lines.push(skippedNote);
|
|
496
|
-
lines.push(chalk.gray(' Read-only — run `agents apply` or `agents repo pull` on a lagging box to reconcile.'));
|
|
497
|
-
return lines;
|
|
498
365
|
}
|
|
499
366
|
function parseTargetArg(arg) {
|
|
500
367
|
const at = arg.indexOf('@');
|
|
@@ -1423,10 +1290,14 @@ export function registerDoctorCommand(program) {
|
|
|
1423
1290
|
const orphanRows = countOrphans();
|
|
1424
1291
|
const hostClis = listCliStatus(cwd);
|
|
1425
1292
|
const repoBehindMarkers = readRepoBehindMarkers();
|
|
1293
|
+
// The local inventory now carries per-version sign-in (RUSH-2069), so it
|
|
1294
|
+
// is the single source for both the accounts line and the logged-out
|
|
1295
|
+
// criticals. Collected once (async: it parses each version's account).
|
|
1296
|
+
const inventory = await collectLocalFleetInventory(cwd);
|
|
1297
|
+
const localName = machineId();
|
|
1426
1298
|
const duplicateHooks = inspectDuplicateVersionHooks(cwd);
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
// failure just leaves that agent's badge as "logged out".
|
|
1299
|
+
// Legacy account-global sign-in map, kept for `--json` back-compat
|
|
1300
|
+
// (ssh.ts RemoteDoctorJson / menubar read `signIn`). File-based, no home.
|
|
1430
1301
|
const signIn = {};
|
|
1431
1302
|
await Promise.all(Object.entries(clis)
|
|
1432
1303
|
.filter(([, e]) => e.installed)
|
|
@@ -1438,6 +1309,41 @@ export function registerDoctorCommand(program) {
|
|
|
1438
1309
|
/* advisory only */
|
|
1439
1310
|
}
|
|
1440
1311
|
}));
|
|
1312
|
+
// Per-version resource reports drive the missing-hook / missing-plugin /
|
|
1313
|
+
// unwired / content-drift findings. Non-project layers only (the global
|
|
1314
|
+
// home is never reconciled against per-cwd project resources).
|
|
1315
|
+
const reports = [];
|
|
1316
|
+
for (const agent of ALL_AGENT_IDS) {
|
|
1317
|
+
for (const version of listInstalledVersions(agent)) {
|
|
1318
|
+
reports.push(diffVersionResources(agent, version, { cwd, excludeProject: true }));
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
// A MANAGED agent (installed versions) whose binary won't resolve is a
|
|
1322
|
+
// real critical — an unmanaged-and-absent agent is not.
|
|
1323
|
+
const cliMissing = ALL_AGENT_IDS.filter((a) => listInstalledVersions(a).length > 0 && clis[a] && !clis[a].installed);
|
|
1324
|
+
// An isolated copy is skipped by the agent-wide `--fix` sweep, so its
|
|
1325
|
+
// findings must never fold into a collapsed cross-version row.
|
|
1326
|
+
const isolatedVersions = reports
|
|
1327
|
+
.filter((r) => isVersionIsolated(r.agent, r.version))
|
|
1328
|
+
.map((r) => `${r.agent}@${r.version}`);
|
|
1329
|
+
const findings = buildLocalFindings({
|
|
1330
|
+
device: localName,
|
|
1331
|
+
syncRows,
|
|
1332
|
+
orphanRows,
|
|
1333
|
+
repoBehind: repoBehindMarkers,
|
|
1334
|
+
reports,
|
|
1335
|
+
signIn: inventory.signIn ?? {},
|
|
1336
|
+
cliMissing,
|
|
1337
|
+
duplicateHooks,
|
|
1338
|
+
hostClis: toHostCliInput(hostClis),
|
|
1339
|
+
rcSecrets: scanUserRcFiles(),
|
|
1340
|
+
// getEffectiveExecutionPolicy spawns powershell — a doomed process on
|
|
1341
|
+
// POSIX, where the advisory never applies. Probe only on Windows.
|
|
1342
|
+
execPolicy: process.platform === 'win32'
|
|
1343
|
+
? { platform: process.platform, policy: getEffectiveExecutionPolicy() }
|
|
1344
|
+
: undefined,
|
|
1345
|
+
isolatedVersions,
|
|
1346
|
+
});
|
|
1441
1347
|
if (opts.json) {
|
|
1442
1348
|
console.log(JSON.stringify({
|
|
1443
1349
|
clis,
|
|
@@ -1453,12 +1359,14 @@ export function registerDoctorCommand(program) {
|
|
|
1453
1359
|
// reading `sync`/`orphans`/`repos` are unaffected.
|
|
1454
1360
|
health: computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, duplicateHooks),
|
|
1455
1361
|
duplicateHooks,
|
|
1362
|
+
// Prioritized RUSH-2069 findings (critical/warning, per-version, with
|
|
1363
|
+
// remediation). Additive alongside the legacy fields above.
|
|
1364
|
+
findings,
|
|
1456
1365
|
// This host's harness inventory — installed resources per kind,
|
|
1457
|
-
// installed version ids per agent,
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
|
|
1461
|
-
fleet: collectLocalFleetInventory(cwd),
|
|
1366
|
+
// installed version ids per agent, `.agents`/`.system` repo state, and
|
|
1367
|
+
// per-version sign-in — so `agents doctor --devices` can compare
|
|
1368
|
+
// presence and sign-in across the fleet (RUSH-2027/2069). Read-only.
|
|
1369
|
+
fleet: inventory,
|
|
1462
1370
|
hostClis: {
|
|
1463
1371
|
statuses: hostClis.statuses.map((s) => ({
|
|
1464
1372
|
name: s.manifest.name,
|
|
@@ -1480,11 +1388,17 @@ export function registerDoctorCommand(program) {
|
|
|
1480
1388
|
}, null, 2));
|
|
1481
1389
|
return;
|
|
1482
1390
|
}
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1391
|
+
// Single-machine hybrid: CRITICAL section + one `▸ <machine>` block.
|
|
1392
|
+
const accounts = {
|
|
1393
|
+
[localName]: inventory.signIn ?? {},
|
|
1394
|
+
};
|
|
1395
|
+
const header = `${chalk.gray('agents doctor ·')} ${chalk.hex('#a3e635')(localName)}${chalk.gray(` ${getCliVersion()}`)}`;
|
|
1396
|
+
for (const line of renderFindings(findings, accounts, { fleet: false, baseline: localName, header })) {
|
|
1397
|
+
console.log(line);
|
|
1398
|
+
}
|
|
1399
|
+
// Point at the interactive reconcile when anything is out of sync — each
|
|
1400
|
+
// finding carries its own fix, but `agents status` is the one place that
|
|
1401
|
+
// reviews and applies them together (opt-in, never auto-fires here).
|
|
1488
1402
|
if (syncRows.some((r) => r.status !== 'fresh' || (r.unwiredHooks ?? 0) > 0) || repoBehindMarkers.some((m) => m.behind > 0)) {
|
|
1489
1403
|
console.log(chalk.gray('\nRun `agents status` to review and sync what has drifted.'));
|
|
1490
1404
|
}
|