toolroll 0.8.0 → 0.8.2

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.
@@ -11,7 +11,7 @@ import { installMethod } from "./install-method.js";
11
11
  import { isNewer, latestVersionNow } from "./releases.js";
12
12
  import { databasePath } from "./store.js";
13
13
  import { PACKAGE_VERSION } from "./version.js";
14
- import { currentRuntime, machineSystem, readRuntimeUpdate, requestRuntimeUpdateCancel, resumeRuntimeUpdate, retireUpdateJob, runtimeUpdateTerminal, startRuntimeRollback, startRuntimeUpdate, } from "./toolroll-update.js";
14
+ import { currentRuntime, lastCompletedUpdate, machineSystem, readRuntimeUpdate, requestRuntimeUpdateCancel, resumeRuntimeUpdate, retireUpdateJob, startRuntimeRollback, startRuntimeUpdate, } from "./toolroll-update.js";
15
15
  /** Loaded on first use (as backup.ts and store.ts do), so modules that only import this one (the console,
16
16
  * and tests that load it in a browser-like environment) never need `node:sqlite` itself. */
17
17
  function sqlite() {
@@ -90,9 +90,13 @@ export async function runUpdateCommand(args, write, deps = {}) {
90
90
  write("This Toolroll runs from a source checkout. Update it with git; toolroll update replaces installed releases only.");
91
91
  return 1;
92
92
  }
93
+ if (method.kind === "desktop") {
94
+ write("This Toolroll is the Toolroll app, which updates as a whole app. Update it from the Toolroll app.");
95
+ return 1;
96
+ }
93
97
  if (has("rollback")) {
94
- const last = readRuntimeUpdate(stateDir);
95
- if (!last || last.kind !== "update" || last.phase !== "complete" || !runtimeUpdateTerminal(last.phase)) {
98
+ const last = lastCompletedUpdate(stateDir);
99
+ if (!last) {
96
100
  write("There is no completed update to roll back.");
97
101
  return 1;
98
102
  }
@@ -24,6 +24,8 @@ export type UpdatesView = {
24
24
  version: string;
25
25
  notes: string[];
26
26
  } | null;
27
+ /** The version `toolroll update --rollback` returns to: the last completed update's, while it is what runs. */
28
+ rollbackTo: string | null;
27
29
  csrf: string;
28
30
  };
29
31
  /** The steps, each done, current, failed or waiting. Also the live region's fragment. */
@@ -59,17 +59,18 @@ export function updatesHtml(view, notice) {
59
59
  const newer = latest !== null && newerThan(latest, view.current);
60
60
  const state = view.method.kind === "npx" ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">npx runs the latest release each time, so this is current.</p>`
61
61
  : view.method.kind === "source" ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">This runs from a source checkout. Update it with git.</p>`
62
- : newer ? `<h2>Toolroll ${e(latest)} is available</h2><p class="meta">You have ${e(view.current)}. Running work finishes first, and your current version is kept so you can go back.</p>`
63
- : "off" in view.latest ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">Update checks are off.</p><form method="get" action="/settings/updates" class="update-actions"><input type="hidden" name="check" value="now"><button type="submit">Check now</button></form>`
64
- : latest === null ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">Couldn’t check for a newer release: ${e("problem" in view.latest ? view.latest.problem : "")}</p>`
65
- : `<h2>Toolroll ${e(view.current)} is up to date</h2>`;
66
- const form = newer && !["npx", "source"].includes(view.method.kind)
62
+ : newer && view.method.kind === "desktop" ? `<h2>Toolroll ${e(latest)} is available</h2><p class="meta">You have ${e(view.current)}. Update it from the Toolroll app.</p>`
63
+ : newer ? `<h2>Toolroll ${e(latest)} is available</h2><p class="meta">You have ${e(view.current)}. Running work finishes first, and your current version is kept so you can go back.</p>`
64
+ : "off" in view.latest ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">Update checks are off.</p><form method="get" action="/settings/updates" class="update-actions"><input type="hidden" name="check" value="now"><button type="submit">Check now</button></form>`
65
+ : latest === null ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">Couldn’t check for a newer release: ${e("problem" in view.latest ? view.latest.problem : "")}</p>`
66
+ : `<h2>Toolroll ${e(view.current)} is up to date</h2>`;
67
+ const form = newer && !["npx", "source", "desktop"].includes(view.method.kind)
67
68
  ? `<form method="post" action="/settings/updates">${csrf}<input type="hidden" name="version" value="${e(latest)}">` +
68
69
  `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
69
70
  `<div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button>` +
70
71
  `<button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div></form>`
71
72
  : "";
72
- const rollback = j?.kind === "update" && j.phase === "complete" ? `<p class="meta">To go back to ${e(j.from.version)}: <code>toolroll update --rollback</code></p>` : "";
73
+ const rollback = view.rollbackTo !== null ? `<p class="meta">To go back to ${e(view.rollbackTo)}: <code>toolroll update --rollback</code></p>` : "";
73
74
  return `<section class="updates">${note}${whatsNew}${j ? outcomeHtml(j) : ""}<div class="card" data-update-state="${newer ? "available" : "current"}">${state}${form}${rollback}</div></section>`;
74
75
  }
75
76
  export function newerThan(a, b) {
@@ -2,6 +2,7 @@ import type { DatabaseSync } from "node:sqlite";
2
2
  import { type SupervisorRunner } from "./daemon.js";
3
3
  export declare const PROVENANCE_REPOSITORY = "https://github.com/ap9000/toolroll";
4
4
  export declare const PROVENANCE_WORKFLOW = ".github/workflows/publish.yml";
5
+ export declare const PROVENANCE_ISSUER = "https://token.actions.githubusercontent.com";
5
6
  /** The steps a person sees, in order. */
6
7
  export declare const UPDATE_STEPS: readonly ["verifying", "draining", "backing-up", "rehearsing", "switching", "restarting", "health"];
7
8
  export type UpdateStep = typeof UPDATE_STEPS[number];
@@ -66,6 +67,13 @@ export type RuntimeUpdateJournal = {
66
67
  unit: string;
67
68
  pids: number[];
68
69
  };
70
+ /** Each repo's watch daemon that runs this version, recorded the same way: switched with the service, and stopped and
71
+ * restarted with it only when launchd had it loaded before the update (`loaded` absent: 0.8.1 stopped every one). */
72
+ watches?: {
73
+ unit: string;
74
+ pids: number[];
75
+ loaded?: boolean;
76
+ }[];
69
77
  /** Recorded before each change so a resumed or failed run knows what to put back. */
70
78
  switched?: {
71
79
  links: {
@@ -76,10 +84,21 @@ export type RuntimeUpdateJournal = {
76
84
  path: string;
77
85
  saved: string;
78
86
  } | null;
87
+ watches?: {
88
+ path: string;
89
+ saved: string;
90
+ }[];
79
91
  databaseRestored?: boolean;
80
92
  };
81
- /** What the live database held when a failed run restored its backup: nothing written is lost. */
93
+ /** What the live database held when a failed run restored its backup: nothing written is lost. The newest of `kept`. */
82
94
  keptAside?: string;
95
+ /** The live database could not be read, so it was moved aside whole rather than copied. */
96
+ keptAsideUnreadable?: boolean;
97
+ /** Every copy kept aside, oldest first: a retried restore adds one and never drops the pointer to an earlier one. */
98
+ kept?: {
99
+ path: string;
100
+ unreadable: boolean;
101
+ }[];
83
102
  /** The restore put the backup back: a retried restore never puts it back again (what was written since belongs to
84
103
  * the restored version and would be lost), and it keeps a fresh copy aside before every attempt until then. */
85
104
  restoredDatabase?: boolean;
@@ -107,8 +126,18 @@ export type UpdateSystem = {
107
126
  commands: (from: RuntimeRef) => string[];
108
127
  /** The background service's definition, when one runs `from`. */
109
128
  serviceUnit: (from: RuntimeRef) => string | null;
129
+ /** Every repo's watch daemon definition that runs `from`. */
130
+ watchUnits: (from: RuntimeRef) => string[];
131
+ /** npm's own Sigstore verifier: the bundle's certificate chains to Sigstore, its signature is in the transparency
132
+ * log, and the certificate names exactly this identity. */
133
+ sigstore: (bundle: unknown, identity: {
134
+ issuer: string;
135
+ identity: string;
136
+ }) => Promise<void>;
110
137
  /** The processes the service runs now. */
111
138
  servicePids: (unit: string) => Promise<number[]>;
139
+ /** Whether launchd has the service loaded now. */
140
+ serviceLoaded: (unit: string) => Promise<boolean>;
112
141
  /** Unload the service; resolves once launchd no longer has it. */
113
142
  stopService: (unit: string) => Promise<void>;
114
143
  /** Load and start the service from its definition on disk. */
@@ -118,8 +147,11 @@ export type UpdateSystem = {
118
147
  healthTimeoutMs?: number;
119
148
  /** How long stopped service processes may take to exit. */
120
149
  exitTimeoutMs?: number;
121
- /** Fault injection for state-machine tests, never selectable by a flag. */
122
- checkpoint?: (phase: RuntimePhase) => void;
150
+ /** Bytes free for this user in the folder `dir` is on. */
151
+ freeBytes?: (dir: string) => number;
152
+ /** Fault injection for state-machine tests, never selectable by a flag. `kept-aside`: the live database was just
153
+ * kept aside, before the backup is put back. */
154
+ checkpoint?: (phase: RuntimePhase | "kept-aside") => void;
123
155
  };
124
156
  export type UpdateOutcome = {
125
157
  ok: boolean;
@@ -128,16 +160,23 @@ export type UpdateOutcome = {
128
160
  journal: RuntimeUpdateJournal | null;
129
161
  };
130
162
  export declare const runtimeUpdateTerminal: (phase: RuntimePhase) => boolean;
131
- export declare function readRuntimeUpdate(stateDir: string): RuntimeUpdateJournal | null;
163
+ export declare function readRuntimeUpdate(stateDir: string, file?: string): RuntimeUpdateJournal | null;
164
+ /** The update `--rollback` returns from: the last one that completed, whatever was attempted since. */
165
+ export declare function lastCompletedUpdate(stateDir: string): RuntimeUpdateJournal | null;
132
166
  /** Running work by name, for a refusal a person can act on. */
133
167
  export declare function runningWorkWords(db: DatabaseSync): string | null;
134
- /** The npm provenance statement for exactly these bytes must name the
135
- * Toolroll repository and its publish workflow. Anything else is refused.
136
- * npm's own check (UpdateSystem.install) verifies the statement's signature. */
137
- export declare function checkProvenance(attestations: unknown, version: string, sha512Hex: string): {
168
+ export type Provenance = {
138
169
  repository: string;
139
170
  workflow: string;
171
+ issuer: string;
172
+ identity: string;
173
+ bundle: unknown;
140
174
  };
175
+ /** The npm provenance for exactly these bytes of Toolroll itself must be signed by a certificate that names the
176
+ * Toolroll repository, its publish workflow and GitHub Actions as the issuer. The statement's own claims name
177
+ * nobody: only the certificate, whose key must have signed the statement. Sigstore's chain and transparency log are
178
+ * then checked by npm's own verifier (UpdateSystem.sigstore) for the identity returned here. */
179
+ export declare function checkProvenance(attestations: unknown, version: string, sha512Hex: string): Provenance;
141
180
  /** Headlines of this version's changelog section: its bold lead phrases. */
142
181
  export declare function releaseNotes(changelog: string, version: string): string[];
143
182
  /** Every rename here: what is renamed is flushed first (a file itself, a link
@@ -184,7 +223,10 @@ export declare function startRuntimeRollback(o: {
184
223
  export declare function resumeRuntimeUpdate(stateDir: string, system: UpdateSystem, id?: string): Promise<UpdateOutcome>;
185
224
  /** A prepared update whose job could not start. */
186
225
  export declare function abandonRuntimeUpdate(stateDir: string, id: string, why: string, now: Date): void;
187
- export declare function requestRuntimeUpdateCancel(stateDir: string): string;
226
+ /** `locked`: a test's seam, called once the updater's lock is held and before the journal is read again. */
227
+ export declare function requestRuntimeUpdateCancel(stateDir: string, now?: Date, seams?: {
228
+ locked?: () => void;
229
+ }): string;
188
230
  /** Keep the newest release-* runtimes (and any in `keep`), at most KEEP_RUNTIMES; deploy-browser's browser-* and
189
231
  * the rollback-* records are not this updater's to remove. */
190
232
  export declare function pruneRuntimes(stateDir: string, keep: readonly string[]): string[];
@@ -196,30 +238,41 @@ export type RuntimeUpdateStatus = {
196
238
  version: string;
197
239
  notes: string[];
198
240
  } | null;
241
+ /** The last completed update `--rollback` returns from, whatever was attempted since. */
242
+ lastUpdate: {
243
+ from: string;
244
+ to: string;
245
+ } | null;
199
246
  };
200
247
  export declare function runtimeUpdateStatus(stateDir: string): RuntimeUpdateStatus;
201
248
  export declare function markWhatsNewSeen(stateDir: string): void;
202
249
  type Exec = (command: string, args: string[], options?: {
203
250
  cwd?: string;
204
251
  timeout?: number;
252
+ input?: string;
205
253
  }) => {
206
254
  status: number | null;
207
255
  stdout: string;
208
256
  stderr: string;
209
257
  };
210
258
  export declare function currentRuntime(version: string): RuntimeRef;
211
- /** Seams for tests: npm and ps (`exec`), launchctl (`run`), and process liveness. */
259
+ /** Seams for tests: npm, node and ps (`exec`), launchctl (`run`), process liveness, and launchd's waits (`sleep`). */
212
260
  export type MachineSeams = {
213
261
  exec?: Exec;
214
262
  run?: SupervisorRunner;
215
263
  alive?: (pid: number) => boolean;
264
+ sleep?: (ms: number) => Promise<void>;
265
+ execPath?: string;
216
266
  };
217
267
  export declare function machineSystem(home?: string, env?: Record<string, string | undefined>, seams?: MachineSeams): UpdateSystem;
218
- /** The console starts the updater for one prepared journal as its own one-off
219
- * launchd job, so the service it restarts is not its parent. The job resumes
220
- * that id only; it does not run at login (no RunAtLoad: launchd starts it
221
- * with a kickstart) and removes its definition when it finishes. Elsewhere it
222
- * is a detached process. */
268
+ /** npm's own Sigstore verifier. Found from npm itself (the `npm` on PATH, then the one beside node), never only from
269
+ * `npm root --global`: with a custom global prefix (npm's advice for permission errors) that folder holds the global
270
+ * packages, not npm. */
271
+ export declare function findSigstoreVerifier(o: {
272
+ path: string;
273
+ execPath: string;
274
+ npmRoot: () => string | null;
275
+ }): string | null;
223
276
  export declare function launchRuntimeUpdate(args: {
224
277
  databaseFile: string;
225
278
  id: string;
@@ -228,6 +281,7 @@ export declare function launchRuntimeUpdate(args: {
228
281
  home?: string;
229
282
  run?: SupervisorRunner;
230
283
  platform?: NodeJS.Platform;
284
+ npmBin?: () => Promise<string | null>;
231
285
  }): Promise<void>;
232
286
  /** The job's last act: its definition goes, so nothing can start it again. Only the definition for this id. */
233
287
  export declare function retireUpdateJob(id: string, home?: string): void;