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.
- package/CHANGELOG.md +22 -0
- package/README.md +5 -5
- package/dist/agent-onboard.d.ts +41 -18
- package/dist/agent-onboard.js +129 -55
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +14 -13
- package/dist/daemon.d.ts +6 -2
- package/dist/daemon.js +6 -5
- package/dist/first-run.d.ts +4 -1
- package/dist/first-run.js +10 -7
- package/dist/install-method.d.ts +1 -1
- package/dist/install-method.js +4 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +64 -9
- package/dist/prompt.d.ts +6 -0
- package/dist/prompt.js +12 -0
- package/dist/serve.js +49 -23
- package/dist/store.d.ts +1 -1
- package/dist/store.js +5 -4
- package/dist/toolroll-update-cli.js +7 -3
- package/dist/toolroll-update-ui.js +6 -5
- package/dist/toolroll-update.d.ts +46 -12
- package/dist/toolroll-update.js +423 -99
- package/package.json +1 -1
package/dist/toolroll-update.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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.${
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
/**
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
|
474
|
+
const { main, watches } = unitsOf(j);
|
|
319
475
|
if (j.restoreFrom && !j.switched.databaseRestored) {
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
551
|
-
step("restarting",
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
806
|
+
// A service and watch daemons stopped for the backup start again, unchanged.
|
|
589
807
|
let restarted = "";
|
|
590
|
-
|
|
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(
|
|
812
|
+
await system.restartService(unit);
|
|
593
813
|
}
|
|
594
814
|
catch (again) {
|
|
595
|
-
restarted
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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"),
|
|
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
|
-
|
|
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
|
-
|
|
857
|
-
|
|
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
|
-
|
|
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);
|