@phnx-labs/agents-cli 1.20.70 → 1.20.72
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 +85 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +36 -3
- package/dist/commands/check.js +3 -1
- package/dist/commands/commands.js +2 -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/logs.js +38 -9
- package/dist/commands/mcp.js +2 -0
- package/dist/commands/resource-view.d.ts +2 -0
- package/dist/commands/resource-view.js +8 -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.d.ts +6 -0
- package/dist/commands/sessions.js +14 -6
- package/dist/commands/skills.js +2 -0
- package/dist/commands/ssh.js +24 -6
- package/dist/commands/status.js +8 -2
- package/dist/commands/subagents.js +3 -1
- package/dist/commands/uninstall.d.ts +11 -0
- package/dist/commands/uninstall.js +158 -0
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +50 -28
- package/dist/commands/utils.d.ts +29 -0
- package/dist/commands/utils.js +24 -0
- package/dist/commands/versions.js +120 -11
- package/dist/index.js +5 -3
- package/dist/lib/commands.d.ts +10 -0
- package/dist/lib/commands.js +31 -7
- package/dist/lib/daemon.d.ts +9 -0
- package/dist/lib/daemon.js +43 -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/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/hosts/logs.d.ts +12 -0
- package/dist/lib/hosts/logs.js +18 -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/shims.d.ts +27 -0
- package/dist/lib/shims.js +29 -3
- package/dist/lib/skills.d.ts +8 -1
- package/dist/lib/skills.js +11 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/types.d.ts +5 -1
- package/dist/lib/uninstall.d.ts +84 -0
- package/dist/lib/uninstall.js +347 -0
- package/dist/lib/usage.d.ts +13 -1
- package/dist/lib/usage.js +32 -14
- package/dist/lib/versions.d.ts +16 -0
- package/dist/lib/versions.js +40 -3
- package/package.json +1 -1
|
@@ -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);
|
package/dist/lib/doctor-diff.js
CHANGED
|
@@ -22,21 +22,19 @@
|
|
|
22
22
|
import * as fs from 'fs';
|
|
23
23
|
import * as path from 'path';
|
|
24
24
|
import { AGENTS, agentConfigDirName } from './agents.js';
|
|
25
|
-
import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, } from './state.js';
|
|
25
|
+
import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, getActiveRulesPreset, } from './state.js';
|
|
26
|
+
import { composeRulesFromState } from './rules/compose.js';
|
|
26
27
|
import { getAvailableResources, getActuallySyncedResources, getVersionHomePath, compareVersions, } from './versions.js';
|
|
27
28
|
import { discoverPlugins, marketplaceSpecForName } from './plugins.js';
|
|
28
29
|
import { pluginInstallDir, repairableManifestFields } from './plugin-marketplace.js';
|
|
29
30
|
import { markdownToToml } from './convert.js';
|
|
30
|
-
import {
|
|
31
|
-
import { listCommandsInVersionHome, getVersionCommandsDir } from './commands.js';
|
|
31
|
+
import { listCommandsInVersionHome, getVersionCommandsDir, listPluginCommandNames } from './commands.js';
|
|
32
32
|
import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } from './command-skills.js';
|
|
33
33
|
import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
|
|
34
34
|
import { supports } from './capabilities.js';
|
|
35
35
|
import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
|
|
36
36
|
import { listHooksInVersionHome, listHookEntriesFromDir } from './hooks.js';
|
|
37
37
|
const RULES_DOC_FILENAME = 'README.md';
|
|
38
|
-
const COMPILED_HEADER = '<!-- Auto-compiled by agents-cli from ~/.agents/rules/AGENTS.md + imports.\n' +
|
|
39
|
-
' Edit the source files under ~/.agents/rules/ — edits to this file will be overwritten on next sync. -->\n\n';
|
|
40
38
|
const ALL_KINDS = [
|
|
41
39
|
'commands',
|
|
42
40
|
'skills',
|
|
@@ -173,9 +171,16 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
|
|
|
173
171
|
homePath,
|
|
174
172
|
});
|
|
175
173
|
}
|
|
174
|
+
// Plugin-bundled commands (installed as `<plugin>-<cmd>`) are source-managed by
|
|
175
|
+
// their plugin, tracked under the `plugins` kind — not extras. Without this,
|
|
176
|
+
// `agents doctor` shows every plugin command (swarm-plan, code-review, …) as an
|
|
177
|
+
// unmanaged extra, mirroring the orphan false-positive the prune path had.
|
|
178
|
+
const pluginCommands = listPluginCommandNames();
|
|
176
179
|
for (const name of installed) {
|
|
177
180
|
if (seen.has(name))
|
|
178
181
|
continue;
|
|
182
|
+
if (pluginCommands.has(name))
|
|
183
|
+
continue;
|
|
179
184
|
const extraHome = asSkill
|
|
180
185
|
? path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md')
|
|
181
186
|
: agent === 'goose'
|
|
@@ -236,6 +241,14 @@ function dirsContentMatch(src, dst) {
|
|
|
236
241
|
return true;
|
|
237
242
|
}
|
|
238
243
|
function diffSkills(agent, version, cwd, excludeProject = false) {
|
|
244
|
+
// Native ~/.agents/skills consumers (Gemini, …) read central skills directly.
|
|
245
|
+
// The orchestrator DELETES their version-home skills dir (syncResourcesToVersion)
|
|
246
|
+
// and registers no skills writer, so there is nothing in the version home to
|
|
247
|
+
// reconcile. Mirror diffVersionSkills' native gate (skills.ts) — without it
|
|
248
|
+
// every central skill is false-reported `missing` and held as unreconcilable
|
|
249
|
+
// forever (drift never clears for these agents).
|
|
250
|
+
if (AGENTS[agent].nativeAgentsSkillsDir)
|
|
251
|
+
return [];
|
|
239
252
|
const homeDir = getVersionSkillsDir(agent, version);
|
|
240
253
|
const installed = new Set(listSkillsInVersionHome(agent, version));
|
|
241
254
|
const layerBases = buildLayerBases(cwd, 'skills', { excludeProject });
|
|
@@ -380,18 +393,27 @@ function listRulesNames(cwd, excludeProject = false) {
|
|
|
380
393
|
}
|
|
381
394
|
return out;
|
|
382
395
|
}
|
|
383
|
-
function expectedRuleContent(agent, name, sourcePath) {
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
396
|
+
function expectedRuleContent(agent, name, version, sourcePath) {
|
|
397
|
+
// The instruction file (AGENTS → the agent's CLAUDE.md/GEMINI.md/AGENTS.md)
|
|
398
|
+
// is COMPOSED from `subrules/` fragments for the version's active preset —
|
|
399
|
+
// that is exactly what the rules writer emits (see
|
|
400
|
+
// staleness/writers/rules.ts → composeRulesFromState). Compare against the
|
|
401
|
+
// same rendering so a correctly-synced home file reconciles instead of being
|
|
402
|
+
// held forever against the raw `rules/AGENTS.md` (the whole-repo doc, which a
|
|
403
|
+
// preset composition deliberately never equals — system subrules don't
|
|
404
|
+
// auto-append). The `agent` is not part of the rendering (every capable agent
|
|
405
|
+
// gets identical composed bytes); it is kept for symmetry with the writer's
|
|
406
|
+
// per-agent dispatch and future per-agent presets.
|
|
407
|
+
if (name === 'AGENTS') {
|
|
408
|
+
try {
|
|
409
|
+
return composeRulesFromState({ preset: getActiveRulesPreset(agent, version) }).content;
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
// No rules.yaml / unknown preset — the writer skips too; treat as unknown.
|
|
388
413
|
return null;
|
|
389
|
-
|
|
390
|
-
// its imports relative to the project rules dir, not the user one.
|
|
391
|
-
const baseDir = path.dirname(sourcePath);
|
|
392
|
-
const { content } = resolveImports(root, baseDir);
|
|
393
|
-
return COMPILED_HEADER + content;
|
|
414
|
+
}
|
|
394
415
|
}
|
|
416
|
+
// Any sibling top-level rules file syncs as a raw copy.
|
|
395
417
|
return readSafe(sourcePath);
|
|
396
418
|
}
|
|
397
419
|
function diffRules(agent, version, cwd, excludeProject = false) {
|
|
@@ -418,7 +440,7 @@ function diffRules(agent, version, cwd, excludeProject = false) {
|
|
|
418
440
|
rows.push({ kind: 'rules', name, status: 'missing', source: src.layer, sourcePath: src.path });
|
|
419
441
|
continue;
|
|
420
442
|
}
|
|
421
|
-
const expected = expectedRuleContent(agent, name, src.path);
|
|
443
|
+
const expected = expectedRuleContent(agent, name, version, src.path);
|
|
422
444
|
const actual = readSafe(homePath);
|
|
423
445
|
if (expected == null || actual == null) {
|
|
424
446
|
rows.push({ kind: 'rules', name, status: 'diff', source: src.layer, sourcePath: src.path, homePath });
|
|
@@ -30,6 +30,10 @@ export interface DiffContext {
|
|
|
30
30
|
/** agents-cli version the source is on — the fleet target version. */
|
|
31
31
|
targetCliVersion: string;
|
|
32
32
|
sourceAuth: SourceAuth;
|
|
33
|
+
/** Secrets-bundle names the profile declares. Values are keychain-local and
|
|
34
|
+
* can't be pushed, so each reachable device surfaces them as a manual recreate
|
|
35
|
+
* (`needs-secret`) — informational, never an executed mutation. */
|
|
36
|
+
secretsBundles?: string[];
|
|
33
37
|
}
|
|
34
38
|
/** Pure: desired vs probed -> per-device diff + flat action list. */
|
|
35
39
|
export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
|
package/dist/lib/fleet/apply.js
CHANGED
|
@@ -44,6 +44,7 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
44
44
|
};
|
|
45
45
|
const rowActions = [];
|
|
46
46
|
const loginBlocked = [];
|
|
47
|
+
const secretsNeeded = [];
|
|
47
48
|
if (probe.reachable) {
|
|
48
49
|
// agents-cli presence.
|
|
49
50
|
if (!probe.cliVersion) {
|
|
@@ -82,8 +83,18 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
}
|
|
86
|
+
// secrets — surfaced, never pushed (values are keychain-local). Declared
|
|
87
|
+
// once at the manifest level, so every reachable device gets the same
|
|
88
|
+
// manual-recreate reminder. Not an executable action (see idempotence
|
|
89
|
+
// check in commands/apply.ts, which excludes needs-* kinds).
|
|
90
|
+
if (ctx.secretsBundles && ctx.secretsBundles.length > 0) {
|
|
91
|
+
for (const bundle of ctx.secretsBundles) {
|
|
92
|
+
secretsNeeded.push(bundle);
|
|
93
|
+
rowActions.push({ device: d.device, kind: 'needs-secret', detail: `recreate secrets bundle '${bundle}' (\`agents secrets create ${bundle}\`)` });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
85
96
|
}
|
|
86
|
-
devices.push({ device: d.device, desired: d, probe, actions: rowActions, loginBlocked });
|
|
97
|
+
devices.push({ device: d.device, desired: d, probe, actions: rowActions, loginBlocked, secretsNeeded });
|
|
87
98
|
actions.push(...rowActions);
|
|
88
99
|
}
|
|
89
100
|
return { devices, actions };
|
|
@@ -161,11 +172,18 @@ export function reconcileDevice(row, device, ctx) {
|
|
|
161
172
|
steps.push({ kind: 'add-agent', ok: r.code === 0, detail: a.detail });
|
|
162
173
|
ok = ok && r.code === 0;
|
|
163
174
|
}
|
|
164
|
-
// 3. config sync.
|
|
175
|
+
// 3. config sync — one `agents sync <scope>` per declared scope. Each scope is
|
|
176
|
+
// a positional repo target (system/user/project/alias); a bare `agents sync`
|
|
177
|
+
// would ignore the profile's declared scopes entirely.
|
|
165
178
|
if (row.actions.some((a) => a.kind === 'sync-config')) {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
179
|
+
const scopes = row.desired.sync.length > 0 ? row.desired.sync : [''];
|
|
180
|
+
let syncOk = true;
|
|
181
|
+
for (const scope of scopes) {
|
|
182
|
+
const r = sshAgents(scope ? ['sync', scope] : ['sync']);
|
|
183
|
+
syncOk = syncOk && r.code === 0;
|
|
184
|
+
}
|
|
185
|
+
steps.push({ kind: 'sync-config', ok: syncOk, detail: `sync config (${row.desired.sync.join(', ') || 'default'})` });
|
|
186
|
+
ok = ok && syncOk;
|
|
169
187
|
}
|
|
170
188
|
// 4. login propagation — one bundle for all pushable agents on this device.
|
|
171
189
|
const pushAgents = [...new Set(row.actions.filter((a) => a.kind === 'push-login').map((a) => a.agent))];
|
|
@@ -182,6 +200,11 @@ export function reconcileDevice(row, device, ctx) {
|
|
|
182
200
|
for (const blocked of row.loginBlocked) {
|
|
183
201
|
steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
|
|
184
202
|
}
|
|
203
|
+
// Surface declared secrets bundles as (non-fatal) manual-recreate reminders —
|
|
204
|
+
// values are keychain-local, never captured or pushed.
|
|
205
|
+
for (const bundle of row.secretsNeeded) {
|
|
206
|
+
steps.push({ kind: 'needs-secret', ok: false, detail: `secrets bundle '${bundle}' must exist on ${row.device} (\`agents ssh ${row.device} -- secrets create ${bundle}\`)` });
|
|
207
|
+
}
|
|
185
208
|
return { device: row.device, ok, steps };
|
|
186
209
|
}
|
|
187
210
|
/** Run a pool of async tasks with a concurrency cap, preserving input order. */
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize the live environment into a `fleet:` manifest.
|
|
3
|
+
*
|
|
4
|
+
* `captureFleet` is PURE — it takes the previous manifest plus already-gathered
|
|
5
|
+
* inputs (device names, per-device agent specs, browser profiles, secret-bundle
|
|
6
|
+
* names, routine names) and returns the new manifest. All I/O (registry read,
|
|
7
|
+
* `agents secrets`/`routines` enumeration, YAML write) happens in the command
|
|
8
|
+
* (`commands/fleet-capture.ts`); keeping this pure makes the privacy contract —
|
|
9
|
+
* NAMES ONLY, never IPs/usernames — trivially unit-testable.
|
|
10
|
+
*
|
|
11
|
+
* Additive by design: it merges OVER an existing manifest and never clobbers a
|
|
12
|
+
* hand-authored per-device override (`agents:` you set by hand wins over a
|
|
13
|
+
* captured one). The captured roster reflects live state, so it becomes the
|
|
14
|
+
* source of truth for WHICH devices exist.
|
|
15
|
+
*/
|
|
16
|
+
import type { FleetManifest, FleetDefaults } from './types.js';
|
|
17
|
+
export interface CaptureInputs {
|
|
18
|
+
/** Registered, non-control device names to record (the roster — names only). */
|
|
19
|
+
devices: string[];
|
|
20
|
+
/** Optional per-device agent specs (e.g. from `--from-pins`), keyed by name. */
|
|
21
|
+
agentsByDevice?: Record<string, string[]>;
|
|
22
|
+
/** Fleet defaults to seed when the manifest has none (source's own agents). */
|
|
23
|
+
defaults?: FleetDefaults;
|
|
24
|
+
/** Secrets-bundle NAMES to ensure exist (values stay in the keychain). */
|
|
25
|
+
secretsBundles?: string[];
|
|
26
|
+
/** Routine NAMES that should be active on the fleet. */
|
|
27
|
+
routines?: string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the new `fleet:` manifest from the previous one and captured inputs.
|
|
31
|
+
* Pure — no SSH, no filesystem, no registry. The returned object carries device
|
|
32
|
+
* names + desired state only; a caller that serializes it to YAML can assert no
|
|
33
|
+
* address/username ever appears.
|
|
34
|
+
*/
|
|
35
|
+
export declare function captureFleet(prev: FleetManifest | undefined, inputs: CaptureInputs): FleetManifest;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize the live environment into a `fleet:` manifest.
|
|
3
|
+
*
|
|
4
|
+
* `captureFleet` is PURE — it takes the previous manifest plus already-gathered
|
|
5
|
+
* inputs (device names, per-device agent specs, browser profiles, secret-bundle
|
|
6
|
+
* names, routine names) and returns the new manifest. All I/O (registry read,
|
|
7
|
+
* `agents secrets`/`routines` enumeration, YAML write) happens in the command
|
|
8
|
+
* (`commands/fleet-capture.ts`); keeping this pure makes the privacy contract —
|
|
9
|
+
* NAMES ONLY, never IPs/usernames — trivially unit-testable.
|
|
10
|
+
*
|
|
11
|
+
* Additive by design: it merges OVER an existing manifest and never clobbers a
|
|
12
|
+
* hand-authored per-device override (`agents:` you set by hand wins over a
|
|
13
|
+
* captured one). The captured roster reflects live state, so it becomes the
|
|
14
|
+
* source of truth for WHICH devices exist.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Build the new `fleet:` manifest from the previous one and captured inputs.
|
|
18
|
+
* Pure — no SSH, no filesystem, no registry. The returned object carries device
|
|
19
|
+
* names + desired state only; a caller that serializes it to YAML can assert no
|
|
20
|
+
* address/username ever appears.
|
|
21
|
+
*/
|
|
22
|
+
export function captureFleet(prev, inputs) {
|
|
23
|
+
const prevDevices = prev && prev.devices !== 'all' && typeof prev.devices === 'object'
|
|
24
|
+
? prev.devices
|
|
25
|
+
: {};
|
|
26
|
+
// Roster: explicit map of the captured names. A hand-authored override for a
|
|
27
|
+
// device that still exists is preserved; a captured agent list only fills in
|
|
28
|
+
// when the manifest didn't already pin one for that device.
|
|
29
|
+
const devices = {};
|
|
30
|
+
for (const name of inputs.devices) {
|
|
31
|
+
const prevOverride = prevDevices[name] ?? {};
|
|
32
|
+
const override = { ...prevOverride };
|
|
33
|
+
const captured = inputs.agentsByDevice?.[name];
|
|
34
|
+
if (override.agents === undefined && captured && captured.length > 0) {
|
|
35
|
+
override.agents = captured;
|
|
36
|
+
}
|
|
37
|
+
devices[name] = override;
|
|
38
|
+
}
|
|
39
|
+
const manifest = {
|
|
40
|
+
// Keep hand-authored defaults; otherwise seed from the source snapshot.
|
|
41
|
+
defaults: prev?.defaults ?? inputs.defaults ?? {},
|
|
42
|
+
devices,
|
|
43
|
+
};
|
|
44
|
+
const bundles = inputs.secretsBundles ?? prev?.secrets?.bundles;
|
|
45
|
+
if (bundles && bundles.length > 0)
|
|
46
|
+
manifest.secrets = { bundles: [...bundles].sort() };
|
|
47
|
+
const routines = inputs.routines ?? prev?.routines;
|
|
48
|
+
if (routines && routines.length > 0)
|
|
49
|
+
manifest.routines = [...routines].sort();
|
|
50
|
+
return manifest;
|
|
51
|
+
}
|
|
@@ -19,6 +19,12 @@ export interface ResolveContext {
|
|
|
19
19
|
registeredDevices: string[];
|
|
20
20
|
/** The source machine, always excluded from the target set. */
|
|
21
21
|
source: string;
|
|
22
|
+
/** Names the bootstrap could not resolve from Tailscale (off-tailnet, ignored,
|
|
23
|
+
* or a typo). These are SKIPPED with the caller's warning rather than aborting
|
|
24
|
+
* the whole reconcile — a manifest naming an asleep laptop must not hard-fail
|
|
25
|
+
* every other device. Without a bootstrap, this is empty and an unregistered
|
|
26
|
+
* name still throws (genuine misconfig, caught early). */
|
|
27
|
+
unresolved?: string[];
|
|
22
28
|
}
|
|
23
29
|
/**
|
|
24
30
|
* Expand a manifest into concrete per-device desired states. `devices: all`
|
|
@@ -66,7 +66,25 @@ export function parseFleetManifest(raw) {
|
|
|
66
66
|
else {
|
|
67
67
|
throw new Error(`fleet: devices must be the string 'all' or a mapping of device -> overrides (got ${JSON.stringify(o.devices)}).`);
|
|
68
68
|
}
|
|
69
|
-
|
|
69
|
+
const manifest = { defaults, devices };
|
|
70
|
+
// Additive, backward-compatible extras (captured by `agents fleet capture`).
|
|
71
|
+
if (o.secrets !== undefined) {
|
|
72
|
+
if (typeof o.secrets !== 'object' || o.secrets === null || Array.isArray(o.secrets)) {
|
|
73
|
+
throw new Error('fleet: secrets must be a mapping with a `bundles:` list.');
|
|
74
|
+
}
|
|
75
|
+
const bundles = o.secrets.bundles;
|
|
76
|
+
if (bundles !== undefined && !isStringArray(bundles)) {
|
|
77
|
+
throw new Error('fleet: secrets.bundles must be a list of bundle names (e.g. [attio]).');
|
|
78
|
+
}
|
|
79
|
+
manifest.secrets = { bundles: bundles };
|
|
80
|
+
}
|
|
81
|
+
if (o.routines !== undefined) {
|
|
82
|
+
if (!isStringArray(o.routines)) {
|
|
83
|
+
throw new Error('fleet: routines must be a list of routine names.');
|
|
84
|
+
}
|
|
85
|
+
manifest.routines = o.routines;
|
|
86
|
+
}
|
|
87
|
+
return manifest;
|
|
70
88
|
}
|
|
71
89
|
/** Read a YAML file and extract + validate its `fleet:` block. */
|
|
72
90
|
export function readFleetFile(filePath) {
|
|
@@ -115,9 +133,14 @@ export function resolveDesired(manifest, ctx) {
|
|
|
115
133
|
}
|
|
116
134
|
return out;
|
|
117
135
|
}
|
|
136
|
+
const unresolved = new Set(ctx.unresolved ?? []);
|
|
118
137
|
for (const [name, override] of Object.entries(manifest.devices)) {
|
|
119
138
|
if (name === ctx.source)
|
|
120
139
|
continue;
|
|
140
|
+
// Bootstrap couldn't register this name (off-tailnet / ignored / typo) —
|
|
141
|
+
// skip it (the caller already surfaced it) instead of aborting the run.
|
|
142
|
+
if (unresolved.has(name))
|
|
143
|
+
continue;
|
|
121
144
|
if (!ctx.registeredDevices.includes(name)) {
|
|
122
145
|
throw new Error(`fleet: device '${name}' is not a registered device. Run \`agents devices add ${name}\` or fix the manifest.`);
|
|
123
146
|
}
|
|
@@ -42,6 +42,23 @@ export interface FleetDeviceOverride {
|
|
|
42
42
|
export interface FleetManifest {
|
|
43
43
|
defaults?: FleetDefaults;
|
|
44
44
|
devices: 'all' | Record<string, FleetDeviceOverride>;
|
|
45
|
+
/**
|
|
46
|
+
* Fleet-wide extras captured by `agents fleet capture` so a fresh machine can
|
|
47
|
+
* reconstruct the whole environment, not just installed agents. All additive,
|
|
48
|
+
* portable, and LEAK-FREE — names only, never connection details.
|
|
49
|
+
*
|
|
50
|
+
* (Browser profiles are deliberately NOT captured here: the central `browser:`
|
|
51
|
+
* block already syncs verbatim via `agents repo push/pull`, so duplicating it
|
|
52
|
+
* would be redundant — and its ssh:// endpoints can carry `user@host`, which
|
|
53
|
+
* must never be copied into a second location.)
|
|
54
|
+
*/
|
|
55
|
+
/** Secrets-bundle NAMES to ensure exist — values live in the keychain and are
|
|
56
|
+
* never captured or pushed; `apply` surfaces missing bundles to recreate. */
|
|
57
|
+
secrets?: {
|
|
58
|
+
bundles?: string[];
|
|
59
|
+
};
|
|
60
|
+
/** Routine NAMES that should be active on the fleet (files sync via the repo). */
|
|
61
|
+
routines?: string[];
|
|
45
62
|
}
|
|
46
63
|
/**
|
|
47
64
|
* A device's desired state after merging defaults with its override and
|
|
@@ -74,7 +91,10 @@ export interface DeviceProbe {
|
|
|
74
91
|
note?: string;
|
|
75
92
|
}
|
|
76
93
|
/** One planned action against a device, in a single reconcile dimension. */
|
|
77
|
-
export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login'
|
|
94
|
+
export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login'
|
|
95
|
+
/** Secrets bundles the profile declares but that can't be pushed (values are
|
|
96
|
+
* keychain-local) — surfaced as a manual recreate, like `needs-login`. */
|
|
97
|
+
| 'needs-secret';
|
|
78
98
|
export interface FleetAction {
|
|
79
99
|
device: string;
|
|
80
100
|
kind: FleetActionKind;
|
|
@@ -101,6 +121,9 @@ export interface DeviceDiff {
|
|
|
101
121
|
/** Agents that must be logged in on the device but can't be propagated
|
|
102
122
|
* (source token is device-bound, e.g. macOS keychain). Surfaced, not faked. */
|
|
103
123
|
loginBlocked: string[];
|
|
124
|
+
/** Secrets-bundle names the profile declares that must be recreated on the
|
|
125
|
+
* device (values are keychain-local — never captured or pushed). Surfaced. */
|
|
126
|
+
secretsNeeded: string[];
|
|
104
127
|
}
|
|
105
128
|
/** A portable auth file captured from a source agent home, ready to propagate. */
|
|
106
129
|
export interface AuthFilePayload {
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -219,6 +219,20 @@ export declare function displayHomePath(dir: string): string;
|
|
|
219
219
|
* branch reconciles instead of failing with "Need to specify how to reconcile
|
|
220
220
|
* divergent branches".
|
|
221
221
|
*/
|
|
222
|
+
/**
|
|
223
|
+
* Auto-commit the machine's OWN generated per-device meta file
|
|
224
|
+
* (`devices/<machineId>/agents.yaml`) if it's the dirty state blocking a pull.
|
|
225
|
+
*
|
|
226
|
+
* That file is committed + synced (so every machine can introspect every other
|
|
227
|
+
* machine's pins), but each box rewrites its own copy whenever a pin changes —
|
|
228
|
+
* leaving the tree perpetually dirty and wedging `agents repo pull` (which
|
|
229
|
+
* refuses a dirty tree). This durably commits just that one path (explicit
|
|
230
|
+
* pathspec) so the pull can proceed; genuine user edits to OTHER files are left
|
|
231
|
+
* untouched and still (correctly) block the pull. No-op when the meta path isn't
|
|
232
|
+
* inside `dir` (system/extra repos) or isn't dirty. Returns the committed rel
|
|
233
|
+
* path, or null. `metaAbs` is injectable for tests; defaults to the live path.
|
|
234
|
+
*/
|
|
235
|
+
export declare function commitOwnDeviceMeta(dir: string, metaAbs?: string): Promise<string | null>;
|
|
222
236
|
export declare function pullRepo(dir: string): Promise<{
|
|
223
237
|
success: boolean;
|
|
224
238
|
commit: string;
|
package/dist/lib/git.js
CHANGED
|
@@ -10,7 +10,7 @@ import * as fs from 'fs';
|
|
|
10
10
|
import * as os from 'os';
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import { IS_WINDOWS, isWindowsAbsolutePath } from './platform/index.js';
|
|
13
|
-
import { getPackageLocalPath } from './state.js';
|
|
13
|
+
import { getPackageLocalPath, getDeviceMetaPath } from './state.js';
|
|
14
14
|
import { DEFAULT_SYSTEM_REPO, systemRepoSlug } from './types.js';
|
|
15
15
|
/**
|
|
16
16
|
* Validate that a clone/pull source uses a safe git transport before it is
|
|
@@ -733,9 +733,47 @@ export function displayHomePath(dir) {
|
|
|
733
733
|
* branch reconciles instead of failing with "Need to specify how to reconcile
|
|
734
734
|
* divergent branches".
|
|
735
735
|
*/
|
|
736
|
+
/**
|
|
737
|
+
* Auto-commit the machine's OWN generated per-device meta file
|
|
738
|
+
* (`devices/<machineId>/agents.yaml`) if it's the dirty state blocking a pull.
|
|
739
|
+
*
|
|
740
|
+
* That file is committed + synced (so every machine can introspect every other
|
|
741
|
+
* machine's pins), but each box rewrites its own copy whenever a pin changes —
|
|
742
|
+
* leaving the tree perpetually dirty and wedging `agents repo pull` (which
|
|
743
|
+
* refuses a dirty tree). This durably commits just that one path (explicit
|
|
744
|
+
* pathspec) so the pull can proceed; genuine user edits to OTHER files are left
|
|
745
|
+
* untouched and still (correctly) block the pull. No-op when the meta path isn't
|
|
746
|
+
* inside `dir` (system/extra repos) or isn't dirty. Returns the committed rel
|
|
747
|
+
* path, or null. `metaAbs` is injectable for tests; defaults to the live path.
|
|
748
|
+
*/
|
|
749
|
+
export async function commitOwnDeviceMeta(dir, metaAbs = getDeviceMetaPath()) {
|
|
750
|
+
const resolvedDir = path.resolve(dir);
|
|
751
|
+
const resolvedMeta = path.resolve(metaAbs);
|
|
752
|
+
if (resolvedMeta !== resolvedDir && !resolvedMeta.startsWith(resolvedDir + path.sep)) {
|
|
753
|
+
return null; // meta lives outside this repo — not ours to commit here
|
|
754
|
+
}
|
|
755
|
+
const rel = path.relative(resolvedDir, resolvedMeta).split(path.sep).join('/');
|
|
756
|
+
try {
|
|
757
|
+
const git = simpleGit(dir);
|
|
758
|
+
const status = await git.status();
|
|
759
|
+
const dirty = status.files.some((f) => f.path === rel);
|
|
760
|
+
if (!dirty)
|
|
761
|
+
return null;
|
|
762
|
+
await git.add([rel]);
|
|
763
|
+
const machine = path.basename(path.dirname(resolvedMeta));
|
|
764
|
+
await git.commit(`chore(devices): snapshot ${machine} agent pins`, [rel]);
|
|
765
|
+
return rel;
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
return null; // best-effort — never let this block the pull path
|
|
769
|
+
}
|
|
770
|
+
}
|
|
736
771
|
export async function pullRepo(dir) {
|
|
737
772
|
try {
|
|
738
773
|
const git = simpleGit(dir);
|
|
774
|
+
// Commit this machine's own device-meta first so a per-machine pin change
|
|
775
|
+
// never wedges the pull. Genuine edits elsewhere still block below.
|
|
776
|
+
await commitOwnDeviceMeta(dir);
|
|
739
777
|
const status = await git.status();
|
|
740
778
|
if (!status.isClean()) {
|
|
741
779
|
return {
|
package/dist/lib/hosts/logs.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* runs to be discoverable there first; until then the bounded tail is the safe
|
|
11
11
|
* concise default.) Kept in one place so the two commands can never drift.
|
|
12
12
|
*/
|
|
13
|
+
import { type HostTask } from './tasks.js';
|
|
13
14
|
export interface HostLogResult {
|
|
14
15
|
/** False when no host task with this id exists (caller may fall through to sessions). */
|
|
15
16
|
found: boolean;
|
|
@@ -21,5 +22,16 @@ export interface HostLogResult {
|
|
|
21
22
|
* default; `full` dumps the entire raw combined-stdout log.
|
|
22
23
|
*/
|
|
23
24
|
export declare function showHostTaskLog(id: string, follow: boolean, full?: boolean): Promise<HostLogResult>;
|
|
25
|
+
/**
|
|
26
|
+
* Machine-readable form of a host-dispatch task's log — the task record plus its
|
|
27
|
+
* combined stdout. Powers `agents logs <id> --json` for the host-task branch.
|
|
28
|
+
* Reconciles a still-'running' record from the remote `.exit` first, like the
|
|
29
|
+
* text path does.
|
|
30
|
+
*/
|
|
31
|
+
export declare function hostTaskLogJson(id: string): {
|
|
32
|
+
found: boolean;
|
|
33
|
+
task?: HostTask;
|
|
34
|
+
log?: string | null;
|
|
35
|
+
};
|
|
24
36
|
/** Last `n` lines of `text`, prefixed with an elision note when truncated. */
|
|
25
37
|
export declare function tailLines(text: string, n: number): string;
|