toolroll 0.8.0 → 0.8.1

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.
@@ -13,7 +13,8 @@
13
13
  * runtimes folder (`staged-upgrades/`, as deploy-browser stages), never over
14
14
  * the running one. npm's own signature and attestation check covers what it
15
15
  * installed, the installed bytes must be the ones downloaded and hashed, and
16
- * their provenance must name ap9000/toolroll and its publish workflow. The
16
+ * the certificate that signed their provenance must name ap9000/toolroll, its
17
+ * publish workflow and GitHub Actions, checked by npm's own Sigstore verifier. The
17
18
  * service is stopped, and its processes seen gone, before the final backup,
18
19
  * so nothing written before the switch is lost. A failed health check puts
19
20
  * back the previous runtime, database and coding catalog on its own, keeping
@@ -21,9 +22,9 @@
21
22
  * previous runtime and its backups. Every update, rollback, refusal and
22
23
  * failure is a ledger entry.
23
24
  */
24
- import { createHash, randomUUID } from "node:crypto";
25
+ import { createHash, randomUUID, verify as signatureValid, X509Certificate } from "node:crypto";
25
26
  import { spawnSync } from "node:child_process";
26
- import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
27
+ import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
27
28
  import { homedir, userInfo } from "node:os";
28
29
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
29
30
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -41,6 +42,9 @@ function sqlite() {
41
42
  }
42
43
  export const PROVENANCE_REPOSITORY = "https://github.com/ap9000/toolroll";
43
44
  export const PROVENANCE_WORKFLOW = ".github/workflows/publish.yml";
45
+ export const PROVENANCE_ISSUER = "https://token.actions.githubusercontent.com";
46
+ /** How long a watch's `toolroll up` may take to exit once launchd stops it (controller-supervisor's shutdown). */
47
+ const UP_EXIT_MS = 45_000;
44
48
  /** The steps a person sees, in order. */
45
49
  export const UPDATE_STEPS = ["verifying", "draining", "backing-up", "rehearsing", "switching", "restarting", "health"];
