agent-dag 3.22.0 → 3.22.3
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/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-3FWd7g_W.css +0 -1
- package/dist/web/assets/index-BOwtoP02.js +0 -266
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -392
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
package/src/server/macmon.mjs
DELETED
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
// The one thermal reading a Mac will not give up on its own.
|
|
2
|
-
//
|
|
3
|
-
// #747. On Apple Silicon there is no command shipped with macOS that prints a
|
|
4
|
-
// CPU or GPU temperature. `powermetrics` needs root. `pmset -g therm` records
|
|
5
|
-
// nothing on M-series and answers "No CPU power status has been recorded", so
|
|
6
|
-
// there is no throttle row either. The AGX driver does not publish the
|
|
7
|
-
// `"Temperature(C)"` that ioreg reads on Intel GPUs. And the SMC keys changed
|
|
8
|
-
// with M1 and are inconsistent between models sharing one chip — an M1 Mac mini
|
|
9
|
-
// uses different FourCCs from an M1 MacBook Pro, with no public mapping — so
|
|
10
|
-
// there is nothing to hard-code either.
|
|
11
|
-
//
|
|
12
|
-
// The sensors are reachable, and without root: they come through a HID sensor
|
|
13
|
-
// hub, which is a private C API. Every tool that shows a temperature on Apple
|
|
14
|
-
// Silicon calls it, and every one of them is native code. A Node process cannot,
|
|
15
|
-
// and this package has zero runtime dependencies, which is worth more than a
|
|
16
|
-
// number.
|
|
17
|
-
//
|
|
18
|
-
// So it asks a tool the user already has, exactly the way the deck already asks
|
|
19
|
-
// `cswap` about accounts and `ccusage` about spend. `macmon` is in
|
|
20
|
-
// homebrew-core — `brew install macmon`, no third-party tap — runs without
|
|
21
|
-
// sudo, supports M1 through M5, and prints JSON.
|
|
22
|
-
//
|
|
23
|
-
// COSTS INTEL NOTHING. This is reached only when ioreg has already answered
|
|
24
|
-
// with nothing, which on an Intel Mac it never does. Deliberately not gated on
|
|
25
|
-
// `process.arch`: a Node built for x64 running under Rosetta on an Apple
|
|
26
|
-
// Silicon Mac reports "x64", and gating on that would skip the one machine this
|
|
27
|
-
// exists for.
|
|
28
|
-
import { run } from "./exec.mjs";
|
|
29
|
-
import { renameWithRetry } from "./installer.mjs";
|
|
30
|
-
import { existsSync } from "node:fs";
|
|
31
|
-
import { chmod, copyFile, mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises";
|
|
32
|
-
import { createHash } from "node:crypto";
|
|
33
|
-
import { homedir } from "node:os";
|
|
34
|
-
import { join } from "node:path";
|
|
35
|
-
|
|
36
|
-
const TOOL_DIR = join(homedir(), ".agents-deck", "tools");
|
|
37
|
-
const MACMON_DIR = join(TOOL_DIR, "macmon");
|
|
38
|
-
|
|
39
|
-
/** Where the deck puts its own copy, and where brew puts one on both prefixes —
|
|
40
|
-
* because ~/.local/bin's lesson applies here too: a tool the user installed is
|
|
41
|
-
* not always on the PATH of the shell that launched the deck. Ours first, so a
|
|
42
|
-
* machine that has both uses the one whose version this code was written
|
|
43
|
-
* against. Apple Silicon's brew prefix before Intel's; that is the only
|
|
44
|
-
* machine that reaches this file. */
|
|
45
|
-
export const MACMON_CANDIDATES = [
|
|
46
|
-
join(MACMON_DIR, "macmon"),
|
|
47
|
-
"/opt/homebrew/bin/macmon",
|
|
48
|
-
"/usr/local/bin/macmon",
|
|
49
|
-
];
|
|
50
|
-
|
|
51
|
-
/** One sample, then exit. `-i` is the sampling window and its default is a full
|
|
52
|
-
* second; 200ms is long enough for an average the tool is willing to publish
|
|
53
|
-
* and short enough that a poll every ten seconds costs nothing anybody can
|
|
54
|
-
* feel. */
|
|
55
|
-
export const MACMON_ARGS = ["pipe", "-s", "1", "-i", "200"];
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The resolution, remembered — including the failure.
|
|
59
|
-
*
|
|
60
|
-
* `null` means "not looked yet" and `false` means "looked, not there", which is
|
|
61
|
-
* the distinction that keeps a machine without macmon from paying a lookup
|
|
62
|
-
* every ten seconds forever. cswapBin memoizes only success and re-probes on
|
|
63
|
-
* failure; that is right for a tool the deck installs and wrong for one it
|
|
64
|
-
* never will.
|
|
65
|
-
*/
|
|
66
|
-
let _bin = null;
|
|
67
|
-
|
|
68
|
-
/** Forget it. For the test, and for the day something installs macmon while the
|
|
69
|
-
* deck is up — nothing calls this on that path yet, and it is one line. */
|
|
70
|
-
export function resetMacmonBin() { _bin = null; }
|
|
71
|
-
|
|
72
|
-
export async function macmonBin({
|
|
73
|
-
exists = existsSync, probe = defaultProbe, candidates = MACMON_CANDIDATES,
|
|
74
|
-
} = {}) {
|
|
75
|
-
if (_bin !== null) return _bin || null;
|
|
76
|
-
// The bare name first, because PATH is the cheap answer and the one a user
|
|
77
|
-
// who installed it themselves will usually have.
|
|
78
|
-
if (await probe("macmon")) return (_bin = "macmon");
|
|
79
|
-
for (const c of candidates) {
|
|
80
|
-
if (exists(c) && await probe(c)) return (_bin = c);
|
|
81
|
-
}
|
|
82
|
-
_bin = false;
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function defaultProbe(bin) {
|
|
87
|
-
return (await run(bin, ["--version"], { timeout: 4_000 })).ok;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* CPU and GPU degrees out of one `macmon pipe` sample.
|
|
92
|
-
*
|
|
93
|
-
* The shape is `{ temp: { cpu_temp_avg, gpu_temp_avg } }`, both in Celsius —
|
|
94
|
-
* read off macmon's own `TempMetrics` struct rather than guessed from an
|
|
95
|
-
* example, because a field renamed upstream should read as "no sensor" and not
|
|
96
|
-
* as zero.
|
|
97
|
-
*
|
|
98
|
-
* A zero is dropped rather than shown. macmon defaults both fields to 0.0 and
|
|
99
|
-
* fills what it read, so a machine that answered for the CPU and not the GPU
|
|
100
|
-
* arrives as `{ cpu_temp_avg: 47.3, gpu_temp_avg: 0 }` — and 0 °C is not a
|
|
101
|
-
* reading, it is the absence of one wearing a number.
|
|
102
|
-
*/
|
|
103
|
-
export function tempsFromMacmonJson(json) {
|
|
104
|
-
let d;
|
|
105
|
-
try { d = typeof json === "string" ? JSON.parse(json) : json; }
|
|
106
|
-
catch { return {}; }
|
|
107
|
-
const t = d?.temp;
|
|
108
|
-
if (!t || typeof t !== "object") return {};
|
|
109
|
-
const out = {};
|
|
110
|
-
for (const [key, field] of [["cpu", "cpu_temp_avg"], ["gpu", "gpu_temp_avg"]]) {
|
|
111
|
-
const v = Number(t[field]);
|
|
112
|
-
// The same plausibility floor the rest of the thermal code uses, stated
|
|
113
|
-
// here rather than imported so this module has no opinion to disagree with.
|
|
114
|
-
if (Number.isFinite(v) && v > 0 && v < 150) out[key] = Math.round(v);
|
|
115
|
-
}
|
|
116
|
-
return out;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Ask macmon, or answer nothing.
|
|
121
|
-
*
|
|
122
|
-
* Never throws and never waits long: `run` does not reject, the sample is
|
|
123
|
-
* capped, and a machine without macmon returns before it spawns anything at
|
|
124
|
-
* all after the first lookup.
|
|
125
|
-
*/
|
|
126
|
-
export async function readMacmonTemps(deps = {}) {
|
|
127
|
-
const bin = await macmonBin(deps);
|
|
128
|
-
if (!bin) return {};
|
|
129
|
-
const r = await run(bin, MACMON_ARGS, { timeout: 6_000 });
|
|
130
|
-
if (!r.ok) return {};
|
|
131
|
-
// `pipe` prints one JSON object per sample and `-s 1` asks for one, but a
|
|
132
|
-
// build that ever printed a banner first would put it on the same stream.
|
|
133
|
-
const line = r.stdout.split("\n").find(l => l.trim().startsWith("{"));
|
|
134
|
-
return line ? tempsFromMacmonJson(line) : {};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// ── fetching it, so the user does not have to ────────────────────────────────
|
|
138
|
-
//
|
|
139
|
-
// `npx ccdeck` should work, and on Apple Silicon "work" includes the two rows
|
|
140
|
-
// this file exists for. Telling somebody to run a brew command first is a step,
|
|
141
|
-
// and a step is a thing most people will not take.
|
|
142
|
-
//
|
|
143
|
-
// So the deck fetches macmon the same way it already fetches uv — see
|
|
144
|
-
// uv-bootstrap.mjs, whose shape this follows including the parts that were
|
|
145
|
-
// learned the hard way. NOT through Homebrew: a machine without brew would then
|
|
146
|
-
// need brew installed first, which is a very large thing to do to somebody who
|
|
147
|
-
// asked for a dashboard.
|
|
148
|
-
//
|
|
149
|
-
// WHAT MAKES THIS SAFE TO RUN ON SOMEBODY'S MACHINE, each verified against the
|
|
150
|
-
// real release rather than assumed:
|
|
151
|
-
//
|
|
152
|
-
// * There is a prebuilt binary. `macmon-v0.8.2.tar.gz`, 746 KB, containing a
|
|
153
|
-
// `Mach-O 64-bit executable arm64`. No compiler, no toolchain, no Rust.
|
|
154
|
-
// * There is a checksum. The release publishes no `.sha256` file, but the
|
|
155
|
-
// GitHub releases API carries a `digest` field per asset, and it matched
|
|
156
|
-
// the bytes actually downloaded. An unverified binary is not something to
|
|
157
|
-
// run on someone's machine, which is uv-bootstrap's rule and is kept here.
|
|
158
|
-
// * It will execute. Apple Silicon refuses an unsigned binary; this one is
|
|
159
|
-
// `adhoc, linker-signed`, which is what the Rust toolchain emits and what
|
|
160
|
-
// macOS accepts. And a programmatic download carries no
|
|
161
|
-
// com.apple.quarantine — only com.apple.provenance, which blocks nothing —
|
|
162
|
-
// so there is no Gatekeeper prompt and nothing for the user to click.
|
|
163
|
-
//
|
|
164
|
-
// NEVER ON THE BOOT'S CRITICAL PATH. It is started by the thermal sampler, on
|
|
165
|
-
// the tick where it found no reading, and nothing waits for it. The boot was
|
|
166
|
-
// just taught not to wait for an install (#742) and this does not undo that.
|
|
167
|
-
|
|
168
|
-
const RELEASE_API = "https://api.github.com/repos/vladkens/macmon/releases/latest";
|
|
169
|
-
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
170
|
-
|
|
171
|
-
/** One attempt per process. A machine that is offline, or behind a proxy that
|
|
172
|
-
* refuses GitHub, must not re-download every ten seconds for as long as the
|
|
173
|
-
* deck runs — and a success does not need a second attempt either. */
|
|
174
|
-
let _fetched = false;
|
|
175
|
-
|
|
176
|
-
/** Cleared with the resolution, so a test can run the whole thing twice. */
|
|
177
|
-
export function resetMacmonFetch() { _fetched = false; }
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* The asset to download, out of what the release actually published.
|
|
181
|
-
*
|
|
182
|
-
* Pure and exported: this is the part that has to keep working when the project
|
|
183
|
-
* changes its file names, and the only way to check that from here is to feed
|
|
184
|
-
* it a release document. `digest` is `sha256:<hex>` — the prefix is part of the
|
|
185
|
-
* field and dropping it silently would make every download fail verification.
|
|
186
|
-
*/
|
|
187
|
-
export function macmonAsset(release) {
|
|
188
|
-
const tag = release?.tag_name;
|
|
189
|
-
const a = (release?.assets ?? []).find(x => typeof x?.name === "string" && x.name.endsWith(".tar.gz"));
|
|
190
|
-
if (!a?.browser_download_url) return null;
|
|
191
|
-
const m = /^sha256:([0-9a-f]{64})$/.exec(String(a.digest ?? ""));
|
|
192
|
-
if (!m) return null;
|
|
193
|
-
return { version: typeof tag === "string" ? tag : "unknown", url: a.browser_download_url, sha256: m[1] };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Download macmon into ~/.agents-deck/tools/macmon.
|
|
199
|
-
*
|
|
200
|
-
* Returns `{ ok: true, bin, version }` or `{ ok: false, reason }`, and never
|
|
201
|
-
* throws: every caller treats a missing macmon as an ordinary state.
|
|
202
|
-
*
|
|
203
|
-
* Apple Silicon only, because that is the only build published and the only
|
|
204
|
-
* machine that has anything to gain — an Intel Mac already answers through
|
|
205
|
-
* ioreg and never reaches this file at all.
|
|
206
|
-
*/
|
|
207
|
-
export async function bootstrapMacmon({
|
|
208
|
-
platform = process.platform, arch = process.arch, env = process.env,
|
|
209
|
-
fetchFn = fetch, dir = MACMON_DIR, findBin = macmonBin,
|
|
210
|
-
} = {}) {
|
|
211
|
-
if (platform !== "darwin") return { ok: false, reason: "unsupported_platform" };
|
|
212
|
-
// ARM64 ONLY, checked rather than merely documented. The release publishes
|
|
213
|
-
// one asset and it is an arm64 Mach-O, so an Intel Mac downloaded 746 KB it
|
|
214
|
-
// could not execute and failed its own --version check afterwards. That is
|
|
215
|
-
// the small half. The larger half is the promise: the README says an Intel
|
|
216
|
-
// Mac never downloads anything, and this reached api.github.com on any Mac
|
|
217
|
-
// whose sensors happened to stay silent — which an Intel Mac's do whenever
|
|
218
|
-
// ioreg publishes no Temperature(C).
|
|
219
|
-
if (arch !== "arm64") return { ok: false, reason: "unsupported_arch" };
|
|
220
|
-
// A macmon the user already has is the other thing the README promises to
|
|
221
|
-
// notice, and until now the skip was emergent rather than checked: a working
|
|
222
|
-
// copy produces a reading, the reading sets thermalEverAnswered, and the
|
|
223
|
-
// give-up branch never fires. That chain breaks on a macmon which runs but
|
|
224
|
-
// reports values this deck rejects as implausible — and then a machine with
|
|
225
|
-
// macmon on PATH downloaded a second one.
|
|
226
|
-
if (await findBin()) return { ok: false, reason: "already_installed" };
|
|
227
|
-
// Both switches, for the reason uv-bootstrap has both: downloading an
|
|
228
|
-
// executable is a bigger step than installing a package with a tool the user
|
|
229
|
-
// already chose, so somebody may want the managed installs and not this.
|
|
230
|
-
if (env.AGENTS_DECK_NO_INSTALL === "1") return { ok: false, reason: "installs_disabled" };
|
|
231
|
-
if (env.AGENTS_DECK_NO_DOWNLOAD === "1") return { ok: false, reason: "download_disabled" };
|
|
232
|
-
if (_fetched) return { ok: false, reason: "already_tried" };
|
|
233
|
-
_fetched = true;
|
|
234
|
-
|
|
235
|
-
let staging = null;
|
|
236
|
-
let partial = null;
|
|
237
|
-
try {
|
|
238
|
-
const res = await fetchFn(RELEASE_API, {
|
|
239
|
-
headers: { accept: "application/vnd.github+json", "user-agent": "agents-deck" },
|
|
240
|
-
signal: AbortSignal.timeout(10_000),
|
|
241
|
-
});
|
|
242
|
-
if (!res.ok) return { ok: false, reason: "release_lookup_failed" };
|
|
243
|
-
const asset = macmonAsset(await res.json());
|
|
244
|
-
// No published digest means no way to know what was downloaded. There is no
|
|
245
|
-
// fallback version here on purpose: uv can have one because any recent uv
|
|
246
|
-
// installs claude-swap, while a hard-coded macmon URL would be a checksum
|
|
247
|
-
// this file invented for bytes it has never seen.
|
|
248
|
-
if (!asset) return { ok: false, reason: "no_verifiable_asset" };
|
|
249
|
-
|
|
250
|
-
const dl = await fetchFn(asset.url, {
|
|
251
|
-
redirect: "follow",
|
|
252
|
-
headers: { "user-agent": "agents-deck" },
|
|
253
|
-
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
254
|
-
});
|
|
255
|
-
if (!dl.ok) return { ok: false, reason: "download_failed" };
|
|
256
|
-
const archive = Buffer.from(await dl.arrayBuffer());
|
|
257
|
-
if (createHash("sha256").update(archive).digest("hex") !== asset.sha256) {
|
|
258
|
-
return { ok: false, reason: "checksum_mismatch" };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// mkdtemp under a directory the user already owns, for every reason
|
|
262
|
-
// uv-bootstrap gives: the name cannot be guessed, and mkdtemp CREATES
|
|
263
|
-
// rather than accepting a directory somebody else made.
|
|
264
|
-
// Staged beside the destination rather than in the system temp directory,
|
|
265
|
-
// so the rename below is same-filesystem and therefore atomic.
|
|
266
|
-
await mkdir(dir, { recursive: true });
|
|
267
|
-
staging = await mkdtemp(join(dir, "macmon-staging-"));
|
|
268
|
-
const archivePath = join(staging, "macmon.tar.gz");
|
|
269
|
-
await writeFile(archivePath, archive);
|
|
270
|
-
if (!(await run("tar", ["-xzf", archivePath, "-C", staging], { timeout: 60_000 })).ok) {
|
|
271
|
-
return { ok: false, reason: "extract_failed" };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// The archive is flat — readme.md, LICENSE, macmon — so this is a name
|
|
275
|
-
// rather than a search. It is checked instead of assumed because a layout
|
|
276
|
-
// change upstream should read as "not in the archive", not as a crash.
|
|
277
|
-
const found = join(staging, "macmon");
|
|
278
|
-
if (!existsSync(found)) return { ok: false, reason: "not_in_archive" };
|
|
279
|
-
|
|
280
|
-
// Copied to a name of its own inside the destination — same filesystem, so
|
|
281
|
-
// the rename below is the atomic kind — and only becomes `macmon` once it
|
|
282
|
-
// is whole, flushed, executable, and has answered `--version`. Interrupt
|
|
283
|
-
// this and all that is left is an inert temp file.
|
|
284
|
-
partial = join(dir, `.macmon-${process.pid}-${Date.now().toString(36)}`);
|
|
285
|
-
await copyFile(found, partial);
|
|
286
|
-
const handle = await open(partial, "r+");
|
|
287
|
-
try { await handle.sync(); } finally { await handle.close(); }
|
|
288
|
-
await chmod(partial, 0o755);
|
|
289
|
-
|
|
290
|
-
// The binary is adhoc/linker-signed and carries no quarantine, so this
|
|
291
|
-
// should simply run — and if some future build does not, the deck finds out
|
|
292
|
-
// here rather than by leaving a file every later boot trusts.
|
|
293
|
-
if (!(await run(partial, ["--version"], { timeout: 20_000 })).ok) {
|
|
294
|
-
return { ok: false, reason: "does_not_run" };
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const dest = join(dir, "macmon");
|
|
298
|
-
await renameWithRetry(partial, dest);
|
|
299
|
-
partial = null;
|
|
300
|
-
// The resolution memo remembers a failure, and the failure it remembers is
|
|
301
|
-
// "there is no macmon". There is one now.
|
|
302
|
-
resetMacmonBin();
|
|
303
|
-
return { ok: true, bin: dest, version: asset.version };
|
|
304
|
-
} catch (err) {
|
|
305
|
-
return { ok: false, reason: "error", detail: String(err?.message ?? err).slice(0, 200) };
|
|
306
|
-
} finally {
|
|
307
|
-
if (staging) await rm(staging, { recursive: true, force: true, maxRetries: 10, retryDelay: 25 }).catch(() => {});
|
|
308
|
-
if (partial) await rm(partial, { force: true }).catch(() => {});
|
|
309
|
-
}
|
|
310
|
-
}
|
package/src/server/npx.mjs
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
// How to run npx from a supervisor that cannot afford npx to be broken.
|
|
2
|
-
//
|
|
3
|
-
// The supervisor's upgrade path is `npx -y <spec>@latest`, and it used to
|
|
4
|
-
// resolve npx by bare name — `npx.cmd` on Windows — letting the OS find it on
|
|
5
|
-
// PATH. That trusts a batch shim to be sitting in a real npm install root,
|
|
6
|
-
// because the shim computes everything else from its own directory:
|
|
7
|
-
//
|
|
8
|
-
// SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
|
|
9
|
-
// SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
|
|
10
|
-
//
|
|
11
|
-
// Reported from a Windows machine on Node 24 where `%~dp0` was the user's home
|
|
12
|
-
// directory and no `node_modules\npm` existed under it. Clicking
|
|
13
|
-
// "Update & restart" printed a raw MODULE_NOT_FOUND stack trace and came back
|
|
14
|
-
// on the same version.
|
|
15
|
-
//
|
|
16
|
-
// The cause was guessed at here as "an npm global prefix pointed somewhere the
|
|
17
|
-
// shim did not follow", and #456 established it was not: `%~dp0` was the DECK'S
|
|
18
|
-
// WORKING DIRECTORY, because a shim asked for by bare name has no directory in
|
|
19
|
-
// its `%0` to be the drive-and-path of. Nothing about that machine's npm was
|
|
20
|
-
// wrong. The correction lives in shimPath in exec.mjs, and the shim fallback
|
|
21
|
-
// below now asks for the full path.
|
|
22
|
-
//
|
|
23
|
-
// Node ships npm, so npx's real entry point can be reached from
|
|
24
|
-
// `process.execPath` with no PATH lookup, no batch file, and no shim-relative
|
|
25
|
-
// arithmetic. That is what this prefers. The shim stays as the fallback for
|
|
26
|
-
// installs whose npm lives somewhere else entirely, and it still goes through
|
|
27
|
-
// spawnSpec — the cmd.exe quoting there is load-bearing and unchanged.
|
|
28
|
-
//
|
|
29
|
-
// The pre-flight fetch below goes through the very same launcher, for the same
|
|
30
|
-
// reason stated the other way round: a supervisor that cannot afford npx to be
|
|
31
|
-
// broken cannot afford to discover that it is only after it has stopped
|
|
32
|
-
// serving. So the fetch happens first, and only a fetch that worked costs the
|
|
33
|
-
// deck its listener.
|
|
34
|
-
import { spawn } from "node:child_process";
|
|
35
|
-
import { existsSync } from "node:fs";
|
|
36
|
-
import { killTree, shimPath, spawnSpec } from "./exec.mjs";
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Where npm's own `npx-cli.js` sits relative to the running Node binary, most
|
|
40
|
-
* likely first. Pure: the platform and the executable path are parameters, so
|
|
41
|
-
* the Windows layout can be checked from any OS.
|
|
42
|
-
*
|
|
43
|
-
* Windows keeps npm beside node.exe (`C:\Program Files\nodejs\node_modules\npm`,
|
|
44
|
-
* and the same shape under nvm-windows). POSIX puts it in a `lib` next to the
|
|
45
|
-
* `bin` — usually the parent (`/usr/local/bin/node` →
|
|
46
|
-
* `/usr/local/lib/node_modules/npm`, as for nvm, fnm and Volta), but not
|
|
47
|
-
* always: Homebrew's node lives in `/usr/local/Cellar/node/<v>/bin` while its
|
|
48
|
-
* npm stays at the prefix, four levels up. So the `lib` spelling is tried at
|
|
49
|
-
* each of the first four ancestors rather than only at the parent. Every
|
|
50
|
-
* candidate is confirmed by the exact file existing, so a wider search cannot
|
|
51
|
-
* pick something that is not npm.
|
|
52
|
-
*
|
|
53
|
-
* The path arithmetic is done on the string rather than through `node:path`,
|
|
54
|
-
* for the same reason npxRoot in self-update.mjs does: `path` here is the
|
|
55
|
-
* platform running the SUITE, so a Windows layout checked from macOS would come
|
|
56
|
-
* back with forward slashes and a `..` nothing resolves.
|
|
57
|
-
*/
|
|
58
|
-
export function npxCliCandidates(execPath = process.execPath, platform = process.platform) {
|
|
59
|
-
if (typeof execPath !== "string" || !execPath) return [];
|
|
60
|
-
const sep = platform === "win32" ? "\\" : "/";
|
|
61
|
-
const dir = execPath.split(/[\\/]/);
|
|
62
|
-
dir.pop(); // the binary itself
|
|
63
|
-
if (!dir.length) return [];
|
|
64
|
-
const beside = [...dir, "node_modules", "npm", "bin", "npx-cli.js"].join(sep);
|
|
65
|
-
const above = [];
|
|
66
|
-
for (let up = 1; up <= 4 && dir.length - up > 0; up++) {
|
|
67
|
-
above.push([...dir.slice(0, -up), "lib", "node_modules", "npm", "bin", "npx-cli.js"].join(sep));
|
|
68
|
-
}
|
|
69
|
-
return platform === "win32" ? [beside, ...above] : [...above, beside];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* What to hand `spawn` to run `npx <args>`.
|
|
74
|
-
*
|
|
75
|
-
* Returns `{ file, args, opts, via }`, where `via` is "node" for the bundled
|
|
76
|
-
* CLI and "shim" for the PATH lookup. `exists` is injected so the choice can be
|
|
77
|
-
* tested without a matching Node install on the machine running the suite.
|
|
78
|
-
*/
|
|
79
|
-
export function npxLaunch(args, {
|
|
80
|
-
platform = process.platform,
|
|
81
|
-
execPath = process.execPath,
|
|
82
|
-
exists = existsSync,
|
|
83
|
-
pathEnv,
|
|
84
|
-
} = {}) {
|
|
85
|
-
for (const cli of npxCliCandidates(execPath, platform)) {
|
|
86
|
-
let found = false;
|
|
87
|
-
try { found = exists(cli); } catch { found = false; }
|
|
88
|
-
// Same node, same npm, spawned directly: nothing here reads PATH and
|
|
89
|
-
// nothing resolves a path relative to a shim's own directory.
|
|
90
|
-
if (found) return { file: execPath, args: [cli, ...args], opts: {}, via: "node", cli };
|
|
91
|
-
}
|
|
92
|
-
// The shim, and on Windows by its FULL path. A bare `npx.cmd` is what made
|
|
93
|
-
// the shim compute `%~dp0` — where it looks for npx-cli.js — from the deck's
|
|
94
|
-
// working directory rather than from its own, which is #456; shimPath in
|
|
95
|
-
// exec.mjs carries the account. The bare name stays as the last resort, so a
|
|
96
|
-
// layout the lookup cannot see is exactly as well off as it was before.
|
|
97
|
-
const shim = platform === "win32"
|
|
98
|
-
? (shimPath("npx.cmd", { execPath, exists, ...(pathEnv === undefined ? {} : { pathEnv }) }) ?? "npx.cmd")
|
|
99
|
-
: "npx";
|
|
100
|
-
return { ...spawnSpec(shim, args, platform), via: "shim", cli: null };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Long enough for a cold tarball on a slow line; short enough that a connection
|
|
104
|
-
// hanging on a dead proxy does not hold the deck's Update button for the three
|
|
105
|
-
// minutes the tab waits before giving up on its own.
|
|
106
|
-
//
|
|
107
|
-
// Not exported (#383): it is the default of `prefetch`'s `timeoutMs` parameter
|
|
108
|
-
// and nothing else. A caller that wants a different budget passes one, and a
|
|
109
|
-
// caller that wants this one passes nothing — there is no third caller for the
|
|
110
|
-
// number to be worth naming across the module boundary.
|
|
111
|
-
const PREFETCH_TIMEOUT_MS = 120_000;
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* What to hand npx to DOWNLOAD a spec without running it.
|
|
115
|
-
*
|
|
116
|
-
* The reason this is not simply `["-y", spec]`: that form is the upgrade
|
|
117
|
-
* itself. npx resolves, unpacks AND executes the package's bin in one command,
|
|
118
|
-
* so asking it "can you fetch this?" the obvious way starts a second deck —
|
|
119
|
-
* which either binds the port the running one holds or, worse, does not, and
|
|
120
|
-
* registers itself with the hooks anyway. Both are outages of their own.
|
|
121
|
-
*
|
|
122
|
-
* `--package` is the half that installs; `--call` is the half that runs, and
|
|
123
|
-
* pointing it at a shell builtin is what makes the install the only effect. The
|
|
124
|
-
* spec never appears as a positional argument, so no bin of ours is ever named,
|
|
125
|
-
* let alone started.
|
|
126
|
-
*
|
|
127
|
-
* `exit 0` rather than anything more informative on purpose: npm runs the call
|
|
128
|
-
* through its script shell — `sh -c` on POSIX, cmd.exe on Windows — and both
|
|
129
|
-
* spell that builtin the same way. Anything that needed a program on PATH would
|
|
130
|
-
* fail exactly where PATH is broken, which is the environment from #184 this
|
|
131
|
-
* pre-flight most needs to answer honestly.
|
|
132
|
-
*/
|
|
133
|
-
export function npxPrefetchArgs(spec) {
|
|
134
|
-
return ["-y", "--package", spec, "--call", "exit 0"];
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Resolve, download and unpack `spec` while the current deck keeps serving.
|
|
139
|
-
*
|
|
140
|
-
* Answers `{ ok, error, hint }` — never throws, because the caller's whole
|
|
141
|
-
* reason for asking is that it must survive the answer being "no". A refusal
|
|
142
|
-
* here costs the click; the alternative, which is what shipped before, was to
|
|
143
|
-
* exit the worker first and discover offline, a proxy, an ETARGET or a broken
|
|
144
|
-
* npx shim with the port already given up and the SSE stream already dropped.
|
|
145
|
-
*
|
|
146
|
-
* Nothing is echoed: the deck this runs alongside owns the terminal and is
|
|
147
|
-
* still drawing its pulse line over the last row, so npm's progress output
|
|
148
|
-
* would smear across it. The tail is kept only as evidence for a failure that
|
|
149
|
-
* may not happen, and reduced to one line by npxFailureSummary when it does.
|
|
150
|
-
*
|
|
151
|
-
* `spawnFn` and `launch` are injected so the choice of launcher and the stdio
|
|
152
|
-
* it is given can be asserted without a Node install to match, and `onChild`
|
|
153
|
-
* hands the process out so a Ctrl+C arriving mid-fetch has something to kill.
|
|
154
|
-
*/
|
|
155
|
-
export function npxPrefetch(spec, {
|
|
156
|
-
launch = npxLaunch,
|
|
157
|
-
spawnFn = spawn,
|
|
158
|
-
timeoutMs = PREFETCH_TIMEOUT_MS,
|
|
159
|
-
onChild = null,
|
|
160
|
-
} = {}) {
|
|
161
|
-
return new Promise((resolve) => {
|
|
162
|
-
const { file, args, opts } = launch(npxPrefetchArgs(spec));
|
|
163
|
-
let child;
|
|
164
|
-
try {
|
|
165
|
-
child = spawnFn(file, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
|
|
166
|
-
} catch (err) {
|
|
167
|
-
resolve({ ok: false, error: `could not run npx: ${err?.message ?? err}`, hint: null });
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
onChild?.(child);
|
|
171
|
-
|
|
172
|
-
let tail = "";
|
|
173
|
-
const keep = (d) => { tail = (tail + String(d)).slice(-8000); };
|
|
174
|
-
// Both streams, one buffer: npm writes its errors to stderr and its
|
|
175
|
-
// resolution notices to stdout, and which of the two carries the sentence
|
|
176
|
-
// worth quoting depends on the failure.
|
|
177
|
-
child.stdout?.on("data", keep);
|
|
178
|
-
child.stderr?.on("data", keep);
|
|
179
|
-
|
|
180
|
-
let settled = false;
|
|
181
|
-
const settle = (result) => {
|
|
182
|
-
if (settled) return;
|
|
183
|
-
settled = true;
|
|
184
|
-
clearTimeout(timer);
|
|
185
|
-
resolve(result);
|
|
186
|
-
};
|
|
187
|
-
const timer = setTimeout(() => {
|
|
188
|
-
// killTree, not kill: on Windows the shim path puts cmd.exe between us
|
|
189
|
-
// and npm, so a plain kill stops the shell and leaves the download
|
|
190
|
-
// running — writing into the very cache directory the next attempt reads.
|
|
191
|
-
killTree(child);
|
|
192
|
-
settle({ ok: false, error: `fetching ${spec} timed out after ${Math.round(timeoutMs / 1000)}s`, hint: null });
|
|
193
|
-
}, timeoutMs);
|
|
194
|
-
timer.unref?.();
|
|
195
|
-
|
|
196
|
-
child.on("error", err => settle({ ok: false, error: `could not run npx: ${err?.message ?? err}`, hint: null }));
|
|
197
|
-
// 'close' rather than 'exit': 'exit' fires when the process ends and says
|
|
198
|
-
// nothing about its pipes, so past one pipe buffer the tail this kept —
|
|
199
|
-
// deliberately the TAIL, because npm's reason is on its last lines — was
|
|
200
|
-
// still only the first 8 KiB when npxFailureSummary read it. 'close' is
|
|
201
|
-
// emitted after both streams have been drained.
|
|
202
|
-
child.on("close", (code, signal) => {
|
|
203
|
-
if (code === 0) { settle({ ok: true, error: null, hint: null }); return; }
|
|
204
|
-
settle({
|
|
205
|
-
ok: false,
|
|
206
|
-
error: npxFailureSummary(tail) ?? `npx could not fetch ${spec} — exited ${code ?? signal}`,
|
|
207
|
-
hint: npxFailureHint(tail),
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Lines that are structure rather than content: a Node crash prints a stack,
|
|
214
|
-
// the source line it threw on, a caret under it, then the error object's own
|
|
215
|
-
// fields and the runtime version. None of that tells a user of a DAG dashboard
|
|
216
|
-
// what went wrong.
|
|
217
|
-
const NOISE = [
|
|
218
|
-
/^\s*at\s/,
|
|
219
|
-
/^\s*\^+\s*$/,
|
|
220
|
-
/^node:internal\//,
|
|
221
|
-
/^\s*throw\s/,
|
|
222
|
-
/^Node\.js v/,
|
|
223
|
-
/^\s*[{}]\s*$/,
|
|
224
|
-
/^\s*(code|errno|syscall|path|requireStack|stack):/,
|
|
225
|
-
/^A complete log/,
|
|
226
|
-
];
|
|
227
|
-
|
|
228
|
-
// The line worth quoting, when there is one. npm's failures and Node's both
|
|
229
|
-
// lead with the sentence a person can act on; everything after it is detail.
|
|
230
|
-
const SIGNAL = /(Error:|error code|ERR_|No matching version|ENOENT|EACCES|EPERM|not found|permission denied)/i;
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* One line summarising why an npx run failed, or null when its output said
|
|
234
|
-
* nothing. Pure, and separate from the printing, because "do not dump a stack
|
|
235
|
-
* trace at the user" is the rule and a rule worth a bug is worth a test.
|
|
236
|
-
*/
|
|
237
|
-
export function npxFailureSummary(output) {
|
|
238
|
-
const lines = String(output ?? "")
|
|
239
|
-
.split(/\r?\n/)
|
|
240
|
-
// A warning is by definition not the reason this failed, and npm emits one
|
|
241
|
-
// for every ordinary exec ("the following package will be installed") whose
|
|
242
|
-
// wording would otherwise outrank the real error below it.
|
|
243
|
-
.filter(l => !/^npm (WARN|warn)\b/.test(l))
|
|
244
|
-
.map(l => l.replace(/^npm (ERR!|error)\s*/i, "").trimEnd())
|
|
245
|
-
.filter(l => l.trim() && !NOISE.some(re => re.test(l)));
|
|
246
|
-
if (!lines.length) return null;
|
|
247
|
-
const pick = lines.find(l => SIGNAL.test(l)) ?? lines[lines.length - 1];
|
|
248
|
-
return pick.trim().slice(0, 300);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* The actionable half, for the one failure shape that is a broken environment
|
|
253
|
-
* rather than a broken network: npx exists, runs, and cannot find the npm it is
|
|
254
|
-
* supposed to load. `npm config get prefix` is the thing to check — a prefix
|
|
255
|
-
* pointing at a directory with no `node_modules/npm` under it leaves exactly
|
|
256
|
-
* this trace.
|
|
257
|
-
*/
|
|
258
|
-
export function npxFailureHint(output) {
|
|
259
|
-
const s = String(output ?? "");
|
|
260
|
-
if (/MODULE_NOT_FOUND/.test(s) && /(npx-cli\.js|npm-cli\.js|npm-prefix\.js)/.test(s)) {
|
|
261
|
-
return "the npx on PATH is a shim whose npm install is missing — check `npm config get prefix`";
|
|
262
|
-
}
|
|
263
|
-
return null;
|
|
264
|
-
}
|