@phnx-labs/agents-cli 1.20.72 → 1.20.73

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.
Files changed (219) hide show
  1. package/CHANGELOG.md +299 -0
  2. package/README.md +40 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +12 -0
  5. package/dist/commands/activity.js +68 -0
  6. package/dist/commands/apply.d.ts +13 -0
  7. package/dist/commands/apply.js +28 -3
  8. package/dist/commands/browser.js +1 -1
  9. package/dist/commands/cloud.js +21 -11
  10. package/dist/commands/doctor.js +8 -4
  11. package/dist/commands/events.d.ts +11 -10
  12. package/dist/commands/events.js +30 -21
  13. package/dist/commands/exec.d.ts +34 -0
  14. package/dist/commands/exec.js +337 -39
  15. package/dist/commands/feed.d.ts +13 -0
  16. package/dist/commands/feed.js +130 -29
  17. package/dist/commands/hq.d.ts +2 -0
  18. package/dist/commands/hq.js +58 -0
  19. package/dist/commands/lease.d.ts +19 -0
  20. package/dist/commands/lease.js +138 -11
  21. package/dist/commands/login.d.ts +2 -0
  22. package/dist/commands/login.js +123 -0
  23. package/dist/commands/logs.js +1 -1
  24. package/dist/commands/mine.d.ts +27 -0
  25. package/dist/commands/mine.js +207 -0
  26. package/dist/commands/monitors.js +79 -49
  27. package/dist/commands/plugins.js +1 -0
  28. package/dist/commands/profiles.js +158 -0
  29. package/dist/commands/repo.js +2 -60
  30. package/dist/commands/resources.d.ts +5 -0
  31. package/dist/commands/resources.js +95 -0
  32. package/dist/commands/routines.d.ts +2 -0
  33. package/dist/commands/routines.js +139 -37
  34. package/dist/commands/secrets.js +234 -47
  35. package/dist/commands/send.d.ts +15 -0
  36. package/dist/commands/send.js +62 -0
  37. package/dist/commands/sessions-tail.d.ts +1 -0
  38. package/dist/commands/sessions-tail.js +20 -9
  39. package/dist/commands/sessions.js +18 -3
  40. package/dist/commands/setup-mine.d.ts +18 -0
  41. package/dist/commands/setup-mine.js +106 -0
  42. package/dist/commands/setup-share.js +2 -2
  43. package/dist/commands/setup.js +3 -1
  44. package/dist/commands/share.d.ts +8 -1
  45. package/dist/commands/share.js +122 -37
  46. package/dist/commands/ssh.d.ts +9 -0
  47. package/dist/commands/ssh.js +196 -26
  48. package/dist/commands/sync.js +1 -1
  49. package/dist/commands/teams.js +11 -2
  50. package/dist/index.js +79 -10
  51. package/dist/lib/activity.d.ts +124 -0
  52. package/dist/lib/activity.js +542 -0
  53. package/dist/lib/agents.js +16 -5
  54. package/dist/lib/artifact-actions.d.ts +1 -1
  55. package/dist/lib/artifact-actions.js +1 -1
  56. package/dist/lib/ask-classifier.d.ts +2 -1
  57. package/dist/lib/ask-classifier.js +3 -3
  58. package/dist/lib/auth-health.d.ts +32 -4
  59. package/dist/lib/auth-health.js +40 -1
  60. package/dist/lib/auto-pull.js +8 -1
  61. package/dist/lib/brand.d.ts +40 -0
  62. package/dist/lib/brand.js +122 -0
  63. package/dist/lib/browser/drivers/ssh.js +4 -7
  64. package/dist/lib/browser/service.js +6 -8
  65. package/dist/lib/channels/providers/index.d.ts +2 -0
  66. package/dist/lib/channels/providers/index.js +20 -0
  67. package/dist/lib/channels/providers/mailbox.d.ts +2 -0
  68. package/dist/lib/channels/providers/mailbox.js +26 -0
  69. package/dist/lib/channels/providers/openclaw-telegram.d.ts +2 -0
  70. package/dist/lib/channels/providers/openclaw-telegram.js +34 -0
  71. package/dist/lib/channels/providers/rush.d.ts +6 -0
  72. package/dist/lib/channels/providers/rush.js +55 -0
  73. package/dist/lib/channels/registry.d.ts +42 -0
  74. package/dist/lib/channels/registry.js +20 -0
  75. package/dist/lib/channels/resolve.d.ts +11 -0
  76. package/dist/lib/channels/resolve.js +12 -0
  77. package/dist/lib/cli-resources.d.ts +18 -0
  78. package/dist/lib/cli-resources.js +53 -4
  79. package/dist/lib/cloud/codex.js +6 -3
  80. package/dist/lib/cloud/factory.d.ts +1 -0
  81. package/dist/lib/cloud/factory.js +10 -4
  82. package/dist/lib/cloud/rush.d.ts +3 -1
  83. package/dist/lib/cloud/rush.js +6 -1
  84. package/dist/lib/cloud/types.d.ts +2 -0
  85. package/dist/lib/codex-home.d.ts +35 -0
  86. package/dist/lib/codex-home.js +136 -0
  87. package/dist/lib/crabbox/cli.d.ts +44 -0
  88. package/dist/lib/crabbox/cli.js +120 -20
  89. package/dist/lib/crabbox/lease.d.ts +52 -1
  90. package/dist/lib/crabbox/lease.js +117 -16
  91. package/dist/lib/crabbox/progress.d.ts +31 -0
  92. package/dist/lib/crabbox/progress.js +76 -0
  93. package/dist/lib/crabbox/runtimes.d.ts +4 -0
  94. package/dist/lib/crabbox/runtimes.js +33 -8
  95. package/dist/lib/crabbox/setup-copy.d.ts +87 -0
  96. package/dist/lib/crabbox/setup-copy.js +127 -0
  97. package/dist/lib/daemon.d.ts +5 -5
  98. package/dist/lib/daemon.js +9 -9
  99. package/dist/lib/drive-sync.js +6 -3
  100. package/dist/lib/event-stream.d.ts +36 -0
  101. package/dist/lib/event-stream.js +68 -0
  102. package/dist/lib/events.d.ts +1 -1
  103. package/dist/lib/exec.d.ts +11 -0
  104. package/dist/lib/exec.js +23 -2
  105. package/dist/lib/feed-policy.d.ts +1 -1
  106. package/dist/lib/feed-policy.js +14 -9
  107. package/dist/lib/feed-ranking.d.ts +32 -0
  108. package/dist/lib/feed-ranking.js +224 -0
  109. package/dist/lib/feed.d.ts +35 -2
  110. package/dist/lib/feed.js +66 -3
  111. package/dist/lib/fleet/auth-sync.d.ts +53 -0
  112. package/dist/lib/fleet/auth-sync.js +92 -0
  113. package/dist/lib/fleet/remote-login.d.ts +187 -0
  114. package/dist/lib/fleet/remote-login.js +556 -0
  115. package/dist/lib/format.d.ts +30 -3
  116. package/dist/lib/format.js +34 -5
  117. package/dist/lib/fs-atomic.d.ts +7 -2
  118. package/dist/lib/fs-atomic.js +8 -12
  119. package/dist/lib/git.d.ts +16 -0
  120. package/dist/lib/git.js +79 -2
  121. package/dist/lib/heal.js +11 -3
  122. package/dist/lib/hosts/progress.d.ts +28 -10
  123. package/dist/lib/hosts/progress.js +79 -22
  124. package/dist/lib/hosts/remote-cmd.js +4 -0
  125. package/dist/lib/hq/floor.d.ts +87 -0
  126. package/dist/lib/hq/floor.js +226 -0
  127. package/dist/lib/menubar/install-menubar.js +14 -20
  128. package/dist/lib/notify.d.ts +1 -0
  129. package/dist/lib/notify.js +3 -3
  130. package/dist/lib/open-url.d.ts +2 -0
  131. package/dist/lib/open-url.js +19 -0
  132. package/dist/lib/openclaw-keychain.d.ts +56 -0
  133. package/dist/lib/openclaw-keychain.js +236 -0
  134. package/dist/lib/overdue.js +10 -0
  135. package/dist/lib/permissions.d.ts +44 -1
  136. package/dist/lib/permissions.js +284 -7
  137. package/dist/lib/project-launch.d.ts +6 -12
  138. package/dist/lib/project-launch.js +13 -228
  139. package/dist/lib/project-resources.d.ts +7 -0
  140. package/dist/lib/project-resources.js +291 -0
  141. package/dist/lib/pty-client.d.ts +27 -0
  142. package/dist/lib/pty-client.js +136 -10
  143. package/dist/lib/refresh.js +14 -10
  144. package/dist/lib/resource-profiles.d.ts +26 -0
  145. package/dist/lib/resource-profiles.js +157 -0
  146. package/dist/lib/resources/permissions.js +7 -1
  147. package/dist/lib/resources/types.d.ts +1 -1
  148. package/dist/lib/resources.d.ts +1 -1
  149. package/dist/lib/resources.js +32 -3
  150. package/dist/lib/routines.d.ts +11 -0
  151. package/dist/lib/routines.js +56 -15
  152. package/dist/lib/runner.d.ts +1 -0
  153. package/dist/lib/runner.js +66 -5
  154. package/dist/lib/secrets/bundles.d.ts +16 -3
  155. package/dist/lib/secrets/bundles.js +206 -37
  156. package/dist/lib/secrets/icloud-import.d.ts +2 -2
  157. package/dist/lib/secrets/icloud-import.js +9 -6
  158. package/dist/lib/secrets/mcp.js +1 -1
  159. package/dist/lib/secrets/vault-age-helper.d.ts +1 -0
  160. package/dist/lib/secrets/vault-age-helper.js +34 -0
  161. package/dist/lib/secrets/vault.d.ts +49 -0
  162. package/dist/lib/secrets/vault.js +397 -0
  163. package/dist/lib/self-heal/checks/path.js +3 -1
  164. package/dist/lib/self-heal/checks/shadowing.js +10 -2
  165. package/dist/lib/self-heal/checks/shims.js +19 -11
  166. package/dist/lib/session/cloud.d.ts +2 -2
  167. package/dist/lib/session/cloud.js +2 -2
  168. package/dist/lib/session/db.d.ts +4 -0
  169. package/dist/lib/session/db.js +44 -7
  170. package/dist/lib/session/discover.d.ts +2 -0
  171. package/dist/lib/session/discover.js +114 -3
  172. package/dist/lib/session/stream-render.d.ts +3 -0
  173. package/dist/lib/session/stream-render.js +130 -0
  174. package/dist/lib/session/types.d.ts +6 -0
  175. package/dist/lib/share/analytics.d.ts +13 -0
  176. package/dist/lib/share/analytics.js +45 -0
  177. package/dist/lib/share/config.d.ts +19 -5
  178. package/dist/lib/share/config.js +44 -8
  179. package/dist/lib/share/provision.d.ts +58 -6
  180. package/dist/lib/share/provision.js +97 -22
  181. package/dist/lib/share/publish.d.ts +36 -13
  182. package/dist/lib/share/publish.js +55 -8
  183. package/dist/lib/share/worker-template.js +83 -17
  184. package/dist/lib/shims.d.ts +9 -0
  185. package/dist/lib/shims.js +91 -13
  186. package/dist/lib/ssh-exec.d.ts +14 -0
  187. package/dist/lib/ssh-exec.js +57 -0
  188. package/dist/lib/staleness/detectors/hooks.js +25 -1
  189. package/dist/lib/staleness/detectors/permissions.js +66 -0
  190. package/dist/lib/staleness/detectors/workflows.js +20 -0
  191. package/dist/lib/staleness/writers/hooks.js +58 -4
  192. package/dist/lib/staleness/writers/permissions.js +2 -2
  193. package/dist/lib/staleness/writers/skills.js +1 -1
  194. package/dist/lib/staleness/writers/sources.d.ts +10 -1
  195. package/dist/lib/staleness/writers/sources.js +68 -1
  196. package/dist/lib/star-nudge.d.ts +45 -0
  197. package/dist/lib/star-nudge.js +91 -0
  198. package/dist/lib/startup/command-registry.d.ts +6 -1
  199. package/dist/lib/startup/command-registry.js +17 -3
  200. package/dist/lib/state.d.ts +2 -0
  201. package/dist/lib/state.js +3 -0
  202. package/dist/lib/subagents-registry.d.ts +2 -0
  203. package/dist/lib/subagents-registry.js +3 -0
  204. package/dist/lib/teams/parsers.js +214 -6
  205. package/dist/lib/teams/supervisor.d.ts +7 -0
  206. package/dist/lib/teams/supervisor.js +2 -1
  207. package/dist/lib/template.d.ts +1 -1
  208. package/dist/lib/template.js +1 -1
  209. package/dist/lib/triggers/webhook.js +36 -3
  210. package/dist/lib/types.d.ts +73 -0
  211. package/dist/lib/version.d.ts +40 -0
  212. package/dist/lib/version.js +94 -16
  213. package/dist/lib/versions.d.ts +11 -0
  214. package/dist/lib/versions.js +208 -76
  215. package/dist/lib/workflows.d.ts +2 -0
  216. package/dist/lib/workflows.js +88 -0
  217. package/package.json +2 -1
  218. package/dist/commands/daemon.d.ts +0 -10
  219. package/dist/commands/daemon.js +0 -121
