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.
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/catalogue/mailpit.js +82 -0
- package/dist/catalogue/postgres.js +632 -0
- package/dist/catalogue/redis.js +135 -0
- package/dist/catalogue.js +13 -0
- package/dist/claude-hooks.js +33 -0
- package/dist/cli.js +182 -0
- package/dist/commands.js +989 -0
- package/dist/config.js +217 -0
- package/dist/env-files.js +123 -0
- package/dist/forward.js +53 -0
- package/dist/hosts.js +79 -0
- package/dist/index.js +11 -0
- package/dist/init.js +88 -0
- package/dist/internal/exec.js +58 -0
- package/dist/internal/ports.js +63 -0
- package/dist/internal/processes.js +42 -0
- package/dist/internal/service-user.js +53 -0
- package/dist/internal/table.js +7 -0
- package/dist/launcher.js +53 -0
- package/dist/layout.js +234 -0
- package/dist/mission-control/act.js +91 -0
- package/dist/mission-control/feed.js +254 -0
- package/dist/mission-control/frame.js +799 -0
- package/dist/mission-control/launch.js +362 -0
- package/dist/mission-control/model.js +325 -0
- package/dist/mission-control/run.js +211 -0
- package/dist/mission-control/work.js +162 -0
- package/dist/setup.js +280 -0
- package/dist/supervisor.js +477 -0
- package/dist/tls.js +99 -0
- package/dist/tools.js +32 -0
- package/dist/versions.js +19 -0
- package/dist/worktrees.js +186 -0
- package/package.json +50 -5
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createConnection, createServer } from "node:net";
|
|
2
|
+
import { BasepadError } from "./exec.js";
|
|
3
|
+
/** How many ports past the first one `freePort` tries. */
|
|
4
|
+
const SPAN = 100;
|
|
5
|
+
/** The first port from `from` on loopback that nothing listens on and basepad can bind. */
|
|
6
|
+
export async function freePort(from) {
|
|
7
|
+
for (let port = from; port < from + SPAN && port <= 65535; port++) {
|
|
8
|
+
if (!(await answers(port)) && (await canListen(port)))
|
|
9
|
+
return port;
|
|
10
|
+
}
|
|
11
|
+
throw new BasepadError(`No free port on 127.0.0.1 from ${from} to ${from + SPAN - 1}.`);
|
|
12
|
+
}
|
|
13
|
+
/** Whether something accepts connections on `port` at any of `hosts`, tried in order. */
|
|
14
|
+
export async function answers(port, hosts = ["127.0.0.1"]) {
|
|
15
|
+
for (const host of hosts) {
|
|
16
|
+
const ok = await new Promise((resolve) => {
|
|
17
|
+
const socket = createConnection({ host, port });
|
|
18
|
+
socket.setTimeout(500);
|
|
19
|
+
socket.on("connect", () => {
|
|
20
|
+
socket.destroy();
|
|
21
|
+
resolve(true);
|
|
22
|
+
});
|
|
23
|
+
socket.on("timeout", () => {
|
|
24
|
+
socket.destroy();
|
|
25
|
+
resolve(false);
|
|
26
|
+
});
|
|
27
|
+
socket.on("error", () => resolve(false));
|
|
28
|
+
});
|
|
29
|
+
if (ok)
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
function canListen(port) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const server = createServer();
|
|
37
|
+
server.once("error", () => resolve(false));
|
|
38
|
+
server.listen({ port, host: "127.0.0.1", exclusive: true }, () => server.close(() => resolve(true)));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** What `waitFor` throws when what it waits on has stopped: waiting longer cannot help. */
|
|
42
|
+
export class Stopped extends Error {
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Polls `check` until it resolves, or throws `failure` with the last error after `timeoutMs`.
|
|
46
|
+
* After each failed check it asks `stopped`, and throws `Stopped` at once when it says so.
|
|
47
|
+
*/
|
|
48
|
+
export async function waitFor(check, failure, timeoutMs, stopped = async () => false) {
|
|
49
|
+
const deadline = Date.now() + timeoutMs;
|
|
50
|
+
for (;;) {
|
|
51
|
+
try {
|
|
52
|
+
return await check();
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (await stopped())
|
|
56
|
+
throw new Stopped(failure);
|
|
57
|
+
if (Date.now() > deadline) {
|
|
58
|
+
throw new BasepadError(`${failure}\n${error.message}`);
|
|
59
|
+
}
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { shellQuote } from "./exec.js";
|
|
3
|
+
/**
|
|
4
|
+
* Whether process `pid` is there, whoever runs it: a server basepad runs as its service user
|
|
5
|
+
* cannot be signalled, and is there all the same.
|
|
6
|
+
*/
|
|
7
|
+
export function alive(pid) {
|
|
8
|
+
return signalled(pid) !== "gone";
|
|
9
|
+
}
|
|
10
|
+
/** Whether process `pid` is there and ours: one we may not signal is another user's. */
|
|
11
|
+
export function aliveAndOurs(pid) {
|
|
12
|
+
return signalled(pid) === "ours";
|
|
13
|
+
}
|
|
14
|
+
function signalled(pid) {
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return "ours";
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return error.code === "EPERM" ? "another's" : "gone";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A shell command writing the shell's process id and `port` to `file`, a line each, for a daemon's
|
|
25
|
+
* `run` that then `exec`s its server: `exec` keeps the shell's process id, so the file names the
|
|
26
|
+
* running server. `runningPort(file)` reads it back.
|
|
27
|
+
*/
|
|
28
|
+
export function recordRunning(file, port) {
|
|
29
|
+
return `printf '%s\\n%s\\n' "$$" ${port} > ${shellQuote(file)}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The port in pid file `file` while its process runs: the process id is its first line, the port
|
|
33
|
+
* its line `portLine` (from 0). None when the file or either line is missing, or the process is gone.
|
|
34
|
+
*/
|
|
35
|
+
export async function runningPort(file, portLine = 1) {
|
|
36
|
+
const lines = (await readFile(file, "utf8").catch(() => "")).split("\n");
|
|
37
|
+
const pid = Number(lines[0]);
|
|
38
|
+
const port = Number(lines[portLine]);
|
|
39
|
+
if (!pid || !port || !alive(pid))
|
|
40
|
+
return undefined;
|
|
41
|
+
return port;
|
|
42
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { chmod, chown, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { BasepadError, exec, must } from "./exec.js";
|
|
4
|
+
const NAME = "basepad";
|
|
5
|
+
const self = { wrap: (argv) => [...argv], grant: async () => { } };
|
|
6
|
+
export async function serviceUser() {
|
|
7
|
+
if (process.getuid?.() !== 0)
|
|
8
|
+
return self;
|
|
9
|
+
if (process.platform !== "linux") {
|
|
10
|
+
throw new BasepadError("As root, basepad runs services as a system user, which it supports on Linux only.");
|
|
11
|
+
}
|
|
12
|
+
const exists = async () => (await exec("id", ["-u", NAME])).code === 0;
|
|
13
|
+
if (!(await exists())) {
|
|
14
|
+
// Another basepad, such as a second project's `up`, may create the user at the same time.
|
|
15
|
+
await must("useradd", [
|
|
16
|
+
"--system",
|
|
17
|
+
"--no-create-home",
|
|
18
|
+
"--shell",
|
|
19
|
+
"/usr/sbin/nologin",
|
|
20
|
+
NAME,
|
|
21
|
+
]).catch(async (error) => {
|
|
22
|
+
if (!(await exists()))
|
|
23
|
+
throw error;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const uid = Number((await must("id", ["-u", NAME])).trim());
|
|
27
|
+
const gid = Number((await must("id", ["-g", NAME])).trim());
|
|
28
|
+
return {
|
|
29
|
+
// setpriv execs the program itself, so the supervisor's pid is the server's and its stop
|
|
30
|
+
// signal reaches the server directly; runuser would stay in between.
|
|
31
|
+
wrap: (argv) => [
|
|
32
|
+
"setpriv",
|
|
33
|
+
`--reuid=${uid}`,
|
|
34
|
+
`--regid=${gid}`,
|
|
35
|
+
"--clear-groups",
|
|
36
|
+
"--",
|
|
37
|
+
...argv,
|
|
38
|
+
],
|
|
39
|
+
async grant({ traverse, owned }) {
|
|
40
|
+
// mise installs under /root (0700), so the service user can't even reach Postgres's binaries.
|
|
41
|
+
// o+x on each ancestor grants passing through, without listing or reading anything.
|
|
42
|
+
for (const path of traverse) {
|
|
43
|
+
for (let dir = path; dir !== dirname(dir); dir = dirname(dir)) {
|
|
44
|
+
const { mode } = await stat(dir);
|
|
45
|
+
if ((mode & 0o001) === 0)
|
|
46
|
+
await chmod(dir, mode | 0o001);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const path of owned)
|
|
50
|
+
await chown(path, uid, gid);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Rows as aligned columns, two spaces apart, the first row being the header. */
|
|
2
|
+
export function table(rows) {
|
|
3
|
+
const widths = rows[0]?.map((_, i) => Math.max(...rows.map((row) => row[i]?.length ?? 0))) ?? [];
|
|
4
|
+
return rows
|
|
5
|
+
.map((row) => row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i] ?? 0))).join(" "))
|
|
6
|
+
.join("\n");
|
|
7
|
+
}
|
package/dist/launcher.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `basepad` bin. It runs on whatever Node the project has, so it is kept to syntax and APIs
|
|
4
|
+
* that Node 18 understands: it checks mise, then runs basepad here when this Node is new enough,
|
|
5
|
+
* or again on a Node that mise provides.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { MISE_MIN_VERSION, NODE_MAJOR } from "./versions.js";
|
|
10
|
+
const INSTALL_MISE = "https://mise.jdx.dev/getting-started.html";
|
|
11
|
+
function fail(message) {
|
|
12
|
+
process.stderr.write(`basepad: ${message}\n`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
/** `2026.10.24` as comparable numbers. */
|
|
16
|
+
function calver(version) {
|
|
17
|
+
return version.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
18
|
+
}
|
|
19
|
+
function older(version, floor) {
|
|
20
|
+
const a = calver(version);
|
|
21
|
+
const b = calver(floor);
|
|
22
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
23
|
+
const x = a[i] || 0;
|
|
24
|
+
const y = b[i] || 0;
|
|
25
|
+
if (x !== y)
|
|
26
|
+
return x < y;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const probe = spawnSync("mise", ["--version"], { encoding: "utf8" });
|
|
31
|
+
if (probe.error) {
|
|
32
|
+
fail(`basepad needs mise ${MISE_MIN_VERSION} or newer, and \`mise\` is not on PATH. Install it: ${INSTALL_MISE}`);
|
|
33
|
+
}
|
|
34
|
+
const found = /\d+\.\d+\.\d+/.exec(probe.stdout || "");
|
|
35
|
+
if (!found) {
|
|
36
|
+
fail(`basepad needs mise ${MISE_MIN_VERSION} or newer, and \`mise --version\` printed no version. Reinstall mise: ${INSTALL_MISE}`);
|
|
37
|
+
}
|
|
38
|
+
if (older(found[0], MISE_MIN_VERSION)) {
|
|
39
|
+
fail(`basepad needs mise ${MISE_MIN_VERSION} or newer; this is mise ${found[0]}. Update mise (\`mise self-update\`, or the way you installed it).`);
|
|
40
|
+
}
|
|
41
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
42
|
+
if (major >= NODE_MAJOR) {
|
|
43
|
+
await import("./cli.js");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const cli = fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
47
|
+
const child = spawnSync("mise", ["exec", `node@${NODE_MAJOR}`, "--", "node", cli].concat(process.argv.slice(2)),
|
|
48
|
+
// The PATH before mise puts its Node first, for the apps basepad's supervisor starts.
|
|
49
|
+
{ stdio: "inherit", env: Object.assign({}, process.env, { BASEPAD_PATH: process.env.PATH }) });
|
|
50
|
+
if (child.error)
|
|
51
|
+
fail(`could not run basepad on Node ${NODE_MAJOR} through mise: ${child.error.message}`);
|
|
52
|
+
process.exit(child.status === null ? 1 : child.status);
|
|
53
|
+
}
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { kindOf } from "./catalogue.js";
|
|
4
|
+
import { BasepadError } from "./internal/exec.js";
|
|
5
|
+
/** The proxy's default port: high, so it needs no privilege, and unlike pitchfork's own suggestion. */
|
|
6
|
+
const PROXY_PORT = 7443;
|
|
7
|
+
/**
|
|
8
|
+
* Reads `BASEPAD_HOME` (default `~/.basepad`) and `BASEPAD_PROXY_PORT` (default 7443, or 443 as root
|
|
9
|
+
* on Linux, where the proxy binds it itself). `portFree` starts as what the port alone tells.
|
|
10
|
+
*/
|
|
11
|
+
export function machine(env, rootOnLinux = isRootOnLinux(), platform = process.platform) {
|
|
12
|
+
const raw = env.BASEPAD_PROXY_PORT;
|
|
13
|
+
const unset = raw === undefined || raw === "";
|
|
14
|
+
const proxyPort = unset ? (rootOnLinux ? 443 : PROXY_PORT) : Number(raw);
|
|
15
|
+
if (!Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
|
|
16
|
+
throw new BasepadError(`BASEPAD_PROXY_PORT must be a port number; got \`${raw}\`.`);
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
home: env.BASEPAD_HOME || join(env.HOME || homedir(), ".basepad"),
|
|
20
|
+
proxyPort,
|
|
21
|
+
portFree: proxyPort === 443,
|
|
22
|
+
forwards443: unset && platform === "darwin",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Root on Linux needs no prompt for anything machine setup does: `up` does it itself. */
|
|
26
|
+
export function isRootOnLinux() {
|
|
27
|
+
return process.platform === "linux" && process.getuid?.() === 0;
|
|
28
|
+
}
|
|
29
|
+
/** A DNS label's limit, in characters. */
|
|
30
|
+
const LABEL_MAX = 63;
|
|
31
|
+
/** App ports start in [20000, 32000): clear of common dev ports and of Linux's ephemeral range. */
|
|
32
|
+
const APP_PORT_BASE = 20000;
|
|
33
|
+
/** Each project gets a block of this many starting ports, chosen by its name. */
|
|
34
|
+
const APP_PORT_BLOCK = 20;
|
|
35
|
+
const APP_PORT_BLOCKS = 600;
|
|
36
|
+
/** Service ports are in [15000, 20000): below the apps', clear of common dev ports. */
|
|
37
|
+
const SERVICE_PORT_BASE = 15000;
|
|
38
|
+
const SERVICE_PORTS = 5000;
|
|
39
|
+
/**
|
|
40
|
+
* The worktree suffix a layout is checked against at load, so a name that leaves too little room
|
|
41
|
+
* fails before any worktree is made: the prefix with a three-digit number.
|
|
42
|
+
*/
|
|
43
|
+
const LONGEST_NUMBER = 999;
|
|
44
|
+
/**
|
|
45
|
+
* Main's layout, or with `suffix` a worktree's. Main's also checks that a worktree's names will
|
|
46
|
+
* fit, with the longest suffix the prefix makes.
|
|
47
|
+
*/
|
|
48
|
+
export function layout(project, machine, suffix) {
|
|
49
|
+
const fail = (reason) => {
|
|
50
|
+
throw new BasepadError(`${project.file}: ${reason}`);
|
|
51
|
+
};
|
|
52
|
+
const { name } = project;
|
|
53
|
+
if (name.length > LABEL_MAX) {
|
|
54
|
+
fail(`\`name\` is ${name.length} characters; a domain label allows ${LABEL_MAX}.`);
|
|
55
|
+
}
|
|
56
|
+
if (suffix !== undefined && suffix.length > LABEL_MAX) {
|
|
57
|
+
fail(`the worktree suffix is ${suffix.length} characters; a domain label allows ${LABEL_MAX}.`);
|
|
58
|
+
}
|
|
59
|
+
/** What `env` sees. The one place the URL form is decided. */
|
|
60
|
+
const urlOf = (host) => `https://${host}${machine.portFree ? "" : `:${machine.proxyPort}`}`;
|
|
61
|
+
const apps = {};
|
|
62
|
+
const block = APP_PORT_BASE + (hash(name) % APP_PORT_BLOCKS) * APP_PORT_BLOCK;
|
|
63
|
+
for (const [i, [key, app]] of Object.entries(project.apps).entries()) {
|
|
64
|
+
if (app.port === false) {
|
|
65
|
+
apps[key] = {};
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (key.length > LABEL_MAX) {
|
|
69
|
+
fail(`app \`${key}\` is ${key.length} characters; a domain label allows ${LABEL_MAX}.`);
|
|
70
|
+
}
|
|
71
|
+
const port = block + (i % APP_PORT_BLOCK);
|
|
72
|
+
if (app.domain === false) {
|
|
73
|
+
apps[key] = { port };
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const host = appHost(key, name, suffix);
|
|
77
|
+
apps[key] = { host, url: urlOf(host), port };
|
|
78
|
+
}
|
|
79
|
+
const namespace = namespaceOf(name, suffix);
|
|
80
|
+
const services = {};
|
|
81
|
+
for (const [key, service] of Object.entries(project.services)) {
|
|
82
|
+
const dir = join(servicesHome(machine, name), key);
|
|
83
|
+
let names;
|
|
84
|
+
try {
|
|
85
|
+
names = kindOf(service).names(service, { name, key, dir, suffix });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof BasepadError)
|
|
89
|
+
fail(error.message);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
const web = kindOf(service).web === true;
|
|
93
|
+
// The service is main's, so its domain is main's in every environment, as its daemon is.
|
|
94
|
+
const host = appHost(key, name);
|
|
95
|
+
if (web && key in project.apps) {
|
|
96
|
+
fail(`service \`${key}\` is served at ${host}, which is app \`${key}\`'s domain. Rename one.`);
|
|
97
|
+
}
|
|
98
|
+
// A worktree's own server runs in its namespace, on a port from its name; else main's.
|
|
99
|
+
const runsIn = kindOf(service).perEnvironment?.(service) ? namespace : name;
|
|
100
|
+
services[key] = {
|
|
101
|
+
...names,
|
|
102
|
+
port: servicePort(`${runsIn}.${key}`),
|
|
103
|
+
dir,
|
|
104
|
+
daemon: daemonId(runsIn, key),
|
|
105
|
+
...(web ? { web: { host, url: urlOf(host), port: servicePort(`${name}.${key}.web`) } } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// Last, so a name main itself cannot have is reported in main's words.
|
|
109
|
+
if (suffix === undefined) {
|
|
110
|
+
const longest = `${project.worktrees.prefix}-${LONGEST_NUMBER}`;
|
|
111
|
+
try {
|
|
112
|
+
layout(project, machine, longest);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (!(error instanceof BasepadError) || !error.message.startsWith(`${project.file}: `)) {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
const reason = error.message.slice(project.file.length + 2);
|
|
119
|
+
const shorten = reason.startsWith("the worktree suffix")
|
|
120
|
+
? "Shorten `worktrees.prefix`."
|
|
121
|
+
: "Shorten `name`, the service key or `worktrees.prefix`.";
|
|
122
|
+
fail(`worktree suffixes like "${longest}" leave too little room: ${reason} ${shorten}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
apps,
|
|
127
|
+
services,
|
|
128
|
+
definitions: definitionsFile(machine, namespace),
|
|
129
|
+
namespace,
|
|
130
|
+
mainNamespace: name,
|
|
131
|
+
...(suffix === undefined ? {} : { suffix }),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Where one of the project's services lives in the layout `lay`, for its kind in the catalogue. */
|
|
135
|
+
export function placeOf(project, lay, machine, key) {
|
|
136
|
+
const service = lay.services[key];
|
|
137
|
+
const suffix = lay.suffix === undefined ? {} : { suffix: lay.suffix };
|
|
138
|
+
return { name: project.name, key, owner: project.root, machine, ...suffix, ...service };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The keys of the services whose servers run in the environment's own namespace: every one in
|
|
142
|
+
* main, a worktree's own servers (`perEnvironment`) in a worktree. The others run in main's.
|
|
143
|
+
*/
|
|
144
|
+
export function ownServers(lay) {
|
|
145
|
+
return Object.entries(lay.services)
|
|
146
|
+
.filter(([, place]) => ofDaemon(place.daemon).namespace === lay.namespace)
|
|
147
|
+
.map(([key]) => key);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* An app's domain: `<app>.<name>.localhost` in main, `<app>.<suffix>.<name>.localhost` in a
|
|
151
|
+
* worktree, as the supervisor's proxy serves it.
|
|
152
|
+
*/
|
|
153
|
+
export function appHost(app, name, suffix) {
|
|
154
|
+
return suffix === undefined ? `${app}.${name}.localhost` : `${app}.${suffix}.${name}.localhost`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* An environment's namespace with the supervisor: `<name>` for main, `<name>.<suffix>` for a
|
|
158
|
+
* worktree. Unchecked, so what basepad made for a worktree is found whatever its names' limits.
|
|
159
|
+
*/
|
|
160
|
+
export function namespaceOf(name, suffix) {
|
|
161
|
+
return suffix === undefined ? name : `${name}.${suffix}`;
|
|
162
|
+
}
|
|
163
|
+
/** The file in basepad's home holding a namespace's supervisor definitions. */
|
|
164
|
+
export function definitionsFile(machine, namespace) {
|
|
165
|
+
return join(machine.home, "environments", `${namespace}.toml`);
|
|
166
|
+
}
|
|
167
|
+
/** A daemon's id with the supervisor: `<namespace>/<name>`. */
|
|
168
|
+
export function daemonId(namespace, name) {
|
|
169
|
+
return `${namespace}/${name}`;
|
|
170
|
+
}
|
|
171
|
+
/** `daemonId` undone: a daemon's namespace and name from its id. */
|
|
172
|
+
export function ofDaemon(id) {
|
|
173
|
+
const slash = id.lastIndexOf("/");
|
|
174
|
+
return { namespace: id.slice(0, slash), name: id.slice(slash + 1) };
|
|
175
|
+
}
|
|
176
|
+
/** The suffix of `namespace` when it is a worktree's of the project `name`; else none. */
|
|
177
|
+
export function suffixOf(name, namespace) {
|
|
178
|
+
return namespace.startsWith(`${name}.`) ? namespace.slice(name.length + 1) : undefined;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The folder worktrees are checked out in: `worktrees.dir`, else `<name>.worktrees` beside the
|
|
182
|
+
* repository's top folder `top`. With `suffix`, that worktree's own folder in it.
|
|
183
|
+
*/
|
|
184
|
+
export function worktreeFolder(project, top, suffix) {
|
|
185
|
+
const dir = project.worktrees.dir ?? join(dirname(top), `${project.name}.worktrees`);
|
|
186
|
+
return suffix === undefined ? dir : join(dir, suffix);
|
|
187
|
+
}
|
|
188
|
+
/** A new worktree's suffix: `<prefix>-<n>` with the lowest `n` from 1 that no name in `taken` has. */
|
|
189
|
+
export function nextSuffix(prefix, taken) {
|
|
190
|
+
const names = new Set(taken);
|
|
191
|
+
let n = 1;
|
|
192
|
+
while (names.has(`${prefix}-${n}`))
|
|
193
|
+
n++;
|
|
194
|
+
return `${prefix}-${n}`;
|
|
195
|
+
}
|
|
196
|
+
/** The folder in basepad's home holding a project's services, one folder per service key. */
|
|
197
|
+
export function servicesHome(machine, name) {
|
|
198
|
+
return join(machine.home, "services", name);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The folder in basepad's home holding a project's worktree additions and removals started from
|
|
202
|
+
* mission control, while they run and until a view reads how they ended.
|
|
203
|
+
*/
|
|
204
|
+
export function workHome(machine, name) {
|
|
205
|
+
return join(machine.home, "work", name);
|
|
206
|
+
}
|
|
207
|
+
/** basepad's own pitchfork's folders in the home, and the CA its proxy signs with, and its key. */
|
|
208
|
+
export function supervisorPaths(machine) {
|
|
209
|
+
const root = join(machine.home, "pitchfork");
|
|
210
|
+
const state = join(root, "state");
|
|
211
|
+
return {
|
|
212
|
+
state,
|
|
213
|
+
config: join(root, "config"),
|
|
214
|
+
logs: join(root, "logs"),
|
|
215
|
+
ca: join(state, "proxy", "ca.pem"),
|
|
216
|
+
caKey: join(state, "proxy", "ca-key.pem"),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** A service's starting port, spread over the service range by `seed`. */
|
|
220
|
+
function servicePort(seed) {
|
|
221
|
+
return SERVICE_PORT_BASE + (hash(seed) % SERVICE_PORTS);
|
|
222
|
+
}
|
|
223
|
+
/** The certificate for `localhost` that apps with no domain serve HTTPS with, and its key. */
|
|
224
|
+
export function localhostCertificate(machine) {
|
|
225
|
+
const dir = join(machine.home, "tls");
|
|
226
|
+
return { cert: join(dir, "localhost.pem"), key: join(dir, "localhost-key.pem") };
|
|
227
|
+
}
|
|
228
|
+
/** FNV-1a: a stable spread of names over port blocks. */
|
|
229
|
+
function hash(text) {
|
|
230
|
+
let h = 0x811c9dc5;
|
|
231
|
+
for (const byte of Buffer.from(text))
|
|
232
|
+
h = Math.imul(h ^ byte, 0x01000193) >>> 0;
|
|
233
|
+
return h;
|
|
234
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/** How long a start waits for each daemon to listen or fail before it says how it went. */
|
|
3
|
+
const START_WAIT_MS = 10_000;
|
|
4
|
+
/** Does what the developer asked. Resolves to what to tell them; never rejects. */
|
|
5
|
+
export async function perform(intent, hands) {
|
|
6
|
+
const { supervisor } = hands;
|
|
7
|
+
switch (intent.kind) {
|
|
8
|
+
case "add":
|
|
9
|
+
case "remove": {
|
|
10
|
+
// Its progress, and how it ended, the feed reads from its files.
|
|
11
|
+
const doing = intent.kind === "add" ? "Adding a worktree" : `Removing ${intent.suffix}`;
|
|
12
|
+
const what = intent.kind === "add" ? "add a worktree" : `remove ${intent.suffix}`;
|
|
13
|
+
return hands.worktrees(intent).then(() => `${doing}: it goes on if you close the view.`, (error) => `Could not ${what}: ${reason(error)}`);
|
|
14
|
+
}
|
|
15
|
+
case "open":
|
|
16
|
+
return hands.open(intent.url).then(() => `Opened ${intent.url} in the browser.`, (error) => `Could not open a browser (${reason(error)}). The URL: ${intent.url}`);
|
|
17
|
+
case "copy":
|
|
18
|
+
return hands.copy(intent.text).then((where) => `Copied ${intent.text} to ${where}.`, (error) => `Could not copy (${reason(error)}). It is: ${intent.text}`);
|
|
19
|
+
}
|
|
20
|
+
const { kind, namespace, apps, services, label } = intent;
|
|
21
|
+
const names = [...apps, ...services];
|
|
22
|
+
try {
|
|
23
|
+
if (kind === "stop") {
|
|
24
|
+
// Held first, as `basepad down` does, so no request starts an app again while it stops.
|
|
25
|
+
await supervisor.hold(namespace, apps);
|
|
26
|
+
await supervisor.stop(namespace, names);
|
|
27
|
+
return `Stopped ${label}.`;
|
|
28
|
+
}
|
|
29
|
+
if (kind === "restart")
|
|
30
|
+
await supervisor.stop(namespace, names);
|
|
31
|
+
const states = await supervisor.start(namespace, names, START_WAIT_MS, {});
|
|
32
|
+
const failed = names.filter((name) => states[name] === "failed");
|
|
33
|
+
if (failed.length > 0) {
|
|
34
|
+
const which = failed.length === 1 && failed[0] === label ? "it" : failed.join(", ");
|
|
35
|
+
return `${label} ${kind === "start" ? "started" : "restarted"}, but ${which} failed: its last lines are in the logs.`;
|
|
36
|
+
}
|
|
37
|
+
return `${kind === "start" ? "Started" : "Restarted"} ${label}.`;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return `Could not ${kind} ${label}: ${reason(error)}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function reason(error) {
|
|
44
|
+
return error instanceof Error ? error.message : String(error);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The developer's own desktop: `open` on macOS, `xdg-open` on Linux; the clipboard through the
|
|
48
|
+
* platform's command, else through the terminal itself (OSC 52), which reaches the developer's
|
|
49
|
+
* clipboard over SSH too, in terminals that allow it.
|
|
50
|
+
*/
|
|
51
|
+
export function desktopHands(supervisor, worktrees, terminal) {
|
|
52
|
+
const mac = process.platform === "darwin";
|
|
53
|
+
const clipboards = mac
|
|
54
|
+
? [["pbcopy", []]]
|
|
55
|
+
: [
|
|
56
|
+
...(process.env.WAYLAND_DISPLAY ? [["wl-copy", []]] : []),
|
|
57
|
+
...(process.env.DISPLAY
|
|
58
|
+
? [
|
|
59
|
+
["xclip", ["-selection", "clipboard"]],
|
|
60
|
+
["xsel", ["--clipboard", "--input"]],
|
|
61
|
+
]
|
|
62
|
+
: []),
|
|
63
|
+
];
|
|
64
|
+
return {
|
|
65
|
+
supervisor,
|
|
66
|
+
worktrees,
|
|
67
|
+
async open(url) {
|
|
68
|
+
await runWith(mac ? "open" : "xdg-open", [url]);
|
|
69
|
+
},
|
|
70
|
+
async copy(text) {
|
|
71
|
+
for (const [command, args] of clipboards) {
|
|
72
|
+
const done = await runWith(command, args, text).then(() => true, () => false);
|
|
73
|
+
if (done)
|
|
74
|
+
return "the clipboard";
|
|
75
|
+
}
|
|
76
|
+
terminal.write(`\u001b]52;c;${Buffer.from(text).toString("base64")}\u0007`);
|
|
77
|
+
return "the clipboard, through the terminal";
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Runs a desktop command with `input` on its stdin. Rejects when it is missing or fails. */
|
|
82
|
+
function runWith(command, args, input) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const child = spawn(command, args, {
|
|
85
|
+
stdio: [input === undefined ? "ignore" : "pipe", "ignore", "ignore"],
|
|
86
|
+
});
|
|
87
|
+
child.on("error", (error) => reject(new Error(error.code === "ENOENT" ? `${command} not found` : error.message)));
|
|
88
|
+
child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${command} exited ${code}`)));
|
|
89
|
+
child.stdin?.end(input);
|
|
90
|
+
});
|
|
91
|
+
}
|