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
package/dist/config.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
10
|
+
import { registerHooks } from "node:module";
|
|
11
|
+
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { FACTORIES, SCHEMAS } from "./catalogue.js";
|
|
15
|
+
import { BasepadError } from "./internal/exec.js";
|
|
16
|
+
export const CONFIG_FILE = "basepad.config.ts";
|
|
17
|
+
export const LOCAL_FILE = "basepad.config.local.ts";
|
|
18
|
+
/**
|
|
19
|
+
* Finds `basepad.config.ts` in `cwd` or its nearest parent that has one, loads it and the local
|
|
20
|
+
* file beside it, and validates the result. Every failure is a `BasepadError` naming the file,
|
|
21
|
+
* and the key when one is at fault.
|
|
22
|
+
*/
|
|
23
|
+
export async function loadProject(cwd) {
|
|
24
|
+
const file = findConfig(cwd);
|
|
25
|
+
const root = dirname(file);
|
|
26
|
+
let config = validate(await importDefault(file), file);
|
|
27
|
+
const local = join(root, LOCAL_FILE);
|
|
28
|
+
if (existsSync(local)) {
|
|
29
|
+
const override = await importDefault(local);
|
|
30
|
+
if (typeof override !== "function") {
|
|
31
|
+
throw new BasepadError(`${local}: its default export must be a function from the project's config to a config.`);
|
|
32
|
+
}
|
|
33
|
+
let changed;
|
|
34
|
+
try {
|
|
35
|
+
changed = await override(config);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new BasepadError(`${local} threw: ${messageOf(error)}`);
|
|
39
|
+
}
|
|
40
|
+
config = validate(changed, local);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
file,
|
|
44
|
+
root,
|
|
45
|
+
name: config.name,
|
|
46
|
+
services: config.services ?? {},
|
|
47
|
+
apps: Object.fromEntries(Object.entries(config.apps ?? {}).map(([key, app]) => [
|
|
48
|
+
key,
|
|
49
|
+
{
|
|
50
|
+
run: app.run,
|
|
51
|
+
cwd: resolve(root, app.cwd ?? "."),
|
|
52
|
+
...(app.port === false ? { port: false } : {}),
|
|
53
|
+
...(app.domain === false ? { domain: false } : {}),
|
|
54
|
+
},
|
|
55
|
+
])),
|
|
56
|
+
env: config.env ?? (() => ({})),
|
|
57
|
+
worktrees: {
|
|
58
|
+
prefix: config.worktrees?.prefix ?? "worktree",
|
|
59
|
+
clone: config.worktrees?.clone ?? [],
|
|
60
|
+
setup: config.worktrees?.setup ?? [],
|
|
61
|
+
...(config.worktrees?.dir === undefined ? {} : { dir: resolve(root, config.worktrees.dir) }),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function findConfig(cwd) {
|
|
66
|
+
for (let dir = resolve(cwd);; dir = dirname(dir)) {
|
|
67
|
+
const file = join(dir, CONFIG_FILE);
|
|
68
|
+
if (existsSync(file))
|
|
69
|
+
return realpathSync(file);
|
|
70
|
+
if (dirname(dir) === dir)
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
throw new BasepadError(`No ${CONFIG_FILE} in ${cwd} or any folder above it. Run \`basepad init\` to write one.`);
|
|
74
|
+
}
|
|
75
|
+
/** What a config imports basepad as. */
|
|
76
|
+
const PACKAGE = "basepad";
|
|
77
|
+
/** The running basepad's own entry: `index.ts` from source, `index.js` once built. */
|
|
78
|
+
const OWN_INDEX = new URL(import.meta.url.endsWith(".ts") ? "./index.ts" : "./index.js", import.meta.url).href;
|
|
79
|
+
/** Real paths of the files basepad loads; the hook reads each as TypeScript ESM. */
|
|
80
|
+
const typescriptFiles = new Set();
|
|
81
|
+
let hooked = false;
|
|
82
|
+
/**
|
|
83
|
+
* Imports a file as TypeScript ESM, whatever the nearest `package.json` says about module type, so
|
|
84
|
+
* a project never renames files or changes settings for basepad, and Node prints no warning.
|
|
85
|
+
*/
|
|
86
|
+
async function importDefault(file) {
|
|
87
|
+
const real = realpathSync(file);
|
|
88
|
+
typescriptFiles.add(pathToFileURL(real).href);
|
|
89
|
+
if (!hooked) {
|
|
90
|
+
hooked = true;
|
|
91
|
+
registerHooks({
|
|
92
|
+
resolve(specifier, context, nextResolve) {
|
|
93
|
+
try {
|
|
94
|
+
return nextResolve(specifier, context);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
// A fresh worktree has no `node_modules` yet: its config gets the running basepad.
|
|
98
|
+
const fromConfig = context.parentURL && typescriptFiles.has(context.parentURL);
|
|
99
|
+
if (specifier !== PACKAGE || !fromConfig)
|
|
100
|
+
throw error;
|
|
101
|
+
return { url: OWN_INDEX, shortCircuit: true };
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
load(url, context, nextLoad) {
|
|
105
|
+
if (!typescriptFiles.has(url))
|
|
106
|
+
return nextLoad(url, context);
|
|
107
|
+
const loaded = nextLoad(url, { ...context, format: "module-typescript" });
|
|
108
|
+
return { ...loaded, format: "module-typescript" };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
let module;
|
|
113
|
+
try {
|
|
114
|
+
module = await import(__rewriteRelativeImportExtension(pathToFileURL(real).href));
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
throw new BasepadError(`${file} failed to load: ${messageOf(error)}`);
|
|
118
|
+
}
|
|
119
|
+
if (module.default === undefined) {
|
|
120
|
+
throw new BasepadError(`${file}: it must \`export default defineConfig({ ... })\`.`);
|
|
121
|
+
}
|
|
122
|
+
return module.default;
|
|
123
|
+
}
|
|
124
|
+
function messageOf(error) {
|
|
125
|
+
return error instanceof Error ? error.message : String(error);
|
|
126
|
+
}
|
|
127
|
+
/** A lowercase DNS label: letters, digits and inner hyphens. */
|
|
128
|
+
export const LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
129
|
+
const label = z
|
|
130
|
+
.string({ error: "must be a string" })
|
|
131
|
+
.regex(LABEL, "must be a lowercase DNS label (letters, digits and inner hyphens)");
|
|
132
|
+
const AppSchema = z.strictObject({
|
|
133
|
+
run: z.string({ error: "`run` must be a command" }).min(1, "`run` must be a command"),
|
|
134
|
+
cwd: z.string({ error: "`cwd` must be a folder relative to the config" }).optional(),
|
|
135
|
+
port: z
|
|
136
|
+
.literal(false, { error: "`port` can only be `false`, for an app that listens on nothing" })
|
|
137
|
+
.optional(),
|
|
138
|
+
domain: z
|
|
139
|
+
.literal(false, {
|
|
140
|
+
error: "`domain` can only be `false`, for an app served at `https://localhost:$PORT`",
|
|
141
|
+
})
|
|
142
|
+
.optional(),
|
|
143
|
+
});
|
|
144
|
+
const ConfigSchema = z
|
|
145
|
+
.strictObject({
|
|
146
|
+
name: label,
|
|
147
|
+
services: z
|
|
148
|
+
.record(label,
|
|
149
|
+
// Each kind's schema is a strict object with a literal `kind`, as a discriminated union needs.
|
|
150
|
+
z.discriminatedUnion("kind", SCHEMAS, {
|
|
151
|
+
error: `a service must be made by a service function, such as ${FACTORIES.join(" or ")}`,
|
|
152
|
+
}))
|
|
153
|
+
.optional(),
|
|
154
|
+
apps: z.record(label, AppSchema).optional(),
|
|
155
|
+
env: z
|
|
156
|
+
.custom((value) => typeof value === "function", {
|
|
157
|
+
error: "`env` must be a function from outputs to env files",
|
|
158
|
+
})
|
|
159
|
+
.optional(),
|
|
160
|
+
worktrees: z
|
|
161
|
+
.strictObject({
|
|
162
|
+
prefix: label.optional(),
|
|
163
|
+
dir: z
|
|
164
|
+
.string({ error: "must be a folder relative to the config" })
|
|
165
|
+
.min(1, "must be a folder relative to the config")
|
|
166
|
+
.optional(),
|
|
167
|
+
clone: z
|
|
168
|
+
.array(z
|
|
169
|
+
.string({ error: "each must be a path relative to the config" })
|
|
170
|
+
.min(1, "each must be a path relative to the config")
|
|
171
|
+
.refine((path) => !isAbsolute(path) && !normalize(path).startsWith(".."), "each must be a path inside the config's folder"))
|
|
172
|
+
.optional(),
|
|
173
|
+
setup: z
|
|
174
|
+
.array(z
|
|
175
|
+
.string({ error: "each must be a shell command" })
|
|
176
|
+
.min(1, "each must be a shell command"))
|
|
177
|
+
.optional(),
|
|
178
|
+
})
|
|
179
|
+
.optional(),
|
|
180
|
+
})
|
|
181
|
+
.superRefine((config, ctx) => {
|
|
182
|
+
for (const [key, app] of Object.entries(config.apps ?? {})) {
|
|
183
|
+
if (app.port === false && app.domain === false) {
|
|
184
|
+
ctx.addIssue({
|
|
185
|
+
code: "custom",
|
|
186
|
+
path: ["apps", key, "domain"],
|
|
187
|
+
message: "an app with no port has no domain already; drop `domain`",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Services and apps share one namespace: `env`'s outputs and the supervisor's daemons.
|
|
192
|
+
for (const key of Object.keys(config.services ?? {})) {
|
|
193
|
+
if (config.apps && key in config.apps) {
|
|
194
|
+
ctx.addIssue({
|
|
195
|
+
code: "custom",
|
|
196
|
+
path: ["apps", key],
|
|
197
|
+
message: "a service has the same key; a key names one service or one app",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
function validate(value, file) {
|
|
203
|
+
const result = ConfigSchema.safeParse(value);
|
|
204
|
+
if (result.success)
|
|
205
|
+
return result.data;
|
|
206
|
+
const lines = result.error.issues.map((issue) => {
|
|
207
|
+
const at = issue.path.map(String).join(".");
|
|
208
|
+
if (issue.code === "unrecognized_keys") {
|
|
209
|
+
const keys = issue.keys.map((key) => `\`${at ? `${at}.` : ""}${key}\``).join(", ");
|
|
210
|
+
return `unknown key ${keys}`;
|
|
211
|
+
}
|
|
212
|
+
// A bad record key's own reason is nested inside the issue.
|
|
213
|
+
const message = issue.code === "invalid_key" ? (issue.issues[0]?.message ?? issue.message) : issue.message;
|
|
214
|
+
return at ? `\`${at}\`: ${message}` : message;
|
|
215
|
+
});
|
|
216
|
+
throw new BasepadError(`${file} is invalid:\n ${lines.join("\n ")}`);
|
|
217
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env files: basepad sets exactly the keys the config names, and nothing else in the file changes.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, resolve } from "node:path";
|
|
7
|
+
/**
|
|
8
|
+
* Copies each env file a worktree lacks from main's folder, so its other lines (secrets, settings)
|
|
9
|
+
* come along before basepad sets its own keys. A file the worktree has is its own, and kept.
|
|
10
|
+
*/
|
|
11
|
+
export async function copyEnvFiles(from, to, paths) {
|
|
12
|
+
for (const path of paths) {
|
|
13
|
+
const source = resolve(from, path);
|
|
14
|
+
const target = resolve(to, path);
|
|
15
|
+
if (existsSync(target) || !existsSync(source))
|
|
16
|
+
continue;
|
|
17
|
+
await mkdir(dirname(target), { recursive: true });
|
|
18
|
+
await copyFile(source, target);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Sets each file's keys, paths resolved from `root`, creating a file that is missing. A file whose
|
|
23
|
+
* keys already read as wanted is not written. Returns the paths it changed.
|
|
24
|
+
*/
|
|
25
|
+
export async function writeEnvFiles(root, files) {
|
|
26
|
+
const changed = [];
|
|
27
|
+
for (const [path, keys] of Object.entries(files)) {
|
|
28
|
+
const file = resolve(root, path);
|
|
29
|
+
const text = await readFile(file, "utf8").catch((error) => {
|
|
30
|
+
if (error.code === "ENOENT")
|
|
31
|
+
return undefined;
|
|
32
|
+
throw error;
|
|
33
|
+
});
|
|
34
|
+
const next = setKeys(text ?? "", keys);
|
|
35
|
+
if (next === text)
|
|
36
|
+
continue;
|
|
37
|
+
await mkdir(dirname(file), { recursive: true });
|
|
38
|
+
await writeFile(file, next);
|
|
39
|
+
changed.push(path);
|
|
40
|
+
}
|
|
41
|
+
return changed;
|
|
42
|
+
}
|
|
43
|
+
/** One `KEY=value` assignment's start: optional `export`, the key, then `=`. */
|
|
44
|
+
const ASSIGNMENT = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_.-]*)(\s*=\s*)/;
|
|
45
|
+
/**
|
|
46
|
+
* Sets `keys` in an env file's text: in place where a key is assigned (every assignment of it),
|
|
47
|
+
* appended in the order given where it is not. Every other byte is kept. A key whose value already
|
|
48
|
+
* reads as the wanted one is left as written, so setting the same keys twice changes nothing.
|
|
49
|
+
*/
|
|
50
|
+
export function setKeys(text, keys) {
|
|
51
|
+
const wanted = new Map(Object.entries(keys).map(([key, value]) => [key, String(value)]));
|
|
52
|
+
const found = new Set();
|
|
53
|
+
const lines = text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
54
|
+
const out = [];
|
|
55
|
+
for (let i = 0; i < lines.length; i++) {
|
|
56
|
+
const line = lines[i];
|
|
57
|
+
const match = ASSIGNMENT.exec(line);
|
|
58
|
+
const key = match?.[2];
|
|
59
|
+
if (!match || key === undefined || !wanted.has(key)) {
|
|
60
|
+
out.push(line);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
found.add(key);
|
|
64
|
+
// A quoted value may run over several lines; take them all as this assignment.
|
|
65
|
+
let assignment = line;
|
|
66
|
+
while (opensQuote(assignment.slice(match[0].length)) && i + 1 < lines.length) {
|
|
67
|
+
i++;
|
|
68
|
+
assignment += lines[i];
|
|
69
|
+
}
|
|
70
|
+
const ending = /\r?\n$/.exec(assignment)?.[0] ?? "";
|
|
71
|
+
const raw = assignment.slice(match[0].length, assignment.length - ending.length);
|
|
72
|
+
const value = wanted.get(key);
|
|
73
|
+
out.push(readValue(raw) === value
|
|
74
|
+
? assignment
|
|
75
|
+
: `${match[1]}${key}${match[3]}${quote(value)}${ending}`);
|
|
76
|
+
}
|
|
77
|
+
const missing = [...wanted].filter(([key]) => !found.has(key));
|
|
78
|
+
if (missing.length === 0)
|
|
79
|
+
return out.join("");
|
|
80
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
81
|
+
const gap = text === "" || text.endsWith("\n") ? "" : newline;
|
|
82
|
+
return `${out.join("")}${gap}${missing.map(([key, value]) => `${key}=${quote(value)}${newline}`).join("")}`;
|
|
83
|
+
}
|
|
84
|
+
/** Whether a raw value starts a quoted string it does not close. */
|
|
85
|
+
function opensQuote(raw) {
|
|
86
|
+
const q = raw[0];
|
|
87
|
+
if (q !== '"' && q !== "'")
|
|
88
|
+
return false;
|
|
89
|
+
return closing(raw, q) === -1;
|
|
90
|
+
}
|
|
91
|
+
/** The index of the quote closing a value that starts with `q`, or -1. */
|
|
92
|
+
function closing(raw, q) {
|
|
93
|
+
for (let i = 1; i < raw.length; i++) {
|
|
94
|
+
if (q === '"' && raw[i] === "\\")
|
|
95
|
+
i++;
|
|
96
|
+
else if (raw[i] === q)
|
|
97
|
+
return i;
|
|
98
|
+
}
|
|
99
|
+
return -1;
|
|
100
|
+
}
|
|
101
|
+
/** What a raw value reads as: quoted literally or with escapes, or bare up to a ` #` comment. */
|
|
102
|
+
function readValue(raw) {
|
|
103
|
+
const q = raw[0];
|
|
104
|
+
if (q === "'" || q === '"') {
|
|
105
|
+
const end = closing(raw, q);
|
|
106
|
+
if (end === -1)
|
|
107
|
+
return raw;
|
|
108
|
+
const inner = raw.slice(1, end);
|
|
109
|
+
return q === "'" ? inner : inner.replace(/\\(.)/gs, (_, c) => (c === "n" ? "\n" : c));
|
|
110
|
+
}
|
|
111
|
+
return raw.replace(/\s+#.*$/, "").trim();
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A value as written: bare when that reads back the same in every common parser, else in single
|
|
115
|
+
* quotes (literal, no expansion), else in double quotes with `\` and `"` escaped.
|
|
116
|
+
*/
|
|
117
|
+
export function quote(value) {
|
|
118
|
+
if (/^[A-Za-z0-9_@%+=:,./?&~-]*$/.test(value))
|
|
119
|
+
return value;
|
|
120
|
+
if (!value.includes("'") && !value.includes("\n"))
|
|
121
|
+
return `'${value}'`;
|
|
122
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n")}"`;
|
|
123
|
+
}
|
package/dist/forward.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { connect, createServer } from "node:net";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/** A peer on this machine, as a dual-stack socket reports it. */
|
|
5
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
6
|
+
/** How often it checks that the supervisor still runs. */
|
|
7
|
+
const CHECK_MS = 500;
|
|
8
|
+
export async function forward({ port, to, whileExists }) {
|
|
9
|
+
const open = new Set();
|
|
10
|
+
const server = createServer((client) => {
|
|
11
|
+
// Bound on every address, it would serve the network: only this machine gets through.
|
|
12
|
+
if (!LOOPBACK.has(client.remoteAddress ?? "")) {
|
|
13
|
+
client.destroy();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const upstream = connect({ host: "127.0.0.1", port: to });
|
|
17
|
+
open.add(client).add(upstream);
|
|
18
|
+
client.pipe(upstream).pipe(client);
|
|
19
|
+
const end = () => {
|
|
20
|
+
client.destroy();
|
|
21
|
+
upstream.destroy();
|
|
22
|
+
open.delete(client);
|
|
23
|
+
open.delete(upstream);
|
|
24
|
+
};
|
|
25
|
+
client.on("error", end).on("close", end);
|
|
26
|
+
upstream.on("error", end).on("close", end);
|
|
27
|
+
});
|
|
28
|
+
await new Promise((resolve, reject) => {
|
|
29
|
+
server.once("error", reject);
|
|
30
|
+
// `::` takes IPv4 too.
|
|
31
|
+
server.listen(port, "::", resolve);
|
|
32
|
+
});
|
|
33
|
+
let close;
|
|
34
|
+
const stopped = new Promise((resolve) => {
|
|
35
|
+
const check = setInterval(() => {
|
|
36
|
+
if (!existsSync(whileExists))
|
|
37
|
+
void close();
|
|
38
|
+
}, CHECK_MS);
|
|
39
|
+
close = () => {
|
|
40
|
+
clearInterval(check);
|
|
41
|
+
for (const socket of open)
|
|
42
|
+
socket.destroy();
|
|
43
|
+
return new Promise((done) => server.close(() => done())).then(resolve);
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
return { port: server.address().port, stopped, close };
|
|
47
|
+
}
|
|
48
|
+
// Run as a process of its own (`node forward.js <port> <to> <whileExists>`), detached from `serve`:
|
|
49
|
+
// it exits once it stops, or at once when it cannot bind (something else took the port).
|
|
50
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
51
|
+
const [port, to, whileExists] = process.argv.slice(2);
|
|
52
|
+
forward({ port: Number(port), to: Number(to), whileExists: whileExists }).then((forwarder) => forwarder.stopped.then(() => process.exit(0)), () => process.exit(1));
|
|
53
|
+
}
|
package/dist/hosts.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { access, constants, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
/**
|
|
4
|
+
* Name resolution for server-side code. Browsers and curl resolve `*.localhost` themselves, and so
|
|
5
|
+
* do macOS and Linux with systemd's resolver; glibc without it (a container) and musl do not. There
|
|
6
|
+
* basepad adds the project's domains to the hosts file, or says which lines to add.
|
|
7
|
+
*/
|
|
8
|
+
const HOSTS_FILE = "/etc/hosts";
|
|
9
|
+
/** `text` with the project's block of domains set: replaced where it is, else appended. Pure. */
|
|
10
|
+
export function withHosts(text, name, hosts) {
|
|
11
|
+
const start = `# >>> basepad ${name}\n`;
|
|
12
|
+
const end = `# <<< basepad ${name}\n`;
|
|
13
|
+
const block = `${start}${hosts.map((host) => `127.0.0.1 ${host}\n`).join("")}${end}`;
|
|
14
|
+
const at = text.indexOf(start);
|
|
15
|
+
const until = at === -1 ? -1 : text.indexOf(end, at);
|
|
16
|
+
if (at !== -1 && until !== -1) {
|
|
17
|
+
return text.slice(0, at) + block + text.slice(until + end.length);
|
|
18
|
+
}
|
|
19
|
+
const gap = text === "" || text.endsWith("\n") ? "" : "\n";
|
|
20
|
+
return `${text}${gap}${block}`;
|
|
21
|
+
}
|
|
22
|
+
/** `text` without the project's block of domains. Pure. */
|
|
23
|
+
export function withoutHosts(text, name) {
|
|
24
|
+
const start = `# >>> basepad ${name}\n`;
|
|
25
|
+
const end = `# <<< basepad ${name}\n`;
|
|
26
|
+
const at = text.indexOf(start);
|
|
27
|
+
const until = at === -1 ? -1 : text.indexOf(end, at);
|
|
28
|
+
if (at === -1 || until === -1)
|
|
29
|
+
return text;
|
|
30
|
+
return text.slice(0, at) + text.slice(until + end.length);
|
|
31
|
+
}
|
|
32
|
+
/** Takes the project's block out of the hosts file, when it is there and writable. */
|
|
33
|
+
export async function dropHosts(name) {
|
|
34
|
+
const current = await readFile(HOSTS_FILE, "utf8").catch(() => undefined);
|
|
35
|
+
if (current === undefined)
|
|
36
|
+
return;
|
|
37
|
+
const text = withoutHosts(current, name);
|
|
38
|
+
if (text !== current && (await writable(HOSTS_FILE)))
|
|
39
|
+
await writeFile(HOSTS_FILE, text);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Makes the project's domains resolve: nothing when they already do (and no block of ours is
|
|
43
|
+
* there to keep current), else writes its block when the file is writable, else returns the lines
|
|
44
|
+
* for the developer to add. Returns what to tell them.
|
|
45
|
+
*/
|
|
46
|
+
export async function syncHosts(name, hosts) {
|
|
47
|
+
if (hosts.length === 0)
|
|
48
|
+
return [];
|
|
49
|
+
const current = await readFile(HOSTS_FILE, "utf8").catch(() => undefined);
|
|
50
|
+
if (current === undefined)
|
|
51
|
+
return [];
|
|
52
|
+
const ours = current.includes(`# >>> basepad ${name}\n`);
|
|
53
|
+
if (!ours) {
|
|
54
|
+
const missing = [];
|
|
55
|
+
for (const host of hosts)
|
|
56
|
+
if (!(await resolves(host)))
|
|
57
|
+
missing.push(host);
|
|
58
|
+
if (missing.length === 0)
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
const text = withHosts(current, name, hosts);
|
|
62
|
+
if (text === current)
|
|
63
|
+
return [];
|
|
64
|
+
if (!(await writable(HOSTS_FILE))) {
|
|
65
|
+
return [
|
|
66
|
+
`This system does not resolve ${name}'s domains for server-side code. Add to ${HOSTS_FILE}:`,
|
|
67
|
+
...hosts.map((host) => ` 127.0.0.1 ${host}`),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
// In place, not by rename: in a container the hosts file is a bind mount.
|
|
71
|
+
await writeFile(HOSTS_FILE, text);
|
|
72
|
+
return [`Added ${name}'s domains to ${HOSTS_FILE}.`];
|
|
73
|
+
}
|
|
74
|
+
async function resolves(host) {
|
|
75
|
+
return lookup(host).then(() => true, () => false);
|
|
76
|
+
}
|
|
77
|
+
async function writable(file) {
|
|
78
|
+
return access(file, constants.W_OK).then(() => true, () => false);
|
|
79
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a project's `basepad.config.ts` imports: `defineConfig` and the service kinds. ADR 0001: the
|
|
3
|
+
* config is the only way a project talks to basepad.
|
|
4
|
+
*/
|
|
5
|
+
export { mailpit } from "./catalogue/mailpit.js";
|
|
6
|
+
export { postgres } from "./catalogue/postgres.js";
|
|
7
|
+
export { redis } from "./catalogue/redis.js";
|
|
8
|
+
/** Types a project's config. It returns the config unchanged. */
|
|
9
|
+
export function defineConfig(config) {
|
|
10
|
+
return config;
|
|
11
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { CONFIG_FILE, LOCAL_FILE } from "./config.js";
|
|
5
|
+
import { BasepadError } from "./internal/exec.js";
|
|
6
|
+
/**
|
|
7
|
+
* `basepad init`: a starter config named from the folder, the local file kept out of git, and
|
|
8
|
+
* basepad added as a devDependency.
|
|
9
|
+
*/
|
|
10
|
+
const PACKAGE = "basepad";
|
|
11
|
+
export async function init({ cwd, print }) {
|
|
12
|
+
const file = join(cwd, CONFIG_FILE);
|
|
13
|
+
if (existsSync(file)) {
|
|
14
|
+
throw new BasepadError(`${file} already exists; \`basepad init\` leaves it as it is.`);
|
|
15
|
+
}
|
|
16
|
+
const name = labelOf(basename(cwd));
|
|
17
|
+
await writeFile(file, starter(name));
|
|
18
|
+
print(`Wrote ${CONFIG_FILE} with name "${name}".`);
|
|
19
|
+
if (await ignoreLocalFile(cwd))
|
|
20
|
+
print(`Added ${LOCAL_FILE} to .gitignore.`);
|
|
21
|
+
if (await addDevDependency(cwd)) {
|
|
22
|
+
print(`Added ${PACKAGE} to devDependencies in package.json; install it with your package manager.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** A folder's name as a lowercase DNS label. Never truncated: `status` says when it is too long. */
|
|
26
|
+
function labelOf(folder) {
|
|
27
|
+
const label = folder
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
30
|
+
.replace(/^-+|-+$/g, "");
|
|
31
|
+
return label || "project";
|
|
32
|
+
}
|
|
33
|
+
function starter(name) {
|
|
34
|
+
return `import { defineConfig } from "${PACKAGE}";
|
|
35
|
+
|
|
36
|
+
export default defineConfig({
|
|
37
|
+
name: ${JSON.stringify(name)},
|
|
38
|
+
// services: {
|
|
39
|
+
// db: postgres({ version: 18 }), // import postgres from "${PACKAGE}" too
|
|
40
|
+
// },
|
|
41
|
+
apps: {
|
|
42
|
+
// web: { run: "npm run dev" }, // https://web.${name}.localhost
|
|
43
|
+
},
|
|
44
|
+
// env: ({ db, web }) => ({
|
|
45
|
+
// ".env.local": { DATABASE_URL: db.url, APP_URL: web.url },
|
|
46
|
+
// }),
|
|
47
|
+
});
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
/** Adds the local file to `.gitignore`, creating it. Returns whether it changed anything. */
|
|
51
|
+
async function ignoreLocalFile(cwd) {
|
|
52
|
+
const file = join(cwd, ".gitignore");
|
|
53
|
+
const text = await readFile(file, "utf8").catch(() => "");
|
|
54
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim());
|
|
55
|
+
if (lines.includes(LOCAL_FILE) || lines.includes(`/${LOCAL_FILE}`))
|
|
56
|
+
return false;
|
|
57
|
+
const gap = text === "" || text.endsWith("\n") ? "" : "\n";
|
|
58
|
+
await writeFile(file, `${text}${gap}${LOCAL_FILE}\n`);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
/** Adds basepad to `package.json`'s devDependencies when the file exists and lacks it. */
|
|
62
|
+
async function addDevDependency(cwd) {
|
|
63
|
+
const file = join(cwd, "package.json");
|
|
64
|
+
const text = await readFile(file, "utf8").catch(() => undefined);
|
|
65
|
+
if (text === undefined)
|
|
66
|
+
return false;
|
|
67
|
+
let pkg;
|
|
68
|
+
try {
|
|
69
|
+
pkg = JSON.parse(text);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw new BasepadError(`${file} is not valid JSON: ${error.message}`);
|
|
73
|
+
}
|
|
74
|
+
const has = (field) => typeof pkg[field] === "object" && pkg[field] !== null && PACKAGE in pkg[field];
|
|
75
|
+
if (has("dependencies") || has("devDependencies"))
|
|
76
|
+
return false;
|
|
77
|
+
pkg.devDependencies = {
|
|
78
|
+
...pkg.devDependencies,
|
|
79
|
+
[PACKAGE]: `^${await ownVersion()}`,
|
|
80
|
+
};
|
|
81
|
+
const indent = /^[ \t]+/m.exec(text)?.[0] ?? "\t";
|
|
82
|
+
await writeFile(file, `${JSON.stringify(pkg, null, indent)}\n`);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
async function ownVersion() {
|
|
86
|
+
const text = await readFile(new URL("../package.json", import.meta.url), "utf8");
|
|
87
|
+
return JSON.parse(text).version;
|
|
88
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/** A failure the user can act on. The CLI prints its message without a stack. */
|
|
3
|
+
export class BasepadError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/** Runs a program to completion and captures its output. Never throws for a non-zero exit. */
|
|
6
|
+
export function exec(file, args, options = {}) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn(file, args, {
|
|
9
|
+
cwd: options.cwd,
|
|
10
|
+
env: options.env ?? process.env,
|
|
11
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
12
|
+
});
|
|
13
|
+
child.stdin?.end(options.input);
|
|
14
|
+
let stdout = "";
|
|
15
|
+
let stderr = "";
|
|
16
|
+
child.stdout?.setEncoding("utf8").on("data", (chunk) => {
|
|
17
|
+
stdout += chunk;
|
|
18
|
+
});
|
|
19
|
+
child.stderr?.setEncoding("utf8").on("data", (chunk) => {
|
|
20
|
+
stderr += chunk;
|
|
21
|
+
});
|
|
22
|
+
child.on("error", (error) => {
|
|
23
|
+
if (error.code === "ENOENT")
|
|
24
|
+
reject(new BasepadError(`basepad needs \`${file}\` on PATH.`));
|
|
25
|
+
else
|
|
26
|
+
reject(error);
|
|
27
|
+
});
|
|
28
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Runs a program and returns its stdout, or throws with what it printed. */
|
|
32
|
+
export async function must(file, args, options = {}) {
|
|
33
|
+
const out = await exec(file, args, options);
|
|
34
|
+
if (out.code !== 0) {
|
|
35
|
+
const detail = (out.stderr || out.stdout).trim();
|
|
36
|
+
throw new BasepadError(`\`${[file, ...args].join(" ")}\` failed (exit ${out.code}):\n${detail}`);
|
|
37
|
+
}
|
|
38
|
+
return out.stdout;
|
|
39
|
+
}
|
|
40
|
+
/** Quotes one word for a POSIX shell. */
|
|
41
|
+
export function shellQuote(word) {
|
|
42
|
+
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Runs a program on the user's own terminal, so it can ask them things (`sudo`'s password, a
|
|
46
|
+
* confirmation). Returns its exit code; never throws for a non-zero exit.
|
|
47
|
+
*/
|
|
48
|
+
export function interactive(file, args, options = {}) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const child = spawn(file, args, {
|
|
51
|
+
cwd: options.cwd,
|
|
52
|
+
env: options.env ?? process.env,
|
|
53
|
+
stdio: "inherit",
|
|
54
|
+
});
|
|
55
|
+
child.on("error", reject);
|
|
56
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
57
|
+
});
|
|
58
|
+
}
|