@datumos/datum-cli 0.0.0-stage → 0.588.0

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.
@@ -0,0 +1,779 @@
1
+ /**
2
+ * Self-update of the `datum` CLI (#1512, docs/cli.md "Updates").
3
+ *
4
+ * The shape is the one Claude Code, bun and rustup use: a new version is
5
+ * installed NEXT TO the running one and a pointer is switched atomically, so
6
+ * the next start runs it — the running process is never overwritten in
7
+ * place (a root-owned global npm prefix answers EACCES, and Windows refuses
8
+ * to replace files a process holds open).
9
+ *
10
+ * <data dir>/versions/<v>/ one extracted CLI bundle (package + node_modules)
11
+ * <data dir>/current the version the launcher runs (rename = atomic)
12
+ * <data dir>/update-state.json last check, the server's answer, the opt-out
13
+ *
14
+ * The package installed with `npm i -g @datumos/datum-cli` stays the launcher:
15
+ * before main() it runs `preflight()`, which hands the process over to the
16
+ * newest installed version (never an OLDER one — an `npm i -g` of a newer
17
+ * release wins over a stale pointer) and, at most every
18
+ * UPDATE_CHECK_INTERVAL_MS, starts a detached `datum update --background`
19
+ * that asks the SERVER — not the npm registry, which a machine may not
20
+ * reach — for the release it runs (`GET /api/cli/latest`). The
21
+ * server serves the bundle built from its own commit, so a CLI follows the
22
+ * platform it talks to. Below the server's `minVersion` the update runs in
23
+ * the foreground before the command: an old CLI must not fail a command the
24
+ * API already changed under it (#758).
25
+ *
26
+ * Modes (DATUM_AUTOUPDATE): `on` (default), `off`, `min-only` (update ONLY
27
+ * when below the server's minimum — the AgentCore runtime image, whose CLI
28
+ * is otherwise pinned and smoke-tested). DATUM_NO_AUTOUPDATE=1, CI=true and
29
+ * `datum update --auto off` turn it off. A CLI running from the repository
30
+ * (tsx, server/dist in the app image) is never updated — only the published
31
+ * package is.
32
+ *
33
+ * Every failure here is swallowed into state + at most one stderr line: an
34
+ * updater that breaks the command it wraps is worse than no updater.
35
+ */
36
+ import { spawn } from "node:child_process";
37
+ import { createHash } from "node:crypto";
38
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
39
+ import { homedir } from "node:os";
40
+ import path from "node:path";
41
+ import { BundleSignatureError, ENV_REQUIRE_SIGNED_UPDATE, checkBundleSignature, signatureRequired, } from "./bundleSignature.js";
42
+ import { isLoopbackHost } from "./secureOrigin.js";
43
+ /** The public package (npmjs.org, #2613) — what a bundle is built and signed as. */
44
+ export const CLI_PACKAGE_NAME = "@datumos/datum-cli";
45
+ /**
46
+ * The name the same package carried in a private registry before it went
47
+ * public. Still published there as an alias, and still the launcher of every
48
+ * install made before the switch — those keep updating themselves from the
49
+ * server's bundle, which is why both names count wherever one is checked.
50
+ */
51
+ export const LEGACY_CLI_PACKAGE_NAME = "@datum/datum-cli";
52
+ export const CLI_PACKAGE_NAMES = [CLI_PACKAGE_NAME, LEGACY_CLI_PACKAGE_NAME];
53
+ /** How often the background check may ask the server. */
54
+ export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
55
+ /**
56
+ * A newer release the last check already KNOWS of (a `datum update --check`,
57
+ * a background check that failed to install) is retried this often — not
58
+ * after the full check interval, which would leave a known update idle.
59
+ */
60
+ export const UPDATE_RETRY_INTERVAL_MS = 15 * 60 * 1000;
61
+ /**
62
+ * After an install of the SAME bundle (version + integrity) failed, the next
63
+ * automatic attempt waits this long — by the number of failures in a row,
64
+ * the last value repeating (#3325). A deterministic failure (a bundle this
65
+ * machine cannot extract) must not come back with every command; a newer
66
+ * release, a rebuilt bundle or an explicit `datum update` tries at once.
67
+ */
68
+ export const UPDATE_FAILURE_BACKOFF_MS = [15 * 60 * 1000, 6 * 60 * 60 * 1000, 24 * 60 * 60 * 1000];
69
+ /** A lock older than this belongs to a dead updater and is broken. */
70
+ export const UPDATE_LOCK_STALE_MS = 10 * 60 * 1000;
71
+ /** Installed versions kept besides the current one (rollback targets). */
72
+ export const KEEP_PREVIOUS_VERSIONS = 2;
73
+ /** The server route the check asks (routes/cliRelease.ts). */
74
+ export const CLI_LATEST_PATH = "/api/cli/latest";
75
+ export const ENV_AUTOUPDATE = "DATUM_AUTOUPDATE";
76
+ export const ENV_NO_AUTOUPDATE = "DATUM_NO_AUTOUPDATE";
77
+ /** Set on the process the launcher hands over to — it must not hand over again. */
78
+ export const ENV_DELEGATED = "DATUM_CLI_DELEGATED";
79
+ /** The npm-installed launcher's own version, told to the process it hands over to. */
80
+ export const ENV_LAUNCHER_VERSION = "DATUM_CLI_LAUNCHER_VERSION";
81
+ /** Overrides the data directory (tests, unusual homes). */
82
+ export const ENV_DATA_DIR = "DATUM_DATA_DIR";
83
+ export const AUTOUPDATE_ON = "on";
84
+ export const AUTOUPDATE_OFF = "off";
85
+ export const AUTOUPDATE_MIN_ONLY = "min-only";
86
+ const MODES = [AUTOUPDATE_ON, AUTOUPDATE_OFF, AUTOUPDATE_MIN_ONLY];
87
+ export function parseVersion(text) {
88
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(text ?? "").trim());
89
+ if (!m)
90
+ return null;
91
+ return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null };
92
+ }
93
+ /** Semver order, enough of it: numeric core, a prerelease sorts below its release. */
94
+ export function compareVersions(a, b) {
95
+ const x = parseVersion(a);
96
+ const y = parseVersion(b);
97
+ if (!x || !y)
98
+ throw new Error(`not a version: ${x ? b : a}`);
99
+ for (let i = 0; i < 3; i++) {
100
+ const d = x.core[i] - y.core[i];
101
+ if (d !== 0)
102
+ return d;
103
+ }
104
+ if (x.pre === y.pre)
105
+ return 0;
106
+ if (x.pre === null)
107
+ return 1;
108
+ if (y.pre === null)
109
+ return -1;
110
+ return comparePrerelease(x.pre, y.pre);
111
+ }
112
+ /** SemVer §11: dot-separated identifiers, numeric ones numerically and below
113
+ * alphanumeric ones, a shorter equal prefix lower (`alpha` < `alpha.1`). */
114
+ function comparePrerelease(a, b) {
115
+ const xs = a.split(".");
116
+ const ys = b.split(".");
117
+ for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
118
+ const x = xs[i];
119
+ const y = ys[i];
120
+ if (x === undefined)
121
+ return -1;
122
+ if (y === undefined)
123
+ return 1;
124
+ const xn = /^\d+$/.test(x);
125
+ const yn = /^\d+$/.test(y);
126
+ if (xn && yn) {
127
+ const d = Number(x) - Number(y);
128
+ if (d !== 0)
129
+ return d;
130
+ }
131
+ else if (xn !== yn) {
132
+ return xn ? -1 : 1;
133
+ }
134
+ else if (x !== y) {
135
+ return x < y ? -1 : 1;
136
+ }
137
+ }
138
+ return 0;
139
+ }
140
+ function newest(versions) {
141
+ let best = null;
142
+ for (const v of versions) {
143
+ if (!v || !parseVersion(v))
144
+ continue;
145
+ if (best === null || compareVersions(v, best) > 0)
146
+ best = v;
147
+ }
148
+ return best;
149
+ }
150
+ // ─── where things live ───────────────────────────────────────────────────
151
+ export function updatePaths(env = process.env, platform = process.platform) {
152
+ const override = env[ENV_DATA_DIR]?.trim();
153
+ let root;
154
+ if (override) {
155
+ root = override;
156
+ }
157
+ else if (platform === "win32") {
158
+ root = path.join(env.LOCALAPPDATA?.trim() || path.join(homedir(), "AppData", "Local"), "datum");
159
+ }
160
+ else {
161
+ root = path.join(env.XDG_DATA_HOME?.trim() || path.join(homedir(), ".local", "share"), "datum");
162
+ }
163
+ return {
164
+ root,
165
+ versionsDir: path.join(root, "versions"),
166
+ currentFile: path.join(root, "current"),
167
+ stateFile: path.join(root, "update-state.json"),
168
+ lockDir: path.join(root, "update.lock"),
169
+ claimDir: path.join(root, "background.claim"),
170
+ };
171
+ }
172
+ /** The entry point of an installed version (the bundle is the package root). */
173
+ export function entryFor(paths, version) {
174
+ return path.join(paths.versionsDir, version, "cli", "datum.js");
175
+ }
176
+ /**
177
+ * Is `entryDir` (the directory of the running datum.js) the PUBLISHED
178
+ * package — or a repository build? Only the former updates itself: the app
179
+ * image's `datum` runs server/dist from the commit it was built from, and a
180
+ * developer's tsx run is whatever they checked out.
181
+ */
182
+ export function isPackagedInstall(entryDir) {
183
+ try {
184
+ const pkg = JSON.parse(readFileSync(path.join(entryDir, "..", "package.json"), "utf8"));
185
+ return typeof pkg.name === "string" && CLI_PACKAGE_NAMES.includes(pkg.name);
186
+ }
187
+ catch {
188
+ return false;
189
+ }
190
+ }
191
+ function readPackageName(dir) {
192
+ try {
193
+ const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
194
+ return typeof pkg.name === "string" ? pkg.name : null;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ export function readCurrent(paths) {
201
+ try {
202
+ const v = readFileSync(paths.currentFile, "utf8").trim();
203
+ return parseVersion(v) ? v : null;
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
209
+ /** Installed versions whose entry file is there, newest first. */
210
+ export function installedVersions(paths) {
211
+ let names;
212
+ try {
213
+ names = readdirSync(paths.versionsDir);
214
+ }
215
+ catch {
216
+ return [];
217
+ }
218
+ return names
219
+ .filter((n) => parseVersion(n) && existsSync(entryFor(paths, n)))
220
+ .sort((a, b) => compareVersions(b, a));
221
+ }
222
+ export function readState(paths) {
223
+ try {
224
+ const parsed = JSON.parse(readFileSync(paths.stateFile, "utf8"));
225
+ return parsed && typeof parsed === "object" ? parsed : {};
226
+ }
227
+ catch {
228
+ return {};
229
+ }
230
+ }
231
+ /** Write-then-rename: a reader never sees half a file. */
232
+ function writeAtomic(file, text) {
233
+ mkdirSync(path.dirname(file), { recursive: true });
234
+ const tmp = `${file}.${process.pid}.tmp`;
235
+ writeFileSync(tmp, text);
236
+ renameSync(tmp, file);
237
+ }
238
+ export function writeState(paths, patch) {
239
+ const next = { ...readState(paths), ...patch };
240
+ writeAtomic(paths.stateFile, `${JSON.stringify(next, null, 2)}\n`);
241
+ return next;
242
+ }
243
+ /** The note an install left (UpdateState.notice), cleared as it is taken — shown once. */
244
+ export function takeNotice(paths) {
245
+ const { notice } = readState(paths);
246
+ if (!notice)
247
+ return null;
248
+ try {
249
+ writeState(paths, { notice: undefined });
250
+ }
251
+ catch {
252
+ return null; // an unwritable home would repeat it forever — say nothing instead
253
+ }
254
+ return notice;
255
+ }
256
+ // ─── policy ──────────────────────────────────────────────────────────────
257
+ const truthy = (v) => !!v && !/^(0|false|no|off)$/i.test(v.trim());
258
+ export function autoUpdateMode(env, state) {
259
+ if (truthy(env[ENV_NO_AUTOUPDATE]))
260
+ return AUTOUPDATE_OFF;
261
+ const explicit = env[ENV_AUTOUPDATE]?.trim().toLowerCase();
262
+ if (explicit && MODES.includes(explicit))
263
+ return explicit;
264
+ if (state.autoUpdate === false)
265
+ return AUTOUPDATE_OFF;
266
+ // A CI job wants the CLI it pinned, not one that changes between steps.
267
+ if (truthy(env.CI))
268
+ return AUTOUPDATE_OFF;
269
+ return AUTOUPDATE_ON;
270
+ }
271
+ /**
272
+ * The version the launcher should hand over to: the pointer, when it names
273
+ * an installed version NEWER than the one running. Never an older one.
274
+ */
275
+ export function pickDelegate(ownVersion, paths, env) {
276
+ if (env[ENV_DELEGATED])
277
+ return null;
278
+ const current = readCurrent(paths);
279
+ if (!current || !parseVersion(ownVersion))
280
+ return null;
281
+ if (compareVersions(current, ownVersion) <= 0)
282
+ return null;
283
+ const entry = entryFor(paths, current);
284
+ return existsSync(entry) ? entry : null;
285
+ }
286
+ /** The version this machine effectively runs: the newer of the package and the pointer. */
287
+ export function effectiveVersion(ownVersion, paths) {
288
+ return newest([ownVersion, readCurrent(paths)]) ?? ownVersion;
289
+ }
290
+ const elapsed = (iso, now, ms) => {
291
+ const t = iso ? Date.parse(iso) : NaN;
292
+ return !Number.isFinite(t) || now - t >= ms;
293
+ };
294
+ export function checkIsDue(state, now) {
295
+ return elapsed(state.lastCheckAt, now, UPDATE_CHECK_INTERVAL_MS);
296
+ }
297
+ /** One line of an error that tar (or anything else) answered in several — for the state, `--check` and stderr. */
298
+ export function oneLine(message) {
299
+ return message.trim().replace(/\s*\n\s*/g, "; ");
300
+ }
301
+ /** `2026-11-13T22:34:14Z (in 5 h 58 min)` — when a deferred attempt runs, said for a person. */
302
+ export function describeWhen(at, now) {
303
+ const iso = new Date(at).toISOString().replace(/\.\d{3}Z$/, "Z");
304
+ const minutes = Math.max(0, Math.ceil((at - now) / 60_000));
305
+ const h = Math.floor(minutes / 60);
306
+ const m = minutes % 60;
307
+ return `${iso} (in ${h ? `${h} h ` : ""}${m} min)`;
308
+ }
309
+ export function failureBackoffMs(count) {
310
+ const steps = UPDATE_FAILURE_BACKOFF_MS;
311
+ return steps[Math.min(Math.max(count, 1), steps.length) - 1];
312
+ }
313
+ /**
314
+ * When may the automatic update try to install `latest` again? Null when
315
+ * nothing holds it back — no failure recorded, or the failure was another
316
+ * bundle (a newer release, a rebuilt one: other integrity).
317
+ */
318
+ export function installDeferredUntil(state, latest, now) {
319
+ const failed = state.failedInstall;
320
+ if (!failed || !latest?.package)
321
+ return null;
322
+ if (failed.version !== latest.version || failed.integrity !== latest.package.integrity)
323
+ return null;
324
+ const at = Date.parse(failed.at);
325
+ if (!Number.isFinite(at))
326
+ return null;
327
+ const until = at + failureBackoffMs(failed.count);
328
+ return now < until ? until : null;
329
+ }
330
+ /** Record a failed install of `latest` — one more in a row when it is the same bundle. */
331
+ function recordFailedInstall(paths, latest, message, now) {
332
+ if (!latest.package)
333
+ return;
334
+ const error = oneLine(message);
335
+ const prev = readState(paths).failedInstall;
336
+ const same = prev?.version === latest.version && prev.integrity === latest.package.integrity;
337
+ writeState(paths, {
338
+ lastError: error,
339
+ failedInstall: {
340
+ version: latest.version,
341
+ integrity: latest.package.integrity,
342
+ count: same ? prev.count + 1 : 1,
343
+ at: new Date(now).toISOString(),
344
+ error,
345
+ },
346
+ });
347
+ }
348
+ /**
349
+ * Should the launcher start a background update now? When a check is due —
350
+ * or when the last answer already names a newer installable release than
351
+ * the one running (retried every UPDATE_RETRY_INTERVAL_MS, not every command,
352
+ * and not before the backoff of a failed install of that bundle has passed).
353
+ */
354
+ export function backgroundIsDue(state, running, now) {
355
+ if (!elapsed(state.lastAttemptAt, now, UPDATE_RETRY_INTERVAL_MS))
356
+ return false;
357
+ if (checkIsDue(state, now))
358
+ return true;
359
+ const known = state.latest;
360
+ if (installDeferredUntil(state, known, now) !== null)
361
+ return false;
362
+ return !!known?.package && !!parseVersion(known.version) && compareVersions(known.version, running) > 0;
363
+ }
364
+ // ─── talking to the server ───────────────────────────────────────────────
365
+ /**
366
+ * Code is only ever installed from a server reached over TLS: the integrity
367
+ * the CLI checks comes from the SAME server as the bundle, so over plain
368
+ * HTTP anybody on the path could hand over both. The one exception is a
369
+ * loopback host — the local dev stack, where the path is this machine.
370
+ */
371
+ export function isTrustedUpdateSource(baseUrl) {
372
+ let url;
373
+ try {
374
+ url = new URL(baseUrl);
375
+ }
376
+ catch {
377
+ return false;
378
+ }
379
+ if (url.protocol === "https:")
380
+ return true;
381
+ return url.protocol === "http:" && isLoopbackHost(url.hostname);
382
+ }
383
+ export class UntrustedUpdateSourceError extends Error {
384
+ constructor(baseUrl) {
385
+ super(`refusing to update from ${baseUrl} — the CLI installs code only from an https:// server (or a loopback http:// one)`);
386
+ }
387
+ }
388
+ function isCliLatest(v) {
389
+ if (!v || typeof v !== "object")
390
+ return false;
391
+ const o = v;
392
+ if (!parseVersion(o.version) || !parseVersion(o.minVersion))
393
+ return false;
394
+ if (o.package === null)
395
+ return true;
396
+ const p = o.package;
397
+ return !!p && typeof p.url === "string" && typeof p.integrity === "string";
398
+ }
399
+ export async function fetchLatest(baseUrl, fetchImpl, timeoutMs) {
400
+ // No redirects, here or for the bundle: a redirect could land either
401
+ // request on another origin, and the same-origin rule would be moot.
402
+ const res = await fetchImpl(new URL(CLI_LATEST_PATH, baseUrl).toString(), {
403
+ signal: AbortSignal.timeout(timeoutMs),
404
+ headers: { accept: "application/json" },
405
+ redirect: "error",
406
+ });
407
+ if (!res.ok)
408
+ throw new Error(`${CLI_LATEST_PATH} answered HTTP ${res.status}`);
409
+ const body = await res.json();
410
+ if (!isCliLatest(body))
411
+ throw new Error(`${CLI_LATEST_PATH} answered an unexpected shape`);
412
+ return body;
413
+ }
414
+ /** `sha512-<base64>` — the npm/SRI spelling the server sends. */
415
+ export function sriSha512(bytes) {
416
+ return `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
417
+ }
418
+ export const runProcess = (cmd, args, opts = {}) => new Promise((resolve) => {
419
+ const child = spawn(cmd, args, {
420
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
421
+ env: { ...process.env, ...opts.env },
422
+ stdio: ["ignore", "pipe", "pipe"],
423
+ // Windows: tar.exe and the smoke `node --version` are console programs —
424
+ // without this each pops up its own console window (#3325).
425
+ windowsHide: true,
426
+ });
427
+ let stdout = "";
428
+ let stderr = "";
429
+ child.stdout.on("data", (d) => (stdout += d.toString()));
430
+ child.stderr.on("data", (d) => (stderr += d.toString()));
431
+ child.on("error", (err) => resolve({ code: 127, stdout, stderr: stderr + err.message }));
432
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
433
+ });
434
+ export class UpdateLockedError extends Error {
435
+ }
436
+ const LOCK_OWNER_FILE = "owner";
437
+ /**
438
+ * Take the install lock; returns the owner token that releases it. `mkdir`
439
+ * is atomic; a stale lock (its holder long gone) is taken over by an atomic
440
+ * RENAME away first — of two processes seeing the same stale lock only one
441
+ * rename succeeds, so a fresh lock is never removed by a second taker.
442
+ */
443
+ function acquireLock(paths, now) {
444
+ mkdirSync(paths.root, { recursive: true });
445
+ const token = `${process.pid}-${now}-${Math.random().toString(36).slice(2)}`;
446
+ const take = () => {
447
+ try {
448
+ mkdirSync(paths.lockDir);
449
+ }
450
+ catch {
451
+ return false;
452
+ }
453
+ writeFileSync(path.join(paths.lockDir, LOCK_OWNER_FILE), token);
454
+ return true;
455
+ };
456
+ if (take())
457
+ return token;
458
+ try {
459
+ if (now - statSync(paths.lockDir).mtimeMs >= UPDATE_LOCK_STALE_MS) {
460
+ const stale = `${paths.lockDir}.stale-${token}`;
461
+ renameSync(paths.lockDir, stale);
462
+ rmSync(stale, { recursive: true, force: true });
463
+ if (take())
464
+ return token;
465
+ }
466
+ }
467
+ catch {
468
+ // lost the takeover race, or the lock vanished under us
469
+ }
470
+ throw new UpdateLockedError("another datum update is running");
471
+ }
472
+ /** Release ONLY a lock this process holds — never one somebody took over. */
473
+ function releaseLock(paths, token) {
474
+ try {
475
+ if (readFileSync(path.join(paths.lockDir, LOCK_OWNER_FILE), "utf8") !== token)
476
+ return;
477
+ const released = `${paths.lockDir}.released-${token}`;
478
+ renameSync(paths.lockDir, released);
479
+ rmSync(released, { recursive: true, force: true });
480
+ }
481
+ catch {
482
+ // already gone
483
+ }
484
+ }
485
+ /**
486
+ * The tar that extracts the bundle. On Windows it is the system's own
487
+ * `%SystemRoot%\System32\tar.exe` (bsdtar, Windows 10+), named by its path:
488
+ * a GNU tar earlier on PATH (Git for Windows' Unix tools, MSYS) reads
489
+ * `C:\…\x.tgz` as a remote `host:file` and fails (#3325).
490
+ */
491
+ export function tarCommand(platform = process.platform, env = process.env) {
492
+ if (platform !== "win32")
493
+ return "tar";
494
+ const systemTar = path.join(env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows", "System32", "tar.exe");
495
+ return existsSync(systemTar) ? systemTar : "tar";
496
+ }
497
+ /** Keep the current version plus KEEP_PREVIOUS_VERSIONS older ones. */
498
+ function prune(paths, current) {
499
+ const keep = new Set([current, ...installedVersions(paths).filter((v) => v !== current).slice(0, KEEP_PREVIOUS_VERSIONS)]);
500
+ let names = [];
501
+ try {
502
+ names = readdirSync(paths.versionsDir);
503
+ }
504
+ catch {
505
+ return;
506
+ }
507
+ for (const name of names) {
508
+ if (!keep.has(name))
509
+ rmSync(path.join(paths.versionsDir, name), { recursive: true, force: true });
510
+ }
511
+ }
512
+ /**
513
+ * Download → verify sha512 → extract into a staging directory → smoke
514
+ * (`--version` must name the version) → rename into versions/<v> → switch
515
+ * the pointer. Any step failing leaves the previous version in charge.
516
+ */
517
+ export async function installVersion(latest, baseUrl, paths, deps) {
518
+ if (!latest.package)
519
+ throw new Error("the server carries no CLI bundle to install");
520
+ if (!isTrustedUpdateSource(baseUrl))
521
+ throw new UntrustedUpdateSourceError(baseUrl);
522
+ // The bundle must come from the server that announced it, never a URL it names elsewhere.
523
+ const bundleUrl = new URL(latest.package.url, baseUrl);
524
+ if (bundleUrl.origin !== new URL(baseUrl).origin) {
525
+ throw new Error(`the server announced a CLI bundle on another origin (${bundleUrl.origin}) — not installed`);
526
+ }
527
+ const version = latest.version;
528
+ const lockToken = acquireLock(paths, deps.now());
529
+ const staging = path.join(paths.versionsDir, `.staging-${version}-${process.pid}`);
530
+ try {
531
+ const res = await deps.fetch(bundleUrl.toString(), {
532
+ signal: AbortSignal.timeout(120_000),
533
+ redirect: "error",
534
+ });
535
+ if (!res.ok)
536
+ throw new Error(`the CLI bundle download answered HTTP ${res.status}`);
537
+ const bytes = new Uint8Array(await res.arrayBuffer());
538
+ const integrity = sriSha512(bytes);
539
+ if (integrity !== latest.package.integrity) {
540
+ throw new Error("the CLI bundle does not match the integrity the server announced — not installed");
541
+ }
542
+ // The integrity came from the same server as the bytes; the signature
543
+ // ties them to Datum's CI instead (#2612) — a hostile host cannot forge it.
544
+ const signature = checkBundleSignature({ version, integrity, signature: latest.package.signature }, { rootKeys: deps.rootKeys, packageNames: CLI_PACKAGE_NAMES });
545
+ if (!signature.signed) {
546
+ const required = deps.requireSigned ?? signatureRequired(process.env, isLoopbackHost(bundleUrl.hostname));
547
+ if (required)
548
+ throw new BundleSignatureError(`the CLI bundle is not signed by Datum (${signature.reason}) — not installed`);
549
+ }
550
+ rmSync(staging, { recursive: true, force: true });
551
+ mkdirSync(staging, { recursive: true });
552
+ const archive = path.join(staging, "..", `.staging-${version}-${process.pid}.tgz`);
553
+ writeFileSync(archive, bytes);
554
+ try {
555
+ // bsdtar/GNU tar: macOS, Linux and Windows 10+ all ship one.
556
+ const tar = await deps.run(tarCommand(), ["-xzf", archive, "-C", staging]);
557
+ if (tar.code !== 0)
558
+ throw new Error(`extracting the CLI bundle failed: ${tar.stderr.trim() || `tar exit ${tar.code}`}`);
559
+ }
560
+ finally {
561
+ rmSync(archive, { force: true });
562
+ }
563
+ const entry = path.join(staging, "cli", "datum.js");
564
+ if (!existsSync(entry))
565
+ throw new Error("the CLI bundle has no cli/datum.js");
566
+ if (signature.signed && readPackageName(staging) !== signature.name) {
567
+ throw new BundleSignatureError(`the CLI bundle is not the ${signature.name} package it is signed as — not installed`);
568
+ }
569
+ const smoke = await deps.run(deps.nodePath, [entry, "--version"], {
570
+ env: { [ENV_DELEGATED]: "1", [ENV_AUTOUPDATE]: AUTOUPDATE_OFF },
571
+ });
572
+ if (smoke.code !== 0 || !smoke.stdout.includes(`datum ${version} `)) {
573
+ throw new Error(`the new CLI does not start (\`--version\` → ${smoke.stdout.trim() || smoke.stderr.trim() || smoke.code})`);
574
+ }
575
+ const target = path.join(paths.versionsDir, version);
576
+ rmSync(target, { recursive: true, force: true });
577
+ renameSync(staging, target);
578
+ writeAtomic(paths.currentFile, `${version}\n`);
579
+ if (!signature.signed) {
580
+ // Recorded, not printed: most installs run in the detached background
581
+ // check, whose stderr goes nowhere — the next foreground run shows it.
582
+ // Only once it IS installed: a failed attempt must not claim it was (#3325).
583
+ writeState(paths, {
584
+ notice: `datum: ${version} was installed from an UNSIGNED bundle served by ${bundleUrl.origin} (${signature.reason}) — set ${ENV_REQUIRE_SIGNED_UPDATE}=1 to refuse those\n`,
585
+ });
586
+ }
587
+ prune(paths, version);
588
+ return version;
589
+ }
590
+ finally {
591
+ rmSync(staging, { recursive: true, force: true });
592
+ releaseLock(paths, lockToken);
593
+ }
594
+ }
595
+ /**
596
+ * Ask the server; install when it runs a newer release than this machine.
597
+ * `onlyBelowMin`: install ONLY when below the server's minimum (min-only).
598
+ */
599
+ export async function checkForUpdate(opts) {
600
+ const { baseUrl, ownVersion, paths, deps } = opts;
601
+ if (!isTrustedUpdateSource(baseUrl))
602
+ throw new UntrustedUpdateSourceError(baseUrl);
603
+ let latest;
604
+ try {
605
+ latest = await fetchLatest(baseUrl, deps.fetch, opts.timeoutMs ?? 10_000);
606
+ }
607
+ catch (err) {
608
+ writeState(paths, { lastCheckAt: new Date(deps.now()).toISOString(), lastError: err.message });
609
+ throw err;
610
+ }
611
+ writeState(paths, { lastCheckAt: new Date(deps.now()).toISOString(), latest, lastError: undefined });
612
+ const running = effectiveVersion(ownVersion, paths);
613
+ const wanted = opts.onlyBelowMin
614
+ ? compareVersions(running, latest.minVersion) < 0
615
+ : compareVersions(latest.version, running) > 0;
616
+ if (!wanted || !latest.package)
617
+ return { latest, installed: null, running };
618
+ // An installed copy of that version (a rollback, say) only needs the pointer.
619
+ if (existsSync(entryFor(paths, latest.version))) {
620
+ writeAtomic(paths.currentFile, `${latest.version}\n`);
621
+ return { latest, installed: latest.version, running: latest.version };
622
+ }
623
+ const deferredUntil = opts.ignoreBackoff ? null : installDeferredUntil(readState(paths), latest, deps.now());
624
+ if (deferredUntil !== null)
625
+ return { latest, installed: null, running, deferredUntil };
626
+ try {
627
+ const installed = await installVersion(latest, baseUrl, paths, deps);
628
+ writeState(paths, { failedInstall: undefined });
629
+ return { latest, installed, running: installed };
630
+ }
631
+ catch (err) {
632
+ // Another updater at work is not a failure of the bundle.
633
+ if (err instanceof UpdateLockedError)
634
+ writeState(paths, { lastError: err.message });
635
+ else
636
+ recordFailedInstall(paths, latest, err.message, deps.now());
637
+ throw err;
638
+ }
639
+ }
640
+ /** Point `current` at an installed version — `datum update --to <v>` (rollback). */
641
+ export function switchTo(paths, version) {
642
+ if (!existsSync(entryFor(paths, version))) {
643
+ const have = installedVersions(paths);
644
+ throw new Error(`version ${version} is not installed here${have.length ? ` (installed: ${have.join(", ")})` : ""}`);
645
+ }
646
+ writeAtomic(paths.currentFile, `${version}\n`);
647
+ }
648
+ /** `datum update --to package`: drop the pointer, run the npm-installed package again. */
649
+ export function clearCurrent(paths) {
650
+ rmSync(paths.currentFile, { force: true });
651
+ }
652
+ /** `datum update …` itself must not recurse into another check. */
653
+ function isUpdateCommand(argv) {
654
+ return argv.find((a) => !a.startsWith("-")) === "update";
655
+ }
656
+ /**
657
+ * The ONE launcher allowed to start a background update in this retry
658
+ * window: `mkdir` is atomic, so of N concurrent commands exactly one gets
659
+ * the claim; a claim older than the window is taken over by renaming it
660
+ * away first (rename is atomic too — two launchers cannot both win it).
661
+ */
662
+ export function claimBackground(paths, now) {
663
+ mkdirSync(paths.root, { recursive: true });
664
+ try {
665
+ mkdirSync(paths.claimDir);
666
+ return true;
667
+ }
668
+ catch {
669
+ // held — take it over only when its window has passed
670
+ }
671
+ try {
672
+ if (now - statSync(paths.claimDir).mtimeMs < UPDATE_RETRY_INTERVAL_MS)
673
+ return false;
674
+ const stale = `${paths.claimDir}.stale-${process.pid}-${now}`;
675
+ renameSync(paths.claimDir, stale);
676
+ rmSync(stale, { recursive: true, force: true });
677
+ mkdirSync(paths.claimDir);
678
+ return true;
679
+ }
680
+ catch {
681
+ return false;
682
+ }
683
+ }
684
+ export const spawnBackgroundCheck = (entryFile, env) => {
685
+ const child = spawn(process.execPath, [entryFile, "update", "--background"], {
686
+ detached: true,
687
+ stdio: "ignore",
688
+ // Windows: a detached child gets its own console window unless hidden (#3325).
689
+ windowsHide: true,
690
+ env: { ...process.env, ...env, [ENV_DELEGATED]: "1" },
691
+ });
692
+ child.on("error", () => { });
693
+ child.unref();
694
+ };
695
+ export async function preflight(ctx) {
696
+ const none = { delegate: null, backgroundStarted: false };
697
+ if (ctx.env[ENV_DELEGATED] || !isPackagedInstall(ctx.entryDir))
698
+ return none;
699
+ const notice = takeNotice(ctx.paths);
700
+ if (notice)
701
+ ctx.err.write(notice);
702
+ const state = readState(ctx.paths);
703
+ const mode = autoUpdateMode(ctx.env, state);
704
+ const updating = isUpdateCommand(ctx.argv);
705
+ // An untrusted source (plain http to a remote host) is skipped silently:
706
+ // `datum update` explains it, a normal command must not nag.
707
+ const source = ctx.baseUrl && isTrustedUpdateSource(ctx.baseUrl) ? ctx.baseUrl : null;
708
+ if (mode !== AUTOUPDATE_OFF && source && !updating) {
709
+ try {
710
+ let known = state.latest;
711
+ // Ask in the foreground, briefly: min-only once per interval (it has no
712
+ // background check, and a fresh container has no state at all); `on`
713
+ // only on the very first run — a freshly installed CLI below the
714
+ // minimum must not fail its first command waiting for a background answer.
715
+ const foregroundCheck = mode === AUTOUPDATE_MIN_ONLY ? checkIsDue(state, ctx.deps.now()) : !state.lastCheckAt;
716
+ if (foregroundCheck) {
717
+ try {
718
+ known = await fetchLatest(source, ctx.deps.fetch, 3_000);
719
+ writeState(ctx.paths, { lastCheckAt: new Date(ctx.deps.now()).toISOString(), latest: known, lastError: undefined });
720
+ }
721
+ catch (err) {
722
+ writeState(ctx.paths, { lastCheckAt: new Date(ctx.deps.now()).toISOString(), lastError: err.message });
723
+ }
724
+ }
725
+ const running = effectiveVersion(ctx.ownVersion, ctx.paths);
726
+ const deferredUntil = installDeferredUntil(readState(ctx.paths), known, ctx.deps.now());
727
+ if (known && known.package && compareVersions(running, known.minVersion) < 0 && deferredUntil !== null) {
728
+ // Below the minimum, yet the same bundle failed moments ago: say so, do not retry it on every command.
729
+ ctx.err.write(`datum: this CLI (${running}) is older than the server's minimum (${known.minVersion}); installing ${known.version} failed — next automatic attempt after ${describeWhen(deferredUntil, ctx.deps.now())}, \`datum update\` retries now\n`);
730
+ }
731
+ else if (known && known.package && compareVersions(running, known.minVersion) < 0) {
732
+ ctx.err.write(`datum: this CLI (${running}) is older than the server's minimum (${known.minVersion}) — updating to ${known.version} first…\n`);
733
+ try {
734
+ const out = await checkForUpdate({
735
+ baseUrl: source,
736
+ ownVersion: ctx.ownVersion,
737
+ paths: ctx.paths,
738
+ deps: ctx.deps,
739
+ onlyBelowMin: mode === AUTOUPDATE_MIN_ONLY,
740
+ });
741
+ if (out.installed)
742
+ ctx.err.write(`datum: updated to ${out.installed}\n${takeNotice(ctx.paths) ?? ""}`);
743
+ }
744
+ catch (err) {
745
+ ctx.err.write(`datum: the update failed (${oneLine(err.message)}) — continuing with ${running}\n`);
746
+ }
747
+ }
748
+ }
749
+ catch {
750
+ // never let the updater break the command
751
+ }
752
+ }
753
+ const delegate = pickDelegate(ctx.ownVersion, ctx.paths, ctx.env);
754
+ let backgroundStarted = false;
755
+ const now = ctx.deps.now();
756
+ if (mode === AUTOUPDATE_ON &&
757
+ source &&
758
+ !updating &&
759
+ backgroundIsDue(readState(ctx.paths), effectiveVersion(ctx.ownVersion, ctx.paths), now)) {
760
+ try {
761
+ // A burst of commands starts one child, not ten: only the launcher that
762
+ // wins the claim spawns — and it re-reads the state under the claim.
763
+ if (!claimBackground(ctx.paths, now))
764
+ return { delegate, backgroundStarted };
765
+ if (!backgroundIsDue(readState(ctx.paths), effectiveVersion(ctx.ownVersion, ctx.paths), now)) {
766
+ return { delegate, backgroundStarted };
767
+ }
768
+ writeState(ctx.paths, { lastAttemptAt: new Date(now).toISOString() });
769
+ // The child resolves no flags: hand it the server this command resolved.
770
+ (ctx.spawnBackground ?? spawnBackgroundCheck)(delegate ?? ctx.entryFile, { ...ctx.env, DATUM_BASE_URL: source });
771
+ backgroundStarted = true;
772
+ }
773
+ catch {
774
+ // an unwritable home just means no background check
775
+ }
776
+ }
777
+ return { delegate, backgroundStarted };
778
+ }
779
+ //# sourceMappingURL=selfUpdate.js.map