toolroll 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `toolroll update`: preview first, `--yes` to act (toolroll-update.ts does
3
+ * the work). `--now` refuses while work runs; `--when-idle` (the default)
4
+ * pauses new work and waits for running work; `--at HH:MM` waits for that
5
+ * time first. `--rollback` returns to the previous runtime and its backup.
6
+ */
7
+ import { homedir, userInfo } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+ import { createRequire } from "node:module";
10
+ import { installMethod } from "./install-method.js";
11
+ import { isNewer, latestVersionNow } from "./releases.js";
12
+ import { databasePath } from "./store.js";
13
+ import { PACKAGE_VERSION } from "./version.js";
14
+ import { currentRuntime, machineSystem, readRuntimeUpdate, requestRuntimeUpdateCancel, resumeRuntimeUpdate, retireUpdateJob, runtimeUpdateTerminal, startRuntimeRollback, startRuntimeUpdate, } from "./toolroll-update.js";
15
+ /** Loaded on first use (as backup.ts and store.ts do), so modules that only import this one (the console,
16
+ * and tests that load it in a browser-like environment) never need `node:sqlite` itself. */
17
+ function sqlite() {
18
+ return createRequire(import.meta.url)("node:sqlite");
19
+ }
20
+ export const UPDATE_HELP = `toolroll update preview updating to the latest release
21
+ toolroll update --yes update: verify, let running work finish, back up, rehearse, switch, restart, check
22
+ --version X a specific release instead of the latest
23
+ --when-idle pause new work and wait for running work (the default)
24
+ --now refuse if any work is running
25
+ --at HH:MM wait until then (e.g. --at 03:00)
26
+ --allow-downgrade with --version, go back to an older release
27
+ toolroll update --rollback [--yes] return to the previous version and its database backup
28
+ toolroll update --status | --resume | --cancel`;
29
+ export async function runUpdateCommand(args, write, deps = {}) {
30
+ const has = (name) => args.includes(`--${name}`);
31
+ const value = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
32
+ const known = new Set(["--yes", "--now", "--when-idle", "--at", "--version", "--rollback", "--resume", "--cancel", "--status", "--by", "--db", "--id", "--allow-downgrade", "--help"]);
33
+ const unknown = args.filter((arg, i) => arg.startsWith("--") && !known.has(arg) && !["--at", "--version", "--by", "--db", "--id"].includes(args[i - 1] ?? ""));
34
+ if (has("help")) {
35
+ write(UPDATE_HELP);
36
+ return 0;
37
+ }
38
+ if (unknown.length > 0) {
39
+ write(`Unknown option ${unknown[0]}.\n${UPDATE_HELP}`);
40
+ return 2;
41
+ }
42
+ const modes = ["now", "when-idle", "at"].filter(has);
43
+ if (modes.length > 1) {
44
+ write("Choose one of --now, --when-idle or --at.");
45
+ return 2;
46
+ }
47
+ const at = value("at");
48
+ if (has("at") && !/^([01]\d|2[0-3]):[0-5]\d$/.test(at ?? "")) {
49
+ write("--at takes a time like 03:00.");
50
+ return 2;
51
+ }
52
+ const when = has("now") ? "now" : has("at") ? "at" : "when-idle";
53
+ const databaseFile = deps.databaseFile ?? value("db") ?? databasePath(process.env, homedir());
54
+ const stateDir = dirname(databaseFile);
55
+ const actor = value("by") ?? userInfo().username;
56
+ const current = deps.current ?? currentRuntime(PACKAGE_VERSION);
57
+ const system = () => deps.system ?? machineSystem();
58
+ if (has("status")) {
59
+ const j = readRuntimeUpdate(stateDir);
60
+ write(j ? `${j.kind === "update" ? "Update" : "Rollback"} ${j.from.version} → ${j.to.version}: ${j.phase}. ${j.detail}` : "No update has run here.");
61
+ return 0;
62
+ }
63
+ if (has("cancel")) {
64
+ write(requestRuntimeUpdateCancel(stateDir));
65
+ return 0;
66
+ }
67
+ if (has("resume")) {
68
+ // The console's job names its journal: it resumes that update or nothing, then removes its own definition.
69
+ const id = value("id");
70
+ if (has("id") && !/^[a-f0-9-]{36}$/.test(id ?? "")) {
71
+ write("--id takes the update's id.");
72
+ return 2;
73
+ }
74
+ try {
75
+ const outcome = await resumeRuntimeUpdate(stateDir, system(), id);
76
+ write(outcome.message);
77
+ return outcome.ok ? 0 : 1;
78
+ }
79
+ finally {
80
+ if (id !== undefined)
81
+ retireUpdateJob(id, deps.home);
82
+ }
83
+ }
84
+ const method = deps.method ?? installMethod(join(current.dist, "bin.js"));
85
+ if (method.kind === "npx") {
86
+ write("npx runs the latest Toolroll each time, so this one is current. Nothing to update.");
87
+ return 0;
88
+ }
89
+ if (method.kind === "source") {
90
+ write("This Toolroll runs from a source checkout. Update it with git; toolroll update replaces installed releases only.");
91
+ return 1;
92
+ }
93
+ if (has("rollback")) {
94
+ const last = readRuntimeUpdate(stateDir);
95
+ if (!last || last.kind !== "update" || last.phase !== "complete" || !runtimeUpdateTerminal(last.phase)) {
96
+ write("There is no completed update to roll back.");
97
+ return 1;
98
+ }
99
+ if (!has("yes")) {
100
+ write(`Roll back ${last.to.version} → ${last.from.version}, with the database as it was before the update (${last.finishedAt?.slice(0, 16).replace("T", " ") ?? "unknown"} UTC).`);
101
+ const since = recordsSince(databaseFile, last.finishedAt ?? last.updatedAt);
102
+ if (since > 0)
103
+ write(`${since} ledger ${since === 1 ? "entry was" : "entries were"} recorded since then. They are set aside in a backup of the current database, not merged back.`);
104
+ write("New work pauses and running work finishes first. Add --yes to roll back.");
105
+ return 0;
106
+ }
107
+ const outcome = await startRuntimeRollback({ stateDir, databaseFile, current, actor, when }, system());
108
+ write(outcome.message);
109
+ return outcome.ok ? 0 : 1;
110
+ }
111
+ let version = value("version");
112
+ if (version === undefined) {
113
+ try {
114
+ version = (await (deps.latest ?? latestVersionNow)()).version;
115
+ }
116
+ catch (error) {
117
+ write(`Could not read the latest release: ${error.message}`);
118
+ return 1;
119
+ }
120
+ }
121
+ if (version === current.version) {
122
+ write(`Toolroll ${version} is current.`);
123
+ return 0;
124
+ }
125
+ const downgrade = !isNewer(version, current.version);
126
+ if (downgrade && !has("allow-downgrade")) {
127
+ write(`Toolroll ${version} is older than ${current.version}. Add --allow-downgrade to go back to it.`);
128
+ return 1;
129
+ }
130
+ if (!has("yes")) {
131
+ write(`Update Toolroll ${current.version} → ${version}.`);
132
+ write(`${when === "now" ? "Refuses if any work is running" : when === "at" ? `Waits until ${at}, then pauses new work and lets running work finish` : "Pauses new work and lets running work finish"}; verifies the package came from ap9000/toolroll's publish workflow; backs up and rehearses the database; keeps ${current.version} for toolroll update --rollback.`);
133
+ write("Add --yes to update.");
134
+ return 0;
135
+ }
136
+ if (when === "at")
137
+ write(`Waiting until ${at}. Keep this running, or schedule it from Settings → Updates in the console.`);
138
+ const outcome = await startRuntimeUpdate({ stateDir, databaseFile, current, actor, version, when, at: at ?? null, allowDowngrade: downgrade }, system());
139
+ write(outcome.message);
140
+ return outcome.ok ? 0 : 1;
141
+ }
142
+ function recordsSince(databaseFile, since) {
143
+ try {
144
+ const db = new (sqlite().DatabaseSync)(databaseFile, { readOnly: true });
145
+ try {
146
+ return Number(db.prepare("SELECT count(*) n FROM action_ledger WHERE at > ? AND action NOT LIKE 'toolroll %'").get(since)?.["n"] ?? 0);
147
+ }
148
+ finally {
149
+ db.close();
150
+ }
151
+ }
152
+ catch {
153
+ return 0;
154
+ }
155
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Settings → Updates: the installed version, one of three ways to update
3
+ * (behind the operator's password), the update's steps live while it runs,
4
+ * and a one-time "What's new" card afterwards. The work itself is the
5
+ * `toolroll update` command (toolroll-update.ts), started as its own job.
6
+ */
7
+ import type { InstallMethod } from "./install-method.js";
8
+ import { type RuntimeUpdateJournal } from "./toolroll-update.js";
9
+ export declare const UPDATES_CSS: string;
10
+ export type UpdatesView = {
11
+ current: string;
12
+ /** The newest release, why it is unknown, or not asked because update checks are off. */
13
+ latest: {
14
+ version: string;
15
+ } | {
16
+ problem: string;
17
+ } | {
18
+ off: true;
19
+ };
20
+ method: InstallMethod;
21
+ journal: RuntimeUpdateJournal | null;
22
+ running: boolean;
23
+ whatsNew: {
24
+ version: string;
25
+ notes: string[];
26
+ } | null;
27
+ csrf: string;
28
+ };
29
+ /** The steps, each done, current, failed or waiting. Also the live region's fragment. */
30
+ export declare function updateStepsHtml(j: RuntimeUpdateJournal, running: boolean): string;
31
+ export declare function updatesHtml(view: UpdatesView, notice: {
32
+ said?: string | null;
33
+ problem?: string | null;
34
+ }): string;
35
+ export declare function newerThan(a: string, b: string): boolean;
36
+ /** Polls the steps every two seconds, keeps trying while the console
37
+ * restarts, and reloads once the update has finished. */
38
+ export declare function updatesScript(): string;
@@ -0,0 +1,89 @@
1
+ import { STEP_WORDS, UPDATE_STEPS, runtimeUpdateTerminal } from "./toolroll-update.js";
2
+ const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3
+ const clock = (iso) => { const d = new Date(iso); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; };
4
+ export const UPDATES_CSS = `.updates{max-width:640px;min-width:0;overflow-wrap:anywhere}.updates .card{margin:0 0 16px}.updates h2{margin:0;font-size:1.0625rem}.updates .card>p{margin:4px 0 0}` +
5
+ `.updates .update-actions{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0 0}.updates .update-actions button{white-space:nowrap}` +
6
+ `.updates button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary);font-weight:600}` +
7
+ `.updates .step-up{display:grid;gap:4px;font-size:.8125rem;margin:16px 0 0;max-width:20rem}.updates .step-up input{min-height:40px}` +
8
+ `.updates ol.update-steps{list-style:none;padding:0;margin:14px 0 0;display:grid;gap:2px}.updates ol.update-steps li{display:grid;grid-template-columns:18px minmax(0,1fr);gap:0 10px;align-items:baseline;padding:5px 0;font-size:.875rem;color:var(--so-muted)}` +
9
+ `.updates ol.update-steps li::before{content:"";width:8px;height:8px;border-radius:50%;border:1.5px solid var(--so-input-line);justify-self:center;transform:translateY(-1px)}` +
10
+ `.updates ol.update-steps li[data-state=done]{color:var(--so-ink)}.updates ol.update-steps li[data-state=done]::before{background:var(--so-success);border-color:var(--so-success)}` +
11
+ `.updates ol.update-steps li[data-state=now]{color:var(--so-ink);font-weight:600}.updates ol.update-steps li[data-state=now]::before{background:var(--so-info);border-color:var(--so-info);box-shadow:0 0 0 4px color-mix(in srgb,var(--so-info) 16%,transparent)}` +
12
+ `.updates ol.update-steps li[data-state=failed]{color:var(--so-danger);font-weight:600}.updates ol.update-steps li[data-state=failed]::before{background:var(--so-danger);border-color:var(--so-danger)}` +
13
+ `.updates ol.update-steps .step-detail{grid-column:2;font-weight:400;color:var(--so-muted);font-size:.8125rem;margin-top:2px}` +
14
+ `.updates .whats-new ul{margin:10px 0 0;padding-left:18px}.updates .whats-new li{margin:4px 0}.updates .whats-new form{margin:14px 0 0}` +
15
+ `.updates .update-problem{color:var(--so-danger)}.updates .stamp{margin:10px 0 0}.updates code{white-space:nowrap}` +
16
+ `@media (prefers-reduced-motion:no-preference){.updates ol.update-steps li[data-state=now]::before{animation:update-pulse 1.6s ease-in-out infinite}}@keyframes update-pulse{50%{box-shadow:0 0 0 7px color-mix(in srgb,var(--so-info) 6%,transparent)}}` +
17
+ `@media (max-width:600px){.updates .update-actions{display:grid}.updates .update-actions button{width:100%;min-height:44px}.updates .step-up{max-width:none}.updates .step-up input{min-height:44px}}`;
18
+ /** The steps, each done, current, failed or waiting. Also the live region's fragment. */
19
+ export function updateStepsHtml(j, running) {
20
+ const finished = runtimeUpdateTerminal(j.phase);
21
+ const failed = ["restored", "refused", "needs-attention", "rolling-back"].includes(j.phase);
22
+ const last = UPDATE_STEPS.indexOf([...j.steps].reverse().find(s => UPDATE_STEPS.includes(s.phase))?.phase);
23
+ const items = UPDATE_STEPS.map((step, i) => {
24
+ const state = j.phase === "complete" || i < last ? "done" : i > last ? "waiting" : failed ? "failed" : finished ? "waiting" : "now";
25
+ // Only where it adds something: which work it waits for, or what went wrong.
26
+ const detail = (state === "now" && step === "draining") || (state === "failed" && j.phase === "rolling-back") ? `<span class="step-detail">${e(j.detail)}</span>` : "";
27
+ return `<li data-step="${step}" data-state="${state}">${e(STEP_WORDS[step])}${detail}</li>`;
28
+ }).join("");
29
+ const title = j.phase === "scheduled" ? `Update to ${e(j.to.version)} scheduled for ${e(clock(j.at ?? j.startedAt))}`
30
+ : j.phase === "rolling-back" ? `Restoring ${e(j.from.version)}`
31
+ : j.kind === "rollback" ? `Rolling back to ${e(j.to.version)}` : `Updating to ${e(j.to.version)}`;
32
+ const stalled = !running && !finished ? `<p class="update-problem" role="alert">The updater stopped. Run <code>toolroll update --resume</code> to continue it.</p>` : "";
33
+ return `<div id="update-live" data-phase="${e(j.phase)}" data-done="${finished ? 1 : 0}"><h2>${title}</h2>` +
34
+ (j.phase === "scheduled" ? `<p class="meta">Running work finishes first. You can close this page.</p>` : `<ol class="update-steps" aria-label="Update steps">${items}</ol>`) + stalled + `</div>`;
35
+ }
36
+ function outcomeHtml(j) {
37
+ if (j.phase === "complete")
38
+ return j.kind === "rollback" ? `<div class="card" data-update-outcome="rolled-back"><h2>Back on ${e(j.to.version)}</h2><p class="meta">${e(j.detail)}</p></div>` : "";
39
+ if (j.phase === "cancelled")
40
+ return `<div class="card" data-update-outcome="cancelled"><h2>Update cancelled</h2><p class="meta">Nothing changed.</p></div>`;
41
+ const title = j.phase === "refused" ? `Didn't update to ${e(j.to.version)}` : j.phase === "restored" ? `Update to ${e(j.to.version)} didn't finish` : "The update needs attention";
42
+ return `<div class="card" data-update-outcome="${e(j.phase)}"><h2>${title}</h2><p class="${j.phase === "needs-attention" ? "update-problem" : ""}" role="alert">${e(j.detail)}</p>` +
43
+ `<details><summary>Steps</summary>${updateStepsHtml(j, false).replace(/<h2>.*?<\/h2>/, "")}</details></div>`;
44
+ }
45
+ export function updatesHtml(view, notice) {
46
+ const note = notice.problem ? `<p class="update-problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
47
+ const j = view.journal;
48
+ const active = j !== null && !runtimeUpdateTerminal(j.phase);
49
+ const csrf = `<input type="hidden" name="csrf" value="${e(view.csrf)}">`;
50
+ const whatsNew = view.whatsNew === null ? "" : `<div class="card whats-new" data-whats-new="${e(view.whatsNew.version)}"><h2>What’s new in ${e(view.whatsNew.version)}</h2>` +
51
+ (view.whatsNew.notes.length > 0 ? `<ul>${view.whatsNew.notes.map(line => `<li>${e(line)}</li>`).join("")}</ul>` : "") +
52
+ `<p class="meta"><a href="https://github.com/ap9000/toolroll/releases/tag/v${e(view.whatsNew.version)}" rel="noreferrer">Full release notes</a></p>` +
53
+ `<form method="post" action="/settings/updates/seen">${csrf}<button type="submit">Got it</button></form></div>`;
54
+ if (active) {
55
+ const cancel = ["scheduled", "draining"].includes(j.phase) ? `<form method="post" action="/settings/updates/cancel" class="update-actions">${csrf}<button type="submit">Cancel update</button></form>` : "";
56
+ return `<section class="updates">${note}<div class="card" id="update-region">${updateStepsHtml(j, view.running)}${cancel}</div><p class="meta stamp" id="update-region-stamp"></p></section>`;
57
+ }
58
+ const latest = "version" in view.latest ? view.latest.version : null;
59
+ const newer = latest !== null && newerThan(latest, view.current);
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
+ : 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)
67
+ ? `<form method="post" action="/settings/updates">${csrf}<input type="hidden" name="version" value="${e(latest)}">` +
68
+ `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
69
+ `<div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button>` +
70
+ `<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
+ 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
+ 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
+ export function newerThan(a, b) {
76
+ const [x, y] = [a, b].map(v => v.split(".").map(Number));
77
+ for (let i = 0; i < 3; i++)
78
+ if ((x[i] ?? 0) !== (y[i] ?? 0))
79
+ return (x[i] ?? 0) > (y[i] ?? 0);
80
+ return false;
81
+ }
82
+ /** Polls the steps every two seconds, keeps trying while the console
83
+ * restarts, and reloads once the update has finished. */
84
+ export function updatesScript() {
85
+ return `(function(){var region=document.getElementById("update-region");if(!region)return;var stamp=document.getElementById("update-region-stamp");var misses=0;` +
86
+ `function tick(){if(document.hidden){setTimeout(tick,2000);return;}fetch("/settings/updates?fragment=steps",{credentials:"same-origin",cache:"no-store"}).then(function(r){if(!r.ok)throw 0;return r.text();}).then(function(html){misses=0;if(stamp)stamp.textContent="";` +
87
+ `var live=region.querySelector("#update-live");if(live)live.outerHTML=html;var now=region.querySelector("#update-live");if(now&&now.getAttribute("data-done")==="1"){location.reload();return;}setTimeout(tick,2000);})` +
88
+ `.catch(function(){misses++;if(stamp)stamp.textContent=misses>2?"Reconnecting while Toolroll restarts…":"";setTimeout(tick,Math.min(10000,2000*misses));});}setTimeout(tick,2000);})();`;
89
+ }
@@ -0,0 +1,235 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import { type SupervisorRunner } from "./daemon.js";
3
+ export declare const PROVENANCE_REPOSITORY = "https://github.com/ap9000/toolroll";
4
+ export declare const PROVENANCE_WORKFLOW = ".github/workflows/publish.yml";
5
+ /** The steps a person sees, in order. */
6
+ export declare const UPDATE_STEPS: readonly ["verifying", "draining", "backing-up", "rehearsing", "switching", "restarting", "health"];
7
+ export type UpdateStep = typeof UPDATE_STEPS[number];
8
+ export type RuntimePhase = "scheduled" | UpdateStep | "complete" | "rolling-back" | "restored" | "refused" | "cancelled" | "needs-attention";
9
+ export declare const STEP_WORDS: Record<UpdateStep, string>;
10
+ export type When = "now" | "when-idle" | "at";
11
+ export type RuntimeRef = {
12
+ version: string;
13
+ dist: string;
14
+ };
15
+ /** How many release-* runtimes stay on disk: the running one and the one before it. */
16
+ export declare const KEEP_RUNTIMES = 2;
17
+ /** The one-off launchd job the console starts the updater as. */
18
+ export declare const UPDATE_JOB_LABEL = "com.toolroll.update";
19
+ export type RuntimeUpdateJournal = {
20
+ version: 1;
21
+ id: string;
22
+ kind: "update" | "rollback";
23
+ stateDir: string;
24
+ databaseFile: string;
25
+ stageDir: string;
26
+ from: RuntimeRef;
27
+ to: RuntimeRef;
28
+ when: When;
29
+ at: string | null;
30
+ actor: string;
31
+ phase: RuntimePhase;
32
+ detail: string;
33
+ error?: string;
34
+ steps: {
35
+ phase: RuntimePhase;
36
+ at: string;
37
+ }[];
38
+ startedAt: string;
39
+ updatedAt: string;
40
+ finishedAt?: string;
41
+ package?: {
42
+ sha512: string;
43
+ repository: string;
44
+ workflow: string;
45
+ };
46
+ notes?: string[];
47
+ /** This run's own verified copies of the live database and coding catalog: what a failure restores. */
48
+ backupPath?: string;
49
+ backupHash?: string;
50
+ codingBackupPath?: string;
51
+ codingBackupHash?: string;
52
+ /** A rollback installs the update's earlier backups (checked against their recorded hashes). */
53
+ restoreFrom?: {
54
+ path: string;
55
+ hash: string;
56
+ updateId: string;
57
+ codingPath?: string;
58
+ codingHash?: string;
59
+ };
60
+ rehearsal?: {
61
+ tables: number;
62
+ rows: number;
63
+ };
64
+ /** The background service, recorded before it is stopped: its definition and the processes that must be gone. */
65
+ service?: {
66
+ unit: string;
67
+ pids: number[];
68
+ };
69
+ /** Recorded before each change so a resumed or failed run knows what to put back. */
70
+ switched?: {
71
+ links: {
72
+ path: string;
73
+ previous: string;
74
+ }[];
75
+ unit: {
76
+ path: string;
77
+ saved: string;
78
+ } | null;
79
+ databaseRestored?: boolean;
80
+ };
81
+ /** What the live database held when a failed run restored its backup: nothing written is lost. */
82
+ keptAside?: string;
83
+ /** The restore put the backup back: a retried restore never puts it back again (what was written since belongs to
84
+ * the restored version and would be lost), and it keeps a fresh copy aside before every attempt until then. */
85
+ restoredDatabase?: boolean;
86
+ seen?: boolean;
87
+ };
88
+ export type PackageRelease = {
89
+ version: string;
90
+ tarball: string;
91
+ integrity: string;
92
+ attestations: string | null;
93
+ };
94
+ export type UpdateSystem = {
95
+ now: () => Date;
96
+ sleep: (ms: number) => Promise<void>;
97
+ release: (version: string) => Promise<PackageRelease>;
98
+ download: (url: string) => Promise<Uint8Array>;
99
+ attestations: (url: string) => Promise<unknown>;
100
+ /** Installs `release` from the registry into `runtimeDir`, has npm verify its
101
+ * registry signature and attestation there, checks the installed bytes are
102
+ * `release.integrity`, and returns the package's dist. */
103
+ install: (runtimeDir: string, release: PackageRelease) => Promise<string>;
104
+ /** Opens `copy` with the runtime at `dist`, which migrates it as that build would. */
105
+ rehearse: (dist: string, copy: string) => Promise<void>;
106
+ /** Every `toolroll` and `standing-orders` command on PATH: each must be a link into `from` to be switched. */
107
+ commands: (from: RuntimeRef) => string[];
108
+ /** The background service's definition, when one runs `from`. */
109
+ serviceUnit: (from: RuntimeRef) => string | null;
110
+ /** The processes the service runs now. */
111
+ servicePids: (unit: string) => Promise<number[]>;
112
+ /** Unload the service; resolves once launchd no longer has it. */
113
+ stopService: (unit: string) => Promise<void>;
114
+ /** Load and start the service from its definition on disk. */
115
+ restartService: (unit: string) => Promise<void>;
116
+ processAlive: (pid: number) => boolean;
117
+ healthy: (j: RuntimeUpdateJournal) => Promise<boolean>;
118
+ healthTimeoutMs?: number;
119
+ /** How long stopped service processes may take to exit. */
120
+ exitTimeoutMs?: number;
121
+ /** Fault injection for state-machine tests, never selectable by a flag. */
122
+ checkpoint?: (phase: RuntimePhase) => void;
123
+ };
124
+ export type UpdateOutcome = {
125
+ ok: boolean;
126
+ phase: RuntimePhase;
127
+ message: string;
128
+ journal: RuntimeUpdateJournal | null;
129
+ };
130
+ export declare const runtimeUpdateTerminal: (phase: RuntimePhase) => boolean;
131
+ export declare function readRuntimeUpdate(stateDir: string): RuntimeUpdateJournal | null;
132
+ /** Running work by name, for a refusal a person can act on. */
133
+ 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): {
138
+ repository: string;
139
+ workflow: string;
140
+ };
141
+ /** Headlines of this version's changelog section: its bold lead phrases. */
142
+ export declare function releaseNotes(changelog: string, version: string): string[];
143
+ /** Every rename here: what is renamed is flushed first (a file itself, a link
144
+ * by its directory), and the directory after, so a crash never leaves a torn
145
+ * database, catalog or command. */
146
+ export declare function durableRename(temp: string, target: string): void;
147
+ type TableDigest = {
148
+ name: string;
149
+ columns: string[];
150
+ count: number;
151
+ hash: string;
152
+ };
153
+ /** Every historical row, table by table (deploy-browser's rehearsal check). */
154
+ export declare function historySnapshot(db: DatabaseSync): TableDigest[];
155
+ export declare function changedHistory(db: DatabaseSync, before: TableDigest[]): string[];
156
+ export type StartOptions = {
157
+ stateDir: string;
158
+ databaseFile: string;
159
+ current: RuntimeRef;
160
+ actor: string;
161
+ version: string;
162
+ when: When;
163
+ at?: string | null;
164
+ /** An older version replaces the current one only when asked for by name. */
165
+ allowDowngrade?: boolean;
166
+ };
167
+ declare function nextAt(hhmm: string, now: Date): Date;
168
+ /** Record an update without running it: the console's job resumes exactly this journal id. */
169
+ export declare function prepareRuntimeUpdate(o: StartOptions, now: Date): RuntimeUpdateJournal | {
170
+ refused: string;
171
+ };
172
+ /** Start an update: stage, verify, then run the journaled steps. */
173
+ export declare function startRuntimeUpdate(o: StartOptions, system: UpdateSystem): Promise<UpdateOutcome>;
174
+ /** Return to the runtime and database backup an update replaced. */
175
+ export declare function startRuntimeRollback(o: {
176
+ stateDir: string;
177
+ databaseFile: string;
178
+ current: RuntimeRef;
179
+ actor: string;
180
+ when: When;
181
+ }, system: UpdateSystem): Promise<UpdateOutcome>;
182
+ /** Continue a saved update after a crash, where it left off. With `id` (the console's job), only that update:
183
+ * a job never starts a fresh one, and a finished, replaced or rolled-back one is left alone. */
184
+ export declare function resumeRuntimeUpdate(stateDir: string, system: UpdateSystem, id?: string): Promise<UpdateOutcome>;
185
+ /** A prepared update whose job could not start. */
186
+ export declare function abandonRuntimeUpdate(stateDir: string, id: string, why: string, now: Date): void;
187
+ export declare function requestRuntimeUpdateCancel(stateDir: string): string;
188
+ /** Keep the newest release-* runtimes (and any in `keep`), at most KEEP_RUNTIMES; deploy-browser's browser-* and
189
+ * the rollback-* records are not this updater's to remove. */
190
+ export declare function pruneRuntimes(stateDir: string, keep: readonly string[]): string[];
191
+ export type RuntimeUpdateStatus = {
192
+ journal: RuntimeUpdateJournal | null;
193
+ running: boolean;
194
+ /** A completed update whose What's new card has not been dismissed. */
195
+ whatsNew: {
196
+ version: string;
197
+ notes: string[];
198
+ } | null;
199
+ };
200
+ export declare function runtimeUpdateStatus(stateDir: string): RuntimeUpdateStatus;
201
+ export declare function markWhatsNewSeen(stateDir: string): void;
202
+ type Exec = (command: string, args: string[], options?: {
203
+ cwd?: string;
204
+ timeout?: number;
205
+ }) => {
206
+ status: number | null;
207
+ stdout: string;
208
+ stderr: string;
209
+ };
210
+ export declare function currentRuntime(version: string): RuntimeRef;
211
+ /** Seams for tests: npm and ps (`exec`), launchctl (`run`), and process liveness. */
212
+ export type MachineSeams = {
213
+ exec?: Exec;
214
+ run?: SupervisorRunner;
215
+ alive?: (pid: number) => boolean;
216
+ };
217
+ 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. */
223
+ export declare function launchRuntimeUpdate(args: {
224
+ databaseFile: string;
225
+ id: string;
226
+ dist?: string;
227
+ }, seams?: {
228
+ home?: string;
229
+ run?: SupervisorRunner;
230
+ platform?: NodeJS.Platform;
231
+ }): Promise<void>;
232
+ /** The job's last act: its definition goes, so nothing can start it again. Only the definition for this id. */
233
+ export declare function retireUpdateJob(id: string, home?: string): void;
234
+ export declare const nextScheduledAt: typeof nextAt;
235
+ export {};