@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.
- package/CHANGELOG.md +299 -0
- package/README.md +40 -4
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +12 -0
- package/dist/commands/activity.js +68 -0
- package/dist/commands/apply.d.ts +13 -0
- package/dist/commands/apply.js +28 -3
- package/dist/commands/browser.js +1 -1
- package/dist/commands/cloud.js +21 -11
- package/dist/commands/doctor.js +8 -4
- package/dist/commands/events.d.ts +11 -10
- package/dist/commands/events.js +30 -21
- package/dist/commands/exec.d.ts +34 -0
- package/dist/commands/exec.js +337 -39
- package/dist/commands/feed.d.ts +13 -0
- package/dist/commands/feed.js +130 -29
- package/dist/commands/hq.d.ts +2 -0
- package/dist/commands/hq.js +58 -0
- package/dist/commands/lease.d.ts +19 -0
- package/dist/commands/lease.js +138 -11
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +123 -0
- package/dist/commands/logs.js +1 -1
- package/dist/commands/mine.d.ts +27 -0
- package/dist/commands/mine.js +207 -0
- package/dist/commands/monitors.js +79 -49
- package/dist/commands/plugins.js +1 -0
- package/dist/commands/profiles.js +158 -0
- package/dist/commands/repo.js +2 -60
- package/dist/commands/resources.d.ts +5 -0
- package/dist/commands/resources.js +95 -0
- package/dist/commands/routines.d.ts +2 -0
- package/dist/commands/routines.js +139 -37
- package/dist/commands/secrets.js +234 -47
- package/dist/commands/send.d.ts +15 -0
- package/dist/commands/send.js +62 -0
- package/dist/commands/sessions-tail.d.ts +1 -0
- package/dist/commands/sessions-tail.js +20 -9
- package/dist/commands/sessions.js +18 -3
- package/dist/commands/setup-mine.d.ts +18 -0
- package/dist/commands/setup-mine.js +106 -0
- package/dist/commands/setup-share.js +2 -2
- package/dist/commands/setup.js +3 -1
- package/dist/commands/share.d.ts +8 -1
- package/dist/commands/share.js +122 -37
- package/dist/commands/ssh.d.ts +9 -0
- package/dist/commands/ssh.js +196 -26
- package/dist/commands/sync.js +1 -1
- package/dist/commands/teams.js +11 -2
- package/dist/index.js +79 -10
- package/dist/lib/activity.d.ts +124 -0
- package/dist/lib/activity.js +542 -0
- package/dist/lib/agents.js +16 -5
- package/dist/lib/artifact-actions.d.ts +1 -1
- package/dist/lib/artifact-actions.js +1 -1
- package/dist/lib/ask-classifier.d.ts +2 -1
- package/dist/lib/ask-classifier.js +3 -3
- package/dist/lib/auth-health.d.ts +32 -4
- package/dist/lib/auth-health.js +40 -1
- package/dist/lib/auto-pull.js +8 -1
- package/dist/lib/brand.d.ts +40 -0
- package/dist/lib/brand.js +122 -0
- package/dist/lib/browser/drivers/ssh.js +4 -7
- package/dist/lib/browser/service.js +6 -8
- package/dist/lib/channels/providers/index.d.ts +2 -0
- package/dist/lib/channels/providers/index.js +20 -0
- package/dist/lib/channels/providers/mailbox.d.ts +2 -0
- package/dist/lib/channels/providers/mailbox.js +26 -0
- package/dist/lib/channels/providers/openclaw-telegram.d.ts +2 -0
- package/dist/lib/channels/providers/openclaw-telegram.js +34 -0
- package/dist/lib/channels/providers/rush.d.ts +6 -0
- package/dist/lib/channels/providers/rush.js +55 -0
- package/dist/lib/channels/registry.d.ts +42 -0
- package/dist/lib/channels/registry.js +20 -0
- package/dist/lib/channels/resolve.d.ts +11 -0
- package/dist/lib/channels/resolve.js +12 -0
- package/dist/lib/cli-resources.d.ts +18 -0
- package/dist/lib/cli-resources.js +53 -4
- package/dist/lib/cloud/codex.js +6 -3
- package/dist/lib/cloud/factory.d.ts +1 -0
- package/dist/lib/cloud/factory.js +10 -4
- package/dist/lib/cloud/rush.d.ts +3 -1
- package/dist/lib/cloud/rush.js +6 -1
- package/dist/lib/cloud/types.d.ts +2 -0
- package/dist/lib/codex-home.d.ts +35 -0
- package/dist/lib/codex-home.js +136 -0
- package/dist/lib/crabbox/cli.d.ts +44 -0
- package/dist/lib/crabbox/cli.js +120 -20
- package/dist/lib/crabbox/lease.d.ts +52 -1
- package/dist/lib/crabbox/lease.js +117 -16
- package/dist/lib/crabbox/progress.d.ts +31 -0
- package/dist/lib/crabbox/progress.js +76 -0
- package/dist/lib/crabbox/runtimes.d.ts +4 -0
- package/dist/lib/crabbox/runtimes.js +33 -8
- package/dist/lib/crabbox/setup-copy.d.ts +87 -0
- package/dist/lib/crabbox/setup-copy.js +127 -0
- package/dist/lib/daemon.d.ts +5 -5
- package/dist/lib/daemon.js +9 -9
- package/dist/lib/drive-sync.js +6 -3
- package/dist/lib/event-stream.d.ts +36 -0
- package/dist/lib/event-stream.js +68 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +11 -0
- package/dist/lib/exec.js +23 -2
- package/dist/lib/feed-policy.d.ts +1 -1
- package/dist/lib/feed-policy.js +14 -9
- package/dist/lib/feed-ranking.d.ts +32 -0
- package/dist/lib/feed-ranking.js +224 -0
- package/dist/lib/feed.d.ts +35 -2
- package/dist/lib/feed.js +66 -3
- package/dist/lib/fleet/auth-sync.d.ts +53 -0
- package/dist/lib/fleet/auth-sync.js +92 -0
- package/dist/lib/fleet/remote-login.d.ts +187 -0
- package/dist/lib/fleet/remote-login.js +556 -0
- package/dist/lib/format.d.ts +30 -3
- package/dist/lib/format.js +34 -5
- package/dist/lib/fs-atomic.d.ts +7 -2
- package/dist/lib/fs-atomic.js +8 -12
- package/dist/lib/git.d.ts +16 -0
- package/dist/lib/git.js +79 -2
- package/dist/lib/heal.js +11 -3
- package/dist/lib/hosts/progress.d.ts +28 -10
- package/dist/lib/hosts/progress.js +79 -22
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/hq/floor.d.ts +87 -0
- package/dist/lib/hq/floor.js +226 -0
- package/dist/lib/menubar/install-menubar.js +14 -20
- package/dist/lib/notify.d.ts +1 -0
- package/dist/lib/notify.js +3 -3
- package/dist/lib/open-url.d.ts +2 -0
- package/dist/lib/open-url.js +19 -0
- package/dist/lib/openclaw-keychain.d.ts +56 -0
- package/dist/lib/openclaw-keychain.js +236 -0
- package/dist/lib/overdue.js +10 -0
- package/dist/lib/permissions.d.ts +44 -1
- package/dist/lib/permissions.js +284 -7
- package/dist/lib/project-launch.d.ts +6 -12
- package/dist/lib/project-launch.js +13 -228
- package/dist/lib/project-resources.d.ts +7 -0
- package/dist/lib/project-resources.js +291 -0
- package/dist/lib/pty-client.d.ts +27 -0
- package/dist/lib/pty-client.js +136 -10
- package/dist/lib/refresh.js +14 -10
- package/dist/lib/resource-profiles.d.ts +26 -0
- package/dist/lib/resource-profiles.js +157 -0
- package/dist/lib/resources/permissions.js +7 -1
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/resources.d.ts +1 -1
- package/dist/lib/resources.js +32 -3
- package/dist/lib/routines.d.ts +11 -0
- package/dist/lib/routines.js +56 -15
- package/dist/lib/runner.d.ts +1 -0
- package/dist/lib/runner.js +66 -5
- package/dist/lib/secrets/bundles.d.ts +16 -3
- package/dist/lib/secrets/bundles.js +206 -37
- package/dist/lib/secrets/icloud-import.d.ts +2 -2
- package/dist/lib/secrets/icloud-import.js +9 -6
- package/dist/lib/secrets/mcp.js +1 -1
- package/dist/lib/secrets/vault-age-helper.d.ts +1 -0
- package/dist/lib/secrets/vault-age-helper.js +34 -0
- package/dist/lib/secrets/vault.d.ts +49 -0
- package/dist/lib/secrets/vault.js +397 -0
- package/dist/lib/self-heal/checks/path.js +3 -1
- package/dist/lib/self-heal/checks/shadowing.js +10 -2
- package/dist/lib/self-heal/checks/shims.js +19 -11
- package/dist/lib/session/cloud.d.ts +2 -2
- package/dist/lib/session/cloud.js +2 -2
- package/dist/lib/session/db.d.ts +4 -0
- package/dist/lib/session/db.js +44 -7
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +114 -3
- package/dist/lib/session/stream-render.d.ts +3 -0
- package/dist/lib/session/stream-render.js +130 -0
- package/dist/lib/session/types.d.ts +6 -0
- package/dist/lib/share/analytics.d.ts +13 -0
- package/dist/lib/share/analytics.js +45 -0
- package/dist/lib/share/config.d.ts +19 -5
- package/dist/lib/share/config.js +44 -8
- package/dist/lib/share/provision.d.ts +58 -6
- package/dist/lib/share/provision.js +97 -22
- package/dist/lib/share/publish.d.ts +36 -13
- package/dist/lib/share/publish.js +55 -8
- package/dist/lib/share/worker-template.js +83 -17
- package/dist/lib/shims.d.ts +9 -0
- package/dist/lib/shims.js +91 -13
- package/dist/lib/ssh-exec.d.ts +14 -0
- package/dist/lib/ssh-exec.js +57 -0
- package/dist/lib/staleness/detectors/hooks.js +25 -1
- package/dist/lib/staleness/detectors/permissions.js +66 -0
- package/dist/lib/staleness/detectors/workflows.js +20 -0
- package/dist/lib/staleness/writers/hooks.js +58 -4
- package/dist/lib/staleness/writers/permissions.js +2 -2
- package/dist/lib/staleness/writers/skills.js +1 -1
- package/dist/lib/staleness/writers/sources.d.ts +10 -1
- package/dist/lib/staleness/writers/sources.js +68 -1
- package/dist/lib/star-nudge.d.ts +45 -0
- package/dist/lib/star-nudge.js +91 -0
- package/dist/lib/startup/command-registry.d.ts +6 -1
- package/dist/lib/startup/command-registry.js +17 -3
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +3 -0
- package/dist/lib/subagents-registry.d.ts +2 -0
- package/dist/lib/subagents-registry.js +3 -0
- package/dist/lib/teams/parsers.js +214 -6
- package/dist/lib/teams/supervisor.d.ts +7 -0
- package/dist/lib/teams/supervisor.js +2 -1
- package/dist/lib/template.d.ts +1 -1
- package/dist/lib/template.js +1 -1
- package/dist/lib/triggers/webhook.js +36 -3
- package/dist/lib/types.d.ts +73 -0
- package/dist/lib/version.d.ts +40 -0
- package/dist/lib/version.js +94 -16
- package/dist/lib/versions.d.ts +11 -0
- package/dist/lib/versions.js +208 -76
- package/dist/lib/workflows.d.ts +2 -0
- package/dist/lib/workflows.js +88 -0
- package/package.json +2 -1
- package/dist/commands/daemon.d.ts +0 -10
- 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, '&')
|
|
170
|
+
.replace(/</g, '<')
|
|
171
|
+
.replace(/>/g, '>')
|
|
172
|
+
.replace(/"/g, '"');
|
|
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} · ${remotableCount} remotable · <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 →</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
|
+
}
|
package/dist/lib/format.d.ts
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export
|
|
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
|
|
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;
|
package/dist/lib/format.js
CHANGED
|
@@ -9,9 +9,38 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import chalk from 'chalk';
|
|
11
11
|
import { readSync } from 'node:fs';
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
106
|
+
/** True when `--json` was passed. Piped stdout stays human-readable unless requested. */
|
|
78
107
|
export function isJsonMode(opts) {
|
|
79
|
-
return Boolean(opts.json)
|
|
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() {
|
package/dist/lib/fs-atomic.d.ts
CHANGED
|
@@ -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
|
|
20
|
+
export interface FileLockOptions {
|
|
21
|
+
staleMs?: number;
|
|
22
|
+
acquireTimeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function withFileLock<T>(filePath: string, fn: () => T, opts?: FileLockOptions): T;
|