@@ -0,0 +1,556 @@
1
+ /**
2
+ * `agents fleet login` — remote device-code OAuth orchestration.
3
+ *
4
+ * WHY this exists: copying one OAuth credential file across N boxes is fatal — a
5
+ * shared refresh token rotates server-side on first refresh and invalidates the
6
+ * other copies (droid collapsed 10 boxes -> 1 overnight). The durable fix is a
7
+ * per-machine credential: an interactive login per (agent x box). This module
8
+ * makes that bearable by driving every box's device-code flow over SSH, scraping
9
+ * each verification URL + user code, and surfacing them all in ONE local browser
10
+ * page so the human enters codes back-to-back instead of babysitting N terminals.
11
+ *
12
+ * Layering — pure vs I/O, so the valuable logic is unit-testable without SSH:
13
+ * - `scrapeLogin` / `classifyLoginFlow` / `selectLoginTargets` /
14
+ * `buildRemoteLoginSshCommand` / `buildDashboardHtml` are PURE.
15
+ * - `driveRemoteLogin` drives a PTY through an injectable {@link PtyDriver}
16
+ * (fake in tests; real over the pty sidecar in prod).
17
+ * - `detectPending` / `runFleetLogin` do the real fleet I/O.
18
+ *
19
+ * The device codes have a ~15 min TTL, so the default (bulk) mode requests every
20
+ * code concurrently and shows them at once, while `--interactive` drives one box
21
+ * at a time — requesting each code just-in-time so it can't expire while the
22
+ * human works through the earlier ones.
23
+ */
24
+ import * as http from 'http';
25
+ import { isControlDevice } from '../devices/registry.js';
26
+ import { loadDevices } from '../devices/registry.js';
27
+ import { fleetDialTarget } from '../devices/connect.js';
28
+ import { planFleetTargets } from '../devices/fleet.js';
29
+ import { assertValidSshTarget, shellQuote, sshExecAsync } from '../ssh-exec.js';
30
+ import { machineId } from '../session/sync/config.js';
31
+ import { ptyRequest } from '../pty-client.js';
32
+ import { openUrl } from '../open-url.js';
33
+ import { readAuthHealthCache, } from '../auth-health.js';
34
+ import { FLEET_LOGIN_FLOWS, KEYCHAIN_BOUND_ON_MAC, } from './auth-sync.js';
35
+ /**
36
+ * Extract the verification URL and user code from an ANSI-stripped login screen
37
+ * using the flow's regexes. Pure — no I/O, so it is the highest-value unit test
38
+ * target (a wrong code boundary or a missed URL is exactly the bug class this
39
+ * whole feature turns on). Group 1 is preferred over the whole match so a
40
+ * surrounding phrase ("enter code XXXX-XXXX") never leaks into the captured
41
+ * value. A flow with no regex for a field yields `undefined` for that field.
42
+ */
43
+ export function scrapeLogin(screenText, flow) {
44
+ const out = {};
45
+ if (flow.verificationUrlRegex) {
46
+ const m = screenText.match(flow.verificationUrlRegex);
47
+ if (m)
48
+ out.url = (m[1] ?? m[0]).trim();
49
+ }
50
+ if (flow.userCodeRegex) {
51
+ const m = screenText.match(flow.userCodeRegex);
52
+ if (m)
53
+ out.code = (m[1] ?? m[0]).trim();
54
+ }
55
+ return out;
56
+ }
57
+ /**
58
+ * Decide whether `agent`'s login can be driven on a box of `platform`. Only a
59
+ * `device-code` flow is remotable; loopback (browser + 127.0.0.1 on the box),
60
+ * api-key, unknown, or a macOS keychain-bound token are surfaced with an honest
61
+ * reason instead of a mis-drive. Pure.
62
+ */
63
+ export function classifyLoginFlow(agent, platform) {
64
+ const flow = FLEET_LOGIN_FLOWS[agent] ?? null;
65
+ if (!flow)
66
+ return { flow: null, remotable: false, reason: 'no login flow defined' };
67
+ if (platform === 'macos' && KEYCHAIN_BOUND_ON_MAC.has(agent)) {
68
+ return { flow, remotable: false, reason: 'keychain-bound on macOS (log in on the box itself)' };
69
+ }
70
+ switch (flow.flowType) {
71
+ case 'device-code':
72
+ return { flow, remotable: true };
73
+ case 'loopback':
74
+ return { flow, remotable: false, reason: 'loopback OAuth — needs a browser on the box' };
75
+ case 'api-key':
76
+ return { flow, remotable: false, reason: 'api-key login — no device code' };
77
+ case 'unknown':
78
+ default:
79
+ return { flow, remotable: false, reason: 'login flow not yet characterized' };
80
+ }
81
+ }
82
+ /**
83
+ * Does a cached auth verdict mean "this (host, agent) needs an interactive
84
+ * login"? A missing row (never signed in / credential absent) and a `revoked`
85
+ * token do; `live`, `unverified` (signed in, no probe), `expired`, and
86
+ * `rate_limited` are treated as already-logged-in (expired self-heals on next
87
+ * launch — never re-login on it). Pure.
88
+ */
89
+ export function isPendingVerdict(verdict) {
90
+ return verdict === undefined || verdict === 'revoked';
91
+ }
92
+ /** The worst (most-actionable) verdict cached for a host+agent across versions. */
93
+ function worstCachedVerdict(cache, host, agent) {
94
+ const prefix = `${host}:${agent}:`;
95
+ let best;
96
+ for (const [key, health] of Object.entries(cache)) {
97
+ if (!key.startsWith(prefix))
98
+ continue;
99
+ // A revoked row always wins (most actionable); otherwise the first live/other.
100
+ if (health.verdict === 'revoked')
101
+ return 'revoked';
102
+ if (best === undefined)
103
+ best = health.verdict;
104
+ }
105
+ return best;
106
+ }
107
+ /**
108
+ * From a device list, requested agents, and the auth-health cache, compute the
109
+ * pending logins — partitioned (via {@link classifyLoginFlow}) into remotable
110
+ * device-code flows and non-remotable pairs with a reason. Pure: the caller
111
+ * supplies the already-loaded devices + cache, so this is fully unit-testable.
112
+ *
113
+ * A pair is pending when its cached verdict {@link isPendingVerdict} (or
114
+ * `includeLoggedIn` forces it). Agents with no defined login flow are dropped
115
+ * silently (nothing to log into). Control devices are excluded by the caller.
116
+ */
117
+ export function selectLoginTargets(devices, agents, cache, opts = {}) {
118
+ const out = [];
119
+ for (const device of devices) {
120
+ for (const agent of agents) {
121
+ const { flow, remotable, reason } = classifyLoginFlow(agent, device.platform);
122
+ if (!flow)
123
+ continue; // agent has no login flow — nothing to do
124
+ const verdict = worstCachedVerdict(cache, device.name, agent);
125
+ if (!opts.includeLoggedIn && !isPendingVerdict(verdict))
126
+ continue;
127
+ out.push({
128
+ device: device.name,
129
+ agent,
130
+ platform: device.platform,
131
+ target: fleetDialTarget(device),
132
+ flow,
133
+ remotable,
134
+ reason,
135
+ });
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+ /**
141
+ * Build the `ssh -tt <target> <loginCommand>` command string driven through the
142
+ * PTY sidecar. `-tt` forces a tty so the remote CLI enters its interactive
143
+ * device-code flow; `accept-new` learns a first-seen host key without a prompt.
144
+ * Both the target and the login command are shell-quoted. Pure/testable.
145
+ *
146
+ * NOTE: this uses key-based ssh auth (the tailnet fleet's norm). Password-bundle
147
+ * devices need the askpass shim env, which the PTY env allowlist strips — those
148
+ * are a follow-up (TODO), surfaced as an error rather than silently mis-driven.
149
+ */
150
+ export function buildRemoteLoginSshCommand(target, flow) {
151
+ assertValidSshTarget(target);
152
+ // The agent CLIs (kimi/droid/codex/…) are agents-cli shims that live in
153
+ // ~/.agents/.cache/shims and are only on PATH in an interactive/login shell.
154
+ // `ssh <box> <cmd>` runs a NON-login shell, so a bare `kimi` is "command not
155
+ // found" and the login program never launches (the scrape then times out).
156
+ // Prepend the shim dir — resolved on the REMOTE via $HOME, single-quoted so
157
+ // $HOME/$PATH expand on the box, not locally — so the login command resolves.
158
+ const remoteCmd = `PATH="$HOME/.agents/.cache/shims:$PATH" ${flow.loginCommand}`;
159
+ return [
160
+ 'ssh', '-tt',
161
+ '-o', 'StrictHostKeyChecking=accept-new',
162
+ '-o', 'ConnectTimeout=10',
163
+ shellQuote(target),
164
+ shellQuote(remoteCmd),
165
+ ].join(' ');
166
+ }
167
+ function esc(s) {
168
+ return s
169
+ .replace(/&/g, '&amp;')
170
+ .replace(/</g, '&lt;')
171
+ .replace(/>/g, '&gt;')
172
+ .replace(/"/g, '&quot;');
173
+ }
174
+ /**
175
+ * Render the self-contained dashboard page. Inlines all CSS/JS; the page polls
176
+ * `/api/status` and flips rows to their live state. `bulk` shows a grid of every
177
+ * pending login at once; `interactive` shows a one-at-a-time wizard. Pure — a
178
+ * string in, a string out — so it renders (and is asserted) without a server.
179
+ * Terminal-coded per the agents-cli brand (#0a0a0a bg, #a3e635 lime), with a
180
+ * light variant via `prefers-color-scheme`.
181
+ */
182
+ export function buildDashboardHtml(pending, mode) {
183
+ const seed = pending.map((p) => ({
184
+ device: p.device,
185
+ agent: p.agent,
186
+ remotable: p.remotable,
187
+ reason: p.reason,
188
+ state: (p.remotable ? 'pending' : 'skipped'),
189
+ }));
190
+ const seedJson = JSON.stringify(seed);
191
+ const remotableCount = pending.filter((p) => p.remotable).length;
192
+ const subtitle = mode === 'interactive'
193
+ ? 'Guided login — one box at a time, codes requested just-in-time.'
194
+ : 'All pending logins — enter each code in a browser tab, back to back.';
195
+ return `<!doctype html>
196
+ <html lang="en">
197
+ <head>
198
+ <meta charset="utf-8">
199
+ <meta name="viewport" content="width=device-width, initial-scale=1">
200
+ <title>agents fleet login</title>
201
+ <style>
202
+ :root {
203
+ --bg: #0a0a0a; --panel: #141414; --border: #262626; --fg: #e5e5e5;
204
+ --muted: #8a8a8a; --accent: #a3e635; --ready: #38bdf8; --ok: #22c55e;
205
+ --warn: #f59e0b; --err: #ef4444; --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
206
+ }
207
+ @media (prefers-color-scheme: light) {
208
+ :root { --bg: #fafafa; --panel: #fff; --border: #e5e5e5; --fg: #171717; --muted: #6b7280; }
209
+ }
210
+ * { box-sizing: border-box; }
211
+ body { margin: 0; background: var(--bg); color: var(--fg); font-family: Inter, system-ui, sans-serif; }
212
+ .wrap { max-width: 920px; margin: 0 auto; padding: 32px 20px 64px; }
213
+ header h1 { font-family: var(--mono); font-size: 20px; margin: 0 0 4px; }
214
+ header h1 .lime { color: var(--accent); }
215
+ header p { color: var(--muted); margin: 0 0 6px; font-size: 14px; }
216
+ .meta { color: var(--muted); font-size: 12px; font-family: var(--mono); margin-bottom: 24px; }
217
+ .grid { display: grid; gap: 12px; }
218
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
219
+ .card.done { border-color: var(--ok); }
220
+ .card.err { border-color: var(--err); }
221
+ .card.skip { opacity: 0.6; }
222
+ .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
223
+ .who { font-family: var(--mono); font-size: 14px; }
224
+ .who .agent { color: var(--accent); }
225
+ .who .at { color: var(--muted); }
226
+ .state { font-family: var(--mono); font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); }
227
+ .state.pending { color: var(--muted); }
228
+ .state.driving { color: var(--warn); border-color: var(--warn); }
229
+ .state.ready { color: var(--ready); border-color: var(--ready); }
230
+ .state.authorized { color: var(--ok); border-color: var(--ok); }
231
+ .state.error { color: var(--err); border-color: var(--err); }
232
+ .code-line { margin-top: 12px; display: none; align-items: center; gap: 12px; flex-wrap: wrap; }
233
+ .code-line.show { display: flex; }
234
+ .code { font-family: var(--mono); font-size: 22px; letter-spacing: 2px; color: var(--fg); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 6px 14px; }
235
+ a.auth-btn { text-decoration: none; background: var(--accent); color: #0a0a0a; font-weight: 600; font-family: var(--mono); font-size: 13px; padding: 8px 14px; border-radius: 8px; }
236
+ button.copy { font-family: var(--mono); font-size: 12px; background: transparent; color: var(--fg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; cursor: pointer; }
237
+ .ttl { font-family: var(--mono); font-size: 12px; color: var(--muted); }
238
+ .reason { color: var(--muted); font-size: 12px; margin-top: 6px; }
239
+ .empty { color: var(--muted); font-family: var(--mono); }
240
+ </style>
241
+ </head>
242
+ <body>
243
+ <div class="wrap">
244
+ <header>
245
+ <h1>agents <span class="lime">fleet login</span></h1>
246
+ <p>${esc(subtitle)}</p>
247
+ <div class="meta">mode: ${mode} &middot; ${remotableCount} remotable &middot; <span id="tick"></span></div>
248
+ </header>
249
+ <div id="grid" class="grid"></div>
250
+ </div>
251
+ <script>
252
+ const SEED = ${seedJson};
253
+ const MODE = ${JSON.stringify(mode)};
254
+ const keyOf = (s) => s.device + '::' + s.agent;
255
+ let statuses = {};
256
+ for (const s of SEED) statuses[keyOf(s)] = s;
257
+
258
+ function fmtTtl(expiresAt) {
259
+ if (!expiresAt) return '';
260
+ const ms = expiresAt - Date.now();
261
+ if (ms <= 0) return 'expired';
262
+ const m = Math.floor(ms / 60000), sec = Math.floor((ms % 60000) / 1000);
263
+ return 'expires in ' + m + 'm ' + String(sec).padStart(2, '0') + 's';
264
+ }
265
+
266
+ function render() {
267
+ const grid = document.getElementById('grid');
268
+ const items = Object.values(statuses);
269
+ if (items.length === 0) { grid.innerHTML = '<div class="empty">Nothing pending — every requested account is already logged in.</div>'; return; }
270
+ grid.innerHTML = '';
271
+ for (const s of items) {
272
+ const card = document.createElement('div');
273
+ card.className = 'card' + (s.state === 'authorized' ? ' done' : s.state === 'error' ? ' err' : s.state === 'skipped' ? ' skip' : '');
274
+ const stateCls = s.state;
275
+ const stateText = s.state === 'skipped' ? 'not remotable' : s.state;
276
+ card.innerHTML =
277
+ '<div class="row">' +
278
+ '<span class="who"><span class="agent">' + s.agent + '</span><span class="at"> @ </span>' + s.device + '</span>' +
279
+ '<span class="state ' + stateCls + '">' + stateText + '</span>' +
280
+ '</div>' +
281
+ (s.reason && !s.remotable ? '<div class="reason">' + s.reason + '</div>' : '') +
282
+ (s.detail ? '<div class="reason">' + s.detail + '</div>' : '');
283
+ if (s.url && s.code && s.state !== 'authorized') {
284
+ const line = document.createElement('div');
285
+ line.className = 'code-line show';
286
+ const sep = s.url.indexOf('?') === -1 ? '?' : '&';
287
+ const deep = s.url + sep + 'user_code=' + encodeURIComponent(s.code);
288
+ line.innerHTML =
289
+ '<span class="code">' + s.code + '</span>' +
290
+ '<button class="copy" data-code="' + s.code + '">copy code</button>' +
291
+ '<a class="auth-btn" href="' + deep + '" target="_blank" rel="noopener">Authorize &rarr;</a>' +
292
+ '<span class="ttl" data-exp="' + (s.expiresAt || '') + '">' + fmtTtl(s.expiresAt) + '</span>';
293
+ card.appendChild(line);
294
+ }
295
+ grid.appendChild(card);
296
+ }
297
+ grid.querySelectorAll('button.copy').forEach((b) => {
298
+ b.addEventListener('click', () => { navigator.clipboard.writeText(b.getAttribute('data-code')); b.textContent = 'copied'; });
299
+ });
300
+ }
301
+
302
+ async function poll() {
303
+ try {
304
+ const res = await fetch('/api/status');
305
+ const data = await res.json();
306
+ for (const s of data.statuses) statuses[keyOf(s)] = s;
307
+ } catch (e) { /* transient */ }
308
+ render();
309
+ }
310
+
311
+ function tick() {
312
+ document.getElementById('tick').textContent = new Date().toLocaleTimeString();
313
+ document.querySelectorAll('.ttl[data-exp]').forEach((el) => {
314
+ const exp = Number(el.getAttribute('data-exp'));
315
+ if (exp) el.textContent = fmtTtl(exp);
316
+ });
317
+ }
318
+
319
+ render();
320
+ poll();
321
+ setInterval(poll, 2000);
322
+ setInterval(tick, 1000);
323
+ tick();
324
+ </script>
325
+ </body>
326
+ </html>`;
327
+ }
328
+ /** Real driver over the pty sidecar (`ptyRequest`). */
329
+ export function defaultPtyDriver() {
330
+ const expectOk = (res, what) => {
331
+ if (!res.ok)
332
+ throw new Error(`pty ${what} failed: ${res.error ?? 'unknown'}`);
333
+ };
334
+ return {
335
+ async start(opts) {
336
+ const res = await ptyRequest('start', undefined, { rows: opts?.rows ?? 40, cols: opts?.cols ?? 120 });
337
+ expectOk(res, 'start');
338
+ return res.id;
339
+ },
340
+ async exec(id, command) {
341
+ expectOk(await ptyRequest('exec', id, { command }), 'exec');
342
+ },
343
+ async write(id, input) {
344
+ expectOk(await ptyRequest('write', id, { input }), 'write');
345
+ },
346
+ async screen(id) {
347
+ const res = await ptyRequest('screen', id);
348
+ expectOk(res, 'screen');
349
+ return { screen: res.screen ?? '', exited: Boolean(res.exited) };
350
+ },
351
+ async stop(id) {
352
+ await ptyRequest('stop', id).catch(() => undefined);
353
+ },
354
+ };
355
+ }
356
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
357
+ /**
358
+ * Drive one remote device-code login: launch `ssh -tt <target> <loginCommand>`
359
+ * in a PTY, send the flow's `deviceCodeSelect` keystrokes (if any) to reach the
360
+ * device-code path, then poll the screen until {@link scrapeLogin} yields both a
361
+ * URL and a code (or the deadline / session-exit). The human completes the code
362
+ * in the browser; the caller polls the credential file for completion. Testable
363
+ * against a fake {@link PtyDriver}.
364
+ */
365
+ export async function driveRemoteLogin(target, flow, driver, opts = {}) {
366
+ const initialDelayMs = opts.initialDelayMs ?? 4000;
367
+ const pollMs = opts.pollMs ?? 1000;
368
+ const timeoutMs = opts.timeoutMs ?? 90000;
369
+ const id = await driver.start();
370
+ // Everything past start() must tear the session down on a mid-flight throw —
371
+ // otherwise the caller never receives a sessionId and the PTY (and the
372
+ // `ssh -tt` process it drives) leaks until the sidecar's idle reaper. On the
373
+ // normal return paths the caller owns stop() via the returned sessionId.
374
+ try {
375
+ await driver.exec(id, buildRemoteLoginSshCommand(target, flow));
376
+ await sleep(initialDelayMs);
377
+ if (flow.deviceCodeSelect) {
378
+ await driver.write(id, flow.deviceCodeSelect);
379
+ }
380
+ const deadline = Date.now() + timeoutMs;
381
+ let last = {};
382
+ for (;;) {
383
+ const { screen, exited } = await driver.screen(id);
384
+ const scraped = scrapeLogin(screen, flow);
385
+ if (scraped.url)
386
+ last.url = scraped.url;
387
+ if (scraped.code)
388
+ last.code = scraped.code;
389
+ if (last.url && last.code)
390
+ return { ...last, exited, sessionId: id };
391
+ if (exited)
392
+ return { ...last, exited: true, sessionId: id };
393
+ if (Date.now() >= deadline)
394
+ return { ...last, exited: false, sessionId: id };
395
+ await sleep(pollMs);
396
+ }
397
+ }
398
+ catch (e) {
399
+ await driver.stop(id).catch(() => { });
400
+ throw e;
401
+ }
402
+ }
403
+ /**
404
+ * Resolve the online (non-control) fleet devices, then compute pending logins
405
+ * from the shared auth-health cache (populated by `agents fleet ping`). Thin I/O
406
+ * over the pure {@link selectLoginTargets}. Devices/agents can be narrowed by the
407
+ * caller's flags; the default agent set is every agent with a defined flow.
408
+ */
409
+ export async function detectPending(opts = {}) {
410
+ const reg = await loadDevices();
411
+ const self = machineId();
412
+ const online = planFleetTargets(reg)
413
+ .filter((t) => !t.skip && t.device.name !== self && !isControlDevice(t.device))
414
+ .map((t) => t.device);
415
+ const deviceFilter = opts.devices && opts.devices.length > 0 ? new Set(opts.devices) : null;
416
+ const devices = deviceFilter ? online.filter((d) => deviceFilter.has(d.name)) : online;
417
+ const agents = opts.agents && opts.agents.length > 0
418
+ ? opts.agents
419
+ : Object.keys(FLEET_LOGIN_FLOWS);
420
+ const cache = readAuthHealthCache();
421
+ return selectLoginTargets(devices, agents, cache, { includeLoggedIn: opts.includeLoggedIn });
422
+ }
423
+ /**
424
+ * Best-effort epoch-seconds mtime of a home-relative file on a remote box, or 0
425
+ * when it is absent/unreadable. Used to detect the credential file appearing /
426
+ * bumping after the human completes the browser step. POSIX `stat` only (the
427
+ * device-code agents are all POSIX-fleet).
428
+ */
429
+ export async function remoteFileMtime(target, homeRel) {
430
+ const cmd = `stat -c %Y "$HOME/${homeRel}" 2>/dev/null || stat -f %m "$HOME/${homeRel}" 2>/dev/null || echo 0`;
431
+ const res = await sshExecAsync(target, cmd, { timeoutMs: 15000, multiplex: true }).catch(() => null);
432
+ if (!res || res.code !== 0)
433
+ return 0;
434
+ const n = parseInt(res.stdout.trim(), 10);
435
+ return Number.isFinite(n) ? n : 0;
436
+ }
437
+ const DEVICE_CODE_TTL_MS = 15 * 60 * 1000;
438
+ /**
439
+ * Orchestrate `agents fleet login`: detect pending -> serve the local dashboard
440
+ * + open the browser -> drive each box's device-code flow (concurrent in bulk,
441
+ * sequential just-in-time in interactive) -> poll the credential file for
442
+ * completion -> print a final matrix. The pure pieces above carry the logic;
443
+ * this wires them to the live fleet.
444
+ */
445
+ export async function runFleetLogin(opts = {}) {
446
+ const pending = await detectPending({ agents: opts.agents, devices: opts.devices, includeLoggedIn: opts.all });
447
+ if (pending.length === 0)
448
+ return [];
449
+ const mode = opts.interactive ? 'interactive' : 'bulk';
450
+ const codeTtl = opts.codeTtlMs ?? DEVICE_CODE_TTL_MS;
451
+ const key = (device, agent) => `${device}::${agent}`;
452
+ const statuses = new Map();
453
+ for (const p of pending) {
454
+ statuses.set(key(p.device, p.agent), {
455
+ device: p.device,
456
+ agent: String(p.agent),
457
+ remotable: p.remotable,
458
+ reason: p.reason,
459
+ state: p.remotable ? 'pending' : 'skipped',
460
+ });
461
+ }
462
+ // Local dashboard server (loopback only; read-only status endpoint).
463
+ const server = http.createServer((req, res) => {
464
+ const host = (req.headers.host ?? '').replace(/:\d+$/, '').toLowerCase();
465
+ if (host && host !== 'localhost' && host !== '127.0.0.1' && host !== '[::1]' && host !== '::1') {
466
+ res.writeHead(403, { 'content-type': 'text/plain' });
467
+ res.end('forbidden');
468
+ return;
469
+ }
470
+ if (req.method !== 'GET') {
471
+ res.writeHead(405).end('method not allowed');
472
+ return;
473
+ }
474
+ const url = (req.url ?? '/').split('?')[0];
475
+ if (url === '/' || url === '/index.html') {
476
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
477
+ res.end(buildDashboardHtml(pending, mode));
478
+ return;
479
+ }
480
+ if (url === '/api/status') {
481
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
482
+ res.end(JSON.stringify({ statuses: [...statuses.values()] }));
483
+ return;
484
+ }
485
+ res.writeHead(404).end('not found');
486
+ });
487
+ const boundPort = await new Promise((resolve) => {
488
+ server.listen(opts.port ?? 0, '127.0.0.1', () => {
489
+ const addr = server.address();
490
+ resolve(typeof addr === 'object' && addr ? addr.port : (opts.port ?? 0));
491
+ });
492
+ });
493
+ const dashUrl = `http://127.0.0.1:${boundPort}/`;
494
+ if (!opts.json) {
495
+ console.log(`Dashboard: ${dashUrl}`);
496
+ }
497
+ if (opts.open !== false)
498
+ openUrl(dashUrl);
499
+ const driver = opts.driver ?? defaultPtyDriver();
500
+ const remotable = pending.filter((p) => p.remotable);
501
+ const driveOne = async (p) => {
502
+ const k = key(p.device, p.agent);
503
+ const st = statuses.get(k);
504
+ st.state = 'driving';
505
+ const baseMtime = await remoteFileMtime(p.target, p.flow.successFile).catch(() => 0);
506
+ let sessionId;
507
+ try {
508
+ const r = await driveRemoteLogin(p.target, p.flow, driver, opts.drive);
509
+ sessionId = r.sessionId;
510
+ if (r.url && r.code) {
511
+ st.url = r.url;
512
+ st.code = r.code;
513
+ st.expiresAt = Date.now() + codeTtl;
514
+ st.state = 'ready';
515
+ }
516
+ else {
517
+ st.state = 'error';
518
+ st.detail = r.exited ? 'login process exited before printing a code' : 'timed out scraping the device code';
519
+ if (sessionId)
520
+ await driver.stop(sessionId);
521
+ return;
522
+ }
523
+ }
524
+ catch (e) {
525
+ st.state = 'error';
526
+ st.detail = e.message;
527
+ return;
528
+ }
529
+ // Poll the credential file for the human completing the browser step.
530
+ const codeDeadline = st.expiresAt ?? Date.now() + codeTtl;
531
+ for (;;) {
532
+ if (Date.now() > codeDeadline) {
533
+ st.state = 'error';
534
+ st.detail = 'device code expired before authorization';
535
+ break;
536
+ }
537
+ const mtime = await remoteFileMtime(p.target, p.flow.successFile).catch(() => 0);
538
+ if (mtime > baseMtime) {
539
+ st.state = 'authorized';
540
+ break;
541
+ }
542
+ await sleep(3000);
543
+ }
544
+ if (sessionId)
545
+ await driver.stop(sessionId);
546
+ };
547
+ if (mode === 'interactive') {
548
+ for (const p of remotable)
549
+ await driveOne(p); // sequential, just-in-time codes
550
+ }
551
+ else {
552
+ await Promise.all(remotable.map((p) => driveOne(p))); // bulk, concurrent
553
+ }
554
+ server.close();
555
+ return [...statuses.values()];
556
+ }
@@ -1,5 +1,32 @@
1
- /** Print `msg` in red to stderr and exit the process with `code`. */
2
- export declare function die(msg: string, code?: number): never;
1
+ /** Options for {@link die} opt into machine-readable failure output. */
2
+ export interface DieOptions {
3
+ /**
4
+ * Emit a machine-readable `{"error", "hint"?}` object to **stdout** instead of
5
+ * red text on stderr. Pass `isJsonMode(options)` from a `--json` command so an
6
+ * agent parsing stdout gets a structured reason instead of an empty stream and
7
+ * a bare nonzero exit (RUSH-1830).
8
+ */
9
+ json?: boolean;
10
+ /** Optional recovery hint — the command to run instead. Included in both modes. */
11
+ hint?: string;
12
+ }
13
+ /**
14
+ * Render a fatal error to the right stream. Pure — no I/O, no `process.exit` — so
15
+ * the human-vs-agent split is unit-testable. A `--json` caller gets
16
+ * `{"error","hint"?}` on **stdout** (where a JSON consumer reads); a human gets
17
+ * red text (plus a gray hint line) on **stderr**.
18
+ */
19
+ export declare function formatDie(msg: string, opts?: DieOptions): {
20
+ stream: 'stdout' | 'stderr';
21
+ text: string;
22
+ };
23
+ /**
24
+ * Print `msg` and exit the process with `code`. Humans get red text on stderr;
25
+ * a `--json` caller (pass `{ json: true }`) gets `{"error","hint"?}` on stdout so
26
+ * an agent has a parseable reason. Backward-compatible: `die(msg)` / `die(msg, code)`
27
+ * keep the original red-stderr behavior.
28
+ */
29
+ export declare function die(msg: string, code?: number, opts?: DieOptions): never;
3
30
  /**
4
31
  * Truncate `s` to at most `max` characters, appending a single-char ellipsis
5
32
  * (`…`) when shortened. Character-count based (not ANSI/width aware — use
@@ -25,7 +52,7 @@ export declare function visibleWidth(s: string): number;
25
52
  export declare function padRight(s: string, width: number): string;
26
53
  /** Pad `s` with trailing spaces to a target *visible* width (ANSI-aware). */
27
54
  export declare function padVisible(s: string, width: number): string;
28
- /** True when `--json` was passed or stdout is not a TTY. */
55
+ /** True when `--json` was passed. Piped stdout stays human-readable unless requested. */
29
56
  export declare function isJsonMode(opts: {
30
57
  json?: boolean;
31
58
  }): boolean;
@@ -9,9 +9,38 @@
9
9
  */
10
10
  import chalk from 'chalk';
11
11
  import { readSync } from 'node:fs';
12
- /** Print `msg` in red to stderr and exit the process with `code`. */
13
- export function die(msg, code = 1) {
14
- console.error(chalk.red(msg));
12
+ /**
13
+ * Render a fatal error to the right stream. Pure — no I/O, no `process.exit` so
14
+ * the human-vs-agent split is unit-testable. A `--json` caller gets
15
+ * `{"error","hint"?}` on **stdout** (where a JSON consumer reads); a human gets
16
+ * red text (plus a gray hint line) on **stderr**.
17
+ */
18
+ export function formatDie(msg, opts = {}) {
19
+ if (opts.json) {
20
+ const payload = { error: msg };
21
+ if (opts.hint)
22
+ payload.hint = opts.hint;
23
+ return { stream: 'stdout', text: JSON.stringify(payload) };
24
+ }
25
+ const lines = [chalk.red(msg)];
26
+ if (opts.hint)
27
+ lines.push(chalk.gray(opts.hint));
28
+ return { stream: 'stderr', text: lines.join('\n') };
29
+ }
30
+ /**
31
+ * Print `msg` and exit the process with `code`. Humans get red text on stderr;
32
+ * a `--json` caller (pass `{ json: true }`) gets `{"error","hint"?}` on stdout so
33
+ * an agent has a parseable reason. Backward-compatible: `die(msg)` / `die(msg, code)`
34
+ * keep the original red-stderr behavior.
35
+ */
36
+ export function die(msg, code = 1, opts = {}) {
37
+ const { stream, text } = formatDie(msg, opts);
38
+ // Keep console.* (not process.std*.write): the suite spies on console.error /
39
+ // console.log to capture command output, and fd-level writes bypass those spies.
40
+ if (stream === 'stdout')
41
+ console.log(text);
42
+ else
43
+ console.error(text);
15
44
  process.exit(code);
16
45
  }
17
46
  /**
@@ -74,9 +103,9 @@ export function padVisible(s, width) {
74
103
  const w = visibleWidth(s);
75
104
  return w >= width ? s : s + ' '.repeat(width - w);
76
105
  }
77
- /** True when `--json` was passed or stdout is not a TTY. */
106
+ /** True when `--json` was passed. Piped stdout stays human-readable unless requested. */
78
107
  export function isJsonMode(opts) {
79
- return Boolean(opts.json) || !process.stdout.isTTY;
108
+ return Boolean(opts.json);
80
109
  }
81
110
  /** Read all of stdin synchronously and return it UTF-8 decoded and trimmed. */
82
111
  export function readStdinSync() {
@@ -1,3 +1,4 @@
1
+ import * as fs from 'fs';
1
2
  export declare function sleepSync(ms: number): void;
2
3
  /**
3
4
  * Ensures the target file (and its parent directory) exist so proper-lockfile
@@ -9,11 +10,15 @@ export declare function ensureLockTarget(filePath: string, initialContent?: stri
9
10
  * Writes content to filePath via a temp file + rename so readers never see a
10
11
  * partial write. On POSIX, rename(2) is atomic.
11
12
  */
12
- export declare function atomicWriteFileSync(filePath: string, content: string): void;
13
+ export declare function atomicWriteFileSync(filePath: string, content: string, options?: fs.WriteFileOptions): void;
13
14
  /**
14
15
  * Acquires an exclusive proper-lockfile lock on filePath, runs fn, then
15
16
  * releases the lock. Retries with capped linear back-off until either the lock
16
17
  * is acquired or LOCK_ACQUIRE_TIMEOUT_MS elapses. Breaks stale locks older than
17
18
  * LOCK_STALE_MS, so a crashed holder never blocks past the stale window.
18
19
  */
19
- export declare function withFileLock<T>(filePath: string, fn: () => T): T;
20
+ export interface FileLockOptions {
21
+ staleMs?: number;
22
+ acquireTimeoutMs?: number;
23
+ }
24
+ export declare function withFileLock<T>(filePath: string, fn: () => T, opts?: FileLockOptions): T;