basepad 0.0.0-stage → 0.0.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,477 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { request as httpsRequest } from "node:https";
5
+ import { dirname, join } from "node:path";
6
+ import { createInterface } from "node:readline";
7
+ import { connect } from "node:tls";
8
+ import { fileURLToPath } from "node:url";
9
+ import { z } from "zod";
10
+ import { interactive, must, shellQuote } from "./internal/exec.js";
11
+ import { answers } from "./internal/ports.js";
12
+ import { daemonId, ofDaemon, supervisorPaths } from "./layout.js";
13
+ import { install } from "./tools.js";
14
+ import { PITCHFORK_VERSION } from "./versions.js";
15
+ /**
16
+ * The settings basepad's pitchfork runs with, in its user config (which also holds pitchfork's
17
+ * registry, so basepad sets keys through pitchfork rather than writing the file). The proxy's are
18
+ * read once, at supervisor start.
19
+ */
20
+ function settings(machine) {
21
+ return {
22
+ // Every daemon basepad runs is waited on by basepad itself, never by a fixed delay.
23
+ "general.ready_delay": "0s",
24
+ // A worktree's domains are <daemon>.<folder>.<name>.localhost, its folder being a git worktree.
25
+ "general.worktree": "true",
26
+ "proxy.enable": "true",
27
+ "proxy.https": "true",
28
+ "proxy.port": String(machine.proxyPort),
29
+ // Name resolution, trust and port-free URLs are `basepad setup`'s, not pitchfork's own.
30
+ "proxy.dns": "false",
31
+ "proxy.sync_hosts": "false",
32
+ "proxy.auto_trust": "false",
33
+ // A first request to an app's domain starts it, and what it depends on. `hold` keeps an app
34
+ // stopped, until `start`.
35
+ "proxy.auto_start": "true",
36
+ };
37
+ }
38
+ /** What `serve` prints before a settings change restarts a running supervisor. */
39
+ export const RESTART_WARNING = "basepad's supervisor restarts to take new settings, which stops every project's running apps and services. Run `basepad up` in a project to start it again.";
40
+ /**
41
+ * Installs pitchfork through mise and opens basepad's supervisor. Nothing starts until asked.
42
+ * `print` gets what the developer must know before it happens: a restart that stops their apps.
43
+ */
44
+ export async function openSupervisor(machine, print) {
45
+ const paths = supervisorPaths(machine);
46
+ const dirs = {
47
+ PITCHFORK_STATE_DIR: paths.state,
48
+ PITCHFORK_CONFIG_DIR: paths.config,
49
+ PITCHFORK_LOGS_DIR: paths.logs,
50
+ };
51
+ const pitchfork = (await install("pitchfork", PITCHFORK_VERSION, machine.home)).bin("pitchfork");
52
+ const env = supervisorEnv(dirs);
53
+ const run = (...args) => must(pitchfork, args, { cwd: machine.home, env });
54
+ const running = () => existsSync(join(dirs.PITCHFORK_STATE_DIR, "sock"));
55
+ const { ca } = paths;
56
+ /** Sets what differs; a running supervisor restarts to take changed settings (its daemons stop). */
57
+ const applySettings = async () => {
58
+ const current = z
59
+ .array(z.looseObject({ key: z.string(), value: z.string() }))
60
+ .parse(JSON.parse(await run("settings", "--json")));
61
+ const now = new Map(current.map((entry) => [entry.key, entry.value]));
62
+ const changes = Object.entries(settings(machine)).filter(([key, value]) => now.get(key) !== value);
63
+ for (const [key, value] of changes)
64
+ await run("settings", "set", key, value, "--global");
65
+ if (changes.length > 0 && running()) {
66
+ print(RESTART_WARNING);
67
+ await run("supervisor", "start", "--force");
68
+ }
69
+ };
70
+ /** The namespace's daemons. With no supervisor running there are none, and none is started. */
71
+ const list = async (namespace) => {
72
+ if (!running())
73
+ return [];
74
+ return z.array(Listed).parse(JSON.parse(await run("list", "--json", "--namespace", namespace)));
75
+ };
76
+ const states = async (namespace) => {
77
+ const out = {};
78
+ for (const daemon of await list(namespace))
79
+ out[daemon.name] = await stateOf(daemon);
80
+ return out;
81
+ };
82
+ const stopAll = async (namespace, names) => {
83
+ const running = (await list(namespace)).filter((d) => LIVE.has(d.status) && (names === undefined || names.includes(d.name)));
84
+ if (running.length > 0)
85
+ await run("stop", ...running.map((d) => d.id));
86
+ };
87
+ const hold = async (namespace, names) => {
88
+ for (const daemon of await list(namespace)) {
89
+ if (names.includes(daemon.name) && !daemon.disabled)
90
+ await run("disable", daemon.id);
91
+ }
92
+ };
93
+ const registrations = async () => z
94
+ .array(z.looseObject({ namespace: z.string(), dir: z.string() }))
95
+ .parse(JSON.parse(await run("config", "list", "--json")))
96
+ .map(({ namespace, dir }) => ({ namespace, dir }));
97
+ const registeredDir = async (namespace) => (await registrations()).find((entry) => entry.namespace === namespace)?.dir;
98
+ /** Drops a namespace from pitchfork's registry, which keeps its folder after its file is detached. */
99
+ const forget = async (namespace, definitions) => {
100
+ await run("config", "remove", definitions);
101
+ const file = join(dirs.PITCHFORK_CONFIG_DIR, "config.toml");
102
+ if (existsSync(file)) {
103
+ await writeFile(file, withoutNamespace(await readFile(file, "utf8"), namespace));
104
+ }
105
+ };
106
+ const serve = async () => {
107
+ await applySettings();
108
+ if (!running()) {
109
+ await run("supervisor", "start");
110
+ // The proxy (and its CA, on a first start) comes up just after the supervisor returns.
111
+ const until = Date.now() + 5000;
112
+ while (!(await listening(machine.proxyPort)) && Date.now() < until) {
113
+ await new Promise((resolve) => setTimeout(resolve, 100));
114
+ }
115
+ }
116
+ if (machine.forwards443)
117
+ await forward443();
118
+ };
119
+ /**
120
+ * Starts the forwarder (src/forward.ts) when nothing answers on port 443, and waits briefly for
121
+ * it. Something else there (Herd, Valet, a pf redirect) is left alone. It stops with the
122
+ * supervisor; the next `serve` starts it again.
123
+ */
124
+ const forward443 = async () => {
125
+ if ((await probeStandardPort(ca)) !== "closed")
126
+ return;
127
+ const script = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./forward.ts" : "./forward.js", import.meta.url));
128
+ const sock = join(dirs.PITCHFORK_STATE_DIR, "sock");
129
+ spawn(process.execPath, [script, "443", String(machine.proxyPort), sock], {
130
+ detached: true,
131
+ stdio: "ignore",
132
+ }).unref();
133
+ const until = Date.now() + 2000;
134
+ while ((await probeStandardPort(ca)) === "closed" && Date.now() < until) {
135
+ await new Promise((resolve) => setTimeout(resolve, 100));
136
+ }
137
+ };
138
+ return {
139
+ ca,
140
+ serve,
141
+ standardPort: () => probeStandardPort(ca),
142
+ async proxySetupPlan() {
143
+ const out = await run("proxy", "setup", "--dry-run");
144
+ return (out
145
+ .split("\n")
146
+ .map((line) => line.trim())
147
+ .filter((line) => line !== "" && !line.endsWith("following:"))
148
+ // pitchfork lists this step even when the CA exists, and skips it when applying.
149
+ .filter((line) => !(line.startsWith("generate the pitchfork CA") && existsSync(ca))));
150
+ },
151
+ proxySetup: () => interactive(pitchfork, ["proxy", "setup", "--yes"], { cwd: machine.home, env }),
152
+ proxyUndo: () => interactive(pitchfork, ["proxy", "setup", "--undo", "--yes"], { cwd: machine.home, env }),
153
+ async trust() {
154
+ await run("proxy", "trust");
155
+ },
156
+ async register({ namespace, label, dir, definitions, daemons }) {
157
+ await serve();
158
+ const text = toml(daemons);
159
+ // Written even when empty (a worktree with no apps): pitchfork refuses a missing file.
160
+ if ((await readFile(definitions, "utf8").catch(() => undefined)) !== text) {
161
+ await mkdir(dirname(definitions), { recursive: true });
162
+ await writeFile(definitions, text);
163
+ }
164
+ const owner = await registeredDir(namespace);
165
+ if (owner !== undefined && owner !== dir) {
166
+ // pitchfork keeps a namespace's folder even after its file is detached, and refuses
167
+ // another, so the namespace's entry is dropped from its registry by hand.
168
+ await stopAll(namespace);
169
+ await forget(namespace, definitions);
170
+ }
171
+ await run("config", "add", definitions, "--dir", dir, "--namespace", namespace, ...(label === undefined ? [] : ["--label", label]));
172
+ },
173
+ owner: registeredDir,
174
+ registrations,
175
+ async unregister({ namespace, definitions }) {
176
+ if (running()) {
177
+ await stopAll(namespace);
178
+ await run("clean", namespace);
179
+ }
180
+ await forget(namespace, definitions);
181
+ await rm(definitions, { force: true });
182
+ },
183
+ async start(namespace, names, waitMs, hosts) {
184
+ const listed = await list(namespace);
185
+ for (const daemon of listed) {
186
+ if (daemon.disabled && names.includes(daemon.name))
187
+ await run("enable", daemon.id);
188
+ }
189
+ const idle = names.filter((name) => !listed.some((d) => d.name === name && LIVE.has(d.status)));
190
+ // basepad waits on them itself, below, so pitchfork's readiness here is immediate.
191
+ // pitchfork fails the start when a daemon fails: that is an app's failure, read below.
192
+ let refused;
193
+ if (idle.length > 0) {
194
+ await run("start", ...idle.map((name) => daemonId(namespace, name)), "--cmd", "true").catch((error) => {
195
+ refused = error;
196
+ });
197
+ }
198
+ const until = Date.now() + waitMs;
199
+ for (;;) {
200
+ const now = await states(namespace);
201
+ const settled = names.every((name) => now[name] !== "starting");
202
+ if (settled || Date.now() >= until) {
203
+ // With none it was asked to start failed, pitchfork's refusal is its own trouble: an
204
+ // app already live, left alone here, that has failed since does not explain it.
205
+ if (refused !== undefined && !idle.some((name) => now[name] === "failed")) {
206
+ throw refused;
207
+ }
208
+ // The proxy re-reads its routes every 2 s, so a new daemon's domain can lag.
209
+ for (const [name, host] of Object.entries(hosts)) {
210
+ if (now[name] !== "running")
211
+ continue;
212
+ while (!(await routed(machine.proxyPort, host)) && Date.now() < until + 3000) {
213
+ await new Promise((resolve) => setTimeout(resolve, 200));
214
+ }
215
+ }
216
+ return now;
217
+ }
218
+ await new Promise((resolve) => setTimeout(resolve, 200));
219
+ }
220
+ },
221
+ stop: stopAll,
222
+ hold,
223
+ status: states,
224
+ async logs(namespace, name, since, lines) {
225
+ const out = await run("logs", daemonId(namespace, name), "-n", String(lines), "--since", localTime(since), "--raw", "--no-pager");
226
+ return out.split("\n").filter((line) => line !== "");
227
+ },
228
+ async daemons() {
229
+ if (!running())
230
+ return [];
231
+ const listed = z.array(Listed).parse(JSON.parse(await run("list", "--json")));
232
+ return Promise.all(listed.map(async (daemon) => {
233
+ const state = await stateOf(daemon);
234
+ const port = daemon.active_port ?? daemon.port?.[0];
235
+ return {
236
+ id: daemon.id,
237
+ namespace: daemon.namespace ?? ofDaemon(daemon.id).namespace,
238
+ name: daemon.name,
239
+ state: state === "stopped" && !daemon.disabled ? "idle" : state,
240
+ ...(daemon.pid == null ? {} : { pid: daemon.pid }),
241
+ ...(port === undefined || port === null ? {} : { port }),
242
+ ...(daemon.error == null ? {} : { error: daemon.error }),
243
+ };
244
+ }));
245
+ },
246
+ follow(ids, history, line) {
247
+ if (ids.length === 0)
248
+ return () => { };
249
+ let stopped = false;
250
+ // One process follows them all from now. `-n` with several ids counts lines over all of
251
+ // them, so a chatty daemon would push out a crashed one's: each one's history is its own.
252
+ const tail = spawn(pitchfork, ["logs", ...ids, "--tail", "-n", "0"], {
253
+ cwd: machine.home,
254
+ env,
255
+ stdio: ["ignore", "pipe", "ignore"],
256
+ });
257
+ const kill = () => tail.kill();
258
+ process.once("exit", kill);
259
+ const live = [];
260
+ let replayed = false;
261
+ createInterface({ input: tail.stdout }).on("line", (text) => {
262
+ const entry = parseLogLine(text, ids);
263
+ if (entry === undefined || stopped)
264
+ return;
265
+ if (replayed)
266
+ line(entry);
267
+ else
268
+ live.push(entry);
269
+ });
270
+ Promise.all(ids.map((id) => (history === 0
271
+ ? Promise.resolve("")
272
+ : run("logs", id, "-n", String(history), "--no-pager")).then((out) => out
273
+ .split("\n")
274
+ .map((text) => parseLogLine(text, [id]))
275
+ .filter((entry) => entry !== undefined), () => []))).then((lists) => {
276
+ if (stopped)
277
+ return;
278
+ // A stable sort: each daemon's own lines stay in order within a second.
279
+ const past = lists.flat().sort((a, b) => a.stamp.localeCompare(b.stamp));
280
+ // A line printed while both ran is in each; the history's copy is kept.
281
+ const key = (e) => `${e.id}\0${e.stamp}\0${e.text}`;
282
+ const seen = new Set(past.map(key));
283
+ for (const entry of [...past, ...live.filter((e) => !seen.has(key(e)))])
284
+ line(entry);
285
+ replayed = true;
286
+ });
287
+ return () => {
288
+ stopped = true;
289
+ process.off("exit", kill);
290
+ kill();
291
+ };
292
+ },
293
+ logsCommand(namespace, name) {
294
+ // basepad's own pitchfork binary, so the command needs no mise and no PATH.
295
+ return `PITCHFORK_LOGS_DIR=${shellQuote(dirs.PITCHFORK_LOGS_DIR)} ${shellQuote(pitchfork)} logs ${daemonId(namespace, name)}`;
296
+ },
297
+ };
298
+ }
299
+ /**
300
+ * The environment pitchfork runs in, which its supervisor passes on to every daemon: the caller's,
301
+ * minus any `PITCHFORK_*` setting meant for a developer's own pitchfork, and with the PATH the
302
+ * developer had before the launcher moved basepad onto mise's Node.
303
+ */
304
+ function supervisorEnv(dirs) {
305
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PITCHFORK_")));
306
+ if (env.BASEPAD_PATH)
307
+ env.PATH = env.BASEPAD_PATH;
308
+ delete env.BASEPAD_PATH;
309
+ return { ...env, ...dirs };
310
+ }
311
+ /** pitchfork's user config without the `[namespaces.<namespace>]` table. */
312
+ function withoutNamespace(text, namespace) {
313
+ const headers = new Set([`[namespaces.${namespace}]`, `[namespaces."${namespace}"]`]);
314
+ const out = [];
315
+ let skipping = false;
316
+ for (const line of text.split("\n")) {
317
+ const trimmed = line.trim();
318
+ if (trimmed.startsWith("["))
319
+ skipping = headers.has(trimmed);
320
+ if (!skipping)
321
+ out.push(line);
322
+ }
323
+ return out.join("\n");
324
+ }
325
+ /** Whether the proxy has a route for `host`: its answer is anything but its own "No route". */
326
+ function routed(port, host) {
327
+ return new Promise((resolve) => {
328
+ const req = httpsRequest({
329
+ host: "127.0.0.1",
330
+ port,
331
+ servername: host,
332
+ headers: { host },
333
+ rejectUnauthorized: false,
334
+ timeout: 1000,
335
+ }, (res) => {
336
+ let body = "";
337
+ res.setEncoding("utf8").on("data", (chunk) => {
338
+ body += chunk;
339
+ });
340
+ res.on("end", () => resolve(!(res.statusCode === 404 && body.includes("No route for"))));
341
+ });
342
+ // A slow answer comes from the app, so the proxy did route it.
343
+ req.on("timeout", () => {
344
+ resolve(true);
345
+ req.destroy();
346
+ });
347
+ req.on("error", () => resolve(false));
348
+ req.end();
349
+ });
350
+ }
351
+ /** The name the probe asks the proxy for: any name under `localhost` gets a certificate. */
352
+ const PROBE_HOST = "basepad-probe.localhost";
353
+ async function probeStandardPort(ca) {
354
+ const pem = await readFile(ca, "utf8").catch(() => undefined);
355
+ return new Promise((resolve) => {
356
+ const socket = connect({
357
+ host: "127.0.0.1",
358
+ port: 443,
359
+ servername: PROBE_HOST,
360
+ ca: pem ?? [],
361
+ rejectUnauthorized: false,
362
+ });
363
+ const done = (answer) => {
364
+ socket.destroy();
365
+ resolve(answer);
366
+ };
367
+ socket.setTimeout(1000, () => done("other"));
368
+ socket.on("secureConnect", () => done(pem && socket.authorized ? "proxy" : "other"));
369
+ socket.on("error", (error) => done(error.code === "ECONNREFUSED" ? "closed" : "other"));
370
+ });
371
+ }
372
+ /**
373
+ * One of pitchfork's log lines, `MM-DD HH:MM:SS [<id>] message`; with a single id given, without
374
+ * the `[<id>]`. `stamp` sorts lines by time.
375
+ */
376
+ function parseLogLine(text, ids) {
377
+ const tagged = /^(?<day>\d\d-\d\d) (?<time>\d\d:\d\d:\d\d) \[(?<id>[^\]]+)\] ?(?<message>.*)$/.exec(text)?.groups;
378
+ const plain = /^(?<day>\d\d-\d\d) (?<time>\d\d:\d\d:\d\d) ?(?<message>.*)$/.exec(text)?.groups;
379
+ const [only] = ids;
380
+ const [groups, id] = tagged?.id !== undefined && ids.includes(tagged.id)
381
+ ? [tagged, tagged.id]
382
+ : ids.length === 1
383
+ ? [plain, only]
384
+ : [];
385
+ if (groups === undefined || id === undefined)
386
+ return undefined;
387
+ const { day = "", time = "", message = "" } = groups;
388
+ return { id, time, text: message, stamp: `${day} ${time}` };
389
+ }
390
+ /** One entry of `pitchfork list --json`; only what basepad reads. */
391
+ const Listed = z.looseObject({
392
+ id: z.string(),
393
+ namespace: z.string().optional(),
394
+ name: z.string(),
395
+ pid: z.number().nullable().optional(),
396
+ error: z.string().nullable().optional(),
397
+ status: z.string(),
398
+ disabled: z.boolean().optional(),
399
+ active_port: z.number().nullable().optional(),
400
+ port: z.array(z.number()).optional(),
401
+ });
402
+ /** pitchfork's statuses of a daemon that is up or on its way up. */
403
+ const LIVE = new Set(["running", "waiting", "restarting"]);
404
+ /**
405
+ * A daemon with a port is running once something listens there; until then it is starting.
406
+ * pitchfork's `active_port` says it saw the daemon listen, so it is running with no connection of
407
+ * basepad's: a dev server logs each one, and mission control asks every second. pitchfork finds it
408
+ * some time after the daemon is ready, so until then basepad asks the port it gave the daemon:
409
+ * else an app that just answered its first request reads as starting.
410
+ */
411
+ async function stateOf(daemon) {
412
+ if (daemon.status === "running") {
413
+ if (daemon.active_port != null)
414
+ return "running";
415
+ const port = daemon.port?.[0];
416
+ return port === undefined || (await listening(port)) ? "running" : "starting";
417
+ }
418
+ if (LIVE.has(daemon.status))
419
+ return "starting";
420
+ if (daemon.status === "errored" || daemon.status === "failed" || daemon.status === "completed") {
421
+ return "failed";
422
+ }
423
+ return "stopped";
424
+ }
425
+ /** Whether a daemon listens on `port`, on either loopback address. */
426
+ function listening(port) {
427
+ return answers(port, ["127.0.0.1", "::1"]);
428
+ }
429
+ /**
430
+ * The definitions file. pitchfork's own `dir` key would make the proxy refuse a daemon that runs
431
+ * outside the registered folder, so each daemon `cd`s into its folder itself.
432
+ *
433
+ * Each daemon is ready once it listens on its port, so the proxy, starting it on a first request,
434
+ * forwards that request only once it can answer; a daemon with no port is ready at once. Never
435
+ * pitchfork's delay readiness, the default: it asks whether the process is live as the delay
436
+ * ends, which with no delay is mid-`exec` on macOS and can read as dead, and then `pitchfork start`
437
+ * waits forever on a daemon that runs. basepad's own `start` waits on daemons itself.
438
+ */
439
+ function toml(daemons) {
440
+ return Object.entries(daemons)
441
+ .map(([name, daemon]) => {
442
+ const lines = [
443
+ `[daemons.${name}]`,
444
+ `run = ${JSON.stringify(`cd ${shellQuote(daemon.dir)} && ${daemon.run}`)}`,
445
+ `ready_cmd = ${JSON.stringify(readyCommand(daemon))}`,
446
+ ];
447
+ if (daemon.depends !== undefined && daemon.depends.length > 0) {
448
+ lines.push(`depends = ${JSON.stringify(daemon.depends)}`);
449
+ }
450
+ if (daemon.port !== undefined) {
451
+ lines.push(`port = { expect = [${daemon.port}], bump = true }`);
452
+ }
453
+ if (daemon.proxy === false)
454
+ lines.push("proxy = false");
455
+ if (daemon.stopSignal !== undefined) {
456
+ lines.push(`stop_signal = ${JSON.stringify(daemon.stopSignal)}`);
457
+ }
458
+ return `${lines.join("\n")}\n`;
459
+ })
460
+ .join("\n");
461
+ }
462
+ /**
463
+ * A shell command that succeeds once the daemon listens on either loopback address: on `PORT`,
464
+ * which pitchfork gives a daemon with a port, else on the port it `listens` on.
465
+ */
466
+ function readyCommand(daemon) {
467
+ const port = daemon.port !== undefined ? "$PORT" : daemon.listens;
468
+ if (port === undefined)
469
+ return "true";
470
+ const probe = `: <>/dev/tcp/127.0.0.1/${port} || : <>/dev/tcp/::1/${port}`;
471
+ return `bash -c '${probe}' 2>/dev/null`;
472
+ }
473
+ /** `YYYY-MM-DD HH:MM:SS` in local time, as `pitchfork logs --since` reads it. */
474
+ function localTime(date) {
475
+ const p = (n) => String(n).padStart(2, "0");
476
+ return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())} ${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`;
477
+ }
package/dist/tls.js ADDED
@@ -0,0 +1,99 @@
1
+ import { generateKeyPairSync, randomBytes, X509Certificate } from "node:crypto";
2
+ import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { must } from "./internal/exec.js";
5
+ import { waitFor } from "./internal/ports.js";
6
+ /** Days a new certificate lasts: under the 398 browsers accept. */
7
+ const DAYS = 365;
8
+ /** A certificate with less left than this is made again. */
9
+ const RENEW_MS = 30 * 24 * 60 * 60 * 1000;
10
+ /** How long a lock another basepad holds is waited on before it is taken as abandoned. */
11
+ const STALE_LOCK_MS = 30_000;
12
+ /**
13
+ * Makes the certificate for `localhost` that apps with no domain serve HTTPS with, signed by the
14
+ * proxy's CA, unless one it signed, with time left, is already there. Waits up to `waitMs` for the
15
+ * CA, which the proxy writes as it starts. Two basepads at once take turns, and each file is
16
+ * replaced whole, so an app never reads a key and a certificate that do not match.
17
+ */
18
+ export async function ensureLocalhostCertificate(paths, waitMs = 10_000) {
19
+ const ca = await waitFor(() => readFile(paths.ca, "utf8"), `No CA at ${paths.ca} to sign the certificate for localhost with.`, waitMs);
20
+ if (await current(paths.cert, ca))
21
+ return;
22
+ const dir = dirname(paths.cert);
23
+ await mkdir(dir, { recursive: true });
24
+ const lock = `${paths.cert}.lock`;
25
+ await waitFor(() => takeLock(lock), `${lock} is held by another basepad.`, STALE_LOCK_MS * 2);
26
+ const scratch = await mkdtemp(join(dir, ".new-"));
27
+ try {
28
+ // Another basepad may have made it while this one waited.
29
+ if (await current(paths.cert, ca))
30
+ return;
31
+ const key = join(scratch, "key.pem");
32
+ const cert = join(scratch, "cert.pem");
33
+ const csr = join(scratch, "localhost.csr");
34
+ const extensions = join(scratch, "localhost.ext");
35
+ const { privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
36
+ await writeFile(key, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
37
+ await writeFile(extensions, [
38
+ "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1",
39
+ "extendedKeyUsage=serverAuth",
40
+ "keyUsage=digitalSignature",
41
+ "basicConstraints=CA:FALSE",
42
+ ].join("\n"));
43
+ await must("openssl", ["req", "-new", "-key", key, "-subj", "/CN=localhost", "-out", csr]);
44
+ await must("openssl", [
45
+ "x509",
46
+ "-req",
47
+ "-in",
48
+ csr,
49
+ "-CA",
50
+ paths.ca,
51
+ "-CAkey",
52
+ paths.caKey,
53
+ "-set_serial",
54
+ `0x${randomBytes(8).toString("hex")}`,
55
+ "-days",
56
+ String(DAYS),
57
+ "-sha256",
58
+ "-extfile",
59
+ extensions,
60
+ "-out",
61
+ cert,
62
+ ]);
63
+ await rename(key, paths.key);
64
+ await rename(cert, paths.cert);
65
+ }
66
+ finally {
67
+ await rm(scratch, { recursive: true, force: true });
68
+ await rm(lock, { recursive: true, force: true });
69
+ }
70
+ }
71
+ /** Takes the lock, a folder only one process can make; throws while another holds it. */
72
+ async function takeLock(lock) {
73
+ try {
74
+ await mkdir(lock);
75
+ }
76
+ catch (error) {
77
+ if (error.code !== "EEXIST")
78
+ throw error;
79
+ const age = Date.now() - (await stat(lock)).mtimeMs;
80
+ if (age < STALE_LOCK_MS)
81
+ throw new Error("another basepad is making it");
82
+ await rm(lock, { recursive: true, force: true });
83
+ await mkdir(lock);
84
+ }
85
+ }
86
+ /** Whether the certificate is there, signed by this CA, and not about to expire. */
87
+ async function current(cert, ca) {
88
+ const pem = await readFile(cert, "utf8").catch(() => undefined);
89
+ if (pem === undefined)
90
+ return false;
91
+ try {
92
+ const certificate = new X509Certificate(pem);
93
+ const signed = certificate.verify(new X509Certificate(ca).publicKey);
94
+ return signed && Date.parse(certificate.validTo) - Date.now() > RENEW_MS;
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,32 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { BasepadError, must } from "./internal/exec.js";
5
+ /**
6
+ * Installs `tool@version` through mise when it is missing, and locates it. Runs mise from `cwd`
7
+ * (basepad's home), so no project's mise config is read or needs trusting.
8
+ */
9
+ export async function install(tool, version, cwd) {
10
+ const spec = `${tool}@${version}`;
11
+ await mkdir(cwd, { recursive: true });
12
+ // Always, not only when `mise where` fails: `where` answers for an install still in progress
13
+ // (another basepad installing at the same moment), and `install` waits on mise's lock for it.
14
+ // mise fails an install when it cannot fetch a skill the tool declares; basepad needs only the tool.
15
+ await must("mise", ["install", "--quiet", spec], {
16
+ cwd,
17
+ env: { ...process.env, MISE_SKILLS_FETCH: "false" },
18
+ });
19
+ const dir = (await must("mise", ["where", spec], { cwd })).trim();
20
+ const paths = (await must("mise", ["bin-paths", spec], { cwd })).split("\n").filter(Boolean);
21
+ return {
22
+ dir,
23
+ bin(program) {
24
+ for (const path of paths) {
25
+ const file = join(path, program);
26
+ if (existsSync(file))
27
+ return file;
28
+ }
29
+ throw new BasepadError(`mise's ${spec} has no \`${program}\` in ${paths.join(", ")}.`);
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The tool versions basepad relies on, in one place. Kept free of imports and of syntax newer
3
+ * than Node 18, since the launcher reads it before basepad has moved onto a Node 24.
4
+ */
5
+ /** The oldest mise basepad runs with. Lower it only with a test on that version. */
6
+ export const MISE_MIN_VERSION = "2026.10.24";
7
+ /** The Node basepad runs on, from mise, whatever Node the project has. */
8
+ export const NODE_MAJOR = 24;
9
+ /** pitchfork is pinned: 2.34.0 fixes a port clash between apps started in parallel. */
10
+ export const PITCHFORK_VERSION = "2.34.0";
11
+ /**
12
+ * pgvector, per Postgres major, from conda-forge (mise's Postgres is conda-forge's build). 0.8.0 is
13
+ * the last build for 17.
14
+ */
15
+ export const PGVECTOR_VERSIONS = { 17: "0.8.0", 18: "0.8.6" };
16
+ /** Mailpit's major, from mise's registry. */
17
+ export const MAILPIT_VERSION = "1";
18
+ /** Redis's major, from mise (conda-forge's build). */
19
+ export const REDIS_VERSION = "8";