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,989 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir, realpath, rm } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { z } from "zod";
6
+ import { kindOf, } from "./catalogue.js";
7
+ import { readHookInput } from "./claude-hooks.js";
8
+ import { CONFIG_FILE, LABEL, loadProject } from "./config.js";
9
+ import { copyEnvFiles, writeEnvFiles } from "./env-files.js";
10
+ import { dropHosts, syncHosts } from "./hosts.js";
11
+ import { init } from "./init.js";
12
+ import { BasepadError, shellQuote } from "./internal/exec.js";
13
+ import { Stopped } from "./internal/ports.js";
14
+ import { table } from "./internal/table.js";
15
+ import { daemonId, definitionsFile, isRootOnLinux, layout, localhostCertificate, machine, namespaceOf, ownServers, placeOf, servicesHome, suffixOf, supervisorPaths, } from "./layout.js";
16
+ import { setup, trustAsRoot } from "./setup.js";
17
+ import { openSupervisor } from "./supervisor.js";
18
+ import { ensureLocalhostCertificate } from "./tls.js";
19
+ import { checkedOut, checkOutWorktree, checkoutOf, clonePaths, hasChanges, listWorktrees, removeCheckout, runSetup, } from "./worktrees.js";
20
+ const SERVICE_FLAG = {
21
+ short: "s",
22
+ value: "key",
23
+ description: "The database service to act on; needed when there are several",
24
+ };
25
+ export const COMMANDS = {
26
+ init: {
27
+ summary: `Write a starter ${CONFIG_FILE} in this folder`,
28
+ flags: {},
29
+ run: init,
30
+ },
31
+ setup: {
32
+ summary: "Once per machine: trust basepad's certificate and drop the port from URLs",
33
+ flags: {
34
+ "dry-run": { description: "Print the plan and change nothing" },
35
+ yes: { short: "y", description: "Apply the plan without asking" },
36
+ },
37
+ run: setup,
38
+ },
39
+ up: {
40
+ summary: "Start this project's services and apps, set its env files, then open mission control",
41
+ flags: {
42
+ detach: {
43
+ short: "d",
44
+ description: "Return once started, without opening mission control (as with no terminal)",
45
+ },
46
+ claim: {
47
+ description: "Make this folder the owner of the project's databases, which another checkout under the same name holds",
48
+ },
49
+ },
50
+ run: up,
51
+ },
52
+ down: {
53
+ summary: "Stop this project's apps and services; nothing is deleted",
54
+ flags: {},
55
+ run: down,
56
+ },
57
+ status: {
58
+ summary: "Show each app and service with its address and state",
59
+ flags: {},
60
+ run: status,
61
+ },
62
+ "worktree add": {
63
+ summary: "Make a worktree of main, with its own databases, domains and env files",
64
+ flags: {
65
+ json: { description: "Print only the worktree's name and path, as JSON" },
66
+ branch: {
67
+ value: "name",
68
+ description: "Check out this branch, or make it from main's HEAD when it does not exist",
69
+ },
70
+ start: { description: "Start its apps now, not on the first request to their domains" },
71
+ claude: {
72
+ description: "Claude Code's WorktreeCreate hook: read its input on stdin, use its name as the branch, print only the path",
73
+ },
74
+ },
75
+ run: worktreeAdd,
76
+ },
77
+ "worktree ls": {
78
+ summary: "List main's worktrees with their branches and folders",
79
+ flags: {},
80
+ run: worktreeLs,
81
+ },
82
+ "worktree rm": {
83
+ summary: "Remove a worktree: its apps, databases, registrations and git worktree; its branch stays",
84
+ args: ["worktree?"],
85
+ flags: {
86
+ force: { description: "Remove it even with uncommitted changes, which are lost" },
87
+ claude: {
88
+ description: "Claude Code's WorktreeRemove hook: remove the worktree at its input's path, as --force does",
89
+ },
90
+ },
91
+ run: worktreeRm,
92
+ },
93
+ prune: {
94
+ summary: "Remove what is left of worktrees whose folder is gone, after showing it",
95
+ flags: {
96
+ yes: {
97
+ short: "y",
98
+ description: "Remove worktrees' leftovers without asking; a removed service's data is still asked",
99
+ },
100
+ },
101
+ run: prune,
102
+ },
103
+ "db save": {
104
+ summary: "Save this environment's database as a snapshot, replacing one of the same label",
105
+ args: ["label?"],
106
+ flags: { service: SERVICE_FLAG },
107
+ run: dbSave,
108
+ },
109
+ "db load": {
110
+ summary: "Replace this environment's database with a snapshot, which is kept",
111
+ args: ["label?"],
112
+ flags: { service: SERVICE_FLAG },
113
+ run: dbLoad,
114
+ },
115
+ "db snapshots": {
116
+ summary: "List this environment's snapshots",
117
+ flags: {},
118
+ run: dbSnapshots,
119
+ },
120
+ "db rm": {
121
+ summary: "Delete one of this environment's snapshots",
122
+ args: ["label"],
123
+ flags: { service: SERVICE_FLAG },
124
+ run: dbRm,
125
+ },
126
+ "db import": {
127
+ summary: "Replace this environment's database with a dump file or a postgres:// URL's, after asking",
128
+ args: ["file or url"],
129
+ flags: {
130
+ service: SERVICE_FLAG,
131
+ yes: { short: "y", description: "Replace the database without asking" },
132
+ },
133
+ run: dbImport,
134
+ },
135
+ "db export": {
136
+ summary: "Write a dump of this environment's database to a file, or to standard output",
137
+ args: ["file?"],
138
+ flags: { service: SERVICE_FLAG },
139
+ run: dbExport,
140
+ },
141
+ };
142
+ /** How long `up` waits for apps to listen or fail before it shows their state and returns. */
143
+ const APP_WAIT_MS = 5000;
144
+ /** How many of a failed app's last lines `up` shows. */
145
+ const FAILED_LINES = 20;
146
+ /** The environment whose config is in `cwd` or its nearest parent with one. */
147
+ async function environmentAt(cwd) {
148
+ const project = await loadProject(cwd);
149
+ const checkout = await checkoutOf(project.root);
150
+ if (checkout.suffix === undefined)
151
+ return { project };
152
+ if (!LABEL.test(checkout.suffix)) {
153
+ throw new BasepadError(`${project.root} is git worktree \`${checkout.suffix}\`, and a worktree's folder name is its suffix in domains and databases, so it must be a lowercase DNS label (letters, digits and inner hyphens). Rename it with \`git worktree move <folder> <new-folder>\`.`);
154
+ }
155
+ if (!existsSync(join(checkout.main, CONFIG_FILE))) {
156
+ throw new BasepadError(`${project.root} is git worktree \`${checkout.suffix}\`, made from ${checkout.main}, which has no ${CONFIG_FILE}. A worktree is made from main: commit the config there and run \`basepad up\` in it.`);
157
+ }
158
+ const main = await loadProject(checkout.main);
159
+ if (main.name !== project.name) {
160
+ throw new BasepadError(`This worktree's config names the project "${project.name}", and main's (${main.file}) "${main.name}". A worktree's services are main's, so their names must match.`);
161
+ }
162
+ return { project, suffix: checkout.suffix, main };
163
+ }
164
+ /**
165
+ * Makes an environment ready to start its apps: registers its definitions, starts its services
166
+ * (main's, in a worktree, and its own servers) and provisions them, and writes its env files.
167
+ * Starts no app. A worktree needs main ready first. Main's servers always start: every
168
+ * environment's services need them. Of a worktree's own servers, those `start` picks (all by
169
+ * default); the rest are left to its first start, and its env files say where they will listen.
170
+ */
171
+ async function ready(environment, context, { start = () => true } = {}) {
172
+ const { project, suffix, main } = environment;
173
+ const { here, supervisor, print } = context;
174
+ // Ports, folders and databases; the URL form waits for the supervisor, which answers on 443.
175
+ const ports = layout(project, here, suffix);
176
+ const services = Object.keys(project.services);
177
+ const owner = await supervisor.owner(ports.namespace);
178
+ if (owner !== undefined && owner !== project.root && !context.claim) {
179
+ throw new BasepadError(`"${ports.namespace}" is the name of the checkout at ${owner}. Two checkouts under one name would share its databases and domains, so basepad stops here.\nIf that checkout moved here, or this one should take over, run \`basepad up --claim\`.`);
180
+ }
181
+ const missing = services.filter((key) => main !== undefined && !(key in main.services));
182
+ if (missing.length > 0) {
183
+ throw new BasepadError(`This worktree's config has ${missing.map((key) => `\`${key}\``).join(", ")}, which main's has not. A worktree's services run on main's servers: add ${missing.length > 1 ? "them" : "it"} to main's config and run \`basepad up\` there first.`);
184
+ }
185
+ const daemons = {};
186
+ // Main's servers, and a worktree's own; a worktree's other services run on main's.
187
+ for (const key of ownServers(ports)) {
188
+ const service = project.services[key];
189
+ daemons[key] = await kindOf(service).prepare(service, placeOf(project, ports, here, key));
190
+ }
191
+ const servers = Object.values(ports.services).map((place) => place.daemon);
192
+ // No request reaches these, so the apps with a domain start them.
193
+ const unreached = Object.keys(project.apps).filter((key) => ports.apps[key]?.host === undefined);
194
+ const tls = localhostCertificate(here);
195
+ for (const [key, app] of Object.entries(project.apps)) {
196
+ const run = withLocalBins(project, app.cwd, app.run);
197
+ const port = ports.apps[key]?.port;
198
+ daemons[key] = {
199
+ run: app.domain === false
200
+ ? `export BASEPAD_TLS_CERT=${shellQuote(tls.cert)} BASEPAD_TLS_KEY=${shellQuote(tls.key)} && ${run}`
201
+ : run,
202
+ dir: app.cwd,
203
+ port,
204
+ ...(app.domain === false ? { proxy: false } : {}),
205
+ // So the proxy's start on a first request starts the servers first, wherever they run,
206
+ // and the apps no request reaches.
207
+ depends: unreached.includes(key)
208
+ ? servers
209
+ : [...servers, ...unreached.map((name) => daemonId(ports.namespace, name))],
210
+ };
211
+ }
212
+ // Made before any app can start, once the proxy, which makes the CA, runs.
213
+ if (Object.values(project.apps).some((app) => app.domain === false)) {
214
+ await supervisor.serve();
215
+ const { ca, caKey } = supervisorPaths(here);
216
+ await ensureLocalhostCertificate({ ca, caKey, ...tls });
217
+ }
218
+ await supervisor.register({
219
+ namespace: ports.namespace,
220
+ ...(suffix === undefined ? { label: project.name } : {}),
221
+ dir: project.root,
222
+ definitions: ports.definitions,
223
+ daemons,
224
+ });
225
+ if (suffix === undefined && isRootOnLinux()) {
226
+ for (const line of await trustAsRoot(supervisor))
227
+ print(line);
228
+ }
229
+ const lay = layout(project, await machineWithPortFree(here, supervisor), suffix);
230
+ // Services' domains are main's, as the services are.
231
+ const domains = Object.values(lay.services).flatMap((place) => place.web === undefined || suffix !== undefined ? [] : [place.web.host]);
232
+ for (const line of await syncHosts(lay.namespace, [
233
+ ...Object.values(lay.apps).flatMap((a) => a.host ?? []),
234
+ ...domains,
235
+ ])) {
236
+ print(line);
237
+ }
238
+ const since = new Date(Date.now() - 1000);
239
+ const outputs = {};
240
+ const own = ownServers(lay);
241
+ const runsIn = (key) => (own.includes(key) ? lay.namespace : lay.mainNamespace);
242
+ const started = services.filter((key) => runsIn(key) === lay.mainNamespace || start(key));
243
+ for (const namespace of new Set(started.map(runsIn))) {
244
+ const keys = started.filter((key) => runsIn(key) === namespace);
245
+ const hosts = Object.fromEntries(keys.flatMap((key) => {
246
+ const web = lay.services[key]?.web;
247
+ return web === undefined ? [] : [[key, web.host]];
248
+ }));
249
+ const states = await supervisor.start(namespace, keys, APP_WAIT_MS, hosts);
250
+ const failed = keys.filter((key) => states[key] === "failed");
251
+ if (failed.length > 0)
252
+ throw new BasepadError(await failures(supervisor, namespace, failed, since));
253
+ }
254
+ for (const key of services) {
255
+ const service = project.services[key];
256
+ const namespace = runsIn(key);
257
+ try {
258
+ outputs[key] = await kindOf(service).provision(service, placeOf(project, lay, here, key), {
259
+ claim: context.claim,
260
+ started: started.includes(key),
261
+ print,
262
+ stopped: async () => {
263
+ const state = (await supervisor.status(namespace))[key];
264
+ return state === "failed" || state === "stopped";
265
+ },
266
+ });
267
+ }
268
+ catch (error) {
269
+ if (!(error instanceof Stopped))
270
+ throw error;
271
+ throw new BasepadError(await failures(supervisor, namespace, [key], since));
272
+ }
273
+ }
274
+ for (const [key, app] of Object.entries(lay.apps)) {
275
+ if (app.url !== undefined)
276
+ outputs[key] = { url: app.url };
277
+ }
278
+ const files = envFiles(project, outputs);
279
+ if (main !== undefined)
280
+ await copyEnvFiles(main.root, project.root, Object.keys(files));
281
+ await writeEnvFiles(project.root, files);
282
+ return lay;
283
+ }
284
+ /**
285
+ * Opens the supervisor (or takes the one opened), and readies main first when the environment is a
286
+ * worktree.
287
+ */
288
+ async function open(environment, flags, print, opened) {
289
+ const here = machine(process.env);
290
+ const supervisor = opened ?? (await openSupervisor(here, print));
291
+ const context = { here, supervisor, claim: flags.claim === true, print };
292
+ // `--claim` in a worktree claims the worktree's, never main's.
293
+ if (environment.main !== undefined) {
294
+ await ready({ project: environment.main }, { ...context, claim: false });
295
+ }
296
+ return context;
297
+ }
298
+ async function up({ cwd, flags, print }) {
299
+ const environment = await environmentAt(cwd);
300
+ // Only on a terminal: scripts, CI and agents get `-d`'s behaviour without asking for it.
301
+ if (flags.detach !== true && process.stdin.isTTY && process.stdout.isTTY) {
302
+ return upOnTerminal(environment, flags, print);
303
+ }
304
+ const { project } = environment;
305
+ const context = await open(environment, flags, print);
306
+ const { supervisor, here } = context;
307
+ const lay = await ready(environment, context);
308
+ const { states, failure } = await startApps(supervisor, project, lay);
309
+ print(await stateTables(environment, lay, here, await withServices(supervisor, lay, states)));
310
+ if (failure !== undefined)
311
+ throw new BasepadError(failure);
312
+ }
313
+ /**
314
+ * `up` on a terminal: mission control opens at once and the environment readies meanwhile, so its
315
+ * logs show from the start. What readying prints waits for the view to close; a failure shows in it
316
+ * as a notice.
317
+ */
318
+ async function upOnTerminal(environment, flags, print) {
319
+ const { project } = environment;
320
+ const here = machine(process.env);
321
+ // Loaded only here, so other commands never load the terminal UI.
322
+ const { missionControl } = await import("./mission-control/run.js");
323
+ const held = [];
324
+ const say = (line) => held.push(line);
325
+ const supervisor = await openSupervisor(here, say);
326
+ let served = here;
327
+ let outcome;
328
+ let error;
329
+ let notice;
330
+ const readying = (async () => {
331
+ const context = await open(environment, flags, say, supervisor);
332
+ const lay = await ready(environment, { ...context, print: say });
333
+ // The supervisor answers on 443 by now, so URLs show as they are served.
334
+ served = await machineWithPortFree(here, supervisor);
335
+ outcome = { lay, ...(await startApps(supervisor, project, lay)) };
336
+ if (outcome.failure !== undefined) {
337
+ const failed = Object.keys(project.apps).filter((key) => outcome?.states[key] === "failed");
338
+ notice = `${failed.map((key) => `\`${key}\``).join(", ")} exited on start; ${failed.length > 1 ? "their" : "its"} output is in the logs.`;
339
+ }
340
+ })().catch((caught) => {
341
+ error = caught;
342
+ notice = firstLine(caught instanceof Error ? caught.message : String(caught));
343
+ });
344
+ await missionControl({
345
+ main: environment.main ?? project,
346
+ machine: () => served,
347
+ supervisor,
348
+ failure: () => {
349
+ const told = notice;
350
+ notice = undefined;
351
+ return told;
352
+ },
353
+ selected: layout(project, here, environment.suffix).namespace,
354
+ // This very command, run again on the same Node: mission control adds and removes worktrees
355
+ // with it, in processes of their own that outlive the view.
356
+ basepad: [process.execPath, ...process.execArgv, process.argv[1]],
357
+ });
358
+ // Closed while it readies, the environment still finishes.
359
+ if (outcome === undefined && error === undefined)
360
+ print("Still starting; finishing…");
361
+ await readying;
362
+ for (const line of held)
363
+ print(line);
364
+ if (error !== undefined)
365
+ throw error;
366
+ if (outcome === undefined)
367
+ return;
368
+ const { lay, states, failure } = outcome;
369
+ print(await stateTables(environment, lay, here, await withServices(supervisor, lay, states)));
370
+ if (failure !== undefined)
371
+ throw new BasepadError(failure);
372
+ }
373
+ const firstLine = (text) => text.split("\n")[0] ?? text;
374
+ /**
375
+ * Starts the environment's apps and waits briefly for them. Returns their states, and the report
376
+ * of those that failed, if any did.
377
+ */
378
+ async function startApps(supervisor, project, lay) {
379
+ const since = new Date(Date.now() - 1000);
380
+ const apps = Object.keys(project.apps);
381
+ const hosts = Object.fromEntries(Object.entries(lay.apps).flatMap(([key, app]) => app.host === undefined ? [] : [[key, app.host]]));
382
+ const states = await supervisor.start(lay.namespace, apps, APP_WAIT_MS, hosts);
383
+ const failed = apps.filter((key) => states[key] === "failed");
384
+ if (failed.length === 0)
385
+ return { states };
386
+ return { states, failure: await failures(supervisor, lay.namespace, failed, since) };
387
+ }
388
+ /**
389
+ * `worktree add`: checks out a new worktree of main, copies the paths its config clones, readies it
390
+ * (its databases from main's, its env files from main's) and runs its setup commands. Its apps
391
+ * start on the first request to their domains, or now with `--start`. Prints where it is.
392
+ */
393
+ async function worktreeAdd({ cwd, flags, print }) {
394
+ const hook = flags.claude === true;
395
+ if (hook && (flags.branch !== undefined || flags.json === true)) {
396
+ throw new BasepadError("`--claude` takes the branch from Claude Code's hook input and prints only the path, so it goes without `--branch` and `--json`.");
397
+ }
398
+ // With `--json` or `--claude`, stdout carries the answer alone.
399
+ const say = flags.json === true || hook ? (line) => console.error(line) : print;
400
+ const input = hook ? await readHookInput("WorktreeCreate") : undefined;
401
+ const at = await environmentAt(input?.cwd ?? cwd);
402
+ const main = at.main ?? at.project;
403
+ const context = await open({ project: main }, {}, say);
404
+ await ready({ project: main }, context);
405
+ // Main's config is read before checking out, so a name too long fails before anything is made.
406
+ const requested = input?.name ?? (typeof flags.branch === "string" ? flags.branch : undefined);
407
+ const { suffix, branch, root } = await checkOutWorktree(main, requested);
408
+ const kept = checkedOut(suffix, root);
409
+ say(`${kept}.`);
410
+ let environment;
411
+ let lay;
412
+ try {
413
+ environment = await environmentAt(root);
414
+ const { project } = environment;
415
+ for (const note of await clonePaths(main.root, project.root, project.worktrees.clone))
416
+ say(note);
417
+ // Its own servers with its apps: now with `--start`, else on their first start.
418
+ lay = await ready(environment, context, { start: () => flags.start === true });
419
+ }
420
+ catch (error) {
421
+ if (!(error instanceof BasepadError))
422
+ throw error;
423
+ throw new BasepadError(`${error.message}\n${kept}; once that is fixed, run \`basepad up\` there.`);
424
+ }
425
+ try {
426
+ await runSetup(environment.project.root, environment.project.worktrees.setup, say);
427
+ }
428
+ catch (error) {
429
+ if (!(error instanceof BasepadError))
430
+ throw error;
431
+ throw new BasepadError(`${error.message}\n${kept}; once that is fixed, run the rest of \`worktrees.setup\` there.`);
432
+ }
433
+ const { states, failure } = flags.start === true
434
+ ? await startApps(context.supervisor, environment.project, lay)
435
+ : { states: {}, failure: undefined };
436
+ if (flags.json === true) {
437
+ print(JSON.stringify({ name: suffix, path: root }));
438
+ }
439
+ else {
440
+ const all = await withServices(context.supervisor, lay, states);
441
+ say(await stateTables(environment, lay, context.here, all));
442
+ say("");
443
+ say(`Made worktree ${suffix} at ${root}, on branch ${branch}. ${flags.start === true ? "Its apps are started." : "Its apps start when their domains are first opened, or with `basepad up` there."}`);
444
+ }
445
+ if (hook)
446
+ print(root);
447
+ if (failure !== undefined)
448
+ throw new BasepadError(failure);
449
+ }
450
+ /** A worktree's daemon states with those of its services that run in main's namespace. */
451
+ async function withServices(supervisor, lay, states) {
452
+ const own = ownServers(lay);
453
+ const mains = Object.keys(lay.services).filter((key) => !own.includes(key));
454
+ if (mains.length === 0)
455
+ return states;
456
+ const main = await supervisor.status(lay.mainNamespace);
457
+ return { ...states, ...Object.fromEntries(mains.map((key) => [key, main[key] ?? "stopped"])) };
458
+ }
459
+ /** The machine with `portFree` asked from its port 443, once the supervisor runs. */
460
+ async function machineWithPortFree(here, supervisor) {
461
+ return { ...here, portFree: (await supervisor.standardPort()) === "proxy" };
462
+ }
463
+ /** Runs the config's `env` with each service's and app's outputs, and checks what it returns. */
464
+ function envFiles(project, outputs) {
465
+ let files;
466
+ try {
467
+ files = project.env(outputs);
468
+ }
469
+ catch (error) {
470
+ throw new BasepadError(`${project.file}: \`env\` threw: ${error?.message ?? error}`);
471
+ }
472
+ const result = EnvFilesSchema.safeParse(files);
473
+ if (!result.success) {
474
+ throw new BasepadError(`${project.file}: \`env\` must return env file paths, each to keys with string or number values.`);
475
+ }
476
+ return result.data;
477
+ }
478
+ const EnvFilesSchema = z.record(z.string(), z.record(z.string(), z.union([z.string(), z.number()])));
479
+ /** Runs an app's command with its own and the project's `node_modules/.bin` first, as npm does. */
480
+ function withLocalBins(project, dir, run) {
481
+ const bins = [
482
+ ...new Set([join(dir, "node_modules/.bin"), join(project.root, "node_modules/.bin")]),
483
+ ];
484
+ return `export PATH=${bins.map(shellQuote).join(":")}:"$PATH" && ${run}`;
485
+ }
486
+ /** The apps table, then the services table, each when the project has any. */
487
+ async function stateTables({ project }, lay, here, states) {
488
+ const tables = [];
489
+ if (Object.keys(lay.apps).length > 0) {
490
+ const rows = Object.entries(lay.apps).map(([key, app]) => [
491
+ key,
492
+ app.url ?? "",
493
+ states[key] ?? "stopped",
494
+ ]);
495
+ tables.push(table([["APP", "URL", "STATE"], ...rows]));
496
+ }
497
+ const services = Object.entries(project.services);
498
+ if (services.length > 0) {
499
+ const rows = [];
500
+ for (const [key, service] of services) {
501
+ const address = await kindOf(service).address(service, placeOf(project, lay, here, key));
502
+ rows.push([key, address, states[key] ?? "stopped"]);
503
+ }
504
+ tables.push(table([["SERVICE", "ADDRESS", "STATE"], ...rows]));
505
+ }
506
+ return tables.join("\n\n");
507
+ }
508
+ async function failures(supervisor, namespace, failed, since) {
509
+ const parts = [];
510
+ for (const key of failed) {
511
+ const lines = await supervisor.logs(namespace, key, since, FAILED_LINES);
512
+ parts.push([
513
+ `\`${key}\` exited on start. Its last output:`,
514
+ ...(lines.length > 0 ? lines : ["(none)"]).map((line) => ` ${line}`),
515
+ `For more, run: ${supervisor.logsCommand(namespace, key)}`,
516
+ ].join("\n"));
517
+ }
518
+ return parts.join("\n");
519
+ }
520
+ async function down({ cwd, print }) {
521
+ const { project, suffix } = await environmentAt(cwd);
522
+ const supervisor = await openSupervisor(machine(process.env), print);
523
+ const lay = layout(project, machine(process.env), suffix);
524
+ const { namespace } = lay;
525
+ const apps = Object.keys(project.apps);
526
+ // Its own servers stop with its apps; those on main's keep running for main.
527
+ const own = ownServers(lay);
528
+ const mains = Object.keys(project.services).filter((key) => !own.includes(key));
529
+ // On a terminal, the landing shows while they stop.
530
+ const landed = process.stdout.isTTY
531
+ ? await startLanding(project.name, suffix, [...apps, ...own], mains)
532
+ : undefined;
533
+ try {
534
+ // Held first, so no request starts an app again while it stops.
535
+ await supervisor.hold(namespace, apps);
536
+ await supervisor.stop(namespace);
537
+ }
538
+ catch (error) {
539
+ landed?.fail();
540
+ throw error;
541
+ }
542
+ if (landed !== undefined) {
543
+ await landed.land();
544
+ return;
545
+ }
546
+ const names = (keys) => keys.map((key) => `\`${key}\``).join(", ");
547
+ print(suffix === undefined
548
+ ? `Stopped ${project.name}'s apps and services.`
549
+ : `Stopped worktree ${suffix}'s apps${own.length > 0 ? ` and ${names(own)}` : ""}.${mains.length > 0 ? ` ${names(mains)} ${mains.length > 1 ? "run" : "runs"} on main's and keep${mains.length > 1 ? "" : "s"} running.` : ""}`);
550
+ }
551
+ /** Opens the landing for an environment's apps and services on the terminal. */
552
+ async function startLanding(project, suffix, parts, kept) {
553
+ const [{ landing }, { colorsFor }, { default: chalk }] = await Promise.all([
554
+ import("./mission-control/launch.js"),
555
+ import("./mission-control/frame.js"),
556
+ import("chalk"),
557
+ ]);
558
+ return landing({
559
+ project,
560
+ environment: suffix ?? "main",
561
+ parts,
562
+ kept,
563
+ color: colorsFor(process.env.NO_COLOR, chalk.level),
564
+ });
565
+ }
566
+ async function status({ cwd, print }) {
567
+ const environment = await environmentAt(cwd);
568
+ const { project, suffix } = environment;
569
+ const here = machine(process.env);
570
+ if (Object.keys(project.apps).length === 0 && Object.keys(project.services).length === 0) {
571
+ print(`${project.file} names no apps or services.`);
572
+ return;
573
+ }
574
+ const supervisor = await openSupervisor(here, print);
575
+ const ports = layout(project, here, suffix);
576
+ const states = await withServices(supervisor, ports, await supervisor.status(ports.namespace));
577
+ // With no supervisor running nothing answers on 443, so the URL form is the port's default.
578
+ const lay = layout(project, Object.keys(states).length > 0 ? await machineWithPortFree(here, supervisor) : here, suffix);
579
+ print(await stateTables(environment, lay, here, states));
580
+ if (suffix === undefined)
581
+ await printWorktrees(project, here, supervisor, print);
582
+ }
583
+ /**
584
+ * Main's project, from main's folder or a worktree's, found through git. Reads main's config alone,
585
+ * so a worktree's broken or missing config does not stand in the way.
586
+ */
587
+ async function mainAt(cwd) {
588
+ const here = await realpath(cwd);
589
+ const checkout = await checkoutOf(here);
590
+ return loadProject(checkout.suffix === undefined ? here : checkout.main);
591
+ }
592
+ /**
593
+ * `status` in main: each worktree git lists, with its tables from its own config, then what is left
594
+ * of worktrees whose folder is gone.
595
+ */
596
+ async function printWorktrees(main, here, supervisor, print) {
597
+ for (const tree of await listWorktrees(main)) {
598
+ print("");
599
+ print(`${tree.suffix} on branch ${tree.branch ?? "(detached)"} at ${tree.root}`);
600
+ if (!tree.present) {
601
+ print("Its folder is gone; `basepad prune` clears what is left of it.");
602
+ continue;
603
+ }
604
+ try {
605
+ const environment = await environmentAt(tree.root);
606
+ const ports = layout(environment.project, here, tree.suffix);
607
+ const states = await withServices(supervisor, ports, await supervisor.status(ports.namespace));
608
+ const lay = layout(environment.project, await machineWithPortFree(here, supervisor), tree.suffix);
609
+ print(await stateTables(environment, lay, here, states));
610
+ }
611
+ catch (error) {
612
+ if (!(error instanceof BasepadError))
613
+ throw error;
614
+ print(error.message);
615
+ }
616
+ }
617
+ const left = await leftoversOf(main, here, supervisor);
618
+ const rows = leftoverRows({ ...left, checkouts: [] });
619
+ if (rows.length > 1) {
620
+ print("");
621
+ print("Left by worktrees whose folder is gone; `basepad prune` removes it:");
622
+ print(table(rows));
623
+ }
624
+ }
625
+ async function worktreeLs({ cwd, print }) {
626
+ const main = await mainAt(cwd);
627
+ const trees = await listWorktrees(main);
628
+ if (trees.length === 0) {
629
+ print("No worktrees. Make one with `basepad worktree add`.");
630
+ return;
631
+ }
632
+ const rows = trees.map((tree) => [
633
+ tree.suffix,
634
+ tree.branch ?? "(detached)",
635
+ tree.present ? tree.root : `${tree.root} (gone; \`basepad prune\` clears it)`,
636
+ ]);
637
+ print(table([["WORKTREE", "BRANCH", "FOLDER"], ...rows]));
638
+ }
639
+ /**
640
+ * `worktree rm`: removes a worktree from its suffix alone (git's record, the name pattern of its data,
641
+ * its registration), so it works whatever state its folder or config is in. Its branch stays.
642
+ */
643
+ async function worktreeRm({ cwd, flags, args, print }) {
644
+ if (flags.claude === true)
645
+ return worktreeRmHook(cwd, args);
646
+ const [suffix] = args;
647
+ if (suffix === undefined)
648
+ throw new BasepadError("Usage: basepad worktree rm <worktree> [flags]");
649
+ const main = await mainAt(cwd);
650
+ const tree = (await listWorktrees(main)).find((listed) => listed.suffix === suffix);
651
+ if (tree === undefined) {
652
+ throw new BasepadError(`${main.name} has no worktree \`${suffix}\`. \`basepad worktree ls\` lists them; \`basepad prune\` clears what is left of one whose git worktree is gone.`);
653
+ }
654
+ if (tree.present && flags.force !== true && (await hasChanges(tree.path))) {
655
+ throw new BasepadError(`${tree.path} has uncommitted changes. Commit or stash them, or pass \`--force\` to lose them.`);
656
+ }
657
+ await removeWorktree(main, tree, print);
658
+ }
659
+ /**
660
+ * `worktree rm --claude`: removes the worktree at the hook input's `worktree_path`, changes and all:
661
+ * Claude Code has asked before discarding work, or found none. Says everything on stderr.
662
+ */
663
+ async function worktreeRmHook(cwd, args) {
664
+ if (args.length > 0) {
665
+ throw new BasepadError("`--claude` takes the worktree from Claude Code's hook input, so it goes without a name.");
666
+ }
667
+ const input = await readHookInput("WorktreeRemove");
668
+ const path = await realpath(input.worktree_path).catch(() => input.worktree_path);
669
+ // From the worktree itself while it exists, so a session folder elsewhere does not matter.
670
+ const checkout = existsSync(path) ? await checkoutOf(path) : {};
671
+ const main = checkout.suffix === undefined
672
+ ? await mainAt(input.cwd ?? cwd)
673
+ : await loadProject(checkout.main);
674
+ const tree = (await listWorktrees(main)).find((t) => t.root === path || t.path === path);
675
+ if (tree === undefined) {
676
+ throw new BasepadError(`${input.worktree_path} is no worktree of ${main.name}'s main at ${main.root}. \`basepad worktree ls\` lists them.`);
677
+ }
678
+ await removeWorktree(main, tree, (line) => console.error(line));
679
+ }
680
+ /** Removes a listed worktree of main: what basepad made for it, then its git worktree. */
681
+ async function removeWorktree(main, tree, print) {
682
+ // Main's servers hold the worktree's data, so they run for it to be dropped.
683
+ const context = await open({ project: main }, {}, print);
684
+ await ready({ project: main }, context);
685
+ await removeEnvironment(main, tree.suffix, tree.root, context);
686
+ await removeCheckout(main, tree.path);
687
+ print(`Removed worktree ${tree.suffix}: its apps, databases and git worktree.${tree.branch === undefined ? "" : ` Its branch ${tree.branch} is kept.`}`);
688
+ }
689
+ /**
690
+ * Removes what basepad made for worktree `suffix` of main, from main's config alone: its apps and
691
+ * registration, its domains in the hosts file, and its data on main's servers (never main's own: a
692
+ * `shared` service's is main's).
693
+ */
694
+ async function removeEnvironment(main, suffix, root, { here, supervisor }) {
695
+ // From names alone: main's config may since have made this suffix's names too long.
696
+ const namespace = namespaceOf(main.name, suffix);
697
+ await supervisor.unregister({ namespace, definitions: definitionsFile(here, namespace) });
698
+ await dropHosts(namespace);
699
+ const lay = layout(main, here);
700
+ const tree = worktreeLayout(main, here, suffix);
701
+ for (const [key, service] of Object.entries(main.services)) {
702
+ const store = kindOf(service).worktreeData;
703
+ if (store === undefined)
704
+ continue;
705
+ const place = placeOf(main, lay, here, key);
706
+ // Its data by name or stamp; with no names that fit, by its stamp among every worktree's.
707
+ const data = tree === undefined
708
+ ? (await store.list(service, place)).filter((d) => d.owner === root)
709
+ : await store.list(service, { ...placeOf(main, tree, here, key), owner: root });
710
+ await store.drop(service, place, data.map((d) => d.name));
711
+ }
712
+ }
713
+ /** A worktree's layout, or none when its names are past their limits under main's config. */
714
+ function worktreeLayout(main, here, suffix) {
715
+ try {
716
+ return layout(main, here, suffix);
717
+ }
718
+ catch (error) {
719
+ if (error instanceof BasepadError)
720
+ return undefined;
721
+ throw error;
722
+ }
723
+ }
724
+ /** Found by name alone: git's list, data stamped with a gone folder, registrations of a gone folder. */
725
+ async function leftoversOf(main, here, supervisor) {
726
+ const checkouts = (await listWorktrees(main)).filter((tree) => !tree.present);
727
+ const lay = layout(main, here);
728
+ const data = [];
729
+ for (const [key, service] of Object.entries(main.services)) {
730
+ const store = kindOf(service).worktreeData;
731
+ if (store === undefined)
732
+ continue;
733
+ const found = await store.list(service, placeOf(main, lay, here, key));
734
+ const gone = found.filter((d) => d.owner !== "" && !existsSync(d.owner));
735
+ if (gone.length > 0)
736
+ data.push({ key, data: gone });
737
+ }
738
+ const registrations = (await supervisor.registrations()).filter((entry) => suffixOf(main.name, entry.namespace) !== undefined && !existsSync(entry.dir));
739
+ return { checkouts, data, registrations };
740
+ }
741
+ /** Leftovers as a table's rows, its header first. */
742
+ function leftoverRows({ checkouts, data, registrations }) {
743
+ return [
744
+ ["LEFTOVER", "NAME", "FOLDER"],
745
+ ...checkouts.map((tree) => ["git worktree", tree.suffix, tree.path]),
746
+ ...data.flatMap(({ key, data }) => data.map((d) => [`${key} data`, d.name, d.owner])),
747
+ ...registrations.map((entry) => ["registration", entry.namespace, entry.dir]),
748
+ ];
749
+ }
750
+ /** The folders in basepad's home of services main's config no longer has, by key. */
751
+ async function removedServices(main, here) {
752
+ const home = servicesHome(here, main.name);
753
+ if (!existsSync(home))
754
+ return [];
755
+ return (await readdir(home))
756
+ .filter((key) => !(key in main.services))
757
+ .map((key) => [key, join(home, key)]);
758
+ }
759
+ /**
760
+ * `prune`: lists what is left of worktrees whose folder is gone, and the data of services main's
761
+ * config no longer has, then asks before removing each. `--yes` answers for the first only: a
762
+ * removed service's data is main's too, so it is deleted only on a terminal's yes.
763
+ */
764
+ async function prune({ cwd, flags, print }) {
765
+ const main = await mainAt(cwd);
766
+ const context = await open({ project: main }, {}, print);
767
+ await ready({ project: main }, context);
768
+ const { here, supervisor } = context;
769
+ const left = await leftoversOf(main, here, supervisor);
770
+ const rows = leftoverRows(left);
771
+ const services = await removedServices(main, here);
772
+ if (rows.length === 1 && services.length === 0) {
773
+ print("Nothing to prune.");
774
+ return;
775
+ }
776
+ if (rows.length > 1) {
777
+ print("Left by worktrees whose folder is gone:");
778
+ print(table(rows));
779
+ }
780
+ if (services.length > 0) {
781
+ print(`Data of services no longer in ${main.file}, main's included:`);
782
+ print(table([["SERVICE", "FOLDER"], ...services]));
783
+ }
784
+ let unasked = false;
785
+ if (rows.length > 1) {
786
+ const yes = flags.yes === true || (await ask("Remove what worktrees left?"));
787
+ if (yes === undefined)
788
+ unasked = true;
789
+ else if (yes) {
790
+ await removeLeftovers(main, left, context);
791
+ print("Removed what worktrees left.");
792
+ }
793
+ else
794
+ print("Kept what worktrees left.");
795
+ }
796
+ if (services.length > 0) {
797
+ const yes = await ask(`Delete the data of ${services.map(([key]) => `\`${key}\``).join(", ")}?`);
798
+ if (yes === true) {
799
+ // A service taken out of the config keeps running until stopped.
800
+ await supervisor.stop(layout(main, here).mainNamespace, services.map(([key]) => key));
801
+ for (const [, folder] of services)
802
+ await rm(folder, { recursive: true, force: true });
803
+ print(`Deleted ${services.map(([, folder]) => folder).join(", ")}.`);
804
+ }
805
+ else {
806
+ print(`Kept the data of removed services${yes === undefined ? "; `basepad prune` on a terminal asks to delete it" : ""}.`);
807
+ }
808
+ }
809
+ if (unasked) {
810
+ throw new BasepadError("Nothing worktrees left was removed: there is no terminal to ask. Run `basepad prune --yes` to remove it.");
811
+ }
812
+ }
813
+ async function removeLeftovers(main, left, context) {
814
+ const { here, supervisor } = context;
815
+ const lay = layout(main, here);
816
+ for (const { key, data } of left.data) {
817
+ const service = main.services[key];
818
+ await kindOf(service).worktreeData?.drop(service, placeOf(main, lay, here, key), data.map((d) => d.name));
819
+ }
820
+ for (const { namespace } of left.registrations) {
821
+ await supervisor.unregister({ namespace, definitions: definitionsFile(here, namespace) });
822
+ await dropHosts(namespace);
823
+ }
824
+ for (const tree of left.checkouts)
825
+ await removeCheckout(main, tree.path);
826
+ }
827
+ /** Asks a yes-or-no question on the terminal; with no terminal, no answer. */
828
+ async function ask(question) {
829
+ if (!process.stdin.isTTY)
830
+ return undefined;
831
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
832
+ const answer = await prompt.question(`${question} [y/N] `);
833
+ prompt.close();
834
+ return /^y(es)?$/i.test(answer.trim());
835
+ }
836
+ /** The label `db save` and `db load` use when given none. */
837
+ const DEFAULT_LABEL = "default";
838
+ /** The environment `cwd` is in, with its database services; throws when it has none. */
839
+ async function databaseServicesAt(cwd) {
840
+ const environment = await environmentAt(cwd);
841
+ const { project } = environment;
842
+ const [first, ...rest] = Object.entries(project.services).flatMap(([key, service]) => {
843
+ const { database } = kindOf(service);
844
+ return database === undefined ? [] : [{ key, service, database }];
845
+ });
846
+ if (first === undefined) {
847
+ throw new BasepadError(`${project.file} names no database service.`);
848
+ }
849
+ return { environment, services: [first, ...rest] };
850
+ }
851
+ /**
852
+ * The database a `db` command acts on: `--service`'s, or the environment's one database. With
853
+ * several and no `--service`, says which there are.
854
+ */
855
+ function chosen(project, services, wanted) {
856
+ const names = services.map(({ key }) => `\`${key}\``).join(", ");
857
+ if (typeof wanted === "string") {
858
+ const found = services.find(({ key }) => key === wanted);
859
+ if (found === undefined) {
860
+ throw new BasepadError(`${project.file} has no database service \`${wanted}\`. Its databases: ${names}.`);
861
+ }
862
+ return found;
863
+ }
864
+ const [only, ...more] = services;
865
+ if (more.length > 0) {
866
+ throw new BasepadError(`${project.file} has several databases: ${names}. Name one with \`--service <key>\`.`);
867
+ }
868
+ return only;
869
+ }
870
+ /**
871
+ * Makes the environment ready as `up` makes it, without starting apps, for `db` commands on
872
+ * `services`: of a worktree's own servers, only theirs start. Resolves to what puts a database
873
+ * service on its server.
874
+ */
875
+ async function readyFor(environment, print, services) {
876
+ const keys = services.map(({ key }) => key);
877
+ const context = await open(environment, {}, print);
878
+ const lay = await ready(environment, context, { start: (key) => keys.includes(key) });
879
+ return (service) => ({
880
+ ...service,
881
+ place: placeOf(environment.project, lay, context.here, service.key),
882
+ });
883
+ }
884
+ /** The database a `db` command acts on, chosen as `chosen` says, ready on its server. */
885
+ async function databaseAt({ cwd, flags, print }) {
886
+ const { environment, services } = await databaseServicesAt(cwd);
887
+ const service = chosen(environment.project, services, flags.service);
888
+ const onServer = await readyFor(environment, print, [service]);
889
+ return onServer(service);
890
+ }
891
+ /** A snapshot label given, or the default; a lowercase DNS label, as suffixes are. */
892
+ function labelOf(args) {
893
+ const [label = DEFAULT_LABEL] = args;
894
+ if (!LABEL.test(label)) {
895
+ throw new BasepadError(`A snapshot label must be lowercase letters, digits and inner hyphens; got \`${label}\`.`);
896
+ }
897
+ return label;
898
+ }
899
+ async function dbSave(invocation) {
900
+ const label = labelOf(invocation.args);
901
+ const { key, service, place, database } = await databaseAt(invocation);
902
+ await database.save(service, place, label);
903
+ invocation.print(`Saved \`${key}\` as snapshot ${label}.`);
904
+ }
905
+ async function dbLoad(invocation) {
906
+ const label = labelOf(invocation.args);
907
+ const { key, service, place, database } = await databaseAt(invocation);
908
+ await database.load(service, place, label);
909
+ invocation.print(`Loaded snapshot ${label} into \`${key}\`.`);
910
+ }
911
+ async function dbRm(invocation) {
912
+ const label = labelOf(invocation.args);
913
+ const { key, service, place, database } = await databaseAt(invocation);
914
+ await database.remove(service, place, label);
915
+ invocation.print(`Deleted snapshot ${label} of \`${key}\`.`);
916
+ }
917
+ /** Every database's snapshots in the environment. */
918
+ async function dbSnapshots({ cwd, print }) {
919
+ const { environment, services } = await databaseServicesAt(cwd);
920
+ const onServer = await readyFor(environment, print, services);
921
+ const targets = services.map(onServer);
922
+ const rows = [];
923
+ for (const { key, service, place, database } of targets) {
924
+ for (const snapshot of await database.snapshots(service, place)) {
925
+ rows.push([
926
+ snapshot.label,
927
+ key,
928
+ snapshot.database,
929
+ snapshot.invalid ? "invalid" : size(snapshot.bytes),
930
+ ]);
931
+ }
932
+ }
933
+ if (rows.length === 0) {
934
+ print("No snapshots. Make one with `basepad db save [label]`.");
935
+ return;
936
+ }
937
+ print(table([["LABEL", "SERVICE", "DATABASE", "SIZE"], ...rows]));
938
+ }
939
+ /** Bytes as a person reads them: `7.5 MB`. */
940
+ function size(bytes) {
941
+ const units = ["B", "kB", "MB", "GB", "TB"];
942
+ let value = bytes;
943
+ let unit = 0;
944
+ while (value >= 1000 && unit < units.length - 1) {
945
+ value /= 1000;
946
+ unit++;
947
+ }
948
+ return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
949
+ }
950
+ /**
951
+ * `db import`: replaces the database with a dump or another server's, after asking with its name
952
+ * on a terminal; `--yes` answers. With no terminal and no `--yes`, it changes nothing.
953
+ */
954
+ async function dbImport(invocation) {
955
+ const [given = ""] = invocation.args;
956
+ const { key, service, place, database } = await databaseAt(invocation);
957
+ // A file is the developer's from where they ran it; anything else is the kind's to read.
958
+ const file = resolve(invocation.cwd, given);
959
+ const source = existsSync(file) ? file : given;
960
+ let replaced = "";
961
+ const read = await database.import(service, place, source, async (name) => {
962
+ replaced = name;
963
+ if (invocation.flags.yes === true)
964
+ return;
965
+ const yes = await ask(`Replace \`${key}\`'s database ${name}? Its data is lost.`);
966
+ if (yes === undefined) {
967
+ throw new BasepadError(`Import replaces \`${key}\`'s database ${name}, and there is no terminal to ask. Run it again with \`--yes\` to replace it.`);
968
+ }
969
+ if (!yes)
970
+ throw new BasepadError(`Nothing imported; ${name} is unchanged.`);
971
+ });
972
+ invocation.print(`Imported ${read} into \`${key}\`'s database ${replaced}.`);
973
+ }
974
+ /**
975
+ * `db export`: a dump of the database to a file, or to standard output, which then carries only
976
+ * the dump: anything else basepad says goes to standard error. Never a binary dump to a terminal.
977
+ */
978
+ async function dbExport(invocation) {
979
+ const [given] = invocation.args;
980
+ if (given === undefined && process.stdout.isTTY) {
981
+ throw new BasepadError("With no file, `db export` writes the dump to standard output, which is a terminal here. Name a file, or redirect it: `basepad db export > db.dump`.");
982
+ }
983
+ const say = given === undefined ? (line) => console.error(line) : invocation.print;
984
+ const { key, service, place, database } = await databaseAt({ ...invocation, print: say });
985
+ const file = given === undefined ? undefined : resolve(invocation.cwd, given);
986
+ const format = await database.export(service, place, file);
987
+ if (file !== undefined)
988
+ invocation.print(`Exported \`${key}\` to ${file} as ${format}.`);
989
+ }