46
50
  export const STEP_WORDS = {
@@ -54,6 +58,8 @@ export const UPDATE_JOB_LABEL = "com.toolroll.update";
54
58
  const TERMINAL = ["complete", "restored", "refused", "cancelled"];
55
59
  export const runtimeUpdateTerminal = (phase) => TERMINAL.includes(phase);
56
60
  const journalFile = (stateDir) => join(stateDir, "toolroll-update.json");
61
+ /** The last completed update, kept apart from the journal: what `--rollback` returns from. */
62
+ const lastUpdateFile = (stateDir) => join(stateDir, "toolroll-update.last.json");
57
63
  const cancelFile = (j) => join(j.stageDir, "cancel-request.json");
58
64
  const sha = (bytes, algorithm = "sha256") => createHash(algorithm).update(bytes).digest("hex");
59
65
  const fileHash = (file) => sha(readFileSync(file));
@@ -61,8 +67,7 @@ const quote = (s) => '"' + s.replaceAll('"', '""') + '"';
61
67
  const codingFile = (databaseFile) => `${databaseFile}.coding.sqlite`;
62
68
  class Refusal extends Error {
63
69
  }
64
- export function readRuntimeUpdate(stateDir) {
65
- const file = journalFile(stateDir);
70
+ export function readRuntimeUpdate(stateDir, file = journalFile(stateDir)) {
66
71
  if (!existsSync(file))
67
72
  return null;
68
73
  const stat = lstatSync(file);
@@ -79,6 +84,23 @@ export function readRuntimeUpdate(stateDir) {
79
84
  throw Error("The saved update record is invalid. Preserve it and its backups; nothing was changed.");
80
85
  return j;
81
86
  }
87
+ /** The update `--rollback` returns from: the last one that completed, whatever was attempted since. */
88
+ export function lastCompletedUpdate(stateDir) {
89
+ const kept = existsSync(lastUpdateFile(stateDir)) ? readRuntimeUpdate(stateDir, lastUpdateFile(stateDir)) : readRuntimeUpdate(stateDir);
90
+ return kept?.kind === "update" && kept.phase === "complete" ? kept : null;
91
+ }
92
+ /** Before a new attempt replaces the saved journal: a completed update is kept as what `--rollback` returns from.
93
+ * 0.8.0 and older never wrote `.last.json`, so the update that brought this version would otherwise be lost to the
94
+ * first refused or failed attempt. */
95
+ function keepCompletedUpdate(stateDir) {
96
+ const saved = readRuntimeUpdate(stateDir);
97
+ if (saved?.kind !== "update" || saved.phase !== "complete")
98
+ return;
99
+ const kept = existsSync(lastUpdateFile(stateDir)) ? readRuntimeUpdate(stateDir, lastUpdateFile(stateDir)) : null;
100
+ if (kept && (kept.finishedAt ?? "") >= (saved.finishedAt ?? ""))
101
+ return;
102
+ durableJson(lastUpdateFile(stateDir), saved);
103
+ }
82
104
  function save(j, phase, detail, now) {
83
105
  if (j.phase !== phase)
84
106
  j.steps.push({ phase, at: now.toISOString() });
@@ -115,33 +137,121 @@ export function runningWorkWords(db) {
115
137
  const parts = [...(runs.length ? [`running ${runs.join(", ")}${more > 0 ? ` and ${more} more` : ""}`] : []), ...other];
116
138
  return parts.join("; ");
117
139
  }
118
- /** The npm provenance statement for exactly these bytes must name the
119
- * Toolroll repository and its publish workflow. Anything else is refused.
120
- * npm's own check (UpdateSystem.install) verifies the statement's signature. */
140
+ /** Sigstore's certificate extensions (Fulcio's OID registry) and subjectAltName. */
141
+ const OID = { issuerV1: "1.3.6.1.4.1.57264.1.1", repositoryV1: "1.3.6.1.4.1.57264.1.5", issuer: "1.3.6.1.4.1.57264.1.8", buildSigner: "1.3.6.1.4.1.57264.1.9", sourceRepository: "1.3.6.1.4.1.57264.1.12", subjectAltName: "2.5.29.17" };
142
+ /** One DER element at `at`: its tag and where its contents start and end. */
143
+ function derAt(der, at) {
144
+ let length = der[at + 1] ?? 0, start = at + 2;
145
+ if (length & 0x80) {
146
+ const bytes = length & 0x7f;
147
+ if (bytes < 1 || bytes > 4)
148
+ throw Error("unsupported DER length");
149
+ length = 0;
150
+ for (let i = 0; i < bytes; i++)
151
+ length = length * 256 + (der[start + i] ?? 0);
152
+ start += bytes;
153
+ }
154
+ if (at >= der.length || start + length > der.length)
155
+ throw Error("truncated DER");
156
+ return { tag: der[at], start, end: start + length };
157
+ }
158
+ function derChildren(der, parent) {
159
+ const children = [];
160
+ for (let at = parent.start; at < parent.end;) {
161
+ const child = derAt(der, at);
162
+ children.push(child);
163
+ at = child.end;
164
+ }
165
+ return children;
166
+ }
167
+ function oidText(bytes) {
168
+ const parts = [Math.floor(bytes[0] / 40), bytes[0] % 40];
169
+ let n = 0;
170
+ for (const byte of bytes.subarray(1)) {
171
+ n = n * 128 + (byte & 0x7f);
172
+ if (!(byte & 0x80)) {
173
+ parts.push(n);
174
+ n = 0;
175
+ }
176
+ }
177
+ return parts.join(".");
178
+ }
179
+ /** A certificate's extensions by OID: each one's value octets. Node's X509Certificate does not expose them. */
180
+ function certificateExtensions(der) {
181
+ const tbs = derChildren(der, derAt(der, 0))[0];
182
+ const found = new Map();
183
+ const wrapper = derChildren(der, tbs).find(element => element.tag === 0xa3);
184
+ if (!wrapper)
185
+ return found;
186
+ for (const extension of derChildren(der, derChildren(der, wrapper)[0])) {
187
+ const parts = derChildren(der, extension), oid = parts[0], value = parts[parts.length - 1];
188
+ if (oid.tag !== 0x06 || value.tag !== 0x04)
189
+ throw Error("malformed extension");
190
+ found.set(oidText(der.subarray(oid.start, oid.end)), der.subarray(value.start, value.end));
191
+ }
192
+ return found;
193
+ }
194
+ /** The signing certificate's own words: who asked Sigstore for it (issuer), which repository and which workflow. */
195
+ function certificateIdentity(der) {
196
+ const extensions = certificateExtensions(der);
197
+ const utf8 = (oid) => { const value = extensions.get(oid); if (!value)
198
+ return null; const one = derAt(value, 0); if (one.tag !== 0x0c)
199
+ throw Error("not a UTF8String"); return value.subarray(one.start, one.end).toString("utf8"); };
200
+ const raw = (oid) => extensions.get(oid)?.toString("utf8") ?? null;
201
+ const san = extensions.get(OID.subjectAltName);
202
+ const uris = san ? derChildren(san, derAt(san, 0)).filter(name => name.tag === 0x86).map(name => san.subarray(name.start, name.end).toString("ascii")) : [];
203
+ return { issuer: utf8(OID.issuer) ?? raw(OID.issuerV1), repository: utf8(OID.sourceRepository), repositoryV1: raw(OID.repositoryV1), signer: utf8(OID.buildSigner), uris };
204
+ }
205
+ /** The npm provenance for exactly these bytes of Toolroll itself must be signed by a certificate that names the
206
+ * Toolroll repository, its publish workflow and GitHub Actions as the issuer. The statement's own claims name
207
+ * nobody: only the certificate, whose key must have signed the statement. Sigstore's chain and transparency log are
208
+ * then checked by npm's own verifier (UpdateSystem.sigstore) for the identity returned here. */
121
209
  export function checkProvenance(attestations, version, sha512Hex) {
122
210
  const list = attestations?.attestations;
123
211
  if (!Array.isArray(list) || list.length === 0)
124
212
  throw new Refusal(`Toolroll ${version} has no npm provenance, so it cannot be verified. Nothing was changed.`);
125
- for (const one of list) {
126
- if (!String(one.predicateType ?? "").startsWith("https://slsa.dev/provenance/"))
127
- continue;
128
- let statement;
129
- try {
130
- statement = JSON.parse(Buffer.from(String(one.bundle?.dsseEnvelope?.payload ?? ""), "base64").toString("utf8"));
131
- }
132
- catch {
133
- throw new Refusal(`Toolroll ${version}'s provenance could not be read. Nothing was changed.`);
134
- }
135
- const subject = statement.subject?.find(s => s.name === `pkg:npm/${NAME}@${version}`);
136
- if (!subject || subject.digest?.sha512 !== sha512Hex)
137
- throw new Refusal(`Toolroll ${version}'s provenance is for different bytes than the package downloaded. Nothing was changed.`);
138
- const workflow = statement.predicate?.buildDefinition?.externalParameters?.workflow;
139
- const repository = String(workflow?.repository ?? "").replace(/\.git$/, "");
140
- if (repository !== PROVENANCE_REPOSITORY || workflow?.path !== PROVENANCE_WORKFLOW)
141
- throw new Refusal(`Toolroll ${version} was built by ${repository || "an unnamed repository"} (${workflow?.path ?? "no workflow"}), not ${PROVENANCE_REPOSITORY.replace("https://github.com/", "")} (${PROVENANCE_WORKFLOW}). Nothing was changed.`);
142
- return { repository, workflow: workflow.path };
213
+ const found = list.find(one => String(one?.predicateType ?? "").startsWith("https://slsa.dev/provenance/"));
214
+ if (!found)
215
+ throw new Refusal(`Toolroll ${version} has no build provenance statement. Nothing was changed.`);
216
+ const bundle = found.bundle ?? {}, envelope = bundle.dsseEnvelope ?? {};
217
+ let statement, certificate, who;
218
+ const payload = Buffer.from(String(envelope.payload ?? ""), "base64"), payloadType = String(envelope.payloadType ?? "");
219
+ try {
220
+ const material = bundle.verificationMaterial;
221
+ const der = Buffer.from(String(material?.certificate?.rawBytes ?? material?.x509CertificateChain?.certificates?.[0]?.rawBytes ?? ""), "base64");
222
+ certificate = new X509Certificate(der);
223
+ who = certificateIdentity(der);
224
+ statement = JSON.parse(payload.toString("utf8"));
225
+ }
226
+ catch {
227
+ throw new Refusal(`Toolroll ${version}'s provenance could not be read. Nothing was changed.`);
143
228
  }
144
- throw new Refusal(`Toolroll ${version} has no build provenance statement. Nothing was changed.`);
229
+ // DSSE: the signature covers the pre-authentication encoding of the payload and its type.
230
+ const signed = Buffer.concat([Buffer.from(`DSSEv1 ${Buffer.byteLength(payloadType)} ${payloadType} ${payload.length} `), payload]);
231
+ const signatures = envelope.signatures ?? [];
232
+ let valid = false;
233
+ try {
234
+ valid = signatures.length === 1 && typeof signatures[0].sig === "string" && signatureValid("sha256", signed, certificate.publicKey, Buffer.from(signatures[0].sig, "base64"));
235
+ }
236
+ catch {
237
+ valid = false;
238
+ }
239
+ if (!valid)
240
+ throw new Refusal(`Toolroll ${version}'s provenance is not signed by the certificate it carries. Nothing was changed.`);
241
+ const subject = statement.subject?.find(s => s.name === `pkg:npm/${NAME}@${version}`);
242
+ if (!subject || subject.digest?.sha512 !== sha512Hex)
243
+ throw new Refusal(`Toolroll ${version}'s provenance is for different bytes than the package downloaded. Nothing was changed.`);
244
+ const workflow = `${PROVENANCE_REPOSITORY}/${PROVENANCE_WORKFLOW}@`;
245
+ const identity = who.uris.length === 1 ? who.uris[0] : null;
246
+ const repository = who.repository ?? (who.repositoryV1 === null ? null : `https://github.com/${who.repositoryV1}`);
247
+ const named = who.issuer === PROVENANCE_ISSUER && repository === PROVENANCE_REPOSITORY
248
+ && (who.repositoryV1 === null || `https://github.com/${who.repositoryV1}` === PROVENANCE_REPOSITORY)
249
+ && identity !== null && identity.startsWith(workflow) && (who.signer === null || who.signer.startsWith(workflow));
250
+ if (!named || identity === null || who.issuer === null) {
251
+ const by = identity ?? repository ?? "an unnamed workflow";
252
+ throw new Refusal(`Toolroll ${version} was built by ${by}${who.issuer === PROVENANCE_ISSUER ? "" : ` (signed in by ${who.issuer ?? "no issuer"})`}, not ${PROVENANCE_REPOSITORY.replace("https://github.com/", "")} (${PROVENANCE_WORKFLOW}) on GitHub Actions. Nothing was changed.`);
253
+ }
254
+ return { repository: PROVENANCE_REPOSITORY, workflow: PROVENANCE_WORKFLOW, issuer: who.issuer, identity, bundle };
145
255
  }
146
256
  /** Headlines of this version's changelog section: its bold lead phrases. */
147
257
  export function releaseNotes(changelog, version) {
@@ -241,27 +351,60 @@ function restoreDatabase(j, from) {
241
351
  restoreFile(codingFile(j.databaseFile), from.codingPath, from.codingHash, "coding catalog");
242
352
  ungateCoding(j);
243
353
  }
244
- /** A private copy of the live database before a restore replaces it: what the new version wrote is kept, and named. */
354
+ /** A private copy of the live database before a restore replaces it: what the new version wrote is kept, and named.
355
+ * A database that cannot be read (corrupt, unreadable) is not copied: it is moved aside whole, with its WAL, and the
356
+ * restore goes ahead. */
245
357
  async function keepAside(j) {
246
- const kept = join(j.stageDir, `orders.kept.${randomUUID().slice(0, 8)}.db`);
247
- const db = new (sqlite().DatabaseSync)(j.databaseFile, { readOnly: true });
358
+ const id = randomUUID().slice(0, 8), kept = join(j.stageDir, `orders.kept.${id}.db`);
248
359
  try {
249
- await sqlite().backup(db, kept);
360
+ const db = new (sqlite().DatabaseSync)(j.databaseFile, { readOnly: true });
361
+ try {
362
+ await sqlite().backup(db, kept);
363
+ }
364
+ finally {
365
+ db.close();
366
+ }
367
+ chmodSync(kept, 0o600);
368
+ fsyncPath(kept);
369
+ j.keptAside = kept;
370
+ delete j.keptAsideUnreadable;
250
371
  }
251
- finally {
252
- db.close();
372
+ catch {
373
+ rmSync(kept, { force: true });
374
+ // Already moved aside by an earlier attempt: that path stays the one named.
375
+ if (!existsSync(j.databaseFile))
376
+ return;
377
+ const unreadable = join(j.stageDir, `orders.unreadable.${id}.db`);
378
+ for (const suffix of ["-wal", "-shm", ""])
379
+ if (existsSync(j.databaseFile + suffix))
380
+ durableRename(j.databaseFile + suffix, unreadable + suffix);
381
+ j.keptAside = unreadable;
382
+ j.keptAsideUnreadable = true;
253
383
  }
254
- chmodSync(kept, 0o600);
255
- fsyncPath(kept);
256
- return kept;
257
384
  }
258
385
  // ---- the service: stop, and see it gone -------------------------------------
259
- /** Stop the service and wait until every process it ran has exited: only then may a file it writes be replaced. */
260
- async function stopService(j, system, unit) {
261
- const pids = [...new Set([...(j.service?.unit === unit ? j.service.pids : []), ...await system.servicePids(unit)])];
262
- j.service = { unit, pids };
386
+ /** The service and watch daemons this run stops, switches and restarts: once switched, the ones it switched. */
387
+ function unitsOf(j) {
388
+ return { main: j.switched?.unit?.path ?? j.service?.unit ?? null, watches: j.switched?.watches?.map(w => w.path) ?? j.watches?.map(w => w.unit) ?? [] };
389
+ }
390
+ /** Stop the service and every watch daemon, and wait until every process they ran has exited: only then may a file
391
+ * they write be replaced. */
392
+ async function stopServices(j, system, main, watches) {
393
+ if (!main && watches.length === 0)
394
+ return;
395
+ const pidsOf = async (unit, earlier) => [...new Set([...earlier, ...await system.servicePids(unit)])];
396
+ if (main)
397
+ j.service = { unit: main, pids: await pidsOf(main, j.service?.unit === main ? j.service.pids : []) };
398
+ const watching = [];
399
+ for (const unit of watches)
400
+ watching.push({ unit, pids: await pidsOf(unit, j.watches?.find(w => w.unit === unit)?.pids ?? []) });
401
+ if (watching.length > 0)
402
+ j.watches = watching;
263
403
  save(j, j.phase, j.detail, system.now());
264
- await system.stopService(unit);
404
+ for (const unit of [main, ...watches])
405
+ if (unit)
406
+ await system.stopService(unit);
407
+ const pids = [...(main ? j.service.pids : []), ...watching.flatMap(w => w.pids)];
265
408
  const deadline = system.now().getTime() + (system.exitTimeoutMs ?? 60_000);
266
409
  for (;;) {
267
410
  const alive = pids.filter(pid => system.processAlive(pid));
@@ -280,8 +423,8 @@ function pointLink(path, target) {
280
423
  }
281
424
  /** What runs the current version: its service definition and every command on PATH. */
282
425
  function switchable(j, system) {
283
- const unit = system.serviceUnit(j.from), links = system.commands(j.from);
284
- if (!unit && links.length === 0)
426
+ const unit = system.serviceUnit(j.from), watches = system.watchUnits(j.from), links = system.commands(j.from);
427
+ if (!unit && watches.length === 0 && links.length === 0)
285
428
  throw new Refusal(`No background service or toolroll command runs ${j.from.version} from ${j.from.dist}, so there is nothing to switch. Nothing was changed.`);
286
429
  const real = (path) => { try {
287
430
  return realpathSync(path);
@@ -296,44 +439,75 @@ function switchable(j, system) {
296
439
  const target = real(link);
297
440
  if (target === null || !target.startsWith(root + sep))
298
441
  throw new Refusal(`${link} runs a different Toolroll (${target ?? "a missing file"}), not ${j.from.version}, so it cannot be switched. Remove it, then update again. Nothing was changed.`);
442
+ // A link is replaced by a rename beside it: its folder must take one, or the switch would stop halfway.
443
+ try {
444
+ accessSync(dirname(link), constants.W_OK);
445
+ }
446
+ catch {
447
+ throw new Refusal(`${dirname(link)} cannot be written, so ${link} would keep running ${j.from.version}. Make that folder writable, then update again. Nothing was changed.`);
448
+ }
299
449
  }
300
- if (unit && !readFileSync(unit, "utf8").includes(j.from.dist))
301
- throw new Refusal("The service definition does not name the current runtime. Nothing was changed.");
302
- return { unit, links };
450
+ for (const one of [unit, ...watches])
451
+ if (one && !readFileSync(one, "utf8").includes(j.from.dist))
452
+ throw new Refusal("The service definition does not name the current runtime. Nothing was changed.");
453
+ return { unit, watches, links };
303
454
  }
304
455
  /** Every change is recorded before it is made; `revert` undoes exactly those.
305
456
  * A rollback puts the earlier database back only once the service is gone. */
306
457
  async function switchRuntime(j, system) {
307
458
  // A resumed switch keeps what it first recorded, so a revert still reaches the original.
308
459
  if (!j.switched) {
309
- const { unit, links } = switchable(j, system);
310
- j.switched = { links: links.map(path => ({ path, previous: readlinkSync(path) })), unit: null };
460
+ const { unit, watches, links } = switchable(j, system);
461
+ j.switched = { links: links.map(path => ({ path, previous: readlinkSync(path) })), unit: null, watches: [] };
311
462
  if (unit) {
312
463
  const saved = join(j.stageDir, "service.saved.plist");
313
464
  writeFileDurably(saved, readFileSync(unit));
314
465
  j.switched.unit = { path: unit, saved };
315
466
  }
316
- save(j, "switching", `Pointing ${unit ? "the service and " : ""}the toolroll and standing-orders commands at ${j.to.version}.`, system.now());
467
+ for (const [i, path] of watches.entries()) {
468
+ const saved = join(j.stageDir, `watch.${i}.saved.plist`);
469
+ writeFileDurably(saved, readFileSync(path));
470
+ j.switched.watches.push({ path, saved });
471
+ }
472
+ save(j, "switching", `Pointing ${unit || watches.length > 0 ? "the service and " : ""}the toolroll and standing-orders commands at ${j.to.version}.`, system.now());
317
473
  }
318
- const unit = j.switched.unit?.path ?? null;
474
+ const { main, watches } = unitsOf(j);
319
475
  if (j.restoreFrom && !j.switched.databaseRestored) {
320
- if (unit)
321
- await stopService(j, system, unit);
476
+ await stopServices(j, system, main, watches);
477
+ refuseWhileWatching(j, system, true);
322
478
  restoreDatabase(j, j.restoreFrom);
323
479
  gate(j);
324
480
  j.switched.databaseRestored = true;
325
481
  save(j, "switching", j.detail, system.now());
326
482
  }
327
- if (unit)
328
- writeFileDurably(unit, readFileSync(j.switched.unit.saved, "utf8").replaceAll(j.from.dist, j.to.dist), 0o644);
483
+ for (const one of [j.switched.unit, ...j.switched.watches ?? []])
484
+ if (one)
485
+ writeFileDurably(one.path, readFileSync(one.saved, "utf8").replaceAll(j.from.dist, j.to.dist), 0o644);
329
486
  for (const link of j.switched.links)
330
487
  pointLink(link.path, join(j.to.dist, "bin.js"));
331
488
  }
489
+ /** Put back every definition and command it can; one it cannot write is skipped and named, so the restore goes on. */
332
490
  function revertSwitch(j) {
333
- if (j.switched?.unit && existsSync(j.switched.unit.saved))
334
- writeFileDurably(j.switched.unit.path, readFileSync(j.switched.unit.saved), 0o644);
335
- for (const link of j.switched?.links ?? [])
336
- pointLink(link.path, link.previous);
491
+ const stuck = [];
492
+ for (const one of [j.switched?.unit, ...j.switched?.watches ?? []]) {
493
+ if (!one || !existsSync(one.saved))
494
+ continue;
495
+ try {
496
+ writeFileDurably(one.path, readFileSync(one.saved), 0o644);
497
+ }
498
+ catch (error) {
499
+ stuck.push(`${one.path} (${error.message})`);
500
+ }
501
+ }
502
+ for (const link of j.switched?.links ?? []) {
503
+ try {
504
+ pointLink(link.path, link.previous);
505
+ }
506
+ catch (error) {
507
+ stuck.push(`${link.path} (${error.message})`);
508
+ }
509
+ }
510
+ return stuck;
337
511
  }
338
512
  function gate(j) {
339
513
  const db = new (sqlite().DatabaseSync)(j.databaseFile);
@@ -371,17 +545,29 @@ function ungateCoding(j) {
371
545
  db.close();
372
546
  }
373
547
  }
374
- /** A foreground `toolroll up` holds a live watch lease; the runtime under it must not be replaced. */
375
- function refuseWhileWatching(j, system) {
376
- const db = new (sqlite().DatabaseSync)(j.databaseFile, { readOnly: true });
548
+ /** A foreground `toolroll up` holds a live watch lease; the runtime or database under it must not be replaced.
549
+ * (A watch daemon releases its lease when it is stopped.) `restoring`: checked right before a backup is put back,
550
+ * where a database that cannot be read has no watch on it to protect. */
551
+ function refuseWhileWatching(j, system, restoring = false) {
552
+ let row;
377
553
  try {
378
- const row = db.prepare("SELECT runner, repo FROM watch_lease WHERE expires_at > ? LIMIT 1").get(system.now().toISOString());
379
- if (row)
380
- throw new Refusal(`toolroll up is running for ${String(row["repo"])} (${String(row["runner"])}). Stop it first; ${j.kind === "update" ? "an update replaces the version it runs" : "a rollback replaces the database"}. Nothing was changed.`);
554
+ const db = new (sqlite().DatabaseSync)(j.databaseFile, { readOnly: true });
555
+ try {
556
+ row = db.prepare("SELECT runner, repo FROM watch_lease WHERE expires_at > ? LIMIT 1").get(system.now().toISOString());
557
+ }
558
+ finally {
559
+ db.close();
560
+ }
381
561
  }
382
- finally {
383
- db.close();
562
+ catch (error) {
563
+ if (!restoring)
564
+ throw error;
565
+ row = undefined;
384
566
  }
567
+ if (!row)
568
+ return;
569
+ const what = `toolroll up is running for ${String(row["repo"])} (${String(row["runner"])}).`;
570
+ throw new Refusal(restoring ? `${what} The database was not put back under it; stop it first.` : `${what} Stop it first; ${j.kind === "update" ? "an update replaces the version it runs" : "a rollback replaces the database"}. Nothing was changed.`);
385
571
  }
386
572
  function nextAt(hhmm, now) {
387
573
  const [h, m] = hhmm.split(":").map(Number);
@@ -410,6 +596,7 @@ export function prepareRuntimeUpdate(o, now) {
410
596
  return { refused: `Toolroll ${o.version} is not newer than ${o.current.version}. To go back to an older release, run toolroll update --version ${o.version} --allow-downgrade.` };
411
597
  try {
412
598
  assertNoneActive(o.stateDir);
599
+ keepCompletedUpdate(o.stateDir);
413
600
  }
414
601
  catch (error) {
415
602
  return { refused: error.message };
@@ -435,12 +622,13 @@ export async function startRuntimeRollback(o, system) {
435
622
  let last;
436
623
  try {
437
624
  assertNoneActive(o.stateDir);
438
- last = readRuntimeUpdate(o.stateDir);
625
+ keepCompletedUpdate(o.stateDir);
626
+ last = lastCompletedUpdate(o.stateDir);
439
627
  }
440
628
  catch (error) {
441
629
  return { ok: false, phase: "refused", message: error.message, journal: null };
442
630
  }
443
- if (!last || last.kind !== "update" || last.phase !== "complete" || !last.backupPath || !last.backupHash)
631
+ if (!last || !last.backupPath || !last.backupHash)
444
632
  return { ok: false, phase: "refused", message: "There is no completed update to roll back.", journal: null };
445
633
  if (!existsSync(join(last.from.dist, "bin.js")) || !existsSync(last.backupPath) || (last.codingBackupPath && !existsSync(last.codingBackupPath)))
446
634
  return { ok: false, phase: "refused", message: `The previous runtime (${last.from.version}) or its backup is no longer on this machine. Nothing was changed.`, journal: null };
@@ -465,14 +653,35 @@ export function abandonRuntimeUpdate(stateDir, id, why, now) {
465
653
  if (j?.id === id && j.phase === "scheduled")
466
654
  save(j, "refused", why, now);
467
655
  }
468
- export function requestRuntimeUpdateCancel(stateDir) {
656
+ export function requestRuntimeUpdateCancel(stateDir, now = new Date()) {
469
657
  const j = readRuntimeUpdate(stateDir);
470
658
  if (!j || runtimeUpdateTerminal(j.phase))
471
659
  return "No update is in progress.";
472
660
  if (!["scheduled", "verifying", "draining"].includes(j.phase))
473
661
  return `The update to ${j.to.version} is past the point it can be cancelled (${j.phase}); it will finish or restore on its own.`;
474
662
  durableJson(cancelFile(j), { id: j.id, action: "cancel" });
475
- return `Cancelling the update to ${j.to.version}. Nothing is switched; new work resumes.`;
663
+ // No updater is running to see the request: cancel here, so the admission pause does not outlive it.
664
+ const lock = sqliteLock(join(j.stageDir, "worker.sqlite"));
665
+ if (!lock)
666
+ return `Cancelling the update to ${j.to.version}. Nothing is switched; new work resumes.`;
667
+ try {
668
+ try {
669
+ ungate(j);
670
+ }
671
+ catch { /* no pause yet */ }
672
+ if (j.kind === "update")
673
+ rmSync(join(j.stageDir, "runtime"), { recursive: true, force: true });
674
+ const noun = j.kind === "update" ? "update" : "rollback";
675
+ save(j, "cancelled", `The ${noun} to ${j.to.version} was cancelled. Nothing changed; new work resumed.`, now);
676
+ try {
677
+ ledger(j.databaseFile, now, j.actor, `toolroll ${noun} cancelled`, "cancelled", `${j.from.version} → ${j.to.version}`);
678
+ }
679
+ catch { /* the journal still says what happened */ }
680
+ return `Cancelled the ${noun} to ${j.to.version}. Nothing was switched; new work resumes.`;
681
+ }
682
+ finally {
683
+ lock.close();
684
+ }
476
685
  }
477
686
  const cancelRequested = (j) => existsSync(cancelFile(j));
478
687
  async function driveRuntimeUpdate(j, system) {
@@ -512,8 +721,8 @@ async function driveRuntimeUpdate(j, system) {
512
721
  else if (fileHash(j.restoreFrom.path) !== j.restoreFrom.hash || (j.restoreFrom.codingPath && fileHash(j.restoreFrom.codingPath) !== j.restoreFrom.codingHash))
513
722
  throw new Refusal("The update's backup changed since it was made. Nothing was changed.");
514
723
  }
515
- const unit = switchable(j, system).unit;
516
- if (!unit)
724
+ const { unit, watches } = switchable(j, system);
725
+ if (!unit && watches.length === 0)
517
726
  refuseWhileWatching(j, system);
518
727
  if (resumedAt <= at("draining")) {
519
728
  step("draining", j.when === "now" ? "Checking that no work is running, then pausing new work." : "Pausing new work and letting running work finish. Nothing is being cancelled.");
@@ -524,9 +733,8 @@ async function driveRuntimeUpdate(j, system) {
524
733
  }
525
734
  }
526
735
  // The service stops before the backup, so nothing it writes afterwards can be lost by a restore.
527
- step("backing-up", unit ? "Stopping the background service, then backing up the database." : "Backing up the database.");
528
- if (unit)
529
- await stopService(j, system, unit);
736
+ step("backing-up", unit || watches.length > 0 ? "Stopping the background service, then backing up the database." : "Backing up the database.");
737
+ await stopServices(j, system, unit, watches);
530
738
  refuseWhileWatching(j, system);
531
739
  const db = new (sqlite().DatabaseSync)(j.databaseFile, { readOnly: true });
532
740
  try {
@@ -547,10 +755,11 @@ async function driveRuntimeUpdate(j, system) {
547
755
  await switchRuntime(j, system);
548
756
  }
549
757
  if (resumedAt <= at("restarting")) {
550
- const unit = j.switched?.unit?.path ?? null;
551
- step("restarting", unit ? "Restarting the background service." : "No background service runs here; the commands now start the new version.");
552
- if (unit)
553
- await system.restartService(unit);
758
+ const { main, watches } = unitsOf(j);
759
+ step("restarting", main || watches.length > 0 ? "Restarting the background service." : "No background service runs here; the commands now start the new version.");
760
+ for (const unit of [main, ...watches])
761
+ if (unit)
762
+ await system.restartService(unit);
554
763
  }
555
764
  step("health", `Checking that ${j.to.version} is running and healthy.`);
556
765
  const deadline = system.now().getTime() + (system.healthTimeoutMs ?? 90_000);
@@ -566,6 +775,15 @@ async function driveRuntimeUpdate(j, system) {
566
775
  const done = j.kind === "update"
567
776
  ? finish(true, "complete", `Toolroll ${j.to.version} is running. Your previous version (${j.from.version}) and its backup are kept; toolroll update --rollback returns to them.`, "toolroll updated", "complete", verb)
568
777
  : finish(true, "complete", `Back on Toolroll ${j.to.version} with the database from before the update. The records made since are kept in ${basename(j.backupPath ?? "")}.`, "toolroll rolled back", "complete", verb);
778
+ // What --rollback returns from, kept apart from the journal a later refused or failed attempt replaces.
779
+ // The update is done: failing to write this must never send it back through the restore below.
780
+ try {
781
+ if (j.kind === "update")
782
+ durableJson(lastUpdateFile(j.stateDir), j);
783
+ else
784
+ rmSync(lastUpdateFile(j.stateDir), { force: true });
785
+ }
786
+ catch { /* the journal still holds it until the next attempt */ }
569
787
  try {
570
788
  pruneRuntimes(j.stateDir, [j.to.dist, j.from.dist]);
571
789
  }
@@ -585,14 +803,16 @@ async function driveRuntimeUpdate(j, system) {
585
803
  // Nothing uses a refused download; the database backup is kept.
586
804
  if (j.kind === "update")
587
805
  rmSync(join(j.stageDir, "runtime"), { recursive: true, force: true });
588
- // A service stopped for the backup starts again, unchanged.
806
+ // A service and watch daemons stopped for the backup start again, unchanged.
589
807
  let restarted = "";
590
- if (j.service) {
808
+ for (const unit of [j.service?.unit, ...(j.watches ?? []).map(w => w.unit)]) {
809
+ if (!unit)
810
+ continue;
591
811
  try {
592
- await system.restartService(j.service.unit);
812
+ await system.restartService(unit);
593
813
  }
594
814
  catch (again) {
595
- restarted = ` The background service did not start again: ${again.message}`;
815
+ restarted += ` ${unit === j.service?.unit ? "The background service" : `The watch ${basename(unit, ".plist")}`} did not start again: ${again.message}`;
596
816
  }
597
817
  }
598
818
  const refused = error instanceof Refusal;
@@ -621,24 +841,30 @@ async function restore(j, system, finish, verb, why) {
621
841
  try {
622
842
  save(j, "rolling-back", `${why} Restoring ${j.from.version} and the database backup.`, system.now());
623
843
  system.checkpoint?.("rolling-back");
624
- const unit = j.switched?.unit?.path ?? j.service?.unit ?? null;
625
- if (unit)
626
- await stopService(j, system, unit);
627
- revertSwitch(j);
844
+ // The commands go back first, so a stop that fails never leaves them on the failed version.
845
+ const stuck = revertSwitch(j);
846
+ const { main, watches } = unitsOf(j);
847
+ await stopServices(j, system, main, watches);
628
848
  if (!j.backupPath || !j.backupHash)
629
849
  throw Error("No verified backup was recorded.");
630
850
  if (!j.restoredDatabase) {
851
+ refuseWhileWatching(j, system, true);
631
852
  // A fresh copy before EVERY attempt that is about to replace the database, not only the first: a retry
632
853
  // after a failure earlier in the restore must not overwrite writes made since without keeping them.
633
- j.keptAside = await keepAside(j);
854
+ await keepAside(j);
634
855
  save(j, "rolling-back", j.detail, system.now());
635
856
  restoreDatabase(j, { path: j.backupPath, hash: j.backupHash, ...(j.codingBackupPath && j.codingBackupHash ? { codingPath: j.codingBackupPath, codingHash: j.codingBackupHash } : {}) });
636
857
  j.restoredDatabase = true;
637
858
  save(j, "rolling-back", j.detail, system.now());
638
859
  }
639
- if (unit)
640
- await system.restartService(unit);
641
- return finish(false, "restored", `${why} Toolroll ${j.from.version} and its database were restored. Anything written since the backup is kept in ${j.keptAside}.`, `toolroll ${noun} failed`, "restored", `${verb}: ${why}`.slice(0, 300));
860
+ for (const unit of [main, ...watches])
861
+ if (unit)
862
+ await system.restartService(unit);
863
+ const kept = !j.keptAside ? "" : j.keptAsideUnreadable ? ` The live database could not be read, so it was left as it was at ${j.keptAside}.` : ` Anything written since the backup is kept in ${j.keptAside}.`;
864
+ const restored = `${why} Toolroll ${j.from.version} and its database were restored.${kept}`;
865
+ if (stuck.length > 0)
866
+ return finish(false, "needs-attention", `${restored} ${stuck.join("; ")} could not be pointed back at ${j.from.version}. Make ${stuck.length === 1 ? "its folder" : "those folders"} writable, then run toolroll update --resume.`, `toolroll ${noun} failed`, "needs attention", `${verb}: ${why} / ${stuck.join("; ")}`.slice(0, 300));
867
+ return finish(false, "restored", restored, `toolroll ${noun} failed`, "restored", `${verb}: ${why}`.slice(0, 300));
642
868
  }
643
869
  catch (error) {
644
870
  const message = error instanceof Error ? error.message : String(error);
@@ -651,17 +877,20 @@ async function verify(j, system) {
651
877
  throw new Refusal(`The registry answered with ${release.version}, not ${j.to.version}. Nothing was changed.`);
652
878
  if (!release.attestations)
653
879
  throw new Refusal(`Toolroll ${j.to.version} has no npm provenance, so it cannot be verified. Nothing was changed.`);
880
+ if (!onRegistry(release.attestations))
881
+ throw new Refusal(`Toolroll ${j.to.version}'s provenance is not served by the npm registry (${release.attestations.slice(0, 120)}). Nothing was changed.`);
654
882
  const bytes = await system.download(release.tarball);
655
883
  const sha512 = createHash("sha512").update(bytes).digest();
656
884
  if (release.integrity !== `sha512-${sha512.toString("base64")}`)
657
885
  throw new Refusal(`The downloaded package does not match the registry's checksum. Nothing was changed.`);
658
886
  const provenance = checkProvenance(await system.attestations(release.attestations), j.to.version, sha512.toString("hex"));
887
+ await system.sigstore(provenance.bundle, { issuer: provenance.issuer, identity: provenance.identity });
659
888
  const runtime = join(j.stageDir, "runtime");
660
889
  mkdirSync(runtime, { recursive: true, mode: 0o700 });
661
890
  j.to.dist = await system.install(runtime, release);
662
891
  if (!j.to.dist.startsWith(runtime + sep) || !existsSync(join(j.to.dist, "bin.js")))
663
892
  throw new Refusal("The staged runtime has no Toolroll command. Nothing was changed.");
664
- j.package = { sha512: sha512.toString("hex"), ...provenance };
893
+ j.package = { sha512: sha512.toString("hex"), repository: provenance.repository, workflow: provenance.workflow };
665
894
  try {
666
895
  j.notes = releaseNotes(readFileSync(join(dirname(j.to.dist), "CHANGELOG.md"), "utf8"), j.to.version);
667
896
  }
@@ -669,6 +898,16 @@ async function verify(j, system) {
669
898
  j.notes = [];
670
899
  }
671
900
  }
901
+ /** Served over HTTPS by the registry itself, as the package is. */
902
+ function onRegistry(url) {
903
+ try {
904
+ const at = new URL(url), registry = new URL(REGISTRY);
905
+ return at.protocol === "https:" && at.host === registry.host && !at.username && !at.password;
906
+ }
907
+ catch {
908
+ return false;
909
+ }
910
+ }
672
911
  async function drain(j, system) {
673
912
  const db = new (sqlite().DatabaseSync)(j.databaseFile);
674
913
  try {
@@ -717,7 +956,14 @@ export function pruneRuntimes(stateDir, keep) {
717
956
  for (const dir of releases)
718
957
  if (kept.size < KEEP_RUNTIMES)
719
958
  kept.add(dir);
720
- const removed = releases.filter(dir => !kept.has(dir));
959
+ // A database a failed update kept aside is the only copy of what was written then: never pruned.
960
+ const holdsKeptAside = (dir) => { try {
961
+ return readdirSync(dir).some(name => /^orders\.(?:kept|unreadable)\./.test(name));
962
+ }
963
+ catch {
964
+ return true;
965
+ } };
966
+ const removed = releases.filter(dir => !kept.has(dir) && !holdsKeptAside(dir));
721
967
  for (const dir of removed)
722
968
  rmSync(dir, { recursive: true, force: true });
723
969
  return removed;
@@ -747,7 +993,7 @@ export function markWhatsNewSeen(stateDir) {
747
993
  durableJson(journalFile(stateDir), j);
748
994
  durableJson(join(j.stageDir, "update.json"), j);
749
995
  }
750
- const exec = (command, args, options = {}) => spawnSync(command, args, { encoding: "utf8", timeout: options.timeout ?? 300_000, maxBuffer: 16 * 1024 * 1024, ...(options.cwd ? { cwd: options.cwd } : {}) });
996
+ const exec = (command, args, options = {}) => spawnSync(command, args, { encoding: "utf8", timeout: options.timeout ?? 300_000, maxBuffer: 16 * 1024 * 1024, ...(options.cwd ? { cwd: options.cwd } : {}), ...(options.input !== undefined ? { input: options.input } : {}) });
751
997
  const LABELS = ["com.toolroll.browser", "com.standing-orders.browser"];
752
998
  export function currentRuntime(version) {
753
999
  return { version, dist: dirname(fileURLToPath(import.meta.url)) };
@@ -767,6 +1013,11 @@ function processAlive(pid) {
767
1013
  return error.code === "EPERM";
768
1014
  }
769
1015
  }
1016
+ /** The Sigstore verifier npm itself ships (npm install and npm audit signatures use it): no dependency of Toolroll's.
1017
+ * It checks the certificate chain, the transparency log entry and that the certificate names this identity. */
1018
+ const SIGSTORE_SCRIPT = `const input = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
1019
+ require(input.verifier).verify(input.bundle, { certificateIssuer: input.issuer, certificateIdentityURI: input.identity })
1020
+ .then(() => process.exit(0), error => { process.stderr.write(String(error && error.message || error)); process.exit(1); });`;
770
1021
  export function machineSystem(home = homedir(), env = process.env, seams = {}) {
771
1022
  const sh = seams.exec ?? exec;
772
1023
  const supervise = seams.run ?? (async (file, args, options) => (await import("./exec.js")).run(file, args, options));
@@ -806,7 +1057,7 @@ export function machineSystem(home = homedir(), env = process.env, seams = {}) {
806
1057
  // tarball is not checked), and the lockfile records exactly which bytes were installed.
807
1058
  writeFileSync(join(runtime, "package.json"), JSON.stringify({ name: `${NAME}-runtime`, private: true, dependencies: { [NAME]: release.version } }, null, 2));
808
1059
  const registry = `--registry=${REGISTRY}/`;
809
- const installed = sh("npm", ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", registry], { cwd: runtime });
1060
+ const installed = sh("npm", ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", "--no-color", registry], { cwd: runtime });
810
1061
  if (installed.status !== 0)
811
1062
  throw new Refusal(`npm could not install Toolroll ${release.version}: ${(installed.stderr || installed.stdout).trim().slice(-400)} Nothing was changed.`);
812
1063
  let locked;
@@ -818,7 +1069,7 @@ export function machineSystem(home = homedir(), env = process.env, seams = {}) {
818
1069
  }
819
1070
  if (locked?.version !== release.version || locked.integrity !== release.integrity || !String(locked.resolved ?? "").startsWith(`${REGISTRY}/`))
820
1071
  throw new Refusal(`npm installed different bytes than the verified Toolroll ${release.version}. Nothing was changed.`);
821
- const signatures = sh("npm", ["audit", "signatures", registry], { cwd: runtime });
1072
+ const signatures = sh("npm", ["audit", "signatures", "--no-color", registry], { cwd: runtime });
822
1073
  const output = `${signatures.stdout}\n${signatures.stderr}`;
823
1074
  if (signatures.status !== 0)
824
1075
  throw new Refusal(`npm could not verify the package signatures: ${output.trim().slice(-400)} Nothing was changed.`);
@@ -846,16 +1097,48 @@ export function machineSystem(home = homedir(), env = process.env, seams = {}) {
846
1097
  }
847
1098
  return [...found];
848
1099
  },
1100
+ sigstore: async (bundle, identity) => {
1101
+ const verifier = findSigstoreVerifier({
1102
+ path: env["PATH"] ?? "", execPath: seams.execPath ?? process.execPath,
1103
+ npmRoot: () => { const root = sh("npm", ["root", "--global", "--no-color"], { timeout: 30_000 }); return root.status === 0 ? root.stdout.trim() : null; },
1104
+ });
1105
+ if (!verifier)
1106
+ throw new Refusal("Toolroll could not find the Sigstore verifier that npm ships (npm 10 or newer), so its provenance cannot be checked. Nothing was changed.");
1107
+ const checked = sh(process.execPath, ["--no-warnings", "-e", SIGSTORE_SCRIPT], { input: JSON.stringify({ verifier, bundle, ...identity }), timeout: 120_000 });
1108
+ if (checked.status !== 0)
1109
+ throw new Refusal(`Sigstore did not verify Toolroll's provenance for ${identity.identity}: ${(checked.stderr || checked.stdout).trim().slice(-400)} Nothing was changed.`);
1110
+ },
849
1111
  serviceUnit: from => unitFor(from),
1112
+ watchUnits: from => {
1113
+ const dir = join(home, "Library", "LaunchAgents");
1114
+ let names = [];
1115
+ try {
1116
+ names = readdirSync(dir);
1117
+ }
1118
+ catch {
1119
+ return [];
1120
+ }
1121
+ return names.filter(name => /^com\.(?:toolroll|standing-orders)\.watch\..+\.plist$/.test(name)).map(name => join(dir, name))
1122
+ .filter(unit => { try {
1123
+ return readFileSync(unit, "utf8").includes(from.dist);
1124
+ }
1125
+ catch {
1126
+ return false;
1127
+ } });
1128
+ },
850
1129
  servicePids: async (unit) => {
851
1130
  const printed = await supervise("launchctl", ["print", `${domain}/${labelOf(unit)}`]);
852
1131
  const pid = Number(printed.stdout.match(/\n\s*pid = (\d+)/)?.[1]);
853
1132
  return printed.code === 0 && pid > 1 ? [pid] : [];
854
1133
  },
855
1134
  stopService: async (unit) => {
856
- const stopped = await stopLaunchdService(definition(unit), supervise);
857
- if (!stopped.ok)
1135
+ // As long as a watch's toolroll up takes to exit.
1136
+ const stopped = await stopLaunchdService(definition(unit), supervise, { waitMs: UP_EXIT_MS, ...(seams.sleep ? { sleep: seams.sleep } : {}) });
1137
+ if (!stopped.ok) {
1138
+ // The stop disabled the label: enabled again before anything kickstarts it, or it could never start.
1139
+ await supervise("launchctl", ["enable", `${domain}/${labelOf(unit)}`]);
858
1140
  throw Error(`${stopped.message}. Nothing was replaced.`);
1141
+ }
859
1142
  },
860
1143
  restartService: async (unit) => {
861
1144
  const started = await installLaunchdService(definition(unit), supervise);
@@ -893,12 +1176,50 @@ export function machineSystem(home = homedir(), env = process.env, seams = {}) {
893
1176
  },
894
1177
  };
895
1178
  }
1179
+ /** npm's own Sigstore verifier. Found from npm itself (the `npm` on PATH, then the one beside node), never only from
1180
+ * `npm root --global`: with a custom global prefix (npm's advice for permission errors) that folder holds the global
1181
+ * packages, not npm. */
1182
+ export function findSigstoreVerifier(o) {
1183
+ const npmPackage = (start) => {
1184
+ for (let dir = dirname(start); dir !== dirname(dir); dir = dirname(dir)) {
1185
+ try {
1186
+ if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === "npm")
1187
+ return dir;
1188
+ }
1189
+ catch { /* not the package folder yet */ }
1190
+ }
1191
+ return null;
1192
+ };
1193
+ const candidates = [
1194
+ ...o.path.split(":").filter(dir => isAbsolute(dir)).map(dir => () => { try {
1195
+ return npmPackage(realpathSync(join(dir, "npm")));
1196
+ }
1197
+ catch {
1198
+ return null;
1199
+ } }),
1200
+ () => join(dirname(o.execPath), "..", "lib", "node_modules", "npm"),
1201
+ () => { const root = o.npmRoot(); return root && isAbsolute(root) ? join(root, "npm") : null; },
1202
+ ];
1203
+ for (const candidate of candidates) {
1204
+ const npm = candidate();
1205
+ const verifier = npm ? join(npm, "node_modules", "sigstore") : null;
1206
+ if (verifier && existsSync(join(verifier, "package.json")))
1207
+ return resolve(verifier);
1208
+ }
1209
+ return null;
1210
+ }
896
1211
  const jobUnit = (home) => join(home, "Library", "LaunchAgents", `${UPDATE_JOB_LABEL}.plist`);
897
1212
  /** The console starts the updater for one prepared journal as its own one-off
898
1213
  * launchd job, so the service it restarts is not its parent. The job resumes
899
1214
  * that id only; it does not run at login (no RunAtLoad: launchd starts it
900
1215
  * with a kickstart) and removes its definition when it finishes. Elsewhere it
901
1216
  * is a detached process. */
1217
+ /** Where npm puts global commands (`npm prefix --global`/bin), or null when npm does not say. */
1218
+ function npmPrefixBin() {
1219
+ const answered = exec("npm", ["prefix", "--global", "--no-color"], { timeout: 30_000 });
1220
+ const prefix = answered.status === 0 ? answered.stdout.trim() : "";
1221
+ return isAbsolute(prefix) ? join(prefix, "bin") : null;
1222
+ }
902
1223
  export async function launchRuntimeUpdate(args, seams = {}) {
903
1224
  const dist = args.dist ?? dirname(fileURLToPath(import.meta.url));
904
1225
  const command = [process.execPath, join(dist, "bin.js"), "update", "--resume", "--id", args.id, "--db", args.databaseFile];
@@ -907,7 +1228,10 @@ export async function launchRuntimeUpdate(args, seams = {}) {
907
1228
  if ((seams.platform ?? process.platform) === "darwin") {
908
1229
  const home = seams.home ?? homedir();
909
1230
  const run = seams.run ?? (await import("./exec.js")).run;
910
- const unit = launchdPlist({ label: UPDATE_JOB_LABEL, command, workingDirectory: stateDir, logPath: log, pathEnv: [dirname(process.execPath), "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":"), keepAlive: false, runAtLoad: false });
1231
+ // Everywhere a toolroll command is usually linked: the job switches every one it finds on this PATH.
1232
+ const npmBin = (seams.npmBin ?? npmPrefixBin)();
1233
+ const pathEnv = [...new Set([dirname(process.execPath), ...(npmBin ? [npmBin] : []), join(home, ".local", "bin"), join(home, "bin"), join(home, "Library", "pnpm"), "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"])].join(":");
1234
+ const unit = launchdPlist({ label: UPDATE_JOB_LABEL, command, workingDirectory: stateDir, logPath: log, pathEnv, keepAlive: false, runAtLoad: false });
911
1235
  const started = await installLaunchdService({ platform: "darwin", label: UPDATE_JOB_LABEL, bin: process.execPath, entry: join(dist, "bin.js"), logPath: log, unitPath: jobUnit(home), unitContent: unit }, run);
912
1236
  if (!started.ok)
913
1237
  throw Error(started.message);