slopkit 0.3.0 → 0.4.0-rc.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,1534 @@
1
+ /**
2
+ * An app's command line and its releases: `serve | update | rollback | probe | version`, the same for every app on the
3
+ * kit.
4
+ *
5
+ * - **One server per home,** held by a lock the OS releases if the process dies. Ready means ready: the line a
6
+ * supervisor waits for comes only once the app serves, and a home whose `web.json` can't be served is degraded.
7
+ * - **Nothing replaces a release that hasn't started on this home's data.** A release tarball from the package's
8
+ * GitHub releases (`edge` is the newest main; `<NAME>_RELEASES` names a mirror) is installed aside and started on a
9
+ * scratch home holding copies of the store, `config.json` and `web.json` (no credentials, none of the environment)
10
+ * until it says it is ready; only then does it replace the running one.
11
+ * - **A rollback is always possible after an update:** each install keeps the release it replaced
12
+ * (`<home>/releases/previous.tgz`, the running one packed on the first) and itself, each with its version and store
13
+ * schema, so `rollback` reinstalls the previous one through the same checks and never crosses a migration silently.
14
+ * - **Automatic rollback only blames an update:** `rollback --crashed` (a supervisor's crash hook) rolls back only a
15
+ * crash of a release an update installed that never stayed up an hour (`releases/proven.json`), never back and forth.
16
+ * - **An update asked from a page is a durable job** in the store: claimed before the server stops, finished after
17
+ * the install, settled at the next start if a stop cut it short.
18
+ *
19
+ * A private repository's releases need a GitHub token: `GITHUB_TOKEN`, `GH_TOKEN`, or the GitHub CLI's login.
20
+ */
21
+ import { execFile, spawn } from "node:child_process";
22
+ import { randomBytes } from "node:crypto";
23
+ import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
24
+ import { copyFile, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
25
+ import { request as httpRequest } from "node:http";
26
+ import { isIP } from "node:net";
27
+ import { homedir, tmpdir } from "node:os";
28
+ import { basename, dirname, join, resolve as resolvePath } from "node:path";
29
+ import { DatabaseSync } from "node:sqlite";
30
+ import { fileURLToPath } from "node:url";
31
+ import { array, check, literal, number, object, optional, record, string } from "../schema.js";
32
+ import { loadWebConfig } from "./config.js";
33
+ import { Jobs } from "./jobs.js";
34
+ import { Log, scoped } from "./log.js";
35
+ const INSTALL_TIMEOUT_MS = 10 * 60_000;
36
+ const CHECK_TIMEOUT_MS = 60_000;
37
+ const LOCK_WAIT_MS = 1000;
38
+ /** A release that has stayed up this long in one server on a home is proven: a crash loop after it isn't its update's. */
39
+ const PROVEN_AFTER_MS = 3_600_000;
40
+ /** The results systemd hands an `OnFailure=` unit (`MONITOR_SERVICE_RESULT`) when the service itself died. */
41
+ // Not `timeout`: for a simple service that is a stop (or an ExecStartPre) that hung, e.g. an operator's slow stop.
42
+ const CRASH_RESULTS = new Set(["exit-code", "signal", "core-dump", "watchdog"]);
43
+ const NAME = /^[a-z][a-z0-9-]{0,31}$/;
44
+ const releaseInfo = object({ version: string({ min: 1, max: 100 }), schema: number({ integer: true, min: 0 }) }, { strict: true });
45
+ const installedRecord = object({
46
+ version: string({ min: 1, max: 100 }),
47
+ via: literal("update", "rollback", "crashed"),
48
+ at: string({ min: 1, max: 100 }),
49
+ }, { strict: true });
50
+ const provenRecord = object({ version: string({ min: 1, max: 100 }), at: string({ min: 1, max: 100 }) }, { strict: true });
51
+ const rolledBackRecord = object({
52
+ from: string({ min: 1, max: 100 }),
53
+ to: string({ min: 1, max: 100 }),
54
+ at: string({ pattern: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/ }),
55
+ reason: string({ min: 1, max: 100 }),
56
+ }, { strict: true });
57
+ const manifestSchema = object({ version: string({ min: 1 }), homepage: optional(string()) });
58
+ /** A package's `bin`: one entry, or entries by name. */
59
+ const binSchema = object({ bin: optional(string()) });
60
+ const binsSchema = object({ bin: optional(record(string())) });
61
+ const reason = (error) => (error instanceof Error ? error.message : String(error));
62
+ /** `MY_APP` for `my-app`: an environment variable's prefix. */
63
+ const upper = (name) => name.toUpperCase().replaceAll("-", "_");
64
+ function readManifest(root) {
65
+ return manifestSchema.parse(JSON.parse(readFileSync(join(root, "package.json"), "utf-8")));
66
+ }
67
+ /** The package's entry point for `name`, from its `bin`. */
68
+ function entry(root, name) {
69
+ const parsed = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
70
+ const single = check(binSchema, parsed);
71
+ if (single.ok && single.value.bin !== undefined) {
72
+ return join(root, single.value.bin);
73
+ }
74
+ const bins = check(binsSchema, parsed);
75
+ const bin = bins.ok ? bins.value.bin?.[name] : undefined;
76
+ if (bin === undefined) {
77
+ throw new Error(`${root}/package.json has no bin for ${name}`);
78
+ }
79
+ return join(root, bin);
80
+ }
81
+ /** A release name: `edge` or a version such as `1.2.0`. */
82
+ function parseRelease(text) {
83
+ const trimmed = text.trim().replace(/^v/, "");
84
+ return trimmed === "edge" || /^\d+\.\d+\.\d+(-[\w.]+)?$/.test(trimmed) ? trimmed : undefined;
85
+ }
86
+ /** The GitHub repository the package's homepage names, so a fork updates from itself. */
87
+ function repository(root) {
88
+ const { homepage } = readManifest(root);
89
+ const match = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)$/.exec(homepage ?? "");
90
+ if (match?.[1] === undefined) {
91
+ throw new Error(`package.json homepage is not a GitHub repository: ${String(homepage)}`);
92
+ }
93
+ return match[1];
94
+ }
95
+ /** A GitHub token, if there is one: the environment's, else the GitHub CLI's. */
96
+ async function githubToken(env) {
97
+ const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
98
+ if (token !== undefined && token !== "") {
99
+ return token;
100
+ }
101
+ return await new Promise((resolve) => {
102
+ execFile("gh", ["auth", "token"], { timeout: 10_000 }, (error, stdout) => resolve(error === null && stdout.trim() ? stdout.trim() : undefined));
103
+ });
104
+ }
105
+ async function github(env, path, accept = "application/vnd.github+json") {
106
+ const token = await githubToken(env);
107
+ return await fetch(`https://api.github.com${path}`, {
108
+ headers: { accept, ...(token === undefined ? {} : { authorization: `Bearer ${token}` }) },
109
+ redirect: "follow",
110
+ signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS),
111
+ });
112
+ }
113
+ /** Where a global install under `prefix` puts the package. */
114
+ function packageUnder(prefix, name) {
115
+ return process.platform === "win32"
116
+ ? join(prefix, "node_modules", name)
117
+ : join(prefix, "lib", "node_modules", name);
118
+ }
119
+ /** The npm prefix the running package was installed under, so an install replaces it. */
120
+ function installPrefix(root) {
121
+ const modules = dirname(root.replace(/[/\\]+$/, ""));
122
+ if (basename(modules) !== "node_modules") {
123
+ throw new Error(`${root} is not a global npm install; update it by hand`);
124
+ }
125
+ const above = dirname(modules);
126
+ return process.platform !== "win32" && basename(above) === "lib" ? dirname(above) : above;
127
+ }
128
+ /** Check an update can run before anything stops: an installed release, and a tarball that exists. */
129
+ async function planUpdate(app, release) {
130
+ if (existsSync(join(app.root, "tsconfig.build.json"))) {
131
+ throw new Error(`${app.name} runs the checkout at ${app.root}; update it there (pull, then build)`);
132
+ }
133
+ const prefix = installPrefix(app.root);
134
+ const tag = release === "edge" ? "edge" : `v${release}`;
135
+ const mirror = app.env[`${upper(app.name)}_RELEASES`];
136
+ if (mirror !== undefined && mirror !== "") {
137
+ const url = `${mirror.replace(/\/+$/, "")}/${tag}/${app.name}.tgz`;
138
+ const head = await fetch(url, { method: "HEAD" });
139
+ if (!head.ok) {
140
+ throw new Error(`No release ${release} at ${url} (HTTP ${head.status})`);
141
+ }
142
+ return {
143
+ release,
144
+ prefix,
145
+ download: async () => Buffer.from(await (await fetch(url)).arrayBuffer()),
146
+ };
147
+ }
148
+ const repo = repository(app.root);
149
+ const response = await github(app.env, `/repos/${repo}/releases/tags/${tag}`);
150
+ if (!response.ok) {
151
+ throw new Error(`No release ${release} in ${repo} (GitHub answered ${response.status})`);
152
+ }
153
+ const assets = check(object({ assets: optional(array(object({ name: string(), url: string() }))) }), await response.json());
154
+ const url = assets.ok
155
+ ? assets.value.assets?.find((each) => each.name === `${app.name}.tgz`)?.url
156
+ : undefined;
157
+ if (url === undefined) {
158
+ throw new TypeError(`Release ${release} has no ${app.name}.tgz`);
159
+ }
160
+ return {
161
+ release,
162
+ prefix,
163
+ download: async () => {
164
+ const file = await github(app.env, new URL(url).pathname, "application/octet-stream");
165
+ if (!file.ok) {
166
+ throw new Error(`The download failed: GitHub answered ${file.status}`);
167
+ }
168
+ return Buffer.from(await file.arrayBuffer());
169
+ },
170
+ };
171
+ }
172
+ function npm(args, output) {
173
+ const child = process.platform === "win32"
174
+ ? spawn(`npm ${args.map((arg) => `"${arg}"`).join(" ")}`, { shell: true })
175
+ : spawn("npm", args);
176
+ const tail = [];
177
+ const read = (chunk) => {
178
+ for (const line of chunk.toString("utf-8").split(/\r?\n/)) {
179
+ if (!line.trim()) {
180
+ continue;
181
+ }
182
+ output(line);
183
+ tail.push(line);
184
+ if (tail.length > 8) {
185
+ tail.shift();
186
+ }
187
+ }
188
+ };
189
+ child.stdout.on("data", read);
190
+ child.stderr.on("data", read);
191
+ const timer = setTimeout(() => {
192
+ child.kill();
193
+ }, INSTALL_TIMEOUT_MS);
194
+ return new Promise((resolve) => {
195
+ child.once("error", (error) => {
196
+ clearTimeout(timer);
197
+ resolve({ ok: false, reason: "npm could not run", tail: [...tail, error.message] });
198
+ });
199
+ child.once("close", (code) => {
200
+ clearTimeout(timer);
201
+ resolve(code === 0 ? { ok: true } : { ok: false, reason: `npm exited ${code ?? 1}`, tail });
202
+ });
203
+ });
204
+ }
205
+ /** What `version --json` printed; undefined for anything else. */
206
+ function releaseFrom(stdout) {
207
+ try {
208
+ const parsed = check(releaseInfo, JSON.parse(stdout));
209
+ return parsed.ok ? parsed.value : undefined;
210
+ }
211
+ catch {
212
+ return undefined;
213
+ }
214
+ }
215
+ /** Ask the package at `root` what it is; undefined for a release from before `version --json`. */
216
+ function askRelease(root, name) {
217
+ return new Promise((resolve) => {
218
+ execFile(process.execPath, [entry(root, name), "version", "--json"], { timeout: CHECK_TIMEOUT_MS, env: { PATH: process.env.PATH } }, (error, stdout) => {
219
+ resolve(error === null ? releaseFrom(stdout) : undefined);
220
+ });
221
+ });
222
+ }
223
+ /**
224
+ * Start the package at `root` on a scratch home holding copies of `home`'s store (`vacuum into`, a consistent
225
+ * snapshot, or `store` instead), `config.json` and `web.json` (the web bound to 127.0.0.1 on a free port), with no
226
+ * credentials and none of the environment, until it says it is ready, then stop it. `<name> degraded` is a failure.
227
+ */
228
+ async function bootCheck(app, root, home, store) {
229
+ const scratch = await mkdtemp(join(tmpdir(), `${app.name}-check-`));
230
+ try {
231
+ const source = store ?? (home === undefined ? undefined : join(home, app.store));
232
+ if (source !== undefined && existsSync(source)) {
233
+ const db = new DatabaseSync(source, { readOnly: true });
234
+ try {
235
+ db.prepare("vacuum into ?").run(join(scratch, app.store));
236
+ }
237
+ finally {
238
+ db.close();
239
+ }
240
+ }
241
+ if (home !== undefined) {
242
+ if (existsSync(join(home, "config.json"))) {
243
+ await copyFile(join(home, "config.json"), join(scratch, "config.json"));
244
+ }
245
+ if (existsSync(join(home, "web.json"))) {
246
+ const raw = await readFile(join(home, "web.json"), "utf-8");
247
+ let web;
248
+ try {
249
+ web = JSON.parse(raw);
250
+ }
251
+ catch {
252
+ web = undefined;
253
+ }
254
+ await writeFile(join(scratch, "web.json"), typeof web === "object" && web !== null && !Array.isArray(web)
255
+ ? JSON.stringify({ ...web, bind: "127.0.0.1", port: 0 })
256
+ : raw, { mode: 0o600 });
257
+ }
258
+ }
259
+ const child = spawn(process.execPath, [entry(root, app.name), "serve"], {
260
+ // Nothing of the real home, credentials or providers reaches the check.
261
+ env: { PATH: process.env.PATH, HOME: scratch, [`${upper(app.name)}_HOME`]: scratch },
262
+ stdio: ["ignore", "pipe", "pipe"],
263
+ });
264
+ const closed = new Promise((resolve) => {
265
+ child.once("close", () => {
266
+ resolve();
267
+ });
268
+ });
269
+ let output = "";
270
+ const ready = await new Promise((resolve) => {
271
+ const timer = setTimeout(() => resolve(false), CHECK_TIMEOUT_MS);
272
+ const settle = (value) => {
273
+ clearTimeout(timer);
274
+ resolve(value);
275
+ };
276
+ const read = (chunk) => {
277
+ output += chunk.toString("utf-8");
278
+ if (output.includes(`${app.name} ready`)) {
279
+ settle(true);
280
+ }
281
+ else if (output.includes(`${app.name} degraded`)) {
282
+ settle(false);
283
+ }
284
+ };
285
+ child.stdout.on("data", read);
286
+ child.stderr.on("data", read);
287
+ child.once("error", (error) => {
288
+ output += `\n${error.message}`;
289
+ settle(false);
290
+ });
291
+ void closed.then(() => settle(false));
292
+ });
293
+ child.kill();
294
+ let grace;
295
+ const gone = await Promise.race([
296
+ closed.then(() => true),
297
+ new Promise((resolve) => {
298
+ grace = setTimeout(() => {
299
+ resolve(false);
300
+ }, 15_000);
301
+ }),
302
+ ]);
303
+ clearTimeout(grace);
304
+ if (!gone) {
305
+ child.kill("SIGKILL");
306
+ }
307
+ if (ready) {
308
+ return { ok: true };
309
+ }
310
+ return {
311
+ ok: false,
312
+ reason: output.includes(`${app.name} degraded`)
313
+ ? "the new release can't serve this home's web.json"
314
+ : "the new release did not start",
315
+ tail: output
316
+ .split(/\r?\n/)
317
+ .filter((line) => line.trim() !== "" && !/^\s+at /.test(line))
318
+ .slice(-8),
319
+ };
320
+ }
321
+ finally {
322
+ await rm(scratch, { recursive: true, force: true }).catch(() => undefined);
323
+ }
324
+ }
325
+ /** Sync a directory, so the renames in it are on the disk. */
326
+ function syncDirectory(path) {
327
+ const dir = openSync(path, "r");
328
+ try {
329
+ fsyncSync(dir);
330
+ }
331
+ finally {
332
+ closeSync(dir);
333
+ }
334
+ }
335
+ /** Write a small record whole and on the disk (a temporary file, synced, renamed over, the directory synced). */
336
+ function writeRecord(path, value) {
337
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
338
+ const temporary = `${path}.tmp`;
339
+ const bytes = Buffer.from(JSON.stringify(value), "utf-8");
340
+ const fd = openSync(temporary, "w", 0o600);
341
+ try {
342
+ for (let at = 0; at < bytes.length;) {
343
+ at += writeSync(fd, bytes, at, bytes.length - at);
344
+ }
345
+ fsyncSync(fd);
346
+ }
347
+ finally {
348
+ closeSync(fd);
349
+ }
350
+ renameSync(temporary, path);
351
+ syncDirectory(dirname(path));
352
+ }
353
+ /** A record as its schema reads it; undefined when missing or unreadable. */
354
+ function readRecord(path, schema) {
355
+ try {
356
+ return schema.parse(JSON.parse(readFileSync(path, "utf-8")));
357
+ }
358
+ catch {
359
+ return undefined;
360
+ }
361
+ }
362
+ /**
363
+ * Record how the new release came to be installed (`installed.json`) and keep it as current. Any install but an
364
+ * automatic rollback ends the record of the last one. Never fails an install: the previous release is already kept.
365
+ */
366
+ async function keep(home, tarball, incoming, via, output) {
367
+ const releases = join(home, "releases");
368
+ try {
369
+ // First, as the crash rollback's guards read it: a release put back by hand is never rolled back "forward".
370
+ if (incoming === undefined) {
371
+ rmSync(join(releases, "installed.json"), { force: true });
372
+ }
373
+ else {
374
+ writeRecord(join(releases, "installed.json"), {
375
+ version: incoming.version,
376
+ via,
377
+ at: new Date().toISOString(),
378
+ });
379
+ }
380
+ if (via !== "crashed") {
381
+ rmSync(join(releases, "rolled-back.json"), { force: true });
382
+ }
383
+ }
384
+ catch (error) {
385
+ output(`couldn't record how the release was installed: ${reason(error)}`);
386
+ }
387
+ try {
388
+ await copyFile(tarball, join(releases, "current.tgz.tmp"));
389
+ await rename(join(releases, "current.tgz.tmp"), join(releases, "current.tgz"));
390
+ if (incoming === undefined) {
391
+ await rm(join(releases, "current.json"), { force: true });
392
+ }
393
+ else {
394
+ writeRecord(join(releases, "current.json"), incoming);
395
+ }
396
+ }
397
+ catch (error) {
398
+ output(`couldn't keep the new release as current: ${reason(error)}`);
399
+ }
400
+ }
401
+ /** Copy a file whole and on the disk: a temporary copy, synced, renamed over. */
402
+ async function copyDurably(from, to) {
403
+ await copyFile(from, `${to}.tmp`);
404
+ const fd = openSync(`${to}.tmp`, "r+");
405
+ try {
406
+ fsyncSync(fd);
407
+ }
408
+ finally {
409
+ closeSync(fd);
410
+ }
411
+ renameSync(`${to}.tmp`, to);
412
+ }
413
+ /**
414
+ * Keep the release about to be replaced as `releases/previous.*`, on the disk, before anything replaces it: staged as
415
+ * `previous.next.*`, then moved over the old previous once the new release is installed. Throws if it can't be kept.
416
+ */
417
+ async function stagePrevious(home, outgoing) {
418
+ const releases = join(home, "releases");
419
+ await mkdir(releases, { recursive: true, mode: 0o700 });
420
+ await copyDurably(outgoing.tarball, join(releases, "previous.next.tgz"));
421
+ writeRecord(join(releases, "previous.next.json"), outgoing.info);
422
+ }
423
+ /** The staged previous release takes the old one's place (the tarball and what it is, each renamed whole). */
424
+ function promotePrevious(home) {
425
+ const releases = join(home, "releases");
426
+ renameSync(join(releases, "previous.next.tgz"), join(releases, "previous.tgz"));
427
+ renameSync(join(releases, "previous.next.json"), join(releases, "previous.json"));
428
+ syncDirectory(releases);
429
+ }
430
+ /** A staged previous release whose install didn't happen: the old one stays. */
431
+ function dropStaged(home) {
432
+ for (const file of ["previous.next.tgz", "previous.next.json", "previous.next.tgz.tmp"]) {
433
+ rmSync(join(home, "releases", file), { force: true });
434
+ }
435
+ }
436
+ /**
437
+ * The release about to be replaced, for `releases/previous.*`: the one the last install kept as current, else the
438
+ * running package packed from where it is installed. Undefined if neither can be had.
439
+ */
440
+ async function replaced(app, schema, home, scratch, output) {
441
+ const releases = join(home, "releases");
442
+ const current = join(releases, "current.tgz");
443
+ try {
444
+ const info = readRecord(join(releases, "current.json"), releaseInfo);
445
+ if (existsSync(current) && info !== undefined && info.version === app.version) {
446
+ return { tarball: current, info };
447
+ }
448
+ const packed = join(scratch, "running");
449
+ await mkdir(packed);
450
+ const done = await npm(["pack", app.root, "--pack-destination", packed], () => undefined);
451
+ if (!done.ok) {
452
+ throw new Error(done.reason);
453
+ }
454
+ const file = (await readdir(packed)).find((name) => name.endsWith(".tgz"));
455
+ if (file === undefined) {
456
+ throw new Error("npm pack made no tarball");
457
+ }
458
+ return { tarball: join(packed, file), info: { version: app.version, schema } };
459
+ }
460
+ catch (error) {
461
+ output(`couldn't keep the running release for a rollback: ${reason(error)}`);
462
+ return undefined;
463
+ }
464
+ }
465
+ /**
466
+ * Install a planned release: aside first, started there on copies of `home`'s store and settings (or `store`), only
467
+ * then over the running one; the release it replaces and itself are kept for a rollback.
468
+ */
469
+ async function install(app, schema, plan, output, home, options = {}) {
470
+ const scratch = await mkdtemp(join(tmpdir(), `${app.name}-update-`));
471
+ try {
472
+ const tarball = join(scratch, `${app.name}.tgz`);
473
+ try {
474
+ await writeFile(tarball, await plan.download());
475
+ }
476
+ catch (error) {
477
+ return { ok: false, reason: "the download failed", tail: [reason(error)] };
478
+ }
479
+ const flags = ["install", "-g", "--no-audit", "--no-fund", "--prefix"];
480
+ const stage = join(scratch, "stage");
481
+ output("installing it aside first, to check it starts");
482
+ const staged = await npm([...flags, stage, tarball], output);
483
+ if (!staged.ok) {
484
+ return staged;
485
+ }
486
+ const checked = await bootCheck(app, packageUnder(stage, app.name), home, options.store);
487
+ if (!checked.ok) {
488
+ return checked;
489
+ }
490
+ const incoming = await askRelease(packageUnder(stage, app.name), app.name);
491
+ if (home !== undefined) {
492
+ // A rollback must stay possible: the running release is kept before anything replaces it, or nothing does.
493
+ const outgoing = await replaced(app, schema, home, scratch, output);
494
+ if (outgoing === undefined) {
495
+ return {
496
+ ok: false,
497
+ reason: "the running release couldn't be kept for a rollback",
498
+ tail: [],
499
+ };
500
+ }
501
+ try {
502
+ await stagePrevious(home, outgoing);
503
+ }
504
+ catch (error) {
505
+ dropStaged(home);
506
+ return {
507
+ ok: false,
508
+ reason: `the running release couldn't be kept for a rollback: ${reason(error)}`,
509
+ tail: [],
510
+ };
511
+ }
512
+ }
513
+ try {
514
+ options.beforeReplace?.();
515
+ }
516
+ catch (error) {
517
+ if (home !== undefined) {
518
+ dropStaged(home);
519
+ }
520
+ return { ok: false, reason: `the store couldn't be put back: ${reason(error)}`, tail: [] };
521
+ }
522
+ output("it starts; installing it");
523
+ const installed = await npm([...flags, plan.prefix, tarball], output);
524
+ if (home !== undefined) {
525
+ if (installed.ok) {
526
+ promotePrevious(home);
527
+ await keep(home, tarball, incoming, options.via ?? "update", output);
528
+ }
529
+ else {
530
+ dropStaged(home);
531
+ }
532
+ }
533
+ return installed;
534
+ }
535
+ finally {
536
+ await rm(scratch, { recursive: true, force: true }).catch(() => undefined);
537
+ }
538
+ }
539
+ /** The store's schema now (`user_version`), or 0 with no store. */
540
+ function storeSchema(path) {
541
+ if (!existsSync(path)) {
542
+ return 0;
543
+ }
544
+ const db = new DatabaseSync(path, { readOnly: true });
545
+ try {
546
+ return Number(db.prepare("pragma user_version").get()?.user_version ?? 0);
547
+ }
548
+ finally {
549
+ db.close();
550
+ }
551
+ }
552
+ function keptPrevious(home) {
553
+ const tarball = join(home, "releases", "previous.tgz");
554
+ if (!existsSync(tarball)) {
555
+ throw new Error("no earlier release is kept yet: each update from this version on keeps the one it replaces");
556
+ }
557
+ return { tarball, info: readRecord(join(home, "releases", "previous.json"), releaseInfo) };
558
+ }
559
+ /** A plan that reinstalls the release kept before the current one. */
560
+ function rollbackPlan(app, home) {
561
+ const { tarball } = keptPrevious(home);
562
+ if (existsSync(join(app.root, "tsconfig.build.json"))) {
563
+ throw new Error(`${app.name} runs the checkout at ${app.root}; roll it back there`);
564
+ }
565
+ return {
566
+ release: "previous",
567
+ prefix: installPrefix(app.root),
568
+ download: () => readFile(tarball),
569
+ };
570
+ }
571
+ /** Mark `version` proven on `home`: the server calls it once it has stayed up long enough. */
572
+ function markProven(home, version) {
573
+ writeRecord(join(home, "releases", "proven.json"), { version, at: new Date().toISOString() });
574
+ }
575
+ /** The automatic rollback that put `version` in place, if it is the one running: what it replaced and when. */
576
+ export function rolledBackTo(home, version) {
577
+ const found = readRecord(join(home, "releases", "rolled-back.json"), rolledBackRecord);
578
+ return found !== undefined && found.to === version ? { from: found.from, at: found.at } : null;
579
+ }
580
+ /** Whether a server holds the home's lock, without creating the lock file. */
581
+ export function running(home) {
582
+ const path = join(home, "owner.sqlite");
583
+ if (!existsSync(path)) {
584
+ return false;
585
+ }
586
+ const owner = new DatabaseSync(path);
587
+ try {
588
+ owner.exec("BEGIN EXCLUSIVE");
589
+ owner.exec("ROLLBACK");
590
+ return false;
591
+ }
592
+ catch {
593
+ return true;
594
+ }
595
+ finally {
596
+ owner.close();
597
+ }
598
+ }
599
+ /**
600
+ * Take the home's lock (`owner.sqlite`, an exclusive transaction the OS releases if this process dies): undefined when
601
+ * a server, or another update or rollback, holds it. Held, no server starts on the home while a release is replaced.
602
+ */
603
+ function lock(home) {
604
+ mkdirSync(home, { recursive: true, mode: 0o700 });
605
+ // A probe's look at the lock (`running`) holds it for an instant: waited out, never taken for a server.
606
+ const owner = new DatabaseSync(join(home, "owner.sqlite"), { timeout: LOCK_WAIT_MS });
607
+ try {
608
+ owner.exec("BEGIN EXCLUSIVE");
609
+ return owner;
610
+ }
611
+ catch {
612
+ owner.close();
613
+ return undefined;
614
+ }
615
+ }
616
+ /**
617
+ * Put `copy` in place of the live store, which is kept whole (its WAL folded in) as `<store>.rolled-back-<time>`. It
618
+ * starts a new dataset: every page starts its copy over and no tap made since runs here. A failure puts back what moved.
619
+ */
620
+ function swapStore(path, copy) {
621
+ const restoring = `${path}.restoring`;
622
+ const aside = `${path}.rolled-back-${new Date().toISOString().replaceAll(":", "-")}`;
623
+ for (const suffix of ["", "-wal", "-shm", "-journal"]) {
624
+ rmSync(`${restoring}${suffix}`, { force: true });
625
+ }
626
+ copyFileSync(copy, restoring);
627
+ let moved = false;
628
+ try {
629
+ const restored = new DatabaseSync(restoring);
630
+ try {
631
+ restored
632
+ .prepare("update meta set value = ? where key = 'dataset'")
633
+ .run(randomBytes(12).toString("hex"));
634
+ }
635
+ finally {
636
+ restored.close();
637
+ }
638
+ const live = new DatabaseSync(path);
639
+ try {
640
+ live.exec("pragma wal_checkpoint(truncate)");
641
+ }
642
+ finally {
643
+ live.close();
644
+ }
645
+ renameSync(path, aside);
646
+ moved = true;
647
+ for (const suffix of ["-wal", "-shm"]) {
648
+ rmSync(`${path}${suffix}`, { force: true });
649
+ }
650
+ renameSync(restoring, path);
651
+ }
652
+ catch (error) {
653
+ if (moved && !existsSync(path)) {
654
+ renameSync(aside, path);
655
+ }
656
+ rmSync(restoring, { force: true });
657
+ throw error;
658
+ }
659
+ syncDirectory(dirname(path));
660
+ return { path, aside, copy };
661
+ }
662
+ /** Undo `swapStore` when the release it was for didn't install: the live store back, untouched; the copy is kept. */
663
+ function unswapStore(swap) {
664
+ rmSync(swap.path);
665
+ for (const suffix of ["-wal", "-shm"]) {
666
+ rmSync(`${swap.path}${suffix}`, { force: true });
667
+ }
668
+ renameSync(swap.aside, swap.path);
669
+ syncDirectory(dirname(swap.path));
670
+ }
671
+ /**
672
+ * Reinstall the kept release through `install` (its boot check on copies). With `copy` (crossing a migration) the
673
+ * store is put back once that release passed its check and before it replaces the running one, and put back as it was
674
+ * if the install then fails: whatever happens, the installed release and the store fit each other.
675
+ */
676
+ async function reinstallPrevious(app, schema, home, plan, copy, via, output) {
677
+ let swap;
678
+ let installed;
679
+ try {
680
+ installed = await install(app, schema, plan, output, home, {
681
+ via,
682
+ ...(copy === undefined
683
+ ? {}
684
+ : {
685
+ store: copy,
686
+ beforeReplace: () => {
687
+ swap = swapStore(join(home, app.store), copy);
688
+ },
689
+ }),
690
+ });
691
+ }
692
+ catch (error) {
693
+ installed = { ok: false, reason: reason(error), tail: [] };
694
+ }
695
+ if (swap === undefined) {
696
+ return installed;
697
+ }
698
+ if (!installed.ok) {
699
+ unswapStore(swap);
700
+ return installed;
701
+ }
702
+ // Used up: the next crossing of that migration keeps a fresh copy, so this older one never comes back again.
703
+ try {
704
+ rmSync(swap.copy, { force: true });
705
+ }
706
+ catch (error) {
707
+ console.log(`couldn't remove ${basename(swap.copy)} after restoring it (remove it by hand): ${reason(error)}`);
708
+ }
709
+ console.log(`store restored from ${basename(swap.copy)}; the one it replaced is ${basename(swap.aside)}`);
710
+ return installed;
711
+ }
712
+ /**
713
+ * `rollback --crashed`, run by a supervisor once the server keeps crashing (systemd: `OnFailure=` with
714
+ * `RestartMode=direct`, which hands it `MONITOR_SERVICE_RESULT`). It rolls back only what an update can be blamed for.
715
+ * Exit 0 rolled back, 3 declined, 1 failed; anything but 0 leaves the installed release and the store as they were.
716
+ */
717
+ async function runCrashed(app, schema, home) {
718
+ const log = new Log(home, app.name, { echo: false });
719
+ const say = scoped(log, "rollback");
720
+ // Every decision is one line on stdout (the supervisor's journal) and one in the home's log, with its reason.
721
+ const end = async (code, line, attributes = {}) => {
722
+ console.log(line);
723
+ say(code === 0 ? "warn" : code === 1 ? "error" : "info", line, attributes);
724
+ await log.flushed();
725
+ return code;
726
+ };
727
+ const decline = (why) => end(3, `no automatic rollback: ${why}`);
728
+ // The live server first: whatever the supervisor says, a running server is never rolled back under itself; and held,
729
+ // none starts while the release is replaced.
730
+ const owner = lock(home);
731
+ if (owner === undefined) {
732
+ return await end(1, `no automatic rollback: a server is running on ${home}`);
733
+ }
734
+ try {
735
+ const result = app.env.MONITOR_SERVICE_RESULT;
736
+ // Only the supervisor's word that the service itself died: a stop that hung, or no word at all, isn't a crash.
737
+ if (result === undefined || !CRASH_RESULTS.has(result)) {
738
+ return await decline(`not a crash: ${result ?? "no MONITOR_SERVICE_RESULT"}`);
739
+ }
740
+ const releases = join(home, "releases");
741
+ if (!existsSync(join(releases, "previous.tgz"))) {
742
+ return await decline("no earlier release is kept");
743
+ }
744
+ if (readRecord(join(releases, "proven.json"), provenRecord)?.version === app.version) {
745
+ return await decline(`${app.version} is proven: it stayed up an hour here, so this isn't its update's doing`);
746
+ }
747
+ if (rolledBackTo(home, app.version) !== null) {
748
+ return await decline(`${app.version} is itself the target of an automatic rollback: never back and forth`);
749
+ }
750
+ const installedBy = readRecord(join(releases, "installed.json"), installedRecord);
751
+ if (installedBy?.version !== app.version || installedBy.via !== "update") {
752
+ return await decline(`${app.version} wasn't installed by an update (${installedBy?.version === app.version ? `but by a ${installedBy.via}` : "no record of it"}): a crash rollback only goes back past an update`);
753
+ }
754
+ const target = readRecord(join(releases, "previous.json"), releaseInfo);
755
+ if (target === undefined) {
756
+ return await decline("the previous release's metadata (releases/previous.json) is missing or unreadable");
757
+ }
758
+ if (target.version === app.version) {
759
+ return await decline(`the kept release is ${app.version} itself`);
760
+ }
761
+ const path = join(home, app.store);
762
+ const crossing = target.schema < storeSchema(path);
763
+ const copy = `${path}.schema-${target.schema}.bak`;
764
+ if (crossing && !existsSync(copy)) {
765
+ return await decline(`${target.version} writes store schema ${target.schema} and no ${basename(copy)} is kept`);
766
+ }
767
+ let plan;
768
+ try {
769
+ plan = rollbackPlan(app, home);
770
+ }
771
+ catch (error) {
772
+ return await end(1, reason(error));
773
+ }
774
+ const marker = join(releases, "rolled-back.json");
775
+ // Recorded before anything is replaced: whichever release ends up installed, the record says only what it is for.
776
+ writeRecord(marker, {
777
+ from: app.version,
778
+ to: target.version,
779
+ at: new Date().toISOString(),
780
+ reason: result,
781
+ });
782
+ let installed;
783
+ try {
784
+ installed = await reinstallPrevious(app, schema, home, plan, crossing ? copy : undefined, "crashed",
785
+ // Its progress goes to the log: the supervisor's journal gets the decision alone.
786
+ (line) => say("info", line));
787
+ }
788
+ catch (error) {
789
+ // Only undoing a store swap after a failed install throws: the record stands, the log says what is left.
790
+ return await end(1, `automatic rollback failed and the store couldn't be put back as it was: ${reason(error)}`);
791
+ }
792
+ if (!installed.ok) {
793
+ rmSync(marker, { force: true });
794
+ return await end(1, `automatic rollback failed: ${installed.reason}; the installed version is unchanged`, { tail: installed.tail.join("\n") });
795
+ }
796
+ return await end(0, `rolled back automatically · ${app.version} → ${target.version}`, {
797
+ from: app.version,
798
+ to: target.version,
799
+ reason: result,
800
+ ...(crossing ? { store: basename(copy) } : {}),
801
+ });
802
+ }
803
+ finally {
804
+ owner.close();
805
+ }
806
+ }
807
+ /** `update [edge | <version>]`: with the server stopped, replace the installed package with a release. */
808
+ async function runUpdate(app, schema, args, home) {
809
+ const [text = "edge", ...rest] = args;
810
+ const release = parseRelease(text);
811
+ if (rest.length > 0 || release === undefined) {
812
+ console.error(`Usage: ${app.name} update [edge | <version>]`);
813
+ return 2;
814
+ }
815
+ // Held throughout: no server starts on the home while its release is replaced.
816
+ const owner = lock(home);
817
+ if (owner === undefined) {
818
+ console.error(`A server runs on ${home}: use the page's update button, or stop it first.`);
819
+ return 1;
820
+ }
821
+ try {
822
+ const plan = await planUpdate(app, release);
823
+ console.log(`Installing ${release} into ${plan.prefix}`);
824
+ const installed = await install(app, schema, plan, (line) => console.log(line), home);
825
+ if (!installed.ok) {
826
+ console.error(`Update failed: ${installed.reason}; the installed version is unchanged.`);
827
+ for (const line of installed.tail) {
828
+ console.error(line);
829
+ }
830
+ return 1;
831
+ }
832
+ }
833
+ catch (error) {
834
+ console.error(reason(error));
835
+ return 1;
836
+ }
837
+ finally {
838
+ owner.close();
839
+ }
840
+ console.log(`updated · ${app.version} → ${readManifest(app.root).version}`);
841
+ return 0;
842
+ }
843
+ /**
844
+ * `rollback [--with-store | --crashed]`: with the server stopped, reinstall the release kept before the current one. A
845
+ * release that writes an older store schema than the store has can't open it: refused, saying which copy of the store
846
+ * from before the migration would come back. `--with-store` puts that copy in place too.
847
+ */
848
+ async function runRollback(app, schema, args, home) {
849
+ const [flag, ...rest] = args;
850
+ if (rest.length > 0 || (flag !== undefined && flag !== "--with-store" && flag !== "--crashed")) {
851
+ console.error(`Usage: ${app.name} rollback [--with-store | --crashed]`);
852
+ return 2;
853
+ }
854
+ if (flag === "--crashed") {
855
+ return await runCrashed(app, schema, home).catch((error) => {
856
+ console.error(`automatic rollback failed: ${reason(error)}`);
857
+ return 1;
858
+ });
859
+ }
860
+ // Held throughout: no server starts on the home while its release is replaced.
861
+ const owner = lock(home);
862
+ if (owner === undefined) {
863
+ console.error(`A server runs on ${home}: stop it first.`);
864
+ return 1;
865
+ }
866
+ try {
867
+ // Whether it can open this store comes first: a refusal changes nothing, wherever the app runs from.
868
+ const kept = keptPrevious(home);
869
+ const path = join(home, app.store);
870
+ const now = storeSchema(path);
871
+ const target = kept.info?.schema;
872
+ const crossing = target === undefined || target < now;
873
+ const copy = target === undefined ? undefined : `${path}.schema-${target}.bak`;
874
+ if (crossing && flag !== "--with-store") {
875
+ const what = target === undefined
876
+ ? "the kept release doesn't say which store schema it writes"
877
+ : `it writes store schema ${target} and the store is at ${now}`;
878
+ const back = copy !== undefined && existsSync(copy)
879
+ ? `\n\`${app.name} rollback --with-store\` puts back ${basename(copy)} (from ${statSync(copy).mtime.toISOString()}), the store from before that migration: everything since is set aside.`
880
+ : `\nNo copy of the store from before that migration is kept here (${app.store}.schema-<n>.bak), so --with-store can't help.`;
881
+ console.error(`Rollback refused: ${what}.${back}`);
882
+ return 1;
883
+ }
884
+ if (crossing && (copy === undefined || !existsSync(copy))) {
885
+ console.error(`Rollback refused: no ${copy === undefined ? `${app.store}.schema-<n>.bak` : basename(copy)} to restore with it.`);
886
+ return 1;
887
+ }
888
+ const plan = rollbackPlan(app, home);
889
+ console.log(`Reinstalling the previous release into ${plan.prefix}`);
890
+ const installed = await reinstallPrevious(app, schema, home, plan, crossing ? copy : undefined, "rollback", (line) => console.log(line));
891
+ if (!installed.ok) {
892
+ console.error(`Rollback failed: ${installed.reason}; the installed version is unchanged.`);
893
+ for (const line of installed.tail) {
894
+ console.error(line);
895
+ }
896
+ return 1;
897
+ }
898
+ }
899
+ catch (error) {
900
+ console.error(reason(error));
901
+ return 1;
902
+ }
903
+ finally {
904
+ owner.close();
905
+ }
906
+ console.log(`rolled back · ${app.version} → ${readManifest(app.root).version}`);
907
+ return 0;
908
+ }
909
+ /** An update job (`slopkit/server/jobs`, kind `update`): one open at a time; every tap while it is open joins it. */
910
+ export const UPDATE_KIND = "update";
911
+ const UPDATE_KEY = "install";
912
+ /** An update still open this long after it was asked is overdue. */
913
+ const UPDATE_DEADLINE_MS = 15 * 60_000;
914
+ const NOTE = /^(?:update to|installing) (\S+) from (\S+)$/;
915
+ function noted(job) {
916
+ const match = NOTE.exec(job.note ?? "");
917
+ return {
918
+ release: job.result?.release ?? match?.[1] ?? "edge",
919
+ from: job.result?.from ?? match?.[2] ?? "unknown",
920
+ };
921
+ }
922
+ /** Ask for an update to `release` from `version` (joins one already open): the job's id, and whether still due. */
923
+ export function askUpdate(jobs, release, version, by, now = Date.now()) {
924
+ const asked = jobs.ask(UPDATE_KIND, UPDATE_KEY, {
925
+ deadlineAt: now + UPDATE_DEADLINE_MS,
926
+ by,
927
+ note: `update to ${release} from ${version}`,
928
+ });
929
+ return { id: asked.id, due: jobs.get(asked.id)?.state === "due" };
930
+ }
931
+ /** Claim the asked update before the server stops: running, "installing <release> from <version>", committed. */
932
+ export function beginUpdate(jobs, version) {
933
+ const due = jobs.open(UPDATE_KIND).find((job) => job.state === "due");
934
+ if (due === undefined) {
935
+ return undefined;
936
+ }
937
+ const { release } = noted(due);
938
+ const started = jobs.start(due.id, `installing ${release} from ${version}`);
939
+ return started === undefined ? undefined : { id: started.id, release, from: version };
940
+ }
941
+ /**
942
+ * At start, before serving: how an update job a stop left running is settled. The install began: installed when this
943
+ * is another version than the one it replaced (the reconciliation read), else failed as interrupted.
944
+ */
945
+ export function recoverUpdate(job, version) {
946
+ const { release, from } = noted(job);
947
+ return from === version
948
+ ? {
949
+ failed: { who: "us", reason: "interrupted: the server stopped before the update finished" },
950
+ }
951
+ : { done: { release, from } };
952
+ }
953
+ /** At start, after running jobs are settled: an update still due failed, nothing changed. Returns how many. */
954
+ export function settleAskedUpdates(jobs) {
955
+ let due = 0;
956
+ for (const job of jobs.open(UPDATE_KIND)) {
957
+ if (job.state === "due") {
958
+ jobs.finish(job.id, {
959
+ failed: {
960
+ who: "us",
961
+ reason: "the server restarted before the update began; nothing changed",
962
+ },
963
+ });
964
+ due++;
965
+ }
966
+ }
967
+ return due;
968
+ }
969
+ /** The newest `kept` finished updates, newest first. */
970
+ export function updateRecords(jobs, kept) {
971
+ return jobs.finished(UPDATE_KIND, kept).map((job) => {
972
+ const { release, from } = noted(job);
973
+ return {
974
+ job: job.id,
975
+ release,
976
+ from,
977
+ result: job.state === "done" ? "installed" : "failed",
978
+ reason: job.state === "done" ? null : (job.reason ?? "no reason given").slice(0, 2000),
979
+ at: job.updatedAt,
980
+ };
981
+ });
982
+ }
983
+ /** The open update job's id, if one is asked or installing. */
984
+ export function updating(jobs) {
985
+ return jobs.open(UPDATE_KIND)[0]?.id ?? null;
986
+ }
987
+ const updateResult = object({ release: optional(string()), from: optional(string()) });
988
+ /**
989
+ * Record how the claimed update `id` ended, in the home's store, after the install and before the server starts again
990
+ * (the server is stopped; its store is closed). A job already settled is left as it is.
991
+ */
992
+ function finishUpdate(path, id, end) {
993
+ const db = new DatabaseSync(path);
994
+ try {
995
+ const jobs = new Jobs({
996
+ db,
997
+ transaction: (body) => {
998
+ db.exec("begin immediate");
999
+ try {
1000
+ const result = body();
1001
+ db.exec("commit");
1002
+ return result;
1003
+ }
1004
+ catch (error) {
1005
+ db.exec("rollback");
1006
+ throw error;
1007
+ }
1008
+ },
1009
+ encode: (value) => JSON.stringify(value),
1010
+ decode: (text) => updateResult.parse(JSON.parse(text)),
1011
+ });
1012
+ const job = jobs.get(id);
1013
+ if (job === undefined || (job.state !== "due" && job.state !== "running")) {
1014
+ return;
1015
+ }
1016
+ jobs.finish(id, "installed" in end
1017
+ ? { done: noted(job) }
1018
+ : { failed: { who: "us", reason: end.failed.slice(0, 2000) } });
1019
+ }
1020
+ finally {
1021
+ db.close();
1022
+ }
1023
+ }
1024
+ /** The newer `edge` commit when this is an edge build behind it; a hint only, nothing installs it. */
1025
+ export async function newerEdge(root, env = process.env) {
1026
+ const directory = typeof root === "string" ? root : fileURLToPath(root);
1027
+ const installed = /-edge\.\d+\.g([0-9a-f]+)$/.exec(readManifest(directory).version)?.[1];
1028
+ if (installed === undefined) {
1029
+ return undefined;
1030
+ }
1031
+ const response = await github(env, `/repos/${repository(directory)}/git/ref/tags/edge`);
1032
+ if (!response.ok) {
1033
+ throw new Error(`GitHub answered ${response.status} for the edge tag`);
1034
+ }
1035
+ const body = check(object({ object: object({ sha: string() }) }), await response.json());
1036
+ if (!body.ok) {
1037
+ throw new TypeError("GitHub's edge tag has no commit");
1038
+ }
1039
+ const { sha } = body.value.object;
1040
+ return sha.startsWith(installed) ? undefined : sha.slice(0, installed.length);
1041
+ }
1042
+ /** Replace this process with the same command, after an update installed a new release; never returns. */
1043
+ function reexecute() {
1044
+ const argv = [...process.execArgv, ...process.argv.slice(1)];
1045
+ // The same pid, so a supervisor (systemd) sees one service throughout.
1046
+ if (process.platform !== "win32" && process.execve !== undefined) {
1047
+ process.execve(process.execPath, [process.execPath, ...argv]);
1048
+ }
1049
+ return new Promise((_resolve, reject) => {
1050
+ const child = spawn(process.execPath, argv, { stdio: "inherit" });
1051
+ child.once("error", reject);
1052
+ child.once("exit", (code) => process.exit(code ?? 1));
1053
+ });
1054
+ }
1055
+ async function serve(app, options, home) {
1056
+ const owner = lock(home);
1057
+ if (owner === undefined) {
1058
+ console.error(`Another ${app.name} server runs on ${home}`);
1059
+ return 1;
1060
+ }
1061
+ // A record a killed run left behind says nothing of this one.
1062
+ rmSync(join(home, "serving.json"), { force: true });
1063
+ const log = new Log(home, app.name);
1064
+ const say = scoped(log, "server");
1065
+ const warnings = [];
1066
+ const web = await loadWebConfig(home, warnings);
1067
+ for (const warning of warnings) {
1068
+ say("warn", warning);
1069
+ }
1070
+ // A home with a web.json is served through it: without the web it is degraded, never ready.
1071
+ const degraded = "off" in web && existsSync(join(home, "web.json")) ? web.off : undefined;
1072
+ if ("off" in web) {
1073
+ say("warn", `web off: ${web.off}`);
1074
+ }
1075
+ // Listening for a stop before anything starts: a signal sent the moment the ready line is out is a stop, never a kill.
1076
+ let stop;
1077
+ const stopped = new Promise((resolve) => {
1078
+ stop = resolve;
1079
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
1080
+ process.once(signal, () => resolve({ reason: signal, update: false }));
1081
+ }
1082
+ });
1083
+ const served = await options.serve({
1084
+ home,
1085
+ version: app.version,
1086
+ log,
1087
+ say,
1088
+ web,
1089
+ env: app.env,
1090
+ update: () => stop?.({ reason: "update", update: true }),
1091
+ });
1092
+ say("info", "started", { pid: process.pid, version: app.version, web: served.url ?? null });
1093
+ const serving = join(home, "serving.json");
1094
+ // The start is in the log before anything says it serves, so a probe right after ready finds the home at rest.
1095
+ await log.flushed();
1096
+ // Before the ready line, so whatever waits for it can probe at once.
1097
+ try {
1098
+ writeRecord(serving, {
1099
+ version: app.version,
1100
+ pid: process.pid,
1101
+ ...(served.url === undefined ? {} : { url: served.url }),
1102
+ at: new Date().toISOString(),
1103
+ });
1104
+ }
1105
+ catch (error) {
1106
+ say("warn", "couldn't record that it serves", { reason: reason(error) });
1107
+ }
1108
+ // The update check and a supervisor's readiness both wait for this line.
1109
+ console.log(degraded === undefined
1110
+ ? `${app.name} ready${served.url === undefined ? "" : ` · ${served.url}`}`
1111
+ : `${app.name} degraded: web off: ${degraded}`);
1112
+ // Up this long without a crash: a crash loop from here on isn't this version's update (`rollback --crashed`).
1113
+ const proving = setTimeout(() => {
1114
+ try {
1115
+ markProven(home, app.version);
1116
+ say("info", "this version is proven here", { version: app.version });
1117
+ }
1118
+ catch (error) {
1119
+ say("warn", "couldn't mark this version proven", { reason: reason(error) });
1120
+ }
1121
+ }, options.provenAfterMs ?? PROVEN_AFTER_MS);
1122
+ proving.unref();
1123
+ // The server runs until it is told to stop, even with the web off and nothing scheduled.
1124
+ const alive = setInterval(() => undefined, 2 ** 30);
1125
+ const { reason: why, update } = await stopped;
1126
+ clearInterval(alive);
1127
+ clearTimeout(proving);
1128
+ // The update is claimed (running, in the store) before anything stops: a stop from here on is settled at the next
1129
+ // start, by the version that starts.
1130
+ const job = update ? served.claim?.() : undefined;
1131
+ say("info", "stopping", {
1132
+ reason: why,
1133
+ ...(job === undefined ? {} : { job: job.id }),
1134
+ });
1135
+ await served.close();
1136
+ rmSync(serving, { force: true });
1137
+ if (job === undefined) {
1138
+ owner.close();
1139
+ await log.flushed();
1140
+ return update ? await reexecute() : 0;
1141
+ }
1142
+ // The server is closed, so npm may replace its files; whatever happens, the same command starts again, and how the
1143
+ // job ended waits for it in the store, so every page that asked hears it.
1144
+ let failed;
1145
+ try {
1146
+ const installed = await install(app, options.schema, await planUpdate(app, job.release), (line) => say("info", line), home);
1147
+ failed = installed.ok ? null : installed.reason;
1148
+ say(installed.ok ? "info" : "warn", installed.ok ? "update installed" : `update failed: ${installed.reason}`, { job: job.id, tail: installed.ok ? null : installed.tail.join("\n") });
1149
+ }
1150
+ catch (error) {
1151
+ failed = reason(error);
1152
+ say("warn", `update failed: ${failed}`, { job: job.id });
1153
+ }
1154
+ try {
1155
+ finishUpdate(join(home, app.store), job.id, failed === null ? { installed: true } : { failed });
1156
+ }
1157
+ catch (error) {
1158
+ // Left running: the next start settles it from the version it runs.
1159
+ say("warn", "couldn't record how the update ended", { job: job.id, reason: reason(error) });
1160
+ }
1161
+ // The lock is held until the release and how the job ended are both in place.
1162
+ owner.close();
1163
+ await log.flushed();
1164
+ return await reexecute();
1165
+ }
1166
+ // The live server, checked from its host.
1167
+ /** What a running server says of itself (`<home>/serving.json`), written before its ready line, removed at its stop. */
1168
+ const servingRecord = object({
1169
+ version: string({ min: 1, max: 100 }),
1170
+ pid: number({ integer: true, min: 1 }),
1171
+ url: optional(string({ min: 1, max: 2000 })),
1172
+ at: string({ min: 1, max: 100 }),
1173
+ }, { strict: true });
1174
+ const PROBE_TIMEOUT_MS = 10_000;
1175
+ /** A system error's code (`ECONNREFUSED`), else its message. */
1176
+ function errorCode(error) {
1177
+ if (typeof error === "object" &&
1178
+ error !== null &&
1179
+ "code" in error &&
1180
+ typeof error.code === "string") {
1181
+ return error.code;
1182
+ }
1183
+ return reason(error);
1184
+ }
1185
+ function pidAlive(pid) {
1186
+ try {
1187
+ process.kill(pid, 0);
1188
+ return true;
1189
+ }
1190
+ catch (error) {
1191
+ // Alive, but another user's: the signal was refused, not undeliverable.
1192
+ return errorCode(error) === "EPERM";
1193
+ }
1194
+ }
1195
+ /** Only the kit's stand-in login lives on this machine; a live server's issuer never does. */
1196
+ function onThisMachine(url) {
1197
+ const host = new URL(url).hostname;
1198
+ return host === "localhost" || host === "[::1]" || host.startsWith("127.");
1199
+ }
1200
+ /** The most of a body read to tell the kit's refusal (`{"kind":"signed-out",…}`) from another's. */
1201
+ const REFUSAL_BYTES = 4096;
1202
+ const refusal = object({ kind: literal("signed-out") });
1203
+ function kitRefusal(status, body) {
1204
+ if (status !== 401) {
1205
+ return false;
1206
+ }
1207
+ try {
1208
+ return refusal.parse(JSON.parse(body)).kind === "signed-out";
1209
+ }
1210
+ catch {
1211
+ return false;
1212
+ }
1213
+ }
1214
+ /** A GET with no identity, sent to `url` under this `Host`: how it was answered. */
1215
+ function anonymous(url, host) {
1216
+ return new Promise((resolve) => {
1217
+ const sent = httpRequest(url, { headers: { host, accept: "application/json" }, timeout: PROBE_TIMEOUT_MS }, (response) => {
1218
+ let body = "";
1219
+ response.setEncoding("utf-8");
1220
+ response.on("data", (chunk) => {
1221
+ body = (body + chunk).slice(0, REFUSAL_BYTES);
1222
+ });
1223
+ response.once("end", () => {
1224
+ const status = response.statusCode ?? 0;
1225
+ resolve({
1226
+ status,
1227
+ location: response.headers.location ?? null,
1228
+ kit: kitRefusal(status, body),
1229
+ });
1230
+ });
1231
+ });
1232
+ sent.once("timeout", () => {
1233
+ sent.destroy(new Error("no answer in time"));
1234
+ });
1235
+ sent.once("error", (error) => {
1236
+ resolve({ error: errorCode(error) });
1237
+ });
1238
+ sent.end();
1239
+ });
1240
+ }
1241
+ /** How the door at `url` answers a request with no identity, redirects not followed. */
1242
+ async function knock(url) {
1243
+ try {
1244
+ const response = await fetch(url, {
1245
+ redirect: "manual",
1246
+ headers: { accept: "application/json" },
1247
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
1248
+ });
1249
+ let body = "";
1250
+ if (response.status === 401) {
1251
+ body = (await response.text()).slice(0, REFUSAL_BYTES);
1252
+ }
1253
+ else {
1254
+ await response.body?.cancel();
1255
+ }
1256
+ return {
1257
+ status: response.status,
1258
+ location: response.headers.get("location"),
1259
+ kit: kitRefusal(response.status, body),
1260
+ };
1261
+ }
1262
+ catch (error) {
1263
+ return {
1264
+ error: error instanceof Error && error.cause !== undefined
1265
+ ? errorCode(error.cause)
1266
+ : reason(error),
1267
+ };
1268
+ }
1269
+ }
1270
+ const ACCESS_LOGIN = "/cdn-cgi/access/login/";
1271
+ /** The proxy's login a redirect goes to, if it is one. */
1272
+ function proxyLogin(answer) {
1273
+ if ("error" in answer ||
1274
+ (answer.status !== 302 && answer.status !== 303) ||
1275
+ answer.location === null) {
1276
+ return undefined;
1277
+ }
1278
+ const location = URL.canParse(answer.location) ? new URL(answer.location) : undefined;
1279
+ return location?.pathname.startsWith(ACCESS_LOGIN) === true ? location : undefined;
1280
+ }
1281
+ function servingProblem(home, found, held) {
1282
+ if (!held) {
1283
+ return found === undefined
1284
+ ? `nothing runs on ${home}`
1285
+ : `nothing runs on ${home} (serving.json is left from pid ${found.pid})`;
1286
+ }
1287
+ if (found === undefined) {
1288
+ return `${home} is held, but no server says it serves (an update or a rollback holds it)`;
1289
+ }
1290
+ return pidAlive(found.pid)
1291
+ ? undefined
1292
+ : `serving.json names pid ${found.pid}, which isn't running`;
1293
+ }
1294
+ function versionProblem(live, installed, expected) {
1295
+ if (live === undefined) {
1296
+ return "no running server";
1297
+ }
1298
+ if (live.version !== installed) {
1299
+ return `running ${live.version}, installed ${installed}: restart it`;
1300
+ }
1301
+ return expected === undefined || live.version === expected
1302
+ ? undefined
1303
+ : `running ${live.version}, expected ${expected}`;
1304
+ }
1305
+ /** Both hosts the server answers, asked with no identity: each must be refused, 401. */
1306
+ /** Where the server listens, for a request from this host: its bound address at the url's port. */
1307
+ function listening(bind, url) {
1308
+ const address = bind === "0.0.0.0" ? "127.0.0.1" : bind === "::" ? "::1" : bind;
1309
+ return `http://${isIP(address) === 6 ? `[${address}]` : address}:${new URL(url).port}/api/`;
1310
+ }
1311
+ /** Both hosts the server answers, asked with no identity on its bound address: each must get the kit's refusal. */
1312
+ async function refusedProblem(url, web) {
1313
+ const own = new URL(url);
1314
+ const hosts = [own.host];
1315
+ if (web.host !== undefined) {
1316
+ // A plain-http host is served on web.port; port 0 is the one the system picked, the url's.
1317
+ const port = web.port === 0 ? own.port : String(web.port);
1318
+ // Asked as the public host even when it is the url's own: that is the request the door sends.
1319
+ hosts.push(web.host.https ? web.host.name : `${web.host.name}:${port}`);
1320
+ }
1321
+ const target = listening(web.bind, url);
1322
+ const answers = await Promise.all(hosts.map((host) => anonymous(target, host)));
1323
+ const unreachable = answers.find((answer) => "error" in answer);
1324
+ if (unreachable !== undefined && "error" in unreachable) {
1325
+ return { hosts, problem: `nothing answers at ${target} (${unreachable.error})` };
1326
+ }
1327
+ const problems = answers.flatMap((answer, index) => {
1328
+ if ("error" in answer || answer.kit) {
1329
+ return [];
1330
+ }
1331
+ return answer.status === 421
1332
+ ? [`the server doesn't answer ${hosts[index]} (421)`]
1333
+ : [`${hosts[index]} answered ${answer.status} with no identity, not the kit's refusal`];
1334
+ });
1335
+ return problems.length === 0 ? { hosts } : { hosts, problem: problems.join("; ") };
1336
+ }
1337
+ /** The public door, knocked with no identity: a proxy web.json verifies sends it to that proxy's login. */
1338
+ function doorProblem(target, answer, web, host) {
1339
+ if ("error" in answer) {
1340
+ return `${target} is unreachable (${answer.error})`;
1341
+ }
1342
+ const login = proxyLogin(answer);
1343
+ const access = web.access;
1344
+ if (access === undefined) {
1345
+ return answer.kit || login !== undefined
1346
+ ? undefined
1347
+ : `${target} answered ${answer.status} with no identity, not the kit's refusal`;
1348
+ }
1349
+ if (answer.status === 401) {
1350
+ return `the server answered itself (401): no proxy in front, though web.json expects ${access.teamDomain}`;
1351
+ }
1352
+ if (login === undefined) {
1353
+ return `${target} answered ${answer.status}, not the proxy's login`;
1354
+ }
1355
+ if (login.origin !== new URL(access.teamDomain).origin) {
1356
+ return `another team's door: ${login.origin}, web.json has ${access.teamDomain}`;
1357
+ }
1358
+ if (login.pathname.slice(ACCESS_LOGIN.length) !== host) {
1359
+ return `the proxy's login is for ${login.pathname.slice(ACCESS_LOGIN.length)}, not ${host}`;
1360
+ }
1361
+ const kid = login.searchParams.get("kid");
1362
+ return kid === access.audience
1363
+ ? undefined
1364
+ : `the door's audience is ${kid ?? "missing"}, web.json has ${access.audience}: every signed request would be refused`;
1365
+ }
1366
+ /** `probe` flags: undefined on bad usage. */
1367
+ function probeFlags(args) {
1368
+ const flags = {};
1369
+ for (let at = 0; at < args.length; at += 2) {
1370
+ const value = args[at + 1];
1371
+ if (value === undefined || value.startsWith("--")) {
1372
+ return undefined;
1373
+ }
1374
+ if (args[at] === "--expect" && flags.expect === undefined) {
1375
+ flags.expect = value;
1376
+ }
1377
+ else if (args[at] === "--door" &&
1378
+ flags.door === undefined &&
1379
+ URL.canParse(value) &&
1380
+ ["http:", "https:"].includes(new URL(value).protocol)) {
1381
+ flags.door = value;
1382
+ }
1383
+ else {
1384
+ return undefined;
1385
+ }
1386
+ }
1387
+ return flags;
1388
+ }
1389
+ /** `probe [--expect <version>] [--door <url>]`: the server running on this home, checked with no identity. */
1390
+ async function runProbe(app, args, home) {
1391
+ const flags = probeFlags(args);
1392
+ if (flags === undefined) {
1393
+ return undefined;
1394
+ }
1395
+ let failed = false;
1396
+ const say = (name, problem, seen) => {
1397
+ failed ||= problem !== undefined;
1398
+ console.log(problem === undefined ? `ok ${name}: ${seen}` : `fail ${name}: ${problem}`);
1399
+ };
1400
+ const found = readRecord(join(home, "serving.json"), servingRecord);
1401
+ const serving = servingProblem(home, found, running(home));
1402
+ const live = serving === undefined ? found : undefined;
1403
+ say("serving", serving, `pid ${found?.pid} since ${found?.at}`);
1404
+ say("version", versionProblem(live, app.version, flags.expect), `running ${app.version}${flags.expect === undefined ? "" : ", as expected"}`);
1405
+ if (!existsSync(join(home, "web.json"))) {
1406
+ say("login", live?.url === undefined
1407
+ ? undefined
1408
+ : "serving a web, web.json is gone: the next start has none", "no web");
1409
+ return failed ? 1 : 0;
1410
+ }
1411
+ const web = await loadWebConfig(home, []);
1412
+ if ("off" in web) {
1413
+ say("login", `web off: ${web.off}`, "");
1414
+ return 1;
1415
+ }
1416
+ const doors = [
1417
+ ...(web.access === undefined ? [] : [`behind ${web.access.teamDomain}`]),
1418
+ ...(web.oidc === undefined ? [] : [`signs in with ${web.oidc.issuer}`]),
1419
+ ];
1420
+ say("login", web.oidc !== undefined && onThisMachine(web.oidc.issuer)
1421
+ ? `web.oidc.issuer ${web.oidc.issuer} is on this machine: the test login`
1422
+ : undefined, doors.join(", "));
1423
+ const url = live?.url;
1424
+ if (url === undefined) {
1425
+ say("refused", live === undefined ? "no running server" : "the running server has no web", "");
1426
+ }
1427
+ else {
1428
+ const refused = await refusedProblem(url, web);
1429
+ say("refused", refused.problem, `401 for ${[...new Set(refused.hosts)].join(" and ")}`);
1430
+ }
1431
+ if (web.host === undefined) {
1432
+ return failed ? 1 : 0;
1433
+ }
1434
+ const port = web.port === 0 && url !== undefined ? new URL(url).port : String(web.port);
1435
+ const target = flags.door ??
1436
+ (web.host.https ? `https://${web.host.name}/` : `http://${web.host.name}:${port}/`);
1437
+ const answer = await knock(target);
1438
+ const login = proxyLogin(answer);
1439
+ const seen = web.access === undefined && login !== undefined
1440
+ ? `behind ${login.origin}'s login, which web.json doesn't verify`
1441
+ : web.access === undefined
1442
+ ? `${target} reaches a server that refuses with no identity`
1443
+ : `${target} goes to ${web.access.teamDomain}'s login for this audience`;
1444
+ say("door", doorProblem(target, answer, web, web.host.name), seen);
1445
+ return failed ? 1 : 0;
1446
+ }
1447
+ function help(options) {
1448
+ const name = options.name;
1449
+ const extra = Object.entries(options.commands ?? {});
1450
+ const rows = [
1451
+ [
1452
+ "serve",
1453
+ `Run the server on the home (${upper(name)}_HOME, default ~/.${name}): ${options.serves}`,
1454
+ ],
1455
+ [
1456
+ "update",
1457
+ "Install a release (edge, the default, or a version) over this one. Stop the server first, or use the page's update button.",
1458
+ ],
1459
+ [
1460
+ "rollback",
1461
+ "Reinstall the release the last update replaced (kept in the home's releases/), with the server stopped, after it starts on a copy of the store. A release that writes an older store schema is refused unless --with-store also puts back the store from before that migration. --crashed is a supervisor's crash hook.",
1462
+ ],
1463
+ [
1464
+ "probe",
1465
+ "Check the server running on the home from this host, with no identity: it serves, the version (--expect: the one that passed), no test login, requests without identity refused, and the public door (--door: where to knock) matching web.json.",
1466
+ ],
1467
+ ...extra.map(([command, each]) => [command, each.help]),
1468
+ [
1469
+ "version",
1470
+ 'Print the installed version; --json: {"version", "schema"} (the store schema it writes).',
1471
+ ],
1472
+ ];
1473
+ const usage = [
1474
+ "serve",
1475
+ "update [edge | <version>]",
1476
+ "rollback [--with-store | --crashed]",
1477
+ "probe [--expect <version>] [--door <url>]",
1478
+ ...extra.map(([, each]) => each.usage),
1479
+ "version [--json]",
1480
+ ].join(" | ");
1481
+ return [
1482
+ `Usage: ${name} [${usage}]`,
1483
+ "",
1484
+ ...rows.map(([command, text]) => ` ${command.padEnd(9)} ${text}`),
1485
+ ].join("\n");
1486
+ }
1487
+ /** Run the command line: the exit code. */
1488
+ export async function runCli(args, options) {
1489
+ if (!NAME.test(options.name)) {
1490
+ throw new Error(`runCli: not an app name: ${options.name}`);
1491
+ }
1492
+ const root = typeof options.root === "string" ? options.root : fileURLToPath(options.root);
1493
+ const env = options.env ?? process.env;
1494
+ const app = {
1495
+ name: options.name,
1496
+ root,
1497
+ version: readManifest(root).version,
1498
+ store: `${options.name}.sqlite`,
1499
+ env,
1500
+ };
1501
+ const home = resolvePath(env[`${upper(app.name)}_HOME`] ?? join(homedir(), `.${app.name}`));
1502
+ const [command = "serve", ...rest] = args;
1503
+ if (command === "serve" && rest.length === 0) {
1504
+ return await serve(app, options, home);
1505
+ }
1506
+ if ((command === "version" || command === "--version") &&
1507
+ (rest.length === 0 || (rest.length === 1 && rest[0] === "--json"))) {
1508
+ console.log(rest.includes("--json")
1509
+ ? JSON.stringify({ version: app.version, schema: options.schema })
1510
+ : app.version);
1511
+ return 0;
1512
+ }
1513
+ if (command === "update") {
1514
+ return await runUpdate(app, options.schema, rest, home);
1515
+ }
1516
+ if (command === "rollback") {
1517
+ return await runRollback(app, options.schema, rest, home);
1518
+ }
1519
+ if (command === "probe") {
1520
+ const code = await runProbe(app, rest, home);
1521
+ if (code !== undefined) {
1522
+ return code;
1523
+ }
1524
+ console.log(help(options));
1525
+ return 2;
1526
+ }
1527
+ const own = options.commands?.[command];
1528
+ if (own !== undefined) {
1529
+ await mkdir(home, { recursive: true, mode: 0o700 });
1530
+ return await own.run(rest, home);
1531
+ }
1532
+ console.log(help(options));
1533
+ return command === "help" || command === "--help" || command === "-h" ? 0 : 2;
1534
+ }