@ree-auz/nook 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/src/forge.mjs ADDED
@@ -0,0 +1,75 @@
1
+ import spawn from "cross-spawn";
2
+ import { which } from "./agents.mjs";
3
+
4
+ const FAILED = new Set(["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
5
+ const PENDING = new Set(["PENDING", "EXPECTED", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED"]);
6
+
7
+ /** One word for a PR's checks: passed, failed, running or no-ci. */
8
+ export function ciState(checks = []) {
9
+ if (!checks.length) return "no-ci";
10
+ let running = false;
11
+ for (const check of checks) {
12
+ const result = check.conclusion || check.state || "";
13
+ if (FAILED.has(result)) return "failed";
14
+ if ((check.status && check.status !== "COMPLETED") || PENDING.has(result)) running = true;
15
+ }
16
+ return running ? "running" : "passed";
17
+ }
18
+
19
+ /** Newest PR per head branch, from `gh pr list --json` output (newest first). */
20
+ export function parsePrList(text) {
21
+ const prs = new Map();
22
+ for (const pr of JSON.parse(text)) {
23
+ if (prs.has(pr.headRefName)) continue;
24
+ prs.set(pr.headRefName, {
25
+ number: pr.number,
26
+ state: pr.state.toLowerCase(),
27
+ head: pr.headRefOid,
28
+ url: pr.url,
29
+ ci: pr.state === "OPEN" ? ciState(pr.statusCheckRollup) : null,
30
+ });
31
+ }
32
+ return prs;
33
+ }
34
+
35
+ function explain(output) {
36
+ if (/auth login|not logged|authentication/i.test(output)) return "gh is not logged in (run gh auth login)";
37
+ if (/none of the git remotes|not a git repository|no git remotes/i.test(output)) return null;
38
+ return `gh failed: ${output.split(/\r?\n/)[0] || "no output"}`;
39
+ }
40
+
41
+ /**
42
+ * PRs of a repository from GitHub, keyed by branch, through the gh CLI.
43
+ * Resolves to { prs } or { error } (null error: the repository is not on GitHub).
44
+ */
45
+ export function githubPrs(repoPath, env = process.env) {
46
+ const gh = which("gh", env);
47
+ if (!gh) return Promise.resolve({ error: "gh is not installed" });
48
+ const fields = "number,state,headRefName,headRefOid,url,statusCheckRollup";
49
+ return new Promise((done) => {
50
+ const child = spawn(gh, ["pr", "list", "--state", "all", "--limit", "200", "--json", fields], {
51
+ cwd: repoPath,
52
+ env: { ...env, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" },
53
+ windowsHide: true,
54
+ });
55
+ let out = "";
56
+ let err = "";
57
+ const timer = setTimeout(() => child.kill(), 20000);
58
+ child.stdout.on("data", (chunk) => (out += chunk));
59
+ child.stderr.on("data", (chunk) => (err += chunk));
60
+ child.on("error", (error) => {
61
+ clearTimeout(timer);
62
+ done({ error: `gh failed: ${error.message}` });
63
+ });
64
+ child.on("close", (code, signal) => {
65
+ clearTimeout(timer);
66
+ if (signal) return done({ error: "gh timed out" });
67
+ if (code !== 0) return done({ error: explain(err.trim()) });
68
+ try {
69
+ done({ prs: parsePrList(out) });
70
+ } catch {
71
+ done({ error: "gh returned output nk could not read" });
72
+ }
73
+ });
74
+ });
75
+ }
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Writes workspace hooks from a CI plan (see ci.mjs). Each hook is one
3
+ * self-contained .mjs file, so it runs the same on Windows, macOS and Linux
4
+ * and its whole content is what nk asks you to allow.
5
+ */
6
+
7
+ export const GENERATED_MARK = "// Generated by nk hooks write";
8
+
9
+ const RUNTIME = String.raw`
10
+ import { spawnSync } from "node:child_process";
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
12
+ import { basename, delimiter, dirname, isAbsolute, join } from "node:path";
13
+ import { parseEnv } from "node:util";
14
+
15
+ // Only nk runs this, inside a workspace; never in the main checkout.
16
+ if (!process.env.NOOK_PATH || !process.env.NOOK_WORKSPACE) {
17
+ console.error("Run by nk only (nk -b, nk <name>, nk rm, nk hooks test)");
18
+ process.exit(1);
19
+ }
20
+ const ws = process.env.NOOK_PATH;
21
+ const firstPort = Number(process.env.NOOK_PORT);
22
+ const slug = (text) => String(text).toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
23
+ const prefix = slug("nook-" + basename(process.env.NOOK_REPO ?? "repo") + "-" + process.env.NOOK_WORKSPACE);
24
+
25
+ /**
26
+ * Each CI service port, then each Compose port variable, gets one of this
27
+ * workspace's ten ports in order: NOOK_PORT, NOOK_PORT + 1, ...
28
+ */
29
+ function portMap() {
30
+ const map = {};
31
+ let next = firstPort;
32
+ const take = (fallback) => (Number.isInteger(next) && next < firstPort + 10 ? next++ : fallback);
33
+ for (const service of plan.services) for (const port of service.ports) map["ci:" + service.name + ":" + port.host] = take(null);
34
+ for (const [name, fallback] of Object.entries(plan.compose?.ports ?? {})) map[name] = take(fallback);
35
+ for (const [service, entries] of Object.entries(plan.compose?.remap ?? {})) for (const entry of entries) map[service + ":" + entry.host] = take(null);
36
+ return map;
37
+ }
38
+
39
+ /**
40
+ * Host ports this workspace moved → its own: CI's 5432, a Compose file's fixed
41
+ * 5432:5432 or \${DB_PORT:-5432}, and the services' own ports (32768:3306 moves 3306 too). An address that still points at the old one
42
+ * would reach the main checkout's service, maybe the real database.
43
+ */
44
+ function movedPorts(ports) {
45
+ const moved = {};
46
+ const add = (from, to) => {
47
+ if (to && String(from) !== String(to)) moved[from] ??= to;
48
+ };
49
+ for (const service of plan.services) for (const port of service.ports) add(port.host, ports["ci:" + service.name + ":" + port.host]);
50
+ for (const [service, entries] of Object.entries(plan.compose?.remap ?? {})) for (const entry of entries) add(entry.host, ports[service + ":" + entry.host]);
51
+ for (const [name, fallback] of Object.entries(plan.compose?.ports ?? {})) if (/^\d+$/.test(fallback)) add(fallback, ports[name]);
52
+ // A service's own port too, when no host port is already 3306: CI publishes mariadb's 3306 on 32768, but
53
+ // .env.example says DB_PORT=3306, the port a database on this machine usually takes (invoiceninja).
54
+ for (const service of plan.services) for (const port of service.ports) add(port.container, ports["ci:" + service.name + ":" + port.host]);
55
+ for (const [service, entries] of Object.entries(plan.compose?.remap ?? {})) for (const entry of entries) add(String(entry.target).split("/")[0], ports[service + ":" + entry.host]);
56
+ return moved;
57
+ }
58
+
59
+ /**
60
+ * The Compose files for this workspace: the repository's, plus one that moves
61
+ * fixed host ports (5432:5432) to this workspace's ports and gives fixed
62
+ * container names (container_name: plane-redis) a workspace prefix, and takes
63
+ * services off the host network. That one lives in
64
+ * the worktree's git folder, so it is not a change in the workspace.
65
+ */
66
+ function composeFiles(ports) {
67
+ const files = plan.compose.files.map((file) => join(ws, file));
68
+ const remap = plan.compose.remap;
69
+ const named = plan.compose.named ?? [];
70
+ const ownNetwork = plan.compose.ownNetwork ?? [];
71
+ const fixed = plan.compose.fixed ?? { networks: [], volumes: [] };
72
+ if (!Object.keys(remap).length && !named.length && !ownNetwork.length && !fixed.networks.length && !fixed.volumes.length) return files;
73
+ const lines = ["# Written by nk: this workspace's own host ports, container names, networks and volumes."];
74
+ const services = [...new Set([...Object.keys(remap), ...named, ...ownNetwork])];
75
+ if (services.length) lines.push("services:");
76
+ for (const service of services) {
77
+ lines.push(" " + JSON.stringify(service) + ":");
78
+ if (named.includes(service)) lines.push(" container_name: " + JSON.stringify(prefix + "-" + service));
79
+ if (ownNetwork.includes(service)) lines.push(" network_mode: !reset null");
80
+ if (!remap[service]) continue;
81
+ lines.push(" ports: !override");
82
+ for (const entry of remap[service]) {
83
+ const port = ports[service + ":" + entry.host];
84
+ if (!port) {
85
+ console.error("No free port left for " + service + " " + entry.host + ": a workspace has ten");
86
+ process.exit(1);
87
+ }
88
+ lines.push(" - " + JSON.stringify("127.0.0.1:" + port + ":" + entry.target));
89
+ }
90
+ }
91
+ // A network or volume with a fixed name gets this workspace's own, as Compose would name it.
92
+ for (const kind of ["networks", "volumes"]) {
93
+ if (!fixed[kind].length) continue;
94
+ lines.push(kind + ":");
95
+ for (const key of fixed[kind]) lines.push(" " + JSON.stringify(key) + ":", " name: " + JSON.stringify(prefix + "_" + key));
96
+ }
97
+ const git = spawnSync("git", ["rev-parse", "--absolute-git-dir"], { cwd: ws, encoding: "utf8" });
98
+ const file = join(git.status === 0 ? git.stdout.trim() : ws, "nook-compose.yaml");
99
+ writeFileSync(file, lines.join("\n") + "\n");
100
+ return [...files, file];
101
+ }
102
+
103
+ /** docker compose for this workspace's own project, never the main checkout's. */
104
+ function compose(args, { check = true } = {}) {
105
+ const env = { ...process.env, COMPOSE_PROJECT_NAME: prefix, COMPOSE_FILE: composeFiles(portMap()).join(delimiter) };
106
+ const result = spawnSync("docker", ["compose", "--project-name", prefix, ...args], { cwd: ws, env, stdio: "inherit" });
107
+ if (check && result.status !== 0) {
108
+ console.error("Failed (exit " + result.status + "): docker compose " + args.join(" "));
109
+ process.exit(result.status || 1);
110
+ }
111
+ }
112
+
113
+ function startCompose() {
114
+ if (!plan.compose?.start.length) return;
115
+ const env = existsSync(join(ws, ".env")) ? parseEnv(readFileSync(join(ws, ".env"), "utf8")) : {};
116
+ const missing = plan.compose.required.filter((key) => !env[key] && !process.env[key]);
117
+ if (missing.length) {
118
+ console.error(plan.compose.file + " needs " + missing.join(", ") + " in .env. Set it once in the shared .env (nk copies it into every workspace), then create the workspace again.");
119
+ process.exit(1);
120
+ }
121
+ // A service that mounts a repository file not there yet (made by composer install and the like) would get a
122
+ // root-owned folder in its place: leave it for later.
123
+ const waiting = plan.compose.start.filter((name) => (plan.compose.mounts?.[name] ?? []).some((path) => !existsSync(join(ws, path))));
124
+ for (const name of waiting) {
125
+ const path = plan.compose.mounts[name].find((file) => !existsSync(join(ws, file)));
126
+ console.warn(name + " not started: it mounts " + path + ", which is not in the workspace yet (an install makes it). Start it afterwards with docker compose up -d " + name);
127
+ }
128
+ const start = plan.compose.start.filter((name) => !waiting.includes(name));
129
+ if (start.length) compose(["up", "--detach", "--wait", "--quiet-pull", ...start]);
130
+ }
131
+
132
+ /** localhost:5432 in a value becomes localhost:<this workspace's port>; so does a bare 5432 in a *_PORT key. */
133
+ function withPorts(value, ports, key = "") {
134
+ const next = String(value).replace(/\b(localhost|127\.0\.0\.1):(\d+)\b/g, (all, host, port) => (ports[port] ? host + ":" + ports[port] : all));
135
+ const bare = /^(["']?)(\d+)\1$/.exec(next.trim());
136
+ return bare && /PORT$/i.test(key) && ports[bare[2]] ? next.replace(bare[2], String(ports[bare[2]])) : next;
137
+ }
138
+
139
+ // Shell built-ins (sh and cmd.exe) are not programs on PATH.
140
+ const BUILTINS = new Set(["cd", "echo", "export", "set", "unset", "source", ".", "eval", "exec", "printf", "test", "[", "true", "false", "if", "for", "while", "case", "call", "rem", "copy", "del", "md", "mkdir", "type", "exit"]);
141
+ const HINTS = { corepack: "Node 25 and later no longer include it; install it with npm install -g corepack" };
142
+
143
+ /** The program a one-line command starts, when this machine does not have it. */
144
+ function missingProgram(command) {
145
+ const word = command.trim().split(/\s+/)[0];
146
+ if (!word || /[\/\\=$%"'&|<>()]/.test(word) || BUILTINS.has(word.toLowerCase())) return null;
147
+ const found = process.platform === "win32" ? spawnSync("where", [word], { stdio: "ignore" }) : spawnSync("sh", ["-c", "command -v " + word], { stdio: "ignore" });
148
+ return found.status === 0 ? null : word;
149
+ }
150
+
151
+ function sh(command, { cwd = ws, env = {}, check = true } = {}) {
152
+ // Multi-line CI scripts are written for bash; on Windows, Git for Windows provides it.
153
+ const shell = process.platform === "win32" && command.includes("\n") ? "bash" : true;
154
+ // A missing program fails with the shell's message, which on Windows is in the system code page; say it plainly instead.
155
+ const missing = check && !command.includes("\n") && missingProgram(command);
156
+ if (missing) {
157
+ console.error(missing + " is not installed or not on PATH" + (HINTS[missing] ? "; " + HINTS[missing] : "") + ". Needed by: " + command);
158
+ process.exit(127);
159
+ }
160
+ // Any docker compose a step runs gets this workspace's own project, never the main checkout's.
161
+ const result = spawnSync(command, { cwd, env: { ...process.env, COMPOSE_PROJECT_NAME: prefix, ...env }, stdio: "inherit", shell });
162
+ if (check && result.status !== 0) {
163
+ console.error("Failed (exit " + result.status + "): " + command.split("\n")[0]);
164
+ process.exit(result.status || 1);
165
+ }
166
+ return result.status;
167
+ }
168
+
169
+ function docker(args, { quiet = false } = {}) {
170
+ const result = spawnSync("docker", args, { encoding: "utf8", stdio: quiet ? "pipe" : ["ignore", "pipe", "inherit"] });
171
+ return { ok: result.status === 0, out: (result.stdout ?? "").trim() };
172
+ }
173
+
174
+ function waitHealthy(name) {
175
+ for (let i = 0; i < 120; i++) {
176
+ const state = docker(["inspect", "-f", "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}", name], { quiet: true }).out;
177
+ if (state === "healthy" || state === "running") return;
178
+ if (state === "unhealthy" || state === "exited") break;
179
+ spawnSync(process.execPath, ["-e", "setTimeout(() => {}, 1000)"]);
180
+ }
181
+ console.error("Service " + name + " did not become ready");
182
+ process.exit(1);
183
+ }
184
+
185
+ function startServices({ ports, reuse }) {
186
+ for (const service of plan.services) {
187
+ const name = prefix + "-" + slug(service.name);
188
+ if (reuse && docker(["container", "inspect", name], { quiet: true }).ok) {
189
+ docker(["start", name]);
190
+ } else {
191
+ const args = ["run", "-d", "--name", name, "--label", "dev.nook.workspace=" + ws];
192
+ for (const port of service.ports) {
193
+ const own = ports["ci:" + service.name + ":" + port.host];
194
+ if (!own) {
195
+ console.error("No free port left for " + service.name + " " + port.host + ": a workspace has ten");
196
+ process.exit(1);
197
+ }
198
+ args.push("-p", "127.0.0.1:" + own + ":" + port.container);
199
+ }
200
+ for (const [key, value] of Object.entries(service.env)) args.push("-e", key + "=" + value);
201
+ if (Array.isArray(service.options)) args.push(...service.options);
202
+ else if (service.options) args.push(...service.options.split(" "));
203
+ args.push(service.image);
204
+ if (!docker(args).ok) process.exit(1);
205
+ }
206
+ waitHealthy(name);
207
+ console.log(service.name + ": " + service.ports.map((port) => "localhost:" + ports["ci:" + service.name + ":" + port.host]).join(", "));
208
+ }
209
+ }
210
+ `;
211
+
212
+ const sources = (plan) => [plan.job && `${plan.source} (job ${plan.job})`, plan.compose?.file].filter(Boolean).join(" and ");
213
+
214
+ function header(plan, event, what) {
215
+ return `${GENERATED_MARK} from ${sources(plan)}.
216
+ // ${what}
217
+ // Edit freely. Running nk hooks write again replaces this file only while the first line is unchanged.
218
+ `;
219
+ }
220
+
221
+ const planText = (plan) =>
222
+ `const plan = ${JSON.stringify({ tools: plan.tools, env: plan.env, services: plan.services, compose: plan.compose && { file: plan.compose.file, files: plan.compose.files, start: plan.compose.start, ports: plan.compose.ports, remap: plan.compose.remap, named: plan.compose.named ?? [], ownNetwork: plan.compose.ownNetwork ?? [], fixed: plan.compose.fixed ?? { networks: [], volumes: [] }, required: plan.compose.required, mounts: plan.compose.mounts ?? {} }, create: plan.create, remove: plan.remove }, null, 2)};\n`;
223
+
224
+ export function createHook(plan) {
225
+ return `${header(plan, "create", "create: checks the tools CI installs, sets this workspace's ports in .env, starts its services, then runs the setup steps.")}${RUNTIME}
226
+ ${planText(plan)}
227
+ const ports = portMap();
228
+
229
+ for (const tool of plan.tools) {
230
+ // On Windows a shell finds pnpm.cmd and the like; it takes one command line, not arguments.
231
+ // go takes "version"; the others take --version.
232
+ const flag = tool.name === "go" ? "version" : "--version";
233
+ const result = process.platform === "win32" ? spawnSync(tool.name + " " + flag, { encoding: "utf8", shell: true }) : spawnSync(tool.name, [flag], { encoding: "utf8" });
234
+ if (result.status !== 0) {
235
+ if (!result.error && missingProgram(tool.name) === null) {
236
+ // The last line says why; the first is often progress, such as corepack downloading.
237
+ console.error(tool.name + " " + flag + " failed: " + (result.stderr || result.stdout).trim().split("\\n").pop());
238
+ process.exit(1);
239
+ }
240
+ console.error(tool.name + " is not installed" + (tool.version ? " (CI uses " + tool.version + ")" : "") + (HINTS[tool.name] ? "; " + HINTS[tool.name] : ""));
241
+ process.exit(1);
242
+ }
243
+ const found = (result.stdout || result.stderr).trim().split("\\n")[0];
244
+ if (tool.version && !found.includes(String(tool.version))) console.warn(tool.name + ": CI uses " + tool.version + ", this machine has " + found);
245
+ }
246
+
247
+ startServices({ ports, reuse: false });
248
+
249
+ // .env: this workspace's Compose project and ports replace whatever the copied file says; CI's values fill gaps.
250
+ const envFile = join(ws, ".env");
251
+ const assignment = (key) => new RegExp("^\\\\s*(export\\\\s+)?" + key + "\\\\s*=");
252
+ const own = plan.compose
253
+ ? {
254
+ COMPOSE_PROJECT_NAME: prefix,
255
+ // So a docker compose you run in the workspace uses its ports too.
256
+ ...((Object.keys(plan.compose.remap).length || plan.compose.named?.length || plan.compose.ownNetwork?.length || plan.compose.fixed?.networks.length || plan.compose.fixed?.volumes.length) && { COMPOSE_FILE: composeFiles(ports).join(delimiter) }),
257
+ ...Object.fromEntries(Object.keys(plan.compose.ports).map((key) => [key, ports[key]])),
258
+ }
259
+ : {};
260
+ const moved = movedPorts(ports);
261
+ const repointed = [];
262
+ const repoint = (line) => {
263
+ const match = /^(\\s*(?:export\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*)(.*)$/.exec(line);
264
+ if (!match) return line;
265
+ const [, head, key, value] = match;
266
+ // localhost:5432 and 127.0.0.1:5432 in a URL; a bare 5432 only in a *_PORT key.
267
+ const next = withPorts(value, moved, key);
268
+ if (next === value) return line;
269
+ repointed.push(key);
270
+ return head + next;
271
+ };
272
+ // CI's values for its own services (the containers nk starts) carry those services' users and passwords:
273
+ // they replace what a template says. Its other values only fill gaps.
274
+ const ciPorts = new Set(plan.services.flatMap((service) => service.ports.map((port) => String(port.host))));
275
+ const fromCi = Object.keys(plan.env).filter((key) => [...plan.env[key].matchAll(/\\b(?:localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0):(\\d+)\\b/g)].some((match) => ciPorts.has(match[1])));
276
+ const original = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
277
+ const replaced = fromCi.filter((key) => original.split(/\\r?\\n/).some((line) => assignment(key).test(line)));
278
+ if (replaced.length) console.log("Set " + replaced.join(", ") + " in .env from CI, to match the services nk starts for this workspace");
279
+ let text = original
280
+ .split(/\\r?\\n/)
281
+ .filter((line) => ![...Object.keys(own), ...fromCi].some((key) => assignment(key).test(line)))
282
+ .map(repoint)
283
+ .join("\\n")
284
+ .trimEnd();
285
+ if (repointed.length) console.log("Pointed .env at this workspace's ports: " + repointed.splice(0).join(", "));
286
+ const missing = Object.entries(plan.env).filter(([key]) => !text.split("\\n").some((line) => assignment(key).test(line)));
287
+ if (Object.keys(own).length) text += (text ? "\\n\\n" : "") + "# This workspace's Compose project and ports (nk hooks write)\\n" + Object.entries(own).map(([key, value]) => key + "=" + value).join("\\n");
288
+ if (missing.length) text += (text ? "\\n\\n" : "") + "# From CI (nk hooks write)\\n" + missing.map(([key, value]) => key + "=" + withPorts(value, moved, key)).join("\\n");
289
+ // A file the repository commits now holds this workspace's ports: keep that change out of git status and commits.
290
+ // A new file the repository does not ignore goes in git's local exclude list (never committed).
291
+ const hideFromGit = (name) => {
292
+ if (spawnSync("git", ["ls-files", "--error-unmatch", name], { cwd: ws, stdio: "ignore" }).status !== 0) {
293
+ if (spawnSync("git", ["check-ignore", "-q", name], { cwd: ws, stdio: "ignore" }).status === 0) return;
294
+ const exclude = spawnSync("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: ws, encoding: "utf8" }).stdout.trim();
295
+ if (!exclude) return;
296
+ const path = isAbsolute(exclude) ? exclude : join(ws, exclude);
297
+ mkdirSync(dirname(path), { recursive: true });
298
+ const current = existsSync(path) ? readFileSync(path, "utf8") : "";
299
+ writeFileSync(path, current + (current && !current.endsWith("\\n") ? "\\n" : "") + "/" + name + "\\n");
300
+ return;
301
+ }
302
+ spawnSync("git", ["update-index", "--skip-worktree", name], { cwd: ws, stdio: "ignore" });
303
+ console.log(name + " is committed in this repository; git now ignores this workspace's changes to it (git update-index --no-skip-worktree " + name + " to undo)");
304
+ };
305
+ if (text) {
306
+ writeFileSync(envFile, text + "\\n");
307
+ hideFromGit(".env");
308
+ }
309
+ // Files such as .env.development or .env.test override .env in many projects: point them at this workspace's ports too.
310
+ // Templates (.env.example, .env.sample) are left as they are.
311
+ for (const name of readdirSync(ws).filter((file) => /^\.env\..+/.test(file) && !/example|sample|template|dist/i.test(file)).sort()) {
312
+ const file = join(ws, name);
313
+ if (!statSync(file).isFile()) continue;
314
+ const before = readFileSync(file, "utf8");
315
+ const after = before.split(/(\\r?\\n)/).map((part) => (/^\\r?\\n$/.test(part) ? part : repoint(part))).join("");
316
+ if (after === before) continue;
317
+ writeFileSync(file, after);
318
+ console.log("Pointed " + name + " at this workspace's ports: " + repointed.splice(0).join(", "));
319
+ hideFromGit(name);
320
+ }
321
+ startCompose();
322
+
323
+ const env = Object.fromEntries(Object.entries(plan.env).map(([key, value]) => [key, withPorts(value, moved, key)]));
324
+ for (const step of plan.create) {
325
+ console.log("> " + step.name);
326
+ const stepEnv = Object.fromEntries(Object.entries(step.env ?? {}).map(([key, value]) => [key, withPorts(value, moved, key)]));
327
+ sh(step.run, { cwd: step.cwd ? join(ws, step.cwd) : ws, env: { ...env, ...stepEnv } });
328
+ }
329
+ `;
330
+ }
331
+
332
+ export function openHook(plan) {
333
+ return `${header(plan, "open", "open: starts this workspace's services again; safe to run any number of times.")}${RUNTIME}
334
+ ${planText(plan)}
335
+ startServices({ ports: portMap(), reuse: true });
336
+ startCompose();
337
+ `;
338
+ }
339
+
340
+ export function removeHook(plan) {
341
+ return `${header(plan, "remove", "remove: runs the cleanup steps, then deletes this workspace's service containers, Compose project and their volumes.")}${RUNTIME}
342
+ ${planText(plan)}
343
+ for (const step of plan.remove) {
344
+ console.log("> " + step.name);
345
+ const moved = movedPorts(portMap());
346
+ const env = Object.fromEntries(Object.entries({ ...plan.env, ...step.env }).map(([key, value]) => [key, withPorts(value, moved, key)]));
347
+ sh(step.run, { cwd: step.cwd ? join(ws, step.cwd) : ws, env, check: false });
348
+ }
349
+ for (const service of plan.services) {
350
+ const name = prefix + "-" + slug(service.name);
351
+ if (docker(["container", "inspect", name], { quiet: true }).ok) docker(["rm", "-f", "-v", name]);
352
+ }
353
+ // Everything this workspace's Compose project made: containers, networks, volumes.
354
+ if (plan.compose) compose(["down", "--volumes", "--remove-orphans"], { check: false });
355
+ `;
356
+ }
357
+
358
+ /** The hook files a plan needs: open only with services, remove only with something to clean. */
359
+ export { sources };
360
+
361
+ export function hookFiles(plan) {
362
+ const files = { "create.mjs": createHook(plan) };
363
+ const composed = plan.compose?.start.length > 0;
364
+ if (plan.services.length || composed) files["open.mjs"] = openHook(plan);
365
+ if (plan.services.length || plan.remove.length || plan.compose) files["remove.mjs"] = removeHook(plan);
366
+ return files;
367
+ }
package/src/git.mjs ADDED
@@ -0,0 +1,86 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+ import { NookError } from "./errors.mjs";
4
+
5
+ export function gitTry(args, { cwd, input } = {}) {
6
+ const result = spawnSync("git", args, {
7
+ cwd,
8
+ input,
9
+ encoding: "utf8",
10
+ maxBuffer: 256 * 1024 * 1024,
11
+ windowsHide: true,
12
+ });
13
+ if (result.error) throw new NookError(`Cannot run git: ${result.error.message}`);
14
+ return { ok: result.status === 0, status: result.status, out: result.stdout.trimEnd(), err: result.stderr.trim() };
15
+ }
16
+
17
+ export function git(args, options) {
18
+ const result = gitTry(args, options);
19
+ if (!result.ok) throw new NookError(`git ${args.join(" ")} failed: ${result.err || `exit ${result.status}`}`);
20
+ return result.out;
21
+ }
22
+
23
+ /** Parses `git worktree list --porcelain`. The first entry is the main worktree. */
24
+ export function listWorktrees(repo) {
25
+ const out = git(["worktree", "list", "--porcelain"], { cwd: repo });
26
+ const items = [];
27
+ let current = null;
28
+ for (const line of out.split(/\r?\n/)) {
29
+ if (line.startsWith("worktree ")) {
30
+ current = { path: resolve(line.slice(9)), branch: null, head: null, bare: false, locked: false };
31
+ items.push(current);
32
+ } else if (current && line.startsWith("HEAD ")) current.head = line.slice(5);
33
+ else if (current && line.startsWith("branch ")) current.branch = line.slice(7).replace(/^refs\/heads\//, "");
34
+ else if (current && line === "bare") current.bare = true;
35
+ else if (current && (line === "locked" || line.startsWith("locked "))) current.locked = true;
36
+ }
37
+ return items;
38
+ }
39
+
40
+ /** The main worktree of the repository that contains `cwd`. */
41
+ export function mainWorktree(cwd) {
42
+ const inside = gitTry(["rev-parse", "--is-inside-work-tree"], { cwd });
43
+ if (!inside.ok || inside.out !== "true") throw new NookError("Not inside a git repository");
44
+ const [main] = listWorktrees(cwd);
45
+ if (!main || main.bare) throw new NookError("Bare repositories are not supported");
46
+ return main.path;
47
+ }
48
+
49
+ export function remoteUrl(repo, remote = "origin") {
50
+ const result = gitTry(["remote", "get-url", remote], { cwd: repo });
51
+ return result.ok ? result.out : "";
52
+ }
53
+
54
+ export function refExists(repo, ref) {
55
+ return gitTry(["show-ref", "--verify", "--quiet", ref], { cwd: repo }).ok;
56
+ }
57
+
58
+ export function defaultBranch(repo) {
59
+ const head = gitTry(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repo });
60
+ if (head.ok && head.out) return head.out.replace(/^origin\//, "");
61
+ const current = gitTry(["symbolic-ref", "--short", "HEAD"], { cwd: repo });
62
+ if (current.ok && current.out) return current.out;
63
+ return "main";
64
+ }
65
+
66
+ /** The ref new workspaces start from: origin/<default> when it exists, else <default>. */
67
+ export function baseRef(repo, branch) {
68
+ return refExists(repo, `refs/remotes/origin/${branch}`) ? `origin/${branch}` : branch;
69
+ }
70
+
71
+ export function statusLines(path) {
72
+ const out = git(["status", "--porcelain", "--untracked-files=all"], { cwd: path });
73
+ return out ? out.split(/\r?\n/) : [];
74
+ }
75
+
76
+ /** Which of `paths` (relative to cwd) git ignores. */
77
+ export function ignoredPaths(cwd, paths) {
78
+ if (!paths.length) return new Set();
79
+ const result = gitTry(["check-ignore", "-z", "--stdin"], { cwd, input: paths.join("\0") + "\0" });
80
+ if (!result.ok && result.status !== 1) throw new NookError(`git check-ignore failed: ${result.err}`);
81
+ return new Set(result.out.split("\0").filter(Boolean));
82
+ }
83
+
84
+ export function isTracked(cwd, path) {
85
+ return gitTry(["ls-files", "--error-unmatch", "--", path], { cwd }).ok;
86
+ }
package/src/home.mjs ADDED
@@ -0,0 +1,48 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, relative, resolve, isAbsolute } from "node:path";
4
+
5
+ /** Nook keeps everything in one folder. Only NOOK_HOME can move it. */
6
+ export function nookHome(env = process.env) {
7
+ return resolve(env.NOOK_HOME || join(homedir(), "nook"));
8
+ }
9
+
10
+ export function homePaths(home) {
11
+ return {
12
+ home,
13
+ config: join(home, "config.toml"),
14
+ workspaces: join(home, "workspace"),
15
+ shared: join(home, "shared"),
16
+ backup: join(home, "backup"),
17
+ logs: join(home, "logs"),
18
+ };
19
+ }
20
+
21
+ /** Resolves symlinks (macOS /var -> /private/var) when the path exists. */
22
+ export function realPath(path) {
23
+ const full = resolve(path);
24
+ try {
25
+ return realpathSync.native(full);
26
+ } catch {
27
+ return full;
28
+ }
29
+ }
30
+
31
+ function comparable(path, platform) {
32
+ const full = realPath(path);
33
+ return platform === "win32" ? full.toLowerCase() : full;
34
+ }
35
+
36
+ export function samePath(a, b, platform = process.platform) {
37
+ return comparable(a, platform) === comparable(b, platform);
38
+ }
39
+
40
+ /** True when `child` is `parent` or anywhere below it. */
41
+ export function isInside(child, parent, platform = process.platform) {
42
+ const rel = relative(comparable(parent, platform), comparable(child, platform));
43
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
44
+ }
45
+
46
+ export function exists(path) {
47
+ return existsSync(path);
48
+ }