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,135 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createConnection } from "node:net";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { BasepadError, shellQuote } from "../internal/exec.js";
|
|
7
|
+
import { freePort, waitFor } from "../internal/ports.js";
|
|
8
|
+
import { recordRunning, runningPort } from "../internal/processes.js";
|
|
9
|
+
import { install } from "../tools.js";
|
|
10
|
+
import { REDIS_VERSION } from "../versions.js";
|
|
11
|
+
/**
|
|
12
|
+
* A Redis of the environment's own: mise's Redis, on loopback only, kept in memory (nothing is
|
|
13
|
+
* saved), on a port derived from the environment's name. Each environment's server has a folder of
|
|
14
|
+
* its own in the service's folder, holding its pid and port while it runs.
|
|
15
|
+
*/
|
|
16
|
+
const HOST = "127.0.0.1";
|
|
17
|
+
/** How long `up` waits for the server to answer before it gives up. */
|
|
18
|
+
const READY_MS = 30_000;
|
|
19
|
+
/**
|
|
20
|
+
* A Redis of the environment's own, in memory. `worktree` is what a worktree uses: its own empty
|
|
21
|
+
* Redis (`empty`, the default), or main's (`shared`).
|
|
22
|
+
*/
|
|
23
|
+
export function redis(options = {}) {
|
|
24
|
+
return { kind: "redis", worktree: options.worktree ?? "empty" };
|
|
25
|
+
}
|
|
26
|
+
/** The file in a server's folder naming the checkout it belongs to. */
|
|
27
|
+
const OWNER = "owner";
|
|
28
|
+
export const redisKind = {
|
|
29
|
+
schema: z.strictObject({
|
|
30
|
+
kind: z.literal("redis"),
|
|
31
|
+
worktree: z.enum(["empty", "shared"], {
|
|
32
|
+
error: "Redis `worktree` must be empty or shared",
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
perEnvironment(service) {
|
|
36
|
+
return service.worktree !== "shared";
|
|
37
|
+
},
|
|
38
|
+
names(service, { dir, suffix }) {
|
|
39
|
+
const worktrees = join(dir, "worktrees");
|
|
40
|
+
const own = suffix !== undefined && service.worktree !== "shared";
|
|
41
|
+
return { server: own ? join(worktrees, suffix) : join(dir, "main"), worktrees };
|
|
42
|
+
},
|
|
43
|
+
async prepare(_service, place) {
|
|
44
|
+
const redis = await install("redis", REDIS_VERSION, place.machine.home);
|
|
45
|
+
const { server } = place;
|
|
46
|
+
await mkdir(server, { recursive: true });
|
|
47
|
+
const port = (await runningPort(pidFile(server))) ?? (await freePort(place.port));
|
|
48
|
+
const argv = [
|
|
49
|
+
redis.bin("redis-server"),
|
|
50
|
+
"--port",
|
|
51
|
+
String(port),
|
|
52
|
+
"--bind",
|
|
53
|
+
HOST,
|
|
54
|
+
// In memory: nothing is saved, so a worktree's starts empty and none outlives its server.
|
|
55
|
+
"--save",
|
|
56
|
+
"",
|
|
57
|
+
"--appendonly",
|
|
58
|
+
"no",
|
|
59
|
+
"--dir",
|
|
60
|
+
server,
|
|
61
|
+
];
|
|
62
|
+
return {
|
|
63
|
+
run: `${recordRunning(pidFile(server), port)} && exec ${argv.map(shellQuote).join(" ")}`,
|
|
64
|
+
dir: server,
|
|
65
|
+
listens: port,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
async provision(_service, place, { started, stopped }) {
|
|
69
|
+
const outputs = (port) => ({ url: `redis://${HOST}:${port}`, host: HOST, port });
|
|
70
|
+
// Not started: where it will listen, which is where `prepare` put it.
|
|
71
|
+
if (!started)
|
|
72
|
+
return outputs((await runningPort(pidFile(place.server))) ?? (await freePort(place.port)));
|
|
73
|
+
const port = await waitFor(async () => {
|
|
74
|
+
const port = await runningPort(pidFile(place.server));
|
|
75
|
+
if (port === undefined)
|
|
76
|
+
throw new Error("It has not started.");
|
|
77
|
+
await ping(port);
|
|
78
|
+
return port;
|
|
79
|
+
}, `Redis for \`${place.key}\` didn't answer within ${READY_MS / 1000} s.`, READY_MS, stopped);
|
|
80
|
+
if (place.suffix !== undefined && place.server.startsWith(place.worktrees)) {
|
|
81
|
+
await writeFile(join(place.server, OWNER), place.owner);
|
|
82
|
+
}
|
|
83
|
+
return outputs(port);
|
|
84
|
+
},
|
|
85
|
+
worktreeData: {
|
|
86
|
+
async list(_service, place) {
|
|
87
|
+
if (!existsSync(place.worktrees))
|
|
88
|
+
return [];
|
|
89
|
+
const all = [];
|
|
90
|
+
for (const name of await readdir(place.worktrees)) {
|
|
91
|
+
const owner = await readFile(join(place.worktrees, name, OWNER), "utf8").catch(() => "");
|
|
92
|
+
all.push({ name, owner });
|
|
93
|
+
}
|
|
94
|
+
if (place.suffix === undefined)
|
|
95
|
+
return all;
|
|
96
|
+
return all.filter((data) => data.owner === place.owner || (data.owner === "" && data.name === place.suffix));
|
|
97
|
+
},
|
|
98
|
+
async drop(_service, place, names) {
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
if (name === "" || name.includes("/") || name === "." || name === "..") {
|
|
101
|
+
throw new BasepadError(`\`${name}\` is no worktree's Redis.`);
|
|
102
|
+
}
|
|
103
|
+
await rm(join(place.worktrees, name), { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
async address(_service, place) {
|
|
108
|
+
return `redis://${HOST}:${(await runningPort(pidFile(place.server))) ?? place.port}`;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
/** The file in a server's folder with its process id and port while it runs. */
|
|
112
|
+
function pidFile(server) {
|
|
113
|
+
return join(server, "redis.pid");
|
|
114
|
+
}
|
|
115
|
+
/** Resolves once Redis on `port` answers PING with PONG. */
|
|
116
|
+
function ping(port) {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const socket = createConnection({ host: HOST, port });
|
|
119
|
+
socket.setTimeout(2000);
|
|
120
|
+
let reply = "";
|
|
121
|
+
socket.on("connect", () => socket.write("PING\r\n"));
|
|
122
|
+
socket.on("data", (chunk) => {
|
|
123
|
+
reply += chunk.toString();
|
|
124
|
+
if (!reply.includes("\r\n"))
|
|
125
|
+
return;
|
|
126
|
+
socket.destroy();
|
|
127
|
+
if (reply.startsWith("+PONG"))
|
|
128
|
+
resolve();
|
|
129
|
+
else
|
|
130
|
+
reject(new Error(`Redis replied ${reply.trim()}`));
|
|
131
|
+
});
|
|
132
|
+
socket.on("timeout", () => socket.destroy(new Error("Redis didn't answer PING.")));
|
|
133
|
+
socket.on("error", reject);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { mailpitKind } from "./catalogue/mailpit.js";
|
|
2
|
+
import { postgresKind } from "./catalogue/postgres.js";
|
|
3
|
+
import { redisKind } from "./catalogue/redis.js";
|
|
4
|
+
/** Every kind, by the name its factory has in a config. */
|
|
5
|
+
const CATALOGUE = { postgres: postgresKind, redis: redisKind, mailpit: mailpitKind };
|
|
6
|
+
/** The kind that provides `service`. */
|
|
7
|
+
export function kindOf(service) {
|
|
8
|
+
return CATALOGUE[service.kind];
|
|
9
|
+
}
|
|
10
|
+
/** Every kind's schema, for Config. */
|
|
11
|
+
export const SCHEMAS = Object.values(CATALOGUE).map((kind) => kind.schema);
|
|
12
|
+
/** Every kind's factory as a config calls it, for messages: `postgres()`. */
|
|
13
|
+
export const FACTORIES = Object.keys(CATALOGUE).map((kind) => `\`${kind}()\``);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { BasepadError } from "./internal/exec.js";
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code's worktree hooks (https://code.claude.com/docs/en/hooks#worktreecreate). A command
|
|
5
|
+
* hook gets the event as JSON on standard input, with the session's folder as `cwd`.
|
|
6
|
+
* `WorktreeCreate` adds the `name` asked for and must print the worktree's path as the last line of
|
|
7
|
+
* standard output; `WorktreeRemove` gets that path back as `worktree_path`.
|
|
8
|
+
*/
|
|
9
|
+
const INPUTS = {
|
|
10
|
+
WorktreeCreate: z.looseObject({ name: z.string().min(1), cwd: z.string().optional() }),
|
|
11
|
+
WorktreeRemove: z.looseObject({ worktree_path: z.string().min(1), cwd: z.string().optional() }),
|
|
12
|
+
};
|
|
13
|
+
/** Reads and checks the hook input for `event` from standard input. */
|
|
14
|
+
export async function readHookInput(event) {
|
|
15
|
+
if (process.stdin.isTTY) {
|
|
16
|
+
throw new BasepadError(`\`--claude\` reads Claude Code's ${event} hook input on stdin.`);
|
|
17
|
+
}
|
|
18
|
+
let text = "";
|
|
19
|
+
for await (const chunk of process.stdin)
|
|
20
|
+
text += chunk;
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = JSON.parse(text);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new BasepadError(`\`--claude\` expects Claude Code's ${event} hook input, as JSON, on stdin.`);
|
|
27
|
+
}
|
|
28
|
+
const parsed = INPUTS[event].safeParse(raw);
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
throw new BasepadError(`Claude Code's ${event} hook input is missing fields:\n${z.prettifyError(parsed.error)}`);
|
|
31
|
+
}
|
|
32
|
+
return parsed.data;
|
|
33
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { COMMANDS } from "./commands.js";
|
|
3
|
+
import { BasepadError } from "./internal/exec.js";
|
|
4
|
+
/** Flags every command takes. */
|
|
5
|
+
const HELP = { help: { short: "h", description: "Show this help" } };
|
|
6
|
+
/** Flags `basepad` takes before a command. */
|
|
7
|
+
const TOP_FLAGS = {
|
|
8
|
+
...HELP,
|
|
9
|
+
version: { short: "v", description: "Show basepad's version" },
|
|
10
|
+
};
|
|
11
|
+
async function main(argv) {
|
|
12
|
+
const [first, ...rest] = argv;
|
|
13
|
+
if (first === undefined) {
|
|
14
|
+
process.stdout.write(topHelp());
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
17
|
+
if (first.startsWith("-")) {
|
|
18
|
+
const { flags } = parseFlags([first, ...rest], TOP_FLAGS, "basepad");
|
|
19
|
+
if (flags.version) {
|
|
20
|
+
process.stdout.write(`${version()}\n`);
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
process.stdout.write(topHelp());
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
// A group (`db`) with no command, or asking for help, lists its commands.
|
|
27
|
+
const verbs = Object.entries(COMMANDS).filter(([key]) => key.startsWith(`${first} `));
|
|
28
|
+
if (verbs.length > 0 && (rest[0] === undefined || ["-h", "--help"].includes(rest[0]))) {
|
|
29
|
+
process.stdout.write(groupHelp(first, verbs));
|
|
30
|
+
return rest[0] === undefined ? 1 : 0;
|
|
31
|
+
}
|
|
32
|
+
// A command is one word, or two for a group's (`worktree add`).
|
|
33
|
+
const pair = rest[0] === undefined ? undefined : `${first} ${rest[0]}`;
|
|
34
|
+
const name = pair !== undefined && pair in COMMANDS ? pair : first;
|
|
35
|
+
const command = COMMANDS[name];
|
|
36
|
+
if (!command) {
|
|
37
|
+
const typed = verbs.length > 0 ? (pair ?? first) : first;
|
|
38
|
+
throw new BasepadError(`Unknown command \`${typed}\`. The nearest is \`${nearest(typed, Object.keys(COMMANDS))}\`; run \`basepad --help\` for every command.`);
|
|
39
|
+
}
|
|
40
|
+
const args = name === pair ? rest.slice(1) : rest;
|
|
41
|
+
const { flags, positional } = parseFlags(args, { ...command.flags, ...HELP }, `basepad ${name}`);
|
|
42
|
+
if (flags.help) {
|
|
43
|
+
process.stdout.write(commandHelp(name, command));
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const wanted = command.args ?? [];
|
|
47
|
+
const required = wanted.filter((arg) => !arg.endsWith("?")).length;
|
|
48
|
+
if (positional.length < required || positional.length > wanted.length) {
|
|
49
|
+
throw new BasepadError(wanted.length === 0
|
|
50
|
+
? `\`basepad ${name}\` takes no arguments; got \`${positional[0]}\`.`
|
|
51
|
+
: `Usage: basepad ${name} ${usageArgs(wanted)}[flags]`);
|
|
52
|
+
}
|
|
53
|
+
await command.run({
|
|
54
|
+
cwd: process.cwd(),
|
|
55
|
+
flags,
|
|
56
|
+
args: positional,
|
|
57
|
+
print: (line) => console.log(line),
|
|
58
|
+
});
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Reads flags by long or short name: a switch, or a flag with a value, which takes the next
|
|
63
|
+
* argument. Other words are the positional arguments; any other flag is an error naming the
|
|
64
|
+
* nearest.
|
|
65
|
+
*/
|
|
66
|
+
function parseFlags(args, known, usage) {
|
|
67
|
+
const flags = {};
|
|
68
|
+
const positional = [];
|
|
69
|
+
const spellings = Object.entries(known).flatMap(([name, flag]) => [
|
|
70
|
+
[`--${name}`, name],
|
|
71
|
+
...(flag.short ? [[`-${flag.short}`, name]] : []),
|
|
72
|
+
]);
|
|
73
|
+
const byArg = new Map(spellings);
|
|
74
|
+
for (let i = 0; i < args.length; i++) {
|
|
75
|
+
const arg = args[i];
|
|
76
|
+
const name = byArg.get(arg);
|
|
77
|
+
const value = name === undefined ? undefined : known[name]?.value;
|
|
78
|
+
if (name !== undefined && value === undefined) {
|
|
79
|
+
flags[name] = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (name !== undefined) {
|
|
83
|
+
const given = args[++i];
|
|
84
|
+
if (given === undefined || given.startsWith("-")) {
|
|
85
|
+
throw new BasepadError(`\`${arg}\` needs a ${value}: \`${usage} ${arg} <${value}>\`.`);
|
|
86
|
+
}
|
|
87
|
+
flags[name] = given;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!arg.startsWith("-")) {
|
|
91
|
+
positional.push(arg);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const near = nearest(arg, Object.keys(known).map((n) => `--${n}`));
|
|
95
|
+
throw new BasepadError(`\`${usage}\` has no flag \`${arg}\`. The nearest is \`${near}\`; run \`${usage} --help\` for its flags.`);
|
|
96
|
+
}
|
|
97
|
+
return { flags, positional };
|
|
98
|
+
}
|
|
99
|
+
/** The candidate with the fewest edits from `word`. */
|
|
100
|
+
function nearest(word, candidates) {
|
|
101
|
+
let best = candidates[0] ?? "";
|
|
102
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
103
|
+
for (const candidate of candidates) {
|
|
104
|
+
const d = distance(word, candidate);
|
|
105
|
+
if (d < bestDistance)
|
|
106
|
+
[best, bestDistance] = [candidate, d];
|
|
107
|
+
}
|
|
108
|
+
return best;
|
|
109
|
+
}
|
|
110
|
+
/** Levenshtein distance. */
|
|
111
|
+
function distance(a, b) {
|
|
112
|
+
let row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
113
|
+
for (let i = 1; i <= a.length; i++) {
|
|
114
|
+
const next = [i];
|
|
115
|
+
for (let j = 1; j <= b.length; j++) {
|
|
116
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
117
|
+
next[j] = Math.min((row[j] ?? 0) + 1, (next[j - 1] ?? 0) + 1, (row[j - 1] ?? 0) + cost);
|
|
118
|
+
}
|
|
119
|
+
row = next;
|
|
120
|
+
}
|
|
121
|
+
return row[b.length] ?? 0;
|
|
122
|
+
}
|
|
123
|
+
function topHelp() {
|
|
124
|
+
return [
|
|
125
|
+
"Usage: basepad <command> [flags]",
|
|
126
|
+
"",
|
|
127
|
+
"Commands:",
|
|
128
|
+
...columns(Object.entries(COMMANDS).map(([name, command]) => [name, command.summary])),
|
|
129
|
+
"",
|
|
130
|
+
"Flags:",
|
|
131
|
+
...flagLines(TOP_FLAGS),
|
|
132
|
+
"",
|
|
133
|
+
"Run `basepad <command> --help` for a command's flags.",
|
|
134
|
+
"",
|
|
135
|
+
].join("\n");
|
|
136
|
+
}
|
|
137
|
+
function groupHelp(group, verbs) {
|
|
138
|
+
return [
|
|
139
|
+
`Usage: basepad ${group} <command> [flags]`,
|
|
140
|
+
"",
|
|
141
|
+
"Commands:",
|
|
142
|
+
...columns(verbs.map(([name, command]) => [name, command.summary])),
|
|
143
|
+
"",
|
|
144
|
+
`Run \`basepad ${group} <command> --help\` for a command's flags.`,
|
|
145
|
+
"",
|
|
146
|
+
].join("\n");
|
|
147
|
+
}
|
|
148
|
+
function commandHelp(name, command) {
|
|
149
|
+
return [
|
|
150
|
+
`Usage: basepad ${name} ${usageArgs(command.args ?? [])}[flags]`,
|
|
151
|
+
"",
|
|
152
|
+
`${command.summary}.`,
|
|
153
|
+
"",
|
|
154
|
+
"Flags:",
|
|
155
|
+
...flagLines({ ...command.flags, ...HELP }),
|
|
156
|
+
"",
|
|
157
|
+
].join("\n");
|
|
158
|
+
}
|
|
159
|
+
/** A command's arguments for its usage line: `<name> `, or `[<name>] ` for an optional one. */
|
|
160
|
+
function usageArgs(args) {
|
|
161
|
+
return args.map((arg) => (arg.endsWith("?") ? `[<${arg.slice(0, -1)}>] ` : `<${arg}> `)).join("");
|
|
162
|
+
}
|
|
163
|
+
function flagLines(flags) {
|
|
164
|
+
return columns(Object.entries(flags).map(([name, flag]) => [
|
|
165
|
+
`${flag.short ? `-${flag.short}, --${name}` : ` --${name}`}${flag.value ? ` <${flag.value}>` : ""}`,
|
|
166
|
+
flag.description,
|
|
167
|
+
]));
|
|
168
|
+
}
|
|
169
|
+
function columns(rows) {
|
|
170
|
+
const width = Math.max(...rows.map(([left]) => left.length));
|
|
171
|
+
return rows.map(([left, right]) => ` ${left.padEnd(width)} ${right}`);
|
|
172
|
+
}
|
|
173
|
+
function version() {
|
|
174
|
+
const pkg = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
175
|
+
return JSON.parse(pkg).version;
|
|
176
|
+
}
|
|
177
|
+
main(process.argv.slice(2)).then((code) => {
|
|
178
|
+
process.exitCode = code;
|
|
179
|
+
}, (error) => {
|
|
180
|
+
console.error(error instanceof BasepadError ? `basepad: ${error.message}` : error);
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
});
|