toolroll 0.7.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.
@@ -0,0 +1,1253 @@
1
+ /**
2
+ * `toolroll update`: a verified, drained, undoable update of an npm-installed
3
+ * Toolroll. It reuses the desktop update engine's pieces (desktop-update.ts):
4
+ * the SQLite admission gate that lets running work finish, the verified
5
+ * private backup (the coding catalog's inside the same reservation), the
6
+ * durable journal written before every irreversible step, the OS lock that
7
+ * shows whether an updater is alive, and resume after a crash. What differs
8
+ * is what gets swapped: a staged npm runtime instead of an app bundle.
9
+ *
10
+ * verify → drain → stop and back up → rehearse → switch → restart → health
11
+ *
12
+ * The new release is installed from the npm registry into a Toolroll-managed
13
+ * runtimes folder (`staged-upgrades/`, as deploy-browser stages), never over
14
+ * the running one. npm's own signature and attestation check covers what it
15
+ * installed, the installed bytes must be the ones downloaded and hashed, and
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
18
+ * service is stopped, and its processes seen gone, before the final backup,
19
+ * so nothing written before the switch is lost. A failed health check puts
20
+ * back the previous runtime, database and coding catalog on its own, keeping
21
+ * what the new version wrote in a named copy. `--rollback` returns to the
22
+ * previous runtime and its backups. Every update, rollback, refusal and
23
+ * failure is a ledger entry.
24
+ */
25
+ import { createHash, randomUUID, verify as signatureValid, X509Certificate } from "node:crypto";
26
+ import { spawnSync } from "node:child_process";
27
+ import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
28
+ import { homedir, userInfo } from "node:os";
29
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
30
+ import { fileURLToPath, pathToFileURL } from "node:url";
31
+ import { createRequire } from "node:module";
32
+ import { durableJson, lingeringWork, sqliteLock, verifiedDatabaseBackup } from "./desktop-update.js";
33
+ import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused, updateGateOwned } from "./desktop-update-gate.js";
34
+ import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, removeCodingUpdateGate } from "./coding-update.js";
35
+ import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
36
+ import { NAME } from "./names.js";
37
+ import { isNewer, REGISTRY } from "./releases.js";
38
+ /** Loaded on first use (as backup.ts and store.ts do), so modules that only import this one (the console,
39
+ * and tests that load it in a browser-like environment) never need `node:sqlite` itself. */
40
+ function sqlite() {
41
+ return createRequire(import.meta.url)("node:sqlite");
42
+ }
43
+ export const PROVENANCE_REPOSITORY = "https://github.com/ap9000/toolroll";
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;
48
+ /** The steps a person sees, in order. */
49
+ export const UPDATE_STEPS = ["verifying", "draining", "backing-up", "rehearsing", "switching", "restarting", "health"];
50
+ export const STEP_WORDS = {
51
+ verifying: "Verify the package", draining: "Let running work finish", "backing-up": "Stop and back up",
52
+ rehearsing: "Rehearse the migration", switching: "Switch to the new version", restarting: "Restart", health: "Health check",
53
+ };
54
+ /** How many release-* runtimes stay on disk: the running one and the one before it. */
55
+ export const KEEP_RUNTIMES = 2;
56
+ /** The one-off launchd job the console starts the updater as. */
57
+ export const UPDATE_JOB_LABEL = "com.toolroll.update";
58
+ const TERMINAL = ["complete", "restored", "refused", "cancelled"];
59
+ export const runtimeUpdateTerminal = (phase) => TERMINAL.includes(phase);
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");
63
+ const cancelFile = (j) => join(j.stageDir, "cancel-request.json");
64
+ const sha = (bytes, algorithm = "sha256") => createHash(algorithm).update(bytes).digest("hex");
65
+ const fileHash = (file) => sha(readFileSync(file));
66
+ const quote = (s) => '"' + s.replaceAll('"', '""') + '"';
67
+ const codingFile = (databaseFile) => `${databaseFile}.coding.sqlite`;
68
+ class Refusal extends Error {
69
+ }
70
+ export function readRuntimeUpdate(stateDir, file = journalFile(stateDir)) {
71
+ if (!existsSync(file))
72
+ return null;
73
+ const stat = lstatSync(file);
74
+ if (!stat.isFile() || stat.isSymbolicLink())
75
+ throw Error("The saved update record is not a regular file. Nothing was changed.");
76
+ const j = JSON.parse(readFileSync(file, "utf8"));
77
+ const real = (path) => { try {
78
+ return realpathSync(path);
79
+ }
80
+ catch {
81
+ return resolve(path);
82
+ } };
83
+ if (j.version !== 1 || !/^[a-f0-9-]{36}$/.test(j.id) || typeof j.stateDir !== "string" || real(j.stateDir) !== real(stateDir) || !isAbsolute(j.stageDir ?? "") || real(dirname(j.stageDir)) !== real(join(stateDir, "staged-upgrades")))
84
+ throw Error("The saved update record is invalid. Preserve it and its backups; nothing was changed.");
85
+ return j;
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
+ }
104
+ function save(j, phase, detail, now) {
105
+ if (j.phase !== phase)
106
+ j.steps.push({ phase, at: now.toISOString() });
107
+ j.phase = phase;
108
+ j.detail = detail;
109
+ j.updatedAt = now.toISOString();
110
+ if (runtimeUpdateTerminal(phase))
111
+ j.finishedAt = now.toISOString();
112
+ durableJson(join(j.stageDir, "update.json"), j);
113
+ durableJson(journalFile(j.stateDir), j);
114
+ }
115
+ /** Every update, rollback, refusal and failure. Plain SQL any build can run,
116
+ * written after any database restore so the restore cannot erase it. */
117
+ function ledger(databaseFile, now, actor, action, outcome, detail) {
118
+ const db = new (sqlite().DatabaseSync)(databaseFile);
119
+ try {
120
+ db.exec("PRAGMA busy_timeout=5000");
121
+ db.prepare("INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail) VALUES (?,?,NULL,NULL,NULL,?,?,'policy',?)")
122
+ .run(now.toISOString(), actor, action, outcome, detail.replace(/[\u0000-\u001f\u007f]+/g, " ").slice(0, 300));
123
+ }
124
+ finally {
125
+ db.close();
126
+ }
127
+ }
128
+ /** Running work by name, for a refusal a person can act on. */
129
+ export function runningWorkWords(db) {
130
+ const active = activeUpdateWork(db);
131
+ if (Object.values(active).every(n => n === 0))
132
+ return null;
133
+ const runs = db.prepare("SELECT tr.external_id id, COALESCE(t.title, tr.external_id) title FROM run r JOIN task_ref tr ON tr.id = r.task_ref LEFT JOIN task t ON t.id = tr.external_id WHERE r.outcome IS NULL ORDER BY r.id LIMIT 3").all()
134
+ .map(row => `${String(row["id"])} (${String(row["title"]).slice(0, 60)})`);
135
+ const more = Number(active["runs"] ?? 0) - runs.length;
136
+ const other = Object.entries(active).filter(([key, n]) => key !== "runs" && n > 0).map(([key, n]) => `${n} ${key === "claims" ? "claimed task" : key === "conversations" ? "chat request" : key === "sessions" ? "held session" : key === "stopping" ? "run still stopping" : key === "codingDeliveries" ? "unconfirmed coding message" : "coding session"}${n === 1 ? "" : "s"}`);
137
+ const parts = [...(runs.length ? [`running ${runs.join(", ")}${more > 0 ? ` and ${more} more` : ""}`] : []), ...other];
138
+ return parts.join("; ");
139
+ }
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. */
209
+ export function checkProvenance(attestations, version, sha512Hex) {
210
+ const list = attestations?.attestations;
211
+ if (!Array.isArray(list) || list.length === 0)
212
+ throw new Refusal(`Toolroll ${version} has no npm provenance, so it cannot be verified. Nothing was changed.`);
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.`);
228
+ }
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 };
255
+ }
256
+ /** Headlines of this version's changelog section: its bold lead phrases. */
257
+ export function releaseNotes(changelog, version) {
258
+ const lines = changelog.split("\n");
259
+ const start = lines.findIndex(line => new RegExp(`^## ${version.replaceAll(".", "\\.")}( |$)`).test(line));
260
+ if (start < 0)
261
+ return [];
262
+ const end = lines.findIndex((line, i) => i > start && line.startsWith("## "));
263
+ const section = lines.slice(start + 1, end < 0 ? undefined : end).join("\n");
264
+ return [...section.matchAll(/^- \*\*(.+?)\*\*/gm)].map(m => m[1].replace(/\.$/, "")).slice(0, 6);
265
+ }
266
+ // ---- durable files ----------------------------------------------------------
267
+ function fsyncPath(path) {
268
+ const fd = openSync(path, "r");
269
+ try {
270
+ fsyncSync(fd);
271
+ }
272
+ finally {
273
+ closeSync(fd);
274
+ }
275
+ }
276
+ /** Every rename here: what is renamed is flushed first (a file itself, a link
277
+ * by its directory), and the directory after, so a crash never leaves a torn
278
+ * database, catalog or command. */
279
+ export function durableRename(temp, target) {
280
+ fsyncPath(lstatSync(temp).isSymbolicLink() ? dirname(temp) : temp);
281
+ renameSync(temp, target);
282
+ if (process.platform !== "win32")
283
+ fsyncPath(dirname(target));
284
+ }
285
+ function tableDigest(db, name, columns) {
286
+ const rows = db.prepare("SELECT " + columns.map(quote).join(",") + " FROM " + quote(name)).all().map(r => JSON.stringify(r)).sort();
287
+ return { count: rows.length, hash: sha(rows.join("\n")) };
288
+ }
289
+ /** Every historical row, table by table (deploy-browser's rehearsal check). */
290
+ export function historySnapshot(db) {
291
+ return db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name <> 'schema_version' ORDER BY name").all()
292
+ .map(row => String(row["name"])).map(name => {
293
+ const columns = db.prepare("PRAGMA table_info(" + quote(name) + ")").all().map(r => String(r["name"]));
294
+ return { name, columns, ...tableDigest(db, name, columns) };
295
+ });
296
+ }
297
+ export function changedHistory(db, before) {
298
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => String(r["name"])));
299
+ return before.filter(t => {
300
+ if (!tables.has(t.name))
301
+ return true;
302
+ const present = new Set(db.prepare("PRAGMA table_info(" + quote(t.name) + ")").all().map(r => String(r["name"])));
303
+ if (t.columns.some(c => !present.has(c)))
304
+ return true;
305
+ const after = tableDigest(db, t.name, t.columns);
306
+ return after.count !== t.count || after.hash !== t.hash;
307
+ }).map(t => t.name);
308
+ }
309
+ async function rehearse(j, system, source) {
310
+ const copy = join(j.stageDir, `rehearsal.${randomUUID()}.db`);
311
+ copyFileSync(source, copy);
312
+ chmodSync(copy, 0o600);
313
+ try {
314
+ let db = new (sqlite().DatabaseSync)(copy, { readOnly: true });
315
+ const before = historySnapshot(db);
316
+ db.close();
317
+ await system.rehearse(j.to.dist, copy);
318
+ db = new (sqlite().DatabaseSync)(copy, { readOnly: true });
319
+ try {
320
+ const changed = changedHistory(db, before);
321
+ if (changed.length > 0)
322
+ throw new Refusal(`Toolroll ${j.to.version} would change saved history in ${changed.slice(0, 4).join(", ")}${changed.length > 4 ? ` and ${changed.length - 4} more` : ""}. Nothing was changed.`);
323
+ if (db.prepare("PRAGMA integrity_check").get()?.["integrity_check"] !== "ok")
324
+ throw new Refusal(`The rehearsed database failed its integrity check under ${j.to.version}. Nothing was changed.`);
325
+ }
326
+ finally {
327
+ db.close();
328
+ }
329
+ return { tables: before.length, rows: before.reduce((n, t) => n + t.count, 0) };
330
+ }
331
+ finally {
332
+ for (const suffix of ["", "-wal", "-shm"])
333
+ rmSync(copy + suffix, { force: true });
334
+ }
335
+ }
336
+ /** Put a verified backup in place of a live SQLite file. Only once the service's processes are gone. */
337
+ function restoreFile(live, backup, hash, what) {
338
+ if (fileHash(backup) !== hash)
339
+ throw Error(`The retained ${what} backup changed. It was not put back.`);
340
+ const temp = `${live}.${randomUUID()}.restore`;
341
+ copyFileSync(backup, temp);
342
+ chmodSync(temp, 0o600);
343
+ for (const suffix of ["-wal", "-shm"])
344
+ rmSync(live + suffix, { force: true });
345
+ durableRename(temp, live);
346
+ }
347
+ /** The database and, when one was backed up, the coding catalog, then this run's coding gate lifted. */
348
+ function restoreDatabase(j, from) {
349
+ restoreFile(j.databaseFile, from.path, from.hash, "database");
350
+ if (from.codingPath && from.codingHash)
351
+ restoreFile(codingFile(j.databaseFile), from.codingPath, from.codingHash, "coding catalog");
352
+ ungateCoding(j);
353
+ }
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. */
357
+ async function keepAside(j) {
358
+ const id = randomUUID().slice(0, 8), kept = join(j.stageDir, `orders.kept.${id}.db`);
359
+ try {
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;
371
+ }
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;
383
+ }
384
+ }
385
+ // ---- the service: stop, and see it gone -------------------------------------
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;
403
+ save(j, j.phase, j.detail, system.now());
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)];
408
+ const deadline = system.now().getTime() + (system.exitTimeoutMs ?? 60_000);
409
+ for (;;) {
410
+ const alive = pids.filter(pid => system.processAlive(pid));
411
+ if (alive.length === 0)
412
+ return;
413
+ if (system.now().getTime() >= deadline)
414
+ throw Error(`The background service is still running (process ${alive.join(", ")}) after it was stopped. Nothing was replaced.`);
415
+ await system.sleep(250);
416
+ }
417
+ }
418
+ // ---- runtime switch: service definition and commands ----------------------
419
+ function pointLink(path, target) {
420
+ const temp = join(dirname(path), `.${basename(path)}.${randomUUID().slice(0, 8)}`);
421
+ symlinkSync(target, temp);
422
+ durableRename(temp, path);
423
+ }
424
+ /** What runs the current version: its service definition and every command on PATH. */
425
+ function switchable(j, system) {
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)
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.`);
429
+ const real = (path) => { try {
430
+ return realpathSync(path);
431
+ }
432
+ catch {
433
+ return null;
434
+ } };
435
+ const root = real(dirname(j.from.dist)) ?? dirname(j.from.dist);
436
+ for (const link of links) {
437
+ if (!lstatSync(link).isSymbolicLink())
438
+ throw new Refusal(`${link} is not a link Toolroll can switch (a shim or a copy), so it would keep running ${j.from.version}. Remove it or reinstall with npm, then update again. Nothing was changed.`);
439
+ const target = real(link);
440
+ if (target === null || !target.startsWith(root + sep))
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
+ }
449
+ }
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 };
454
+ }
455
+ /** Every change is recorded before it is made; `revert` undoes exactly those.
456
+ * A rollback puts the earlier database back only once the service is gone. */
457
+ async function switchRuntime(j, system) {
458
+ // A resumed switch keeps what it first recorded, so a revert still reaches the original.
459
+ if (!j.switched) {
460
+ const { unit, watches, links } = switchable(j, system);
461
+ j.switched = { links: links.map(path => ({ path, previous: readlinkSync(path) })), unit: null, watches: [] };
462
+ if (unit) {
463
+ const saved = join(j.stageDir, "service.saved.plist");
464
+ writeFileDurably(saved, readFileSync(unit));
465
+ j.switched.unit = { path: unit, saved };
466
+ }
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());
473
+ }
474
+ const { main, watches } = unitsOf(j);
475
+ if (j.restoreFrom && !j.switched.databaseRestored) {
476
+ await stopServices(j, system, main, watches);
477
+ refuseWhileWatching(j, system, true);
478
+ restoreDatabase(j, j.restoreFrom);
479
+ gate(j);
480
+ j.switched.databaseRestored = true;
481
+ save(j, "switching", j.detail, system.now());
482
+ }
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);
486
+ for (const link of j.switched.links)
487
+ pointLink(link.path, join(j.to.dist, "bin.js"));
488
+ }
489
+ /** Put back every definition and command it can; one it cannot write is skipped and named, so the restore goes on. */
490
+ function revertSwitch(j) {
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;
511
+ }
512
+ function gate(j) {
513
+ const db = new (sqlite().DatabaseSync)(j.databaseFile);
514
+ try {
515
+ db.exec("PRAGMA busy_timeout=5000");
516
+ installUpdateGate(db, j.id);
517
+ freezeUpdateGate(db, j.id);
518
+ }
519
+ finally {
520
+ db.close();
521
+ }
522
+ }
523
+ /** Lift this run's admission pause, in the database and the coding catalog. A restored database has no pause of
524
+ * its own (its backup dropped it), so the catalog's is lifted on its own. */
525
+ function ungate(j) {
526
+ const db = new (sqlite().DatabaseSync)(j.databaseFile);
527
+ try {
528
+ db.exec("PRAGMA busy_timeout=5000");
529
+ if (updateGateOwned(db, j.id))
530
+ removeUpdateGate(db, j.id);
531
+ else
532
+ removeCodingUpdateGate(db, j.id);
533
+ }
534
+ finally {
535
+ db.close();
536
+ }
537
+ }
538
+ function ungateCoding(j) {
539
+ const db = new (sqlite().DatabaseSync)(j.databaseFile);
540
+ try {
541
+ db.exec("PRAGMA busy_timeout=5000");
542
+ removeCodingUpdateGate(db, j.id);
543
+ }
544
+ finally {
545
+ db.close();
546
+ }
547
+ }
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;
553
+ try {
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
+ }
561
+ }
562
+ catch (error) {
563
+ if (!restoring)
564
+ throw error;
565
+ row = undefined;
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.`);
571
+ }
572
+ function nextAt(hhmm, now) {
573
+ const [h, m] = hhmm.split(":").map(Number);
574
+ const at = new Date(now);
575
+ at.setHours(h, m, 0, 0);
576
+ if (at <= now)
577
+ at.setDate(at.getDate() + 1);
578
+ return at;
579
+ }
580
+ function newJournal(kind, o, now) {
581
+ const id = randomUUID(), stageDir = join(resolve(o.stateDir), "staged-upgrades", `${kind === "update" ? "release" : "rollback"}-${o.to.version}-${id.slice(0, 8)}`);
582
+ mkdirSync(stageDir, { recursive: true, mode: 0o700 });
583
+ chmodSync(stageDir, 0o700);
584
+ return { version: 1, id, kind, stateDir: resolve(o.stateDir), databaseFile: o.databaseFile, stageDir, from: o.from, to: o.to, when: o.when, at: o.at, actor: o.actor, phase: "scheduled", detail: "", steps: [], startedAt: now.toISOString(), updatedAt: now.toISOString() };
585
+ }
586
+ function assertNoneActive(stateDir) {
587
+ const existing = readRuntimeUpdate(stateDir);
588
+ if (existing && !runtimeUpdateTerminal(existing.phase))
589
+ throw new Refusal(`An update to ${existing.to.version} is already ${existing.phase === "scheduled" ? "scheduled" : "under way"} (${existing.detail}) Cancel it with toolroll update --cancel, or resume it with toolroll update --resume.`);
590
+ }
591
+ /** Record an update without running it: the console's job resumes exactly this journal id. */
592
+ export function prepareRuntimeUpdate(o, now) {
593
+ if (!/^\d+\.\d+\.\d+$/.test(o.version))
594
+ return { refused: `${o.version} is not a release version (x.y.z).` };
595
+ if (!o.allowDowngrade && !isNewer(o.version, o.current.version))
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.` };
597
+ try {
598
+ assertNoneActive(o.stateDir);
599
+ keepCompletedUpdate(o.stateDir);
600
+ }
601
+ catch (error) {
602
+ return { refused: error.message };
603
+ }
604
+ const at = o.when === "at" ? nextAt(o.at ?? "03:00", now).toISOString() : null;
605
+ const j = newJournal("update", { stateDir: o.stateDir, databaseFile: o.databaseFile, from: o.current, to: { version: o.version, dist: "" }, when: o.when, at, actor: o.actor }, now);
606
+ save(j, "scheduled", at ? `Scheduled for ${at}.` : "Starting.", now);
607
+ if (at)
608
+ ledger(j.databaseFile, now, j.actor, "toolroll update scheduled", "scheduled", `${j.from.version} → ${j.to.version} at ${at}`);
609
+ return j;
610
+ }
611
+ /** Start an update: stage, verify, then run the journaled steps. */
612
+ export async function startRuntimeUpdate(o, system) {
613
+ if (o.version === o.current.version)
614
+ return { ok: true, phase: "complete", message: `Toolroll ${o.version} is current.`, journal: null };
615
+ const j = prepareRuntimeUpdate(o, system.now());
616
+ if ("refused" in j)
617
+ return { ok: false, phase: "refused", message: j.refused, journal: null };
618
+ return driveRuntimeUpdate(j, system);
619
+ }
620
+ /** Return to the runtime and database backup an update replaced. */
621
+ export async function startRuntimeRollback(o, system) {
622
+ let last;
623
+ try {
624
+ assertNoneActive(o.stateDir);
625
+ keepCompletedUpdate(o.stateDir);
626
+ last = lastCompletedUpdate(o.stateDir);
627
+ }
628
+ catch (error) {
629
+ return { ok: false, phase: "refused", message: error.message, journal: null };
630
+ }
631
+ if (!last || !last.backupPath || !last.backupHash)
632
+ return { ok: false, phase: "refused", message: "There is no completed update to roll back.", journal: null };
633
+ if (!existsSync(join(last.from.dist, "bin.js")) || !existsSync(last.backupPath) || (last.codingBackupPath && !existsSync(last.codingBackupPath)))
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 };
635
+ const now = system.now();
636
+ const j = newJournal("rollback", { stateDir: o.stateDir, databaseFile: o.databaseFile, from: last.to, to: last.from, when: o.when, at: null, actor: o.actor }, now);
637
+ j.restoreFrom = { path: last.backupPath, hash: last.backupHash, updateId: last.id, ...(last.codingBackupPath && last.codingBackupHash ? { codingPath: last.codingBackupPath, codingHash: last.codingBackupHash } : {}) };
638
+ return driveRuntimeUpdate(j, system);
639
+ }
640
+ /** Continue a saved update after a crash, where it left off. With `id` (the console's job), only that update:
641
+ * a job never starts a fresh one, and a finished, replaced or rolled-back one is left alone. */
642
+ export async function resumeRuntimeUpdate(stateDir, system, id) {
643
+ const j = readRuntimeUpdate(stateDir);
644
+ if (id !== undefined && j?.id !== id)
645
+ return { ok: true, phase: j?.phase ?? "complete", message: "The update this job was started for is no longer the saved one. Nothing was changed.", journal: j };
646
+ if (!j || runtimeUpdateTerminal(j.phase))
647
+ return { ok: true, phase: j?.phase ?? "complete", message: "No update is in progress.", journal: j };
648
+ return driveRuntimeUpdate(j, system);
649
+ }
650
+ /** A prepared update whose job could not start. */
651
+ export function abandonRuntimeUpdate(stateDir, id, why, now) {
652
+ const j = readRuntimeUpdate(stateDir);
653
+ if (j?.id === id && j.phase === "scheduled")
654
+ save(j, "refused", why, now);
655
+ }
656
+ export function requestRuntimeUpdateCancel(stateDir, now = new Date()) {
657
+ const j = readRuntimeUpdate(stateDir);
658
+ if (!j || runtimeUpdateTerminal(j.phase))
659
+ return "No update is in progress.";
660
+ if (!["scheduled", "verifying", "draining"].includes(j.phase))
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.`;
662
+ durableJson(cancelFile(j), { id: j.id, action: "cancel" });
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
+ }
685
+ }
686
+ const cancelRequested = (j) => existsSync(cancelFile(j));
687
+ async function driveRuntimeUpdate(j, system) {
688
+ const lock = sqliteLock(join(j.stageDir, "worker.sqlite"), 1000);
689
+ if (!lock)
690
+ return { ok: false, phase: j.phase, message: `An updater is already working on the update to ${j.to.version}.`, journal: j };
691
+ const at = (phase) => UPDATE_STEPS.indexOf(phase);
692
+ const step = (phase, detail) => { save(j, phase, detail, system.now()); system.checkpoint?.(phase); };
693
+ const noun = j.kind === "update" ? "update" : "rollback";
694
+ const verb = j.kind === "update" ? `${j.from.version} → ${j.to.version}` : `${j.from.version} → ${j.to.version} (back)`;
695
+ const finish = (ok, phase, message, action, outcome, detail) => {
696
+ save(j, phase, message, system.now());
697
+ try {
698
+ ledger(j.databaseFile, system.now(), j.actor, action, outcome, detail);
699
+ }
700
+ catch { /* the journal still says what happened */ }
701
+ return { ok, phase, message, journal: j };
702
+ };
703
+ try {
704
+ // Scheduled: wait for the time, still cancellable.
705
+ if (j.phase === "scheduled" && j.at) {
706
+ while (system.now() < new Date(j.at)) {
707
+ if (cancelRequested(j))
708
+ return finish(true, "cancelled", `The ${noun} to ${j.to.version} was cancelled before it started. Nothing changed.`, `toolroll ${noun} cancelled`, "cancelled", verb);
709
+ await system.sleep(Math.min(60_000, new Date(j.at).getTime() - system.now().getTime()));
710
+ }
711
+ }
712
+ // Resumed past the switch: the only question left is whether the new runtime is healthy.
713
+ const resumedAt = at(j.phase);
714
+ if (j.phase === "rolling-back" || j.phase === "needs-attention")
715
+ return await restore(j, system, finish, verb, j.error ?? "An earlier attempt stopped.");
716
+ if (resumedAt < at("switching")) {
717
+ if (resumedAt <= at("verifying")) {
718
+ step("verifying", j.kind === "update" ? `Downloading and verifying Toolroll ${j.to.version}.` : `Checking the previous runtime (${j.to.version}) and its backup.`);
719
+ if (j.kind === "update")
720
+ await verify(j, system);
721
+ else if (fileHash(j.restoreFrom.path) !== j.restoreFrom.hash || (j.restoreFrom.codingPath && fileHash(j.restoreFrom.codingPath) !== j.restoreFrom.codingHash))
722
+ throw new Refusal("The update's backup changed since it was made. Nothing was changed.");
723
+ }
724
+ const { unit, watches } = switchable(j, system);
725
+ if (!unit && watches.length === 0)
726
+ refuseWhileWatching(j, system);
727
+ if (resumedAt <= at("draining")) {
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.");
729
+ await drain(j, system);
730
+ if (cancelRequested(j)) {
731
+ ungate(j);
732
+ return finish(true, "cancelled", `The ${noun} to ${j.to.version} was cancelled. Nothing changed; new work resumed.`, `toolroll ${noun} cancelled`, "cancelled", verb);
733
+ }
734
+ }
735
+ // The service stops before the backup, so nothing it writes afterwards can be lost by a restore.
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);
738
+ refuseWhileWatching(j, system);
739
+ const db = new (sqlite().DatabaseSync)(j.databaseFile, { readOnly: true });
740
+ try {
741
+ assertCodingUpdateStopped(db);
742
+ }
743
+ finally {
744
+ db.close();
745
+ }
746
+ const backup = join(j.stageDir, existsSync(join(j.stageDir, "orders.backup.db")) ? `orders.backup.${randomUUID()}.db` : "orders.backup.db");
747
+ j.backupHash = await verifiedDatabaseBackup(j.databaseFile, backup, j.id, async () => { await codingBackup(j); });
748
+ j.backupPath = backup;
749
+ save(j, "backing-up", "Database and coding catalog backed up.", system.now());
750
+ step("rehearsing", `Rehearsing ${j.to.version} on a copy of the database.`);
751
+ j.rehearsal = await rehearse(j, system, j.restoreFrom?.path ?? j.backupPath);
752
+ }
753
+ if (resumedAt <= at("switching")) {
754
+ step("switching", `Switching to ${j.to.version}.`);
755
+ await switchRuntime(j, system);
756
+ }
757
+ if (resumedAt <= at("restarting")) {
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);
763
+ }
764
+ step("health", `Checking that ${j.to.version} is running and healthy.`);
765
+ const deadline = system.now().getTime() + (system.healthTimeoutMs ?? 90_000);
766
+ let healthy = false;
767
+ do {
768
+ healthy = await system.healthy(j);
769
+ if (!healthy)
770
+ await system.sleep(1000);
771
+ } while (!healthy && system.now().getTime() < deadline);
772
+ if (!healthy)
773
+ throw Error(`Toolroll ${j.to.version} did not pass its health check.`);
774
+ ungate(j);
775
+ const done = j.kind === "update"
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)
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 */ }
787
+ try {
788
+ pruneRuntimes(j.stateDir, [j.to.dist, j.from.dist]);
789
+ }
790
+ catch { /* an old runtime left on disk is harmless */ }
791
+ return done;
792
+ }
793
+ catch (error) {
794
+ if (error.simulatedCrash)
795
+ throw error;
796
+ const message = error instanceof Error ? error.message : String(error);
797
+ j.error = message.slice(0, 1500);
798
+ if (!j.switched) {
799
+ try {
800
+ ungate(j);
801
+ }
802
+ catch { /* no gate yet, or the database is unreadable */ }
803
+ // Nothing uses a refused download; the database backup is kept.
804
+ if (j.kind === "update")
805
+ rmSync(join(j.stageDir, "runtime"), { recursive: true, force: true });
806
+ // A service and watch daemons stopped for the backup start again, unchanged.
807
+ let restarted = "";
808
+ for (const unit of [j.service?.unit, ...(j.watches ?? []).map(w => w.unit)]) {
809
+ if (!unit)
810
+ continue;
811
+ try {
812
+ await system.restartService(unit);
813
+ }
814
+ catch (again) {
815
+ restarted += ` ${unit === j.service?.unit ? "The background service" : `The watch ${basename(unit, ".plist")}`} did not start again: ${again.message}`;
816
+ }
817
+ }
818
+ const refused = error instanceof Refusal;
819
+ return finish(false, "refused", (refused ? message : `The ${noun} stopped before anything was switched: ${message} Nothing changed; new work resumed.`) + restarted, refused ? `toolroll ${noun} refused` : `toolroll ${noun} failed`, refused ? "refused" : "failed", `${verb}: ${message}`);
820
+ }
821
+ return await restore(j, system, finish, verb, message);
822
+ }
823
+ finally {
824
+ lock.close();
825
+ }
826
+ }
827
+ /** The coding catalog, backed up inside the database's write reservation (as the desktop update does). */
828
+ async function codingBackup(j) {
829
+ const source = codingFile(j.databaseFile);
830
+ if (!codingCatalogExists(source))
831
+ return;
832
+ const target = join(j.stageDir, existsSync(join(j.stageDir, "coding.backup.sqlite")) ? `coding.backup.${randomUUID()}.sqlite` : "coding.backup.sqlite");
833
+ await backupCodingCatalog(source, target, j.id);
834
+ j.codingBackupPath = target;
835
+ j.codingBackupHash = fileHash(target);
836
+ }
837
+ /** Put back the previous runtime, then this run's own backups of the database and coding catalog, keeping what
838
+ * was written since in a named copy. */
839
+ async function restore(j, system, finish, verb, why) {
840
+ const noun = j.kind === "update" ? "update" : "rollback";
841
+ try {
842
+ save(j, "rolling-back", `${why} Restoring ${j.from.version} and the database backup.`, system.now());
843
+ system.checkpoint?.("rolling-back");
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);
848
+ if (!j.backupPath || !j.backupHash)
849
+ throw Error("No verified backup was recorded.");
850
+ if (!j.restoredDatabase) {
851
+ refuseWhileWatching(j, system, true);
852
+ // A fresh copy before EVERY attempt that is about to replace the database, not only the first: a retry
853
+ // after a failure earlier in the restore must not overwrite writes made since without keeping them.
854
+ await keepAside(j);
855
+ save(j, "rolling-back", j.detail, system.now());
856
+ restoreDatabase(j, { path: j.backupPath, hash: j.backupHash, ...(j.codingBackupPath && j.codingBackupHash ? { codingPath: j.codingBackupPath, codingHash: j.codingBackupHash } : {}) });
857
+ j.restoredDatabase = true;
858
+ save(j, "rolling-back", j.detail, system.now());
859
+ }
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));
868
+ }
869
+ catch (error) {
870
+ const message = error instanceof Error ? error.message : String(error);
871
+ return finish(false, "needs-attention", `${why} Restoring the previous version also failed: ${message} Run toolroll update --resume to try again; the backup is kept at ${j.backupPath ?? "(none)"}.`, `toolroll ${noun} failed`, "needs attention", `${verb}: ${why} / ${message}`.slice(0, 300));
872
+ }
873
+ }
874
+ async function verify(j, system) {
875
+ const release = await system.release(j.to.version);
876
+ if (release.version !== j.to.version)
877
+ throw new Refusal(`The registry answered with ${release.version}, not ${j.to.version}. Nothing was changed.`);
878
+ if (!release.attestations)
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.`);
882
+ const bytes = await system.download(release.tarball);
883
+ const sha512 = createHash("sha512").update(bytes).digest();
884
+ if (release.integrity !== `sha512-${sha512.toString("base64")}`)
885
+ throw new Refusal(`The downloaded package does not match the registry's checksum. Nothing was changed.`);
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 });
888
+ const runtime = join(j.stageDir, "runtime");
889
+ mkdirSync(runtime, { recursive: true, mode: 0o700 });
890
+ j.to.dist = await system.install(runtime, release);
891
+ if (!j.to.dist.startsWith(runtime + sep) || !existsSync(join(j.to.dist, "bin.js")))
892
+ throw new Refusal("The staged runtime has no Toolroll command. Nothing was changed.");
893
+ j.package = { sha512: sha512.toString("hex"), repository: provenance.repository, workflow: provenance.workflow };
894
+ try {
895
+ j.notes = releaseNotes(readFileSync(join(dirname(j.to.dist), "CHANGELOG.md"), "utf8"), j.to.version);
896
+ }
897
+ catch {
898
+ j.notes = [];
899
+ }
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
+ }
911
+ async function drain(j, system) {
912
+ const db = new (sqlite().DatabaseSync)(j.databaseFile);
913
+ try {
914
+ db.exec("PRAGMA busy_timeout=5000");
915
+ if (j.when === "now") {
916
+ const running = runningWorkWords(db) ?? lingeringWork(db);
917
+ if (running)
918
+ throw new Refusal(`Work is running: ${running}. Nothing was changed. Use When idle to wait for it.`);
919
+ if (updateAdmissionPaused(db) && !updateGateOwned(db, j.id))
920
+ throw new Refusal("Another update owns the admission pause. Nothing was changed.");
921
+ installUpdateGate(db, j.id);
922
+ if (!freezeUpdateGate(db, j.id)) {
923
+ removeUpdateGate(db, j.id);
924
+ throw new Refusal(`Work started just now: ${runningWorkWords(db) ?? "a new run"}. Nothing was changed.`);
925
+ }
926
+ return;
927
+ }
928
+ installUpdateGate(db, j.id);
929
+ for (;;) {
930
+ if (cancelRequested(j))
931
+ return;
932
+ const lingering = Object.values(activeUpdateWork(db)).every(n => n === 0) ? lingeringWork(db) : null;
933
+ if (!lingering && freezeUpdateGate(db, j.id))
934
+ return;
935
+ save(j, "draining", `New work is paused. Waiting for ${lingering ?? runningWorkWords(db) ?? "running work"} to finish. Nothing is being cancelled.`, system.now());
936
+ await system.sleep(2000);
937
+ }
938
+ }
939
+ finally {
940
+ db.close();
941
+ }
942
+ }
943
+ /** Keep the newest release-* runtimes (and any in `keep`), at most KEEP_RUNTIMES; deploy-browser's browser-* and
944
+ * the rollback-* records are not this updater's to remove. */
945
+ export function pruneRuntimes(stateDir, keep) {
946
+ const root = join(stateDir, "staged-upgrades");
947
+ const started = (dir) => { try {
948
+ return String(JSON.parse(readFileSync(join(dir, "update.json"), "utf8")).startedAt ?? "");
949
+ }
950
+ catch {
951
+ return statSync(dir).mtime.toISOString();
952
+ } };
953
+ const releases = readdirSync(root, { withFileTypes: true }).filter(entry => entry.isDirectory() && entry.name.startsWith("release-"))
954
+ .map(entry => join(root, entry.name)).sort((a, b) => started(b).localeCompare(started(a)));
955
+ const kept = new Set(releases.filter(dir => keep.some(dist => dist !== "" && dist.startsWith(dir + sep))));
956
+ for (const dir of releases)
957
+ if (kept.size < KEEP_RUNTIMES)
958
+ kept.add(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));
967
+ for (const dir of removed)
968
+ rmSync(dir, { recursive: true, force: true });
969
+ return removed;
970
+ }
971
+ export function runtimeUpdateStatus(stateDir) {
972
+ let j = null;
973
+ try {
974
+ j = readRuntimeUpdate(stateDir);
975
+ }
976
+ catch {
977
+ j = null;
978
+ }
979
+ let running = false;
980
+ if (j && !runtimeUpdateTerminal(j.phase)) {
981
+ const lock = sqliteLock(join(j.stageDir, "worker.sqlite"));
982
+ running = lock === null;
983
+ lock?.close();
984
+ }
985
+ const whatsNew = j && j.kind === "update" && j.phase === "complete" && !j.seen ? { version: j.to.version, notes: j.notes ?? [] } : null;
986
+ return { journal: j, running, whatsNew };
987
+ }
988
+ export function markWhatsNewSeen(stateDir) {
989
+ const j = readRuntimeUpdate(stateDir);
990
+ if (!j || j.seen || j.phase !== "complete")
991
+ return;
992
+ j.seen = true;
993
+ durableJson(journalFile(stateDir), j);
994
+ durableJson(join(j.stageDir, "update.json"), j);
995
+ }
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 } : {}) });
997
+ const LABELS = ["com.toolroll.browser", "com.standing-orders.browser"];
998
+ export function currentRuntime(version) {
999
+ return { version, dist: dirname(fileURLToPath(import.meta.url)) };
1000
+ }
1001
+ async function json(url) {
1002
+ const response = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(30_000) });
1003
+ if (!response.ok)
1004
+ throw new Refusal(`${url} answered ${response.status}. Nothing was changed.`);
1005
+ return response.json();
1006
+ }
1007
+ function processAlive(pid) {
1008
+ try {
1009
+ process.kill(pid, 0);
1010
+ return true;
1011
+ }
1012
+ catch (error) {
1013
+ return error.code === "EPERM";
1014
+ }
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); });`;
1021
+ export function machineSystem(home = homedir(), env = process.env, seams = {}) {
1022
+ const sh = seams.exec ?? exec;
1023
+ const supervise = seams.run ?? (async (file, args, options) => (await import("./exec.js")).run(file, args, options));
1024
+ const unitFor = (from) => {
1025
+ for (const label of LABELS) {
1026
+ const unit = join(home, "Library", "LaunchAgents", `${label}.plist`);
1027
+ if (existsSync(unit) && readFileSync(unit, "utf8").includes(from.dist))
1028
+ return unit;
1029
+ }
1030
+ return null;
1031
+ };
1032
+ const labelOf = (unit) => basename(unit, ".plist");
1033
+ const domain = `gui/${typeof process.getuid === "function" ? process.getuid() : userInfo().uid}`;
1034
+ const definition = (unit) => {
1035
+ const content = readFileSync(unit, "utf8");
1036
+ const logPath = content.match(/<key>StandardOutPath<\/key>\s*<string>([^<]+)<\/string>/)?.[1] ?? join(dirname(unit), `${labelOf(unit)}.log`);
1037
+ return { platform: "darwin", label: labelOf(unit), unitPath: unit, unitContent: content, logPath, bin: process.execPath, entry: null };
1038
+ };
1039
+ return {
1040
+ now: () => new Date(),
1041
+ sleep: ms => new Promise(done => setTimeout(done, ms)),
1042
+ release: async (version) => {
1043
+ const body = await json(`${REGISTRY}/${NAME}/${version}`);
1044
+ return { version: String(body.version ?? ""), tarball: String(body.dist?.tarball ?? ""), integrity: String(body.dist?.integrity ?? ""), attestations: body.dist?.attestations?.url ?? null };
1045
+ },
1046
+ download: async (url) => {
1047
+ if (!url.startsWith(`${REGISTRY}/`))
1048
+ throw new Refusal("The package is not hosted on the npm registry. Nothing was changed.");
1049
+ const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
1050
+ if (!response.ok)
1051
+ throw new Refusal(`The package download answered ${response.status}. Nothing was changed.`);
1052
+ return new Uint8Array(await response.arrayBuffer());
1053
+ },
1054
+ attestations: url => json(url),
1055
+ install: async (runtime, release) => {
1056
+ // From the registry by name, so npm's signature and attestation check covers the package itself (a local
1057
+ // tarball is not checked), and the lockfile records exactly which bytes were installed.
1058
+ writeFileSync(join(runtime, "package.json"), JSON.stringify({ name: `${NAME}-runtime`, private: true, dependencies: { [NAME]: release.version } }, null, 2));
1059
+ const registry = `--registry=${REGISTRY}/`;
1060
+ const installed = sh("npm", ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", "--no-color", registry], { cwd: runtime });
1061
+ if (installed.status !== 0)
1062
+ throw new Refusal(`npm could not install Toolroll ${release.version}: ${(installed.stderr || installed.stdout).trim().slice(-400)} Nothing was changed.`);
1063
+ let locked;
1064
+ try {
1065
+ locked = JSON.parse(readFileSync(join(runtime, "package-lock.json"), "utf8")).packages?.[`node_modules/${NAME}`];
1066
+ }
1067
+ catch {
1068
+ locked = undefined;
1069
+ }
1070
+ if (locked?.version !== release.version || locked.integrity !== release.integrity || !String(locked.resolved ?? "").startsWith(`${REGISTRY}/`))
1071
+ throw new Refusal(`npm installed different bytes than the verified Toolroll ${release.version}. Nothing was changed.`);
1072
+ const signatures = sh("npm", ["audit", "signatures", "--no-color", registry], { cwd: runtime });
1073
+ const output = `${signatures.stdout}\n${signatures.stderr}`;
1074
+ if (signatures.status !== 0)
1075
+ throw new Refusal(`npm could not verify the package signatures: ${output.trim().slice(-400)} Nothing was changed.`);
1076
+ if (!/\b[1-9]\d* packages? ha(?:s|ve) (?:a )?verified attestations?\b/.test(output))
1077
+ throw new Refusal(`npm did not verify Toolroll ${release.version}'s provenance attestation. Nothing was changed.`);
1078
+ return join(runtime, "node_modules", NAME, "dist");
1079
+ },
1080
+ rehearse: async (dist, copy) => {
1081
+ const script = `import(${JSON.stringify(pathToFileURL(join(dist, "store.js")).href)}).then(m => { m.openStore(${JSON.stringify(copy)}).close(); })`;
1082
+ const done = sh(process.execPath, ["--no-warnings", "--input-type=module", "-e", script], { timeout: 600_000 });
1083
+ if (done.status !== 0)
1084
+ throw new Refusal(`The new version could not open a copy of the database: ${(done.stderr || "").trim().slice(-400)} Nothing was changed.`);
1085
+ },
1086
+ commands: () => {
1087
+ const found = new Set();
1088
+ for (const dir of (env["PATH"] ?? "").split(":").filter(Boolean)) {
1089
+ for (const name of [NAME, "standing-orders"]) {
1090
+ const path = join(dir, name);
1091
+ try {
1092
+ lstatSync(path);
1093
+ found.add(path);
1094
+ }
1095
+ catch { /* not here */ }
1096
+ }
1097
+ }
1098
+ return [...found];
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
+ },
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
+ },
1129
+ servicePids: async (unit) => {
1130
+ const printed = await supervise("launchctl", ["print", `${domain}/${labelOf(unit)}`]);
1131
+ const pid = Number(printed.stdout.match(/\n\s*pid = (\d+)/)?.[1]);
1132
+ return printed.code === 0 && pid > 1 ? [pid] : [];
1133
+ },
1134
+ stopService: async (unit) => {
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)}`]);
1140
+ throw Error(`${stopped.message}. Nothing was replaced.`);
1141
+ }
1142
+ },
1143
+ restartService: async (unit) => {
1144
+ const started = await installLaunchdService(definition(unit), supervise);
1145
+ if (!started.ok)
1146
+ throw Error(`launchctl could not start the service: ${started.message}`);
1147
+ },
1148
+ processAlive: seams.alive ?? processAlive,
1149
+ healthy: async (j) => {
1150
+ const version = sh(process.execPath, [join(j.to.dist, "bin.js"), "--version"], { timeout: 30_000 });
1151
+ if (version.status !== 0 || version.stdout.trim() !== j.to.version)
1152
+ return false;
1153
+ for (const link of j.switched?.links ?? []) {
1154
+ try {
1155
+ if (realpathSync(link.path) !== realpathSync(join(j.to.dist, "bin.js")))
1156
+ return false;
1157
+ }
1158
+ catch {
1159
+ return false;
1160
+ }
1161
+ }
1162
+ const unit = j.switched?.unit?.path;
1163
+ if (!unit)
1164
+ return true;
1165
+ const printed = await supervise("launchctl", ["print", `${domain}/${labelOf(unit)}`]);
1166
+ const pid = Number(printed.stdout.match(/\n\s*pid = (\d+)/)?.[1]);
1167
+ if (!(pid > 1) || !printed.stdout.includes("state = running"))
1168
+ return false;
1169
+ if (!sh("/bin/ps", ["-p", String(pid), "-o", "command="]).stdout.includes(j.to.dist))
1170
+ return false;
1171
+ const port = readFileSync(unit, "utf8").match(/<string>--port<\/string>\s*<string>(\d+)<\/string>/)?.[1];
1172
+ if (!port)
1173
+ return true;
1174
+ const answered = await fetch(`http://127.0.0.1:${port}/login`, { redirect: "manual", signal: AbortSignal.timeout(5000) }).catch(() => null);
1175
+ return answered !== null && answered.status < 500;
1176
+ },
1177
+ };
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
+ }
1211
+ const jobUnit = (home) => join(home, "Library", "LaunchAgents", `${UPDATE_JOB_LABEL}.plist`);
1212
+ /** The console starts the updater for one prepared journal as its own one-off
1213
+ * launchd job, so the service it restarts is not its parent. The job resumes
1214
+ * that id only; it does not run at login (no RunAtLoad: launchd starts it
1215
+ * with a kickstart) and removes its definition when it finishes. Elsewhere it
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
+ }
1223
+ export async function launchRuntimeUpdate(args, seams = {}) {
1224
+ const dist = args.dist ?? dirname(fileURLToPath(import.meta.url));
1225
+ const command = [process.execPath, join(dist, "bin.js"), "update", "--resume", "--id", args.id, "--db", args.databaseFile];
1226
+ const stateDir = dirname(args.databaseFile);
1227
+ const log = join(stateDir, "toolroll-update.log");
1228
+ if ((seams.platform ?? process.platform) === "darwin") {
1229
+ const home = seams.home ?? homedir();
1230
+ const run = seams.run ?? (await import("./exec.js")).run;
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 });
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);
1236
+ if (!started.ok)
1237
+ throw Error(started.message);
1238
+ return;
1239
+ }
1240
+ const { spawn } = await import("node:child_process");
1241
+ const out = openSync(log, "a", 0o600);
1242
+ spawn(command[0], command.slice(1), { detached: true, stdio: ["ignore", out, out], cwd: stateDir }).unref();
1243
+ }
1244
+ /** The job's last act: its definition goes, so nothing can start it again. Only the definition for this id. */
1245
+ export function retireUpdateJob(id, home = homedir()) {
1246
+ const unit = jobUnit(home);
1247
+ try {
1248
+ if (readFileSync(unit, "utf8").includes(`<string>${id}</string>`))
1249
+ rmSync(unit, { force: true });
1250
+ }
1251
+ catch { /* none */ }
1252
+ }
1253
+ export const nextScheduledAt = nextAt;