@astralarium/repo-dev 0.0.0-stage → 0.1.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 +17 -2
- package/dist/allocation.d.ts +15 -0
- package/dist/allocation.js +28 -0
- package/dist/bin/db-image.d.ts +2 -0
- package/dist/bin/db-image.js +29 -0
- package/dist/bin/run-dev.d.ts +2 -0
- package/dist/bin/run-dev.js +15 -0
- package/dist/bin/siblings-inputs.d.ts +2 -0
- package/dist/bin/siblings-inputs.js +20 -0
- package/dist/config.d.ts +240 -0
- package/dist/config.js +90 -0
- package/dist/environment.d.ts +19 -0
- package/dist/environment.js +45 -0
- package/dist/exec.d.ts +4 -0
- package/dist/exec.js +14 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/launcher/hosts.d.ts +26 -0
- package/dist/launcher/hosts.js +152 -0
- package/dist/launcher/launcher.d.ts +23 -0
- package/dist/launcher/launcher.js +452 -0
- package/dist/launcher/servers.d.ts +39 -0
- package/dist/launcher/servers.js +154 -0
- package/dist/load-config.d.ts +3 -0
- package/dist/load-config.js +6 -0
- package/dist/ports.d.ts +3 -0
- package/dist/ports.js +32 -0
- package/dist/process.d.ts +43 -0
- package/dist/process.js +172 -0
- package/dist/resources.d.ts +23 -0
- package/dist/resources.js +74 -0
- package/dist/sibling-inputs.d.ts +5 -0
- package/dist/sibling-inputs.js +32 -0
- package/dist/source-hash.d.ts +2 -0
- package/dist/source-hash.js +29 -0
- package/dist/testkit/built-worker.d.ts +29 -0
- package/dist/testkit/built-worker.js +48 -0
- package/dist/testkit/env-template.d.ts +8 -0
- package/dist/testkit/env-template.js +14 -0
- package/dist/testkit/index.d.ts +8 -0
- package/dist/testkit/index.js +8 -0
- package/dist/testkit/ports.d.ts +8 -0
- package/dist/testkit/ports.js +20 -0
- package/dist/testkit/temp.d.ts +2 -0
- package/dist/testkit/temp.js +10 -0
- package/dist/testkit/test-cache.d.ts +14 -0
- package/dist/testkit/test-cache.js +174 -0
- package/dist/testkit/worker-build.d.ts +26 -0
- package/dist/testkit/worker-build.js +46 -0
- package/dist/vite.d.ts +96 -0
- package/dist/vite.js +254 -0
- package/dist/wait.d.ts +9 -0
- package/dist/wait.js +38 -0
- package/package.json +71 -3
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-checkout hostnames, derived from where the checkout lives: browsers
|
|
3
|
+
* scope cookies by host, so checkouts on one machine keep separate
|
|
4
|
+
* sessions. Extra checkouts are git worktrees or temp-dir copies.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { realpathSync } from "node:fs";
|
|
9
|
+
import { networkInterfaces, tmpdir } from "node:os";
|
|
10
|
+
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
11
|
+
import { ExpectedError } from "@astralarium/repo-telemetry";
|
|
12
|
+
import { output } from "../exec.js";
|
|
13
|
+
import { runningSession } from "./servers.js";
|
|
14
|
+
/** `live` session advertising the LAN hostname. */
|
|
15
|
+
const MDNS_SESSION = "mdns";
|
|
16
|
+
/** Longest DNS label. */
|
|
17
|
+
const LABEL_LENGTH = 63;
|
|
18
|
+
/** `name` as a DNS label: lowercase, runs outside `[a-z0-9-]` as `-`,
|
|
19
|
+
* `-<suffix>` appended, at most 63 characters with `suffix` intact;
|
|
20
|
+
* empty when nothing of `name` survives. */
|
|
21
|
+
export function dnsLabel(name, suffix) {
|
|
22
|
+
const room = LABEL_LENGTH - (suffix === undefined ? 0 : suffix.length + 1);
|
|
23
|
+
const label = name
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
26
|
+
.replace(/^-+/, "")
|
|
27
|
+
.slice(0, room)
|
|
28
|
+
.replace(/-+$/, "");
|
|
29
|
+
return label && suffix !== undefined ? `${label}-${suffix}` : label;
|
|
30
|
+
}
|
|
31
|
+
/** Temp directories, real paths: `os.tmpdir()`, macOS's per-user temp
|
|
32
|
+
* directory (independent of `TMPDIR`), `/tmp`, `/var/tmp`. */
|
|
33
|
+
export function tempRoots() {
|
|
34
|
+
const darwin = output("getconf", ["DARWIN_USER_TEMP_DIR"]);
|
|
35
|
+
const roots = [tmpdir(), ...(darwin ? [darwin] : []), "/tmp", "/var/tmp"];
|
|
36
|
+
return [
|
|
37
|
+
...new Set(roots.flatMap((path) => {
|
|
38
|
+
try {
|
|
39
|
+
return [realpathSync(path)];
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
})),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
/** The checkout's hostname label; undefined for a main worktree. */
|
|
48
|
+
function checkoutLabel(root, temps) {
|
|
49
|
+
for (const temp of temps) {
|
|
50
|
+
const inside = relative(temp, root);
|
|
51
|
+
const [first] = inside.split(sep);
|
|
52
|
+
if (!inside || first === ".." || isAbsolute(inside))
|
|
53
|
+
continue;
|
|
54
|
+
// Paths are unique: checkouts nested under one first directory differ in hash.
|
|
55
|
+
return inside === first
|
|
56
|
+
? { name: first }
|
|
57
|
+
: {
|
|
58
|
+
name: first,
|
|
59
|
+
hash: createHash("sha256").update(inside).digest("hex").slice(0, 6),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (output("git", ["--version"]) === null)
|
|
63
|
+
throw new ExpectedError(`git is unavailable; ${root} needs it to name its hostname`);
|
|
64
|
+
const lines = output("git", [
|
|
65
|
+
"-C",
|
|
66
|
+
root,
|
|
67
|
+
"rev-parse",
|
|
68
|
+
"--path-format=absolute",
|
|
69
|
+
"--git-dir",
|
|
70
|
+
"--git-common-dir",
|
|
71
|
+
])?.split("\n");
|
|
72
|
+
if (lines?.length !== 2)
|
|
73
|
+
throw new ExpectedError(`${root} is neither a git worktree nor under a temp directory; check out extra copies with git worktree add`);
|
|
74
|
+
const [gitDir, commonDir] = lines;
|
|
75
|
+
// A linked worktree's git dir, `.git/worktrees/<name>`, is unique per repository.
|
|
76
|
+
return gitDir === commonDir ? undefined : { name: basename(gitDir) };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The checkout's hostname stem: `<domain>` for a repository's main
|
|
80
|
+
* worktree, `<name>.<domain>` for a linked worktree (git's worktree name)
|
|
81
|
+
* or a checkout under a temp directory (its first directory below it,
|
|
82
|
+
* plus a hash of its path when deeper: `copy-1-ab12cd`). The temp-directory
|
|
83
|
+
* rule wins over git's worktree name.
|
|
84
|
+
*/
|
|
85
|
+
export function checkoutStem(app, temps = tempRoots()) {
|
|
86
|
+
const checkout = checkoutLabel(realpathSync(app.root), temps);
|
|
87
|
+
if (checkout === undefined)
|
|
88
|
+
return app.domain;
|
|
89
|
+
const label = dnsLabel(checkout.name, checkout.hash);
|
|
90
|
+
if (!label)
|
|
91
|
+
throw new ExpectedError(`checkout name ${checkout.name} yields no hostname label`);
|
|
92
|
+
return `${label}.${app.domain}`;
|
|
93
|
+
}
|
|
94
|
+
/** `DEV_LAN`: `1` serves the LAN as well, unset serves loopback only. */
|
|
95
|
+
function lanEnabled(overrides) {
|
|
96
|
+
const value = overrides.DEV_LAN;
|
|
97
|
+
if (value !== undefined && value !== "1")
|
|
98
|
+
throw new ExpectedError("DEV_LAN must be 1 or unset");
|
|
99
|
+
return value === "1";
|
|
100
|
+
}
|
|
101
|
+
/** Hostnames and certificate paths of hostname stem `stem`. */
|
|
102
|
+
export function localHosts(local, overrides, stem) {
|
|
103
|
+
const lan = lanEnabled(overrides);
|
|
104
|
+
const hostname = `${stem}.localhost`;
|
|
105
|
+
const lanHostname = `${stem}.local`;
|
|
106
|
+
const certDir = resolve(local, "certs");
|
|
107
|
+
return {
|
|
108
|
+
hostname,
|
|
109
|
+
lanHostname,
|
|
110
|
+
hostnames: lan ? [hostname, lanHostname] : [hostname],
|
|
111
|
+
lan,
|
|
112
|
+
sslKey: resolve(certDir, `${hostname}-key.pem`),
|
|
113
|
+
sslCert: resolve(certDir, `${hostname}.pem`),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** IPv4 address `DEV_LAN` advertises: the default route interface's.
|
|
117
|
+
* Throws `ExpectedError` without `dns-sd` or a default route. */
|
|
118
|
+
export function lanAddress() {
|
|
119
|
+
if (output("which", ["dns-sd"]) === null)
|
|
120
|
+
throw new ExpectedError("DEV_LAN needs dns-sd (macOS Bonjour)");
|
|
121
|
+
const device = output("route", ["-n", "get", "default"])?.match(/interface: (\S+)/)?.[1];
|
|
122
|
+
const address = (device === undefined ? [] : networkInterfaces()[device])?.find(({ family, internal }) => family === "IPv4" && !internal)?.address;
|
|
123
|
+
if (address === undefined)
|
|
124
|
+
throw new ExpectedError("DEV_LAN needs a default route with an IPv4 address");
|
|
125
|
+
return address;
|
|
126
|
+
}
|
|
127
|
+
/** Stops the checkout's mDNS advertisement; a missing session is fine. */
|
|
128
|
+
export function stopAdvertising(root) {
|
|
129
|
+
const session = runningSession(MDNS_SESSION, root);
|
|
130
|
+
if (session)
|
|
131
|
+
execFileSync("live", ["stop", session.id], { stdio: "inherit" });
|
|
132
|
+
}
|
|
133
|
+
/** Advertises `hosts.lanHostname` → `address` over mDNS (`dns-sd`)
|
|
134
|
+
* under `live`, replacing a running advertisement. */
|
|
135
|
+
export function advertiseHost(root, hosts, address, port) {
|
|
136
|
+
stopAdvertising(root);
|
|
137
|
+
execFileSync("live", [
|
|
138
|
+
"run",
|
|
139
|
+
"-dn",
|
|
140
|
+
MDNS_SESSION,
|
|
141
|
+
"-C",
|
|
142
|
+
root,
|
|
143
|
+
"dns-sd",
|
|
144
|
+
"-P",
|
|
145
|
+
hosts.lanHostname,
|
|
146
|
+
"_https._tcp",
|
|
147
|
+
"local",
|
|
148
|
+
String(port),
|
|
149
|
+
hosts.lanHostname,
|
|
150
|
+
address,
|
|
151
|
+
], { stdio: "ignore" });
|
|
152
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Allocation } from "../allocation.ts";
|
|
2
|
+
import { type DevConfig, type Overrides } from "../config.ts";
|
|
3
|
+
/** `keys` from the shell over `envFile`; empty values count as unset. */
|
|
4
|
+
export declare function readOverrides(envFile: string, keys: readonly string[], shell?: Record<string, string | undefined>): Overrides;
|
|
5
|
+
/** Keys the launcher reads for `app`: its own plus `DEV_LAN`. */
|
|
6
|
+
export declare function overrideKeys(app: DevConfig): string[];
|
|
7
|
+
/** `<local>/auth-secret`, generated on first use. */
|
|
8
|
+
export declare function authSecret(local: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Keeps the saved allocation where it still holds: the database through
|
|
11
|
+
* its provider, server ports a running session of this checkout started
|
|
12
|
+
* on (per its state) or saved ports still free. Anything else moves to the
|
|
13
|
+
* next free port. The checkout root labels the database instance.
|
|
14
|
+
*/
|
|
15
|
+
export declare function allocate(app: Pick<DevConfig, "root" | "database" | "servers">, local: string, base: string): Promise<Allocation>;
|
|
16
|
+
/**
|
|
17
|
+
* Dev server environments for one allocation on hostname stem `stem`
|
|
18
|
+
* (`checkoutStem`), keyed by server name: the app's `environment()` over
|
|
19
|
+
* each server's listener settings.
|
|
20
|
+
*/
|
|
21
|
+
export declare function devEnvironment(app: DevConfig, local: string, overrides: Overrides, allocation: Allocation, stem: string): Record<string, Record<string, string>>;
|
|
22
|
+
/** See `usage`. */
|
|
23
|
+
export declare function main(app: DevConfig, argv: string[]): Promise<void>;
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local dev launcher: generated state under `<root>/.local/`, the app's
|
|
3
|
+
* database, then its dev servers under `live` with the dev config as
|
|
4
|
+
* process env. Idempotent; `reset` is the only command that deletes data.
|
|
5
|
+
* Each checkout claims its own database instance and ports, so worktrees
|
|
6
|
+
* and other projects run side by side.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { createInterface } from "node:readline/promises";
|
|
13
|
+
import { parseArgs, parseEnv } from "node:util";
|
|
14
|
+
import { ExpectedError, log } from "@astralarium/repo-telemetry";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { localDir, readAllocation } from "../allocation.js";
|
|
17
|
+
import { ENV_KEY, envDbName, RESOURCE_NAME, } from "../config.js";
|
|
18
|
+
import { listenerEnv } from "../environment.js";
|
|
19
|
+
import { output } from "../exec.js";
|
|
20
|
+
import { keepOrFindPort } from "../ports.js";
|
|
21
|
+
import { setupResources } from "../resources.js";
|
|
22
|
+
import { advertiseHost, checkoutStem, lanAddress, localHosts, stopAdvertising, } from "./hosts.js";
|
|
23
|
+
import { httpReady, runningSession, sessionPorts, startDevServer, stopSession, } from "./servers.js";
|
|
24
|
+
/** Commands `main` accepts for every app. */
|
|
25
|
+
const COMMANDS = ["start", "stop", "certs"];
|
|
26
|
+
/** Commands running a disposable database instance until it ends. */
|
|
27
|
+
const DISPOSABLE_COMMANDS = ["temp-db", "upgrade-db", "run-snapshot"];
|
|
28
|
+
/** Commands `main` accepts for an app with a database. */
|
|
29
|
+
const DATABASE_COMMANDS = ["build", "reset", ...DISPOSABLE_COMMANDS];
|
|
30
|
+
/** Commands taking `--db-name`. */
|
|
31
|
+
const DB_NAME_COMMANDS = ["start", "stop", ...DATABASE_COMMANDS];
|
|
32
|
+
/** Commands taking server arguments after `--`. */
|
|
33
|
+
const SERVER_ARGS_COMMANDS = ["temp-db", "run-snapshot"];
|
|
34
|
+
/** Usage for `app`: database commands and options only when it has one. */
|
|
35
|
+
function usage(app) {
|
|
36
|
+
const database = app.database !== undefined;
|
|
37
|
+
return [
|
|
38
|
+
`Usage: ${app.program} [command]${database ? " [-y] [--db-name <name>]" : ""}`,
|
|
39
|
+
"",
|
|
40
|
+
` start ${database ? "database and " : ""}dev servers (default); makes missing certificates`,
|
|
41
|
+
` stop dev servers${database ? " and database" : ""}`,
|
|
42
|
+
...(database
|
|
43
|
+
? [" build refresh base images and rebuild database images"]
|
|
44
|
+
: []),
|
|
45
|
+
" certs install the local CA and regenerate HTTPS certificates",
|
|
46
|
+
...(database
|
|
47
|
+
? [
|
|
48
|
+
` reset stop, delete the local database (${app.data}), rebuild`,
|
|
49
|
+
" temp-db [-- <server args>]",
|
|
50
|
+
" disposable database on the current schema, until Ctrl-C",
|
|
51
|
+
" upgrade-db [migrate.sql...]",
|
|
52
|
+
" dump of the PG* database, migrated onto the current schema, to stdout",
|
|
53
|
+
" run-snapshot [--no-scrub] <dump.sql> [-- <server args>]",
|
|
54
|
+
" disposable database loaded from a dump, scrubbed unless --no-scrub",
|
|
55
|
+
" -y, --yes skip the reset confirmation",
|
|
56
|
+
` --db-name database image name and instance base (${DB_NAME_COMMANDS.join(", ")})`,
|
|
57
|
+
]
|
|
58
|
+
: []),
|
|
59
|
+
" -h, --help",
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
62
|
+
/** `keys` from the shell over `envFile`; empty values count as unset. */
|
|
63
|
+
export function readOverrides(envFile, keys, shell = process.env) {
|
|
64
|
+
const file = existsSync(envFile)
|
|
65
|
+
? parseEnv(readFileSync(envFile, "utf8"))
|
|
66
|
+
: {};
|
|
67
|
+
return Object.fromEntries(keys.flatMap((key) => {
|
|
68
|
+
const value = [shell[key], file[key]].find(Boolean);
|
|
69
|
+
return value ? [[key, value]] : [];
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
/** Keys the launcher reads for `app`: its own plus `DEV_LAN`. */
|
|
73
|
+
export function overrideKeys(app) {
|
|
74
|
+
return [...new Set([...app.overrideKeys, "DEV_LAN"])];
|
|
75
|
+
}
|
|
76
|
+
/** `<local>/auth-secret`, generated on first use. */
|
|
77
|
+
export function authSecret(local) {
|
|
78
|
+
const path = resolve(local, "auth-secret");
|
|
79
|
+
if (!existsSync(path)) {
|
|
80
|
+
mkdirSync(local, { recursive: true });
|
|
81
|
+
writeFileSync(path, randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
82
|
+
}
|
|
83
|
+
return readFileSync(path, "utf8").trim();
|
|
84
|
+
}
|
|
85
|
+
/** Live session state of the launched servers: `<local>/servers/`. */
|
|
86
|
+
function serverStateDir(local) {
|
|
87
|
+
return resolve(local, "servers");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Keeps the saved allocation where it still holds: the database through
|
|
91
|
+
* its provider, server ports a running session of this checkout started
|
|
92
|
+
* on (per its state) or saved ports still free. Anything else moves to the
|
|
93
|
+
* next free port. The checkout root labels the database instance.
|
|
94
|
+
*/
|
|
95
|
+
export async function allocate(app, local, base) {
|
|
96
|
+
const saved = readAllocation(local);
|
|
97
|
+
const database = app.database
|
|
98
|
+
? await app.database.allocate({
|
|
99
|
+
base,
|
|
100
|
+
owner: app.root,
|
|
101
|
+
saved: saved.database ?? {},
|
|
102
|
+
})
|
|
103
|
+
: undefined;
|
|
104
|
+
const ports = {};
|
|
105
|
+
const inspectorPorts = {};
|
|
106
|
+
// Ports this allocation already holds: a free-port search skips them.
|
|
107
|
+
const taken = new Set(database ? [database.port] : []);
|
|
108
|
+
const keep = async (started, kept, preferred) => {
|
|
109
|
+
const port = started ?? (await keepOrFindPort(kept, preferred, taken));
|
|
110
|
+
taken.add(port);
|
|
111
|
+
return port;
|
|
112
|
+
};
|
|
113
|
+
for (const server of app.servers) {
|
|
114
|
+
const session = runningSession(server.name, resolve(app.root, server.dir));
|
|
115
|
+
const started = session && sessionPorts(serverStateDir(local), server.name, session.id);
|
|
116
|
+
ports[server.name] = await keep(started?.port, saved.ports?.[server.name], server.port);
|
|
117
|
+
if (server.inspectorPort !== undefined)
|
|
118
|
+
inspectorPorts[server.name] = await keep(started?.inspectorPort, saved.inspectorPorts?.[server.name], server.inspectorPort);
|
|
119
|
+
}
|
|
120
|
+
const allocation = { ports, inspectorPorts };
|
|
121
|
+
if (database)
|
|
122
|
+
allocation.database = database;
|
|
123
|
+
mkdirSync(local, { recursive: true });
|
|
124
|
+
writeFileSync(resolve(local, "dev.json"), JSON.stringify(allocation));
|
|
125
|
+
return allocation;
|
|
126
|
+
}
|
|
127
|
+
/** Origin of `server` on `port` at `hostname`. */
|
|
128
|
+
function serverOrigin(server, port, hostname = "localhost") {
|
|
129
|
+
return `${server.https ? "https" : "http"}://${hostname}:${port}`;
|
|
130
|
+
}
|
|
131
|
+
/** Allocated servers by name; `host` servers on the checkout
|
|
132
|
+
* hostnames. */
|
|
133
|
+
function serverAllocations(app, allocation, hosts) {
|
|
134
|
+
return Object.fromEntries(app.servers.map((server) => {
|
|
135
|
+
const port = allocation.ports[server.name];
|
|
136
|
+
const inspectorPort = allocation.inspectorPorts[server.name];
|
|
137
|
+
const origins = server.host
|
|
138
|
+
? hosts.hostnames.map((host) => serverOrigin(server, port, host))
|
|
139
|
+
: [serverOrigin(server, port)];
|
|
140
|
+
return [
|
|
141
|
+
server.name,
|
|
142
|
+
{
|
|
143
|
+
port,
|
|
144
|
+
origin: serverOrigin(server, port, server.host ? hosts.hostname : undefined),
|
|
145
|
+
origins,
|
|
146
|
+
...(inspectorPort !== undefined && { inspectorPort }),
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
/** What `environment()` returns: string values under environment names. */
|
|
152
|
+
const serverEnvironments = z.record(z.string(), z.record(z.string().regex(ENV_KEY), z.string()));
|
|
153
|
+
/**
|
|
154
|
+
* Dev server environments for one allocation on hostname stem `stem`
|
|
155
|
+
* (`checkoutStem`), keyed by server name: the app's `environment()` over
|
|
156
|
+
* each server's listener settings.
|
|
157
|
+
*/
|
|
158
|
+
export function devEnvironment(app, local, overrides, allocation, stem) {
|
|
159
|
+
const hosts = localHosts(local, overrides, stem);
|
|
160
|
+
const result = serverEnvironments.safeParse(app.environment({
|
|
161
|
+
localDir: local,
|
|
162
|
+
overrides,
|
|
163
|
+
hosts,
|
|
164
|
+
authSecret: authSecret(local),
|
|
165
|
+
database: app.database && allocation.database
|
|
166
|
+
? {
|
|
167
|
+
...allocation.database,
|
|
168
|
+
connectionString: app.database.connectionString(allocation.database),
|
|
169
|
+
}
|
|
170
|
+
: undefined,
|
|
171
|
+
servers: serverAllocations(app, allocation, hosts),
|
|
172
|
+
}));
|
|
173
|
+
if (!result.success)
|
|
174
|
+
throw new ExpectedError(`environment() must return string values under environment names per server:\n${z.prettifyError(result.error)}`);
|
|
175
|
+
const env = result.data;
|
|
176
|
+
return Object.fromEntries(app.servers.map((server) => {
|
|
177
|
+
const own = env[server.name];
|
|
178
|
+
if (!own)
|
|
179
|
+
throw new ExpectedError(`environment() returned no env for ${server.name}`);
|
|
180
|
+
return [
|
|
181
|
+
server.name,
|
|
182
|
+
{
|
|
183
|
+
...listenerEnv(hosts, server, allocation.inspectorPorts[server.name]),
|
|
184
|
+
...own,
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
/** Installs the mkcert CA and generates the checkout's certificate
|
|
190
|
+
* (`localhost` and both checkout hostnames); an existing one stays
|
|
191
|
+
* unless `force`. */
|
|
192
|
+
function installCertificates({ hostname, lanHostname, sslKey, sslCert }, force = false) {
|
|
193
|
+
execFileSync("mkcert", ["-install"], { stdio: "inherit" });
|
|
194
|
+
if (!force && existsSync(sslKey) && existsSync(sslCert))
|
|
195
|
+
return;
|
|
196
|
+
mkdirSync(resolve(sslKey, ".."), { recursive: true });
|
|
197
|
+
execFileSync("mkcert", [
|
|
198
|
+
"-key-file",
|
|
199
|
+
sslKey,
|
|
200
|
+
"-cert-file",
|
|
201
|
+
sslCert,
|
|
202
|
+
"localhost",
|
|
203
|
+
hostname,
|
|
204
|
+
lanHostname,
|
|
205
|
+
], { stdio: "inherit" });
|
|
206
|
+
}
|
|
207
|
+
/** Starts every dev server concurrently and waits for HTTP readiness. */
|
|
208
|
+
async function startServers(app, resources, local, env, allocation) {
|
|
209
|
+
const stateDir = serverStateDir(local);
|
|
210
|
+
const lockfile = resolve(app.root, "pnpm-lock.yaml");
|
|
211
|
+
const ca = app.servers.some((server) => server.https)
|
|
212
|
+
? readFileSync(resolve(execFileSync("mkcert", ["-CAROOT"], { encoding: "utf8" }).trim(), "rootCA.pem"))
|
|
213
|
+
: undefined;
|
|
214
|
+
await Promise.all(app.servers.map((server) => {
|
|
215
|
+
const port = allocation.ports[server.name];
|
|
216
|
+
const inspectorPort = allocation.inspectorPorts[server.name];
|
|
217
|
+
const origin = serverOrigin(server, port);
|
|
218
|
+
const own = env[server.name];
|
|
219
|
+
return startDevServer(resources, {
|
|
220
|
+
name: server.name,
|
|
221
|
+
appDir: resolve(app.root, server.dir),
|
|
222
|
+
stateDir,
|
|
223
|
+
port,
|
|
224
|
+
...(inspectorPort !== undefined && { inspectorPort }),
|
|
225
|
+
env: own,
|
|
226
|
+
inputs: [lockfile, own.SSL_KEY, own.SSL_CERT].filter((file) => file !== undefined && file !== ""),
|
|
227
|
+
ca: server.https ? ca : undefined,
|
|
228
|
+
probe: async (agent) => (await Promise.all(server.ready.map(({ path, status, body }) => httpReady(`${origin}${path}`, agent, {
|
|
229
|
+
status,
|
|
230
|
+
body,
|
|
231
|
+
signal: resources.signal,
|
|
232
|
+
})))).every(Boolean),
|
|
233
|
+
});
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
/** Stops every `live` session, the mDNS advertisement included, and waits
|
|
237
|
+
* for its ports to close: those its state records, else the saved
|
|
238
|
+
* allocation's; missing sessions are fine. */
|
|
239
|
+
async function stopServers(app, local) {
|
|
240
|
+
const saved = readAllocation(local);
|
|
241
|
+
await Promise.all([
|
|
242
|
+
Promise.resolve().then(() => {
|
|
243
|
+
stopAdvertising(app.root);
|
|
244
|
+
}),
|
|
245
|
+
...app.servers.map(async ({ name, dir }) => {
|
|
246
|
+
const session = runningSession(name, resolve(app.root, dir));
|
|
247
|
+
if (session)
|
|
248
|
+
await stopSession(name, session.id, sessionPorts(serverStateDir(local), name, session.id) ?? {
|
|
249
|
+
port: saved.ports?.[name],
|
|
250
|
+
inspectorPort: saved.inspectorPorts?.[name],
|
|
251
|
+
});
|
|
252
|
+
}),
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
/** Interactive `[y/N]`; a non-interactive session must pass `--yes`. */
|
|
256
|
+
async function confirmReset(data) {
|
|
257
|
+
if (!process.stdin.isTTY) {
|
|
258
|
+
throw new ExpectedError(`reset deletes the local database (${data}); pass --yes to confirm non-interactively`);
|
|
259
|
+
}
|
|
260
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
261
|
+
try {
|
|
262
|
+
const answer = await rl.question(`Delete the local database (${data})? [y/N] `);
|
|
263
|
+
if (!/^y(es)?$/i.test(answer.trim()))
|
|
264
|
+
throw new ExpectedError("reset aborted");
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
rl.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Throws an install hint unless `name args` exits 0. */
|
|
271
|
+
function requireTool(name, args) {
|
|
272
|
+
if (output(name, args) === null)
|
|
273
|
+
throw new ExpectedError(`${name} is unavailable; install it (see @astralarium/repo-dev README)`);
|
|
274
|
+
}
|
|
275
|
+
/** The launcher's command line; throws on an unknown option. Positionals
|
|
276
|
+
* after `--` are server arguments. */
|
|
277
|
+
function parseLauncherArgs(argv) {
|
|
278
|
+
const { values, tokens } = parseArgs({
|
|
279
|
+
args: argv,
|
|
280
|
+
allowPositionals: true,
|
|
281
|
+
tokens: true,
|
|
282
|
+
options: {
|
|
283
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
284
|
+
help: { type: "boolean", short: "h", default: false },
|
|
285
|
+
"db-name": { type: "string" },
|
|
286
|
+
"no-scrub": { type: "boolean" },
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const terminator = tokens.find((token) => token.kind === "option-terminator");
|
|
290
|
+
return {
|
|
291
|
+
values,
|
|
292
|
+
positionals: tokens.flatMap((token) => token.kind === "positional" &&
|
|
293
|
+
(terminator === undefined || token.index < terminator.index)
|
|
294
|
+
? [token.value]
|
|
295
|
+
: []),
|
|
296
|
+
serverArgs: terminator ? argv.slice(terminator.index + 1) : [],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/** `operands` and `serverArgs` fit `mode`: a dump for `run-snapshot`, migrate
|
|
300
|
+
* files for `upgrade-db`, none otherwise. */
|
|
301
|
+
function argumentsFit(mode, operands, serverArgs) {
|
|
302
|
+
return ((mode === "upgrade-db" ||
|
|
303
|
+
operands.length === (mode === "run-snapshot" ? 1 : 0)) &&
|
|
304
|
+
(serverArgs.length === 0 || SERVER_ARGS_COMMANDS.includes(mode)));
|
|
305
|
+
}
|
|
306
|
+
/** Runs `command` until it settles; SIGINT and SIGTERM abort its signal. */
|
|
307
|
+
async function untilInterrupted(command) {
|
|
308
|
+
const controller = new AbortController();
|
|
309
|
+
const stop = () => {
|
|
310
|
+
controller.abort();
|
|
311
|
+
};
|
|
312
|
+
process.on("SIGINT", stop);
|
|
313
|
+
process.on("SIGTERM", stop);
|
|
314
|
+
try {
|
|
315
|
+
await command(controller.signal);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
process.off("SIGINT", stop);
|
|
319
|
+
process.off("SIGTERM", stop);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** See `usage`. */
|
|
323
|
+
export async function main(app, argv) {
|
|
324
|
+
let parsed;
|
|
325
|
+
try {
|
|
326
|
+
parsed = parseLauncherArgs(argv);
|
|
327
|
+
}
|
|
328
|
+
catch (cause) {
|
|
329
|
+
throw new ExpectedError(usage(app), { cause });
|
|
330
|
+
}
|
|
331
|
+
const { values, positionals, serverArgs } = parsed;
|
|
332
|
+
if (values.help) {
|
|
333
|
+
console.log(usage(app));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const [mode = "start", ...operands] = positionals;
|
|
337
|
+
const commands = app.database
|
|
338
|
+
? [...COMMANDS, ...DATABASE_COMMANDS]
|
|
339
|
+
: COMMANDS;
|
|
340
|
+
const databaseName = values["db-name"] ?? (app.database ? envDbName(app.database) : "");
|
|
341
|
+
if (!commands.includes(mode) ||
|
|
342
|
+
!argumentsFit(mode, operands, serverArgs) ||
|
|
343
|
+
(app.database === undefined &&
|
|
344
|
+
(values.yes || values["db-name"] !== undefined)) ||
|
|
345
|
+
(app.database !== undefined && !RESOURCE_NAME.test(databaseName)))
|
|
346
|
+
throw new ExpectedError(usage(app));
|
|
347
|
+
if (values["db-name"] !== undefined && !DB_NAME_COMMANDS.includes(mode))
|
|
348
|
+
throw new ExpectedError(`--db-name applies to ${DB_NAME_COMMANDS.join(", ")}`);
|
|
349
|
+
if (values["no-scrub"] !== undefined && mode !== "run-snapshot")
|
|
350
|
+
throw new ExpectedError("--no-scrub applies to run-snapshot");
|
|
351
|
+
const serves = mode === "start" || mode === "reset";
|
|
352
|
+
if (serves)
|
|
353
|
+
requireTool("live", ["--version"]);
|
|
354
|
+
if (mode === "certs" ||
|
|
355
|
+
(serves && app.servers.some((server) => server.https)))
|
|
356
|
+
requireTool("mkcert", ["-version"]);
|
|
357
|
+
const local = localDir(app.root);
|
|
358
|
+
if (mode === "reset" && !values.yes)
|
|
359
|
+
await confirmReset(app.data);
|
|
360
|
+
if (mode === "stop") {
|
|
361
|
+
try {
|
|
362
|
+
requireTool("live", ["--version"]);
|
|
363
|
+
await stopServers(app, local);
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
app.database?.stop(values["db-name"] ??
|
|
367
|
+
readAllocation(local).database?.name ??
|
|
368
|
+
databaseName, app.root);
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (mode === "build") {
|
|
373
|
+
app.database?.build(databaseName);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const database = app.database;
|
|
377
|
+
if (database && DISPOSABLE_COMMANDS.includes(mode)) {
|
|
378
|
+
// pnpm runs scripts and bins in the package directory; INIT_CWD holds
|
|
379
|
+
// the developer's.
|
|
380
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars -- no turbo task runs this CLI
|
|
381
|
+
const cwd = process.env.INIT_CWD ?? process.cwd();
|
|
382
|
+
const files = operands.map((file) => resolve(cwd, file));
|
|
383
|
+
const allocation = await database.allocate({
|
|
384
|
+
base: databaseName,
|
|
385
|
+
owner: app.root,
|
|
386
|
+
saved: readAllocation(local).database ?? {},
|
|
387
|
+
});
|
|
388
|
+
await untilInterrupted((signal) => mode === "upgrade-db"
|
|
389
|
+
? database.upgradeDump({
|
|
390
|
+
allocation,
|
|
391
|
+
image: databaseName,
|
|
392
|
+
migrations: files,
|
|
393
|
+
signal,
|
|
394
|
+
})
|
|
395
|
+
: database.runDisposable({
|
|
396
|
+
allocation,
|
|
397
|
+
image: databaseName,
|
|
398
|
+
serverArgs,
|
|
399
|
+
...(mode === "run-snapshot" && {
|
|
400
|
+
snapshot: { dump: files[0], scrub: !values["no-scrub"] },
|
|
401
|
+
}),
|
|
402
|
+
signal,
|
|
403
|
+
}));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const overrides = readOverrides(resolve(app.root, ".env"), overrideKeys(app));
|
|
407
|
+
const stem = checkoutStem(app);
|
|
408
|
+
const hosts = localHosts(local, overrides, stem);
|
|
409
|
+
if (mode === "certs") {
|
|
410
|
+
installCertificates(hosts, true);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (app.servers.some((server) => server.https) &&
|
|
414
|
+
!(existsSync(hosts.sslKey) && existsSync(hosts.sslCert)))
|
|
415
|
+
installCertificates(hosts);
|
|
416
|
+
const address = hosts.lan ? lanAddress() : undefined;
|
|
417
|
+
if (mode === "reset")
|
|
418
|
+
await stopServers(app, local);
|
|
419
|
+
const allocation = await allocate(app, local, databaseName);
|
|
420
|
+
const env = devEnvironment(app, local, overrides, allocation, stem);
|
|
421
|
+
const servers = serverAllocations(app, allocation, hosts);
|
|
422
|
+
await setupResources(async (resources) => {
|
|
423
|
+
if (app.database && allocation.database) {
|
|
424
|
+
if (mode === "reset")
|
|
425
|
+
app.database.reset(allocation.database.name, databaseName);
|
|
426
|
+
await app.database.ensure(resources, {
|
|
427
|
+
allocation: allocation.database,
|
|
428
|
+
image: databaseName,
|
|
429
|
+
owner: app.root,
|
|
430
|
+
program: app.program,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
await startServers(app, resources, local, env, allocation);
|
|
434
|
+
const lanServer = app.servers.find((server) => server.host);
|
|
435
|
+
if (address !== undefined && lanServer)
|
|
436
|
+
advertiseHost(app.root, hosts, address, allocation.ports[lanServer.name]);
|
|
437
|
+
else
|
|
438
|
+
stopAdvertising(app.root);
|
|
439
|
+
resources.release();
|
|
440
|
+
});
|
|
441
|
+
log.info("dev.ready", "{servers}{hasDatabase, select, true { database: {name} on 127.0.0.1:{port}} other {}}", {
|
|
442
|
+
servers: app.servers
|
|
443
|
+
.map(({ name }) => `${name}: ${servers[name].origins.join(",")}`)
|
|
444
|
+
.concat(address === undefined
|
|
445
|
+
? []
|
|
446
|
+
: [`mdns: ${hosts.lanHostname} → ${address}`])
|
|
447
|
+
.join(" "),
|
|
448
|
+
hasDatabase: allocation.database !== undefined,
|
|
449
|
+
name: allocation.database?.name,
|
|
450
|
+
port: allocation.database?.port,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Agent } from "undici";
|
|
2
|
+
import type { ResourceScope } from "../resources.ts";
|
|
3
|
+
/** Ports a dev server listens on. */
|
|
4
|
+
export interface ServerPorts {
|
|
5
|
+
/** HTTP listener. */
|
|
6
|
+
port: number;
|
|
7
|
+
/** Debugger; present when the server declares one. */
|
|
8
|
+
inspectorPort?: number;
|
|
9
|
+
}
|
|
10
|
+
/** Hashes the effective app config and the contents of `files`; stores no
|
|
11
|
+
* credential values. */
|
|
12
|
+
export declare function configFingerprint(env: Record<string, string>, files?: readonly string[]): string;
|
|
13
|
+
/** Running session `name`, scoped to `appDir`, where `live run -C` put it. */
|
|
14
|
+
export declare function runningSession(name: string, appDir: string): {
|
|
15
|
+
id: string;
|
|
16
|
+
} | undefined;
|
|
17
|
+
/** Ports session `id` of `name` started on, per its state in `stateDir`;
|
|
18
|
+
* undefined when the state records another session. */
|
|
19
|
+
export declare function sessionPorts(stateDir: string, name: string, id: string): ServerPorts | undefined;
|
|
20
|
+
/** Stops session `id` of `name`, then waits for each of its ports to close. */
|
|
21
|
+
export declare function stopSession(name: string, id: string, { port, inspectorPort }: Partial<ServerPorts>, signal?: AbortSignal): Promise<void>;
|
|
22
|
+
/** HTTP readiness through the actual listener, including TLS and optional body checks. */
|
|
23
|
+
export declare function httpReady(url: string, agent: Agent, options?: {
|
|
24
|
+
status?: number;
|
|
25
|
+
body?: string;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
}): Promise<boolean>;
|
|
28
|
+
/** Starts or reconciles one dev service; rollback owns only the new session. */
|
|
29
|
+
export declare function startDevServer(resources: ResourceScope, options: ServerPorts & {
|
|
30
|
+
name: string;
|
|
31
|
+
appDir: string;
|
|
32
|
+
stateDir: string;
|
|
33
|
+
env: Record<string, string>;
|
|
34
|
+
/** Files whose contents restart the session on change: the dependency
|
|
35
|
+
* lockfile (stale prebundled deps), TLS certificates. */
|
|
36
|
+
inputs?: readonly string[];
|
|
37
|
+
ca?: Buffer;
|
|
38
|
+
probe: (agent: Agent) => Promise<boolean>;
|
|
39
|
+
}): Promise<void>;
|