@ree-auz/nook 0.1.0 → 0.1.1
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 -21
- package/README.md +422 -410
- package/bin/nk.mjs +3 -3
- package/docs/setup-hooks.md +107 -107
- package/integrations/linear/README.md +84 -84
- package/integrations/linear/coding-tools.json +7 -7
- package/integrations/linear/open-issue.mjs +147 -147
- package/package.json +34 -34
- package/skills/nook/SKILL.md +71 -71
- package/src/agents.mjs +92 -92
- package/src/backup.mjs +74 -74
- package/src/ci.mjs +378 -378
- package/src/cli.mjs +151 -151
- package/src/commands/check-hooks.mjs +128 -128
- package/src/commands/clean.mjs +175 -175
- package/src/commands/doctor.mjs +100 -91
- package/src/commands/forget.mjs +57 -57
- package/src/commands/hooks.mjs +34 -27
- package/src/commands/init.mjs +154 -154
- package/src/commands/ls.mjs +107 -107
- package/src/commands/rm.mjs +190 -190
- package/src/commands/workspace.mjs +163 -163
- package/src/commands/write-hooks.mjs +185 -185
- package/src/compose-plan.mjs +228 -228
- package/src/compose.mjs +123 -123
- package/src/config.mjs +115 -115
- package/src/context.mjs +189 -189
- package/src/errors.mjs +8 -8
- package/src/forge.mjs +75 -75
- package/src/generate-hooks.mjs +367 -367
- package/src/git.mjs +86 -86
- package/src/home.mjs +48 -48
- package/src/hooks.mjs +135 -135
- package/src/names.mjs +65 -65
- package/src/occupancy-windows.ps1 +59 -59
- package/src/occupancy.mjs +131 -131
- package/src/ports.mjs +89 -45
- package/src/prompt.mjs +100 -100
- package/src/shared.mjs +97 -97
- package/src/status.mjs +30 -30
- package/src/tools.mjs +23 -23
- package/src/ui.mjs +60 -60
package/src/generate-hooks.mjs
CHANGED
|
@@ -1,367 +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
|
-
}
|
|
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
|
+
}
|