@ree-auz/nook 0.0.0-stage → 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/src/ci.mjs ADDED
@@ -0,0 +1,378 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parse } from "yaml";
5
+ import { gitTry } from "./git.mjs";
6
+ import { composeFile, overrideFile } from "./compose-plan.mjs";
7
+
8
+ /**
9
+ * docker run flags from a CI service's options, split as the runner's shell does: quotes keep
10
+ * values whole (--health-cmd "mysqladmin ping") and a backslash escapes the next character
11
+ * (\" inside double quotes). Unbalanced quotes give no options.
12
+ */
13
+ function dockerOptions(text) {
14
+ const out = [];
15
+ let current = "";
16
+ let quote = null;
17
+ let started = false;
18
+ const chars = [...text.trim()];
19
+ for (let i = 0; i < chars.length; i++) {
20
+ const char = chars[i];
21
+ if (char === "\\" && quote !== "'" && i + 1 < chars.length) {
22
+ const next = chars[++i];
23
+ // Inside double quotes only these are escapes; elsewhere the backslash stays.
24
+ if (quote === '"' && !'"\\$`'.includes(next)) current += char;
25
+ current += next;
26
+ started = true;
27
+ } else if (quote) {
28
+ if (char === quote) quote = null;
29
+ else current += char;
30
+ } else if (char === '"' || char === "'") {
31
+ quote = char;
32
+ started = true;
33
+ } else if (/\s/.test(char)) {
34
+ if (started) out.push(current);
35
+ current = "";
36
+ started = false;
37
+ } else {
38
+ current += char;
39
+ started = true;
40
+ }
41
+ }
42
+ if (quote) return [];
43
+ if (started) out.push(current);
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Reads a repository's GitHub Actions workflows and turns the setup part of
49
+ * one job into a plan for workspace hooks, without AI: CI already says how a
50
+ * clean machine gets the project running. What cannot be translated is
51
+ * listed, never guessed.
52
+ */
53
+
54
+ const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
55
+ /** A step that checks the code rather than sets it up: setup ends here. */
56
+ const CHECK_STEP = /\b(test|tests|unittest|pytest|vitest|jest|e2e|lint|eslint|ruff|typecheck|tsc|mypy|format|prettier|check|knip|build|coverage|deploy|publish|release|audit)\b/i;
57
+ /** Actions that only matter on a CI machine. */
58
+ const CI_ONLY = /^(actions\/(checkout|cache|upload-artifact|download-artifact|github-script|labeler|stale)|codecov\/|docker\/(login|setup-buildx|setup-qemu|build-push|metadata)-action)/;
59
+ /** Actions that install a tool: on a developer machine they become a check. */
60
+ const TOOL_ACTIONS = [
61
+ { match: /^actions\/setup-node@/, tool: "node", version: (w) => w["node-version"] },
62
+ { match: /^actions\/setup-python@/, tool: "python", version: (w) => w["python-version"] },
63
+ { match: /^actions\/setup-go@/, tool: "go", version: (w) => w["go-version"] },
64
+ { match: /^actions\/setup-java@/, tool: "java", version: (w) => w["java-version"] },
65
+ { match: /^pnpm\/action-setup@/, tool: "pnpm", version: (w) => w.version },
66
+ { match: /^oven-sh\/setup-bun@/, tool: "bun", version: (w) => w["bun-version"] },
67
+ { match: /^astral-sh\/setup-uv@/, tool: "uv", version: (w) => w.version, also: (w) => (w["python-version"] ? [{ name: "python", version: String(w["python-version"]) }] : []) },
68
+ { match: /^denoland\/setup-deno@/, tool: "deno", version: (w) => w["deno-version"] },
69
+ { match: /^dtolnay\/rust-toolchain@|^actions-rs\/toolchain@/, tool: "cargo" },
70
+ { match: /^ruby\/setup-ruby@/, tool: "ruby", version: (w) => w["ruby-version"] },
71
+ { match: /^erlef\/setup-beam@/, tool: "elixir", version: (w) => w["elixir-version"] },
72
+ { match: /^shivammathur\/setup-php@/, tool: "php", version: (w) => w["php-version"] },
73
+ ];
74
+
75
+ const EXPRESSION = /\$\{\{/;
76
+
77
+ /**
78
+ * CI runs in a throwaway machine, so its steps may assume nothing else is
79
+ * there. On a developer machine the real database may be. Only commands that
80
+ * install dependencies into the workspace folder are copied; any other step
81
+ * (a script, docker, a migration) is listed for a person or agent to review.
82
+ */
83
+ const INSTALL = /^(yarn( install)?(\s+--?[\w-]+(=\S+)?)*$|npm (ci|install|i)|pnpm (install|i|fetch)|bun (install|ci)|corepack enable|uv sync|uv pip install|pip install|python3? -m pip install|poetry install|pipenv (install|sync)|bundle install|go mod download|cargo fetch|composer install|deno (cache|install)|(npx|pnpm exec|yarn) playwright install)(\s+[\w@./:=,+-]+)*$/;
84
+ const SHELL_SYNTAX = /[;|&`$<>()\\]|\n/;
85
+
86
+ export function isInstallCommand(command) {
87
+ const text = String(command).trim();
88
+ return !SHELL_SYNTAX.test(text) && INSTALL.test(text);
89
+ }
90
+
91
+ /** A database or queue address that is not one of CI's services: on this machine it may be a real one. */
92
+ const SERVICE_URL = /\b(postgres(ql)?|mysql|mariadb|mongodb(\+srv)?|redis|rediss|amqp|nats|kafka):\/\//i;
93
+ const LOCAL_PORT = /\b(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)\b/g;
94
+ const label = (step, index) => step.name || step.run?.split("\n")[0] || step.uses || `step ${index + 1}`;
95
+
96
+ export function workflowFiles(repoPath) {
97
+ const listed = gitTry(["ls-files"], { cwd: repoPath });
98
+ return listed.ok ? listed.out.split(/\r?\n/).filter((file) => WORKFLOW.test(file)) : [];
99
+ }
100
+
101
+ /** A fingerprint of the CI files (workflows and local actions) and the Compose files, so nk can tell when they changed after the hooks were tested. */
102
+ export function ciHash(repoPath) {
103
+ const compose = composeFile(repoPath);
104
+ const listed = gitTry(["ls-files", ".github/actions"], { cwd: repoPath });
105
+ const actions = listed.ok ? listed.out.split(/\r?\n/).filter((file) => /\/action\.ya?ml$/.test(file)) : [];
106
+ const files = [...workflowFiles(repoPath), ...actions, ...[compose, overrideFile(repoPath, compose)].filter(Boolean)];
107
+ if (!files.length) return null;
108
+ const hash = createHash("sha256");
109
+ for (const file of files) hash.update(`${file}\n`).update(readFileSync(join(repoPath, file), "utf8").replace(/\r\n/g, "\n"));
110
+ return hash.digest("hex");
111
+ }
112
+
113
+ /** "5433:5432" → { host: 5433, container: 5432 }; "5432" or 5432 → both 5432. */
114
+ function servicePort(spec) {
115
+ const parts = String(spec).split(":");
116
+ const container = Number(parts.at(-1).split("/")[0]);
117
+ const host = parts.length > 1 ? Number(parts.at(-2)) : container;
118
+ return Number.isInteger(container) && Number.isInteger(host) ? { host, container } : null;
119
+ }
120
+
121
+ function plainEnv(env, untranslated, where) {
122
+ const out = {};
123
+ for (const [key, value] of Object.entries(env ?? {})) {
124
+ if (EXPRESSION.test(String(value))) untranslated.push({ step: where, why: `${key} comes from a CI expression (${String(value).trim()})` });
125
+ else out[key] = String(value);
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /** A condition that reads only earlier steps' outputs (cache hits, changed files), not the OS or the event. */
131
+ function onlyStepOutputs(condition) {
132
+ const rest = condition
133
+ .replace(/\$\{\{|\}\}/g, " ")
134
+ .replace(/steps\.[\w-]+\.outputs\.[\w-]+/g, " ")
135
+ .replace(/'[^']*'/g, " ")
136
+ .replace(/==|!=|&&|\|\||!|\(|\)|\btrue\b|\bfalse\b/g, " ");
137
+ return /steps\./.test(condition) && !rest.trim();
138
+ }
139
+
140
+ /** The plan for one job: tools, environment, services, setup commands, cleanup commands. */
141
+ export function planJob(file, jobName, job, workflowEnv = {}, knownPorts = []) {
142
+ const untranslated = [];
143
+ const skipped = [];
144
+ const tools = [];
145
+ const create = [];
146
+ const remove = [];
147
+ // defaults.run.working-directory (job, or workflow) is where every run: step runs unless it names its own.
148
+ const defaultDir = job.defaults?.run?.["working-directory"];
149
+ const where = (step) => step["working-directory"] ?? (EXPRESSION.test(String(defaultDir ?? "")) ? undefined : defaultDir);
150
+ const rawEnv = { ...plainEnv(workflowEnv, untranslated, "workflow env"), ...plainEnv(job.env, untranslated, `job ${jobName} env`) };
151
+
152
+ const services = [];
153
+ for (const [name, service] of Object.entries(job.services ?? {})) {
154
+ if (!service?.image || EXPRESSION.test(service.image)) {
155
+ untranslated.push({ step: `service ${name}`, why: "no plain image name" });
156
+ continue;
157
+ }
158
+ services.push({
159
+ name,
160
+ image: service.image,
161
+ env: plainEnv(service.env, untranslated, `service ${name}`),
162
+ ports: (service.ports ?? []).map(servicePort).filter(Boolean),
163
+ options: service.options && !EXPRESSION.test(service.options) ? dockerOptions(String(service.options)) : [],
164
+ });
165
+ }
166
+
167
+ if (job.container && services.some((service) => !service.ports.length))
168
+ untranslated.push({ step: "job container", why: "CI runs this job inside a container and reaches its services by name; here they need host ports, which the CI file does not list" });
169
+
170
+ // Ports the workspace has its own of: CI's services, and the Compose services nk starts.
171
+ const servicePorts = new Set([...services.flatMap((service) => service.ports.map((port) => port.host)), ...knownPorts.map(Number)]);
172
+ /** Drops values that could reach something outside the workspace's own services. */
173
+ const safeEnv = (values, where) => {
174
+ const kept = {};
175
+ for (const [key, value] of Object.entries(values)) {
176
+ const ports = [...value.matchAll(LOCAL_PORT)].map((match) => Number(match[2]));
177
+ const strayPort = ports.find((port) => !servicePorts.has(port));
178
+ if (strayPort !== undefined)
179
+ untranslated.push({
180
+ step: where,
181
+ // An http address is usually the app itself; anything else could be a real database on this machine.
182
+ why: /^https?:\/\//i.test(value.trim())
183
+ ? `${key} points at local port ${strayPort}, which is not a service nk starts; likely the app's own address, so the shared .env keeps its value`
184
+ : `${key} points at local port ${strayPort}, which is not a service nk starts; on this machine it may be a real database`,
185
+ });
186
+ else if (SERVICE_URL.test(value) && !ports.length)
187
+ untranslated.push({ step: where, why: `${key} is a service address outside the workspace` });
188
+ else kept[key] = value;
189
+ }
190
+ return kept;
191
+ };
192
+ const env = safeEnv(rawEnv, "env");
193
+
194
+ let checking = false;
195
+ let checks = 0;
196
+ let tests = 0;
197
+ (job.steps ?? []).forEach((step, index) => {
198
+ const name = label(step, index);
199
+ const condition = step.if === undefined ? "" : String(step.if);
200
+ const cleanup = /\balways\(\)/.test(condition);
201
+ if (step.uses) {
202
+ const tool = TOOL_ACTIONS.find((entry) => entry.match.test(step.uses));
203
+ if (tool) {
204
+ const version = tool.version?.(step.with ?? {});
205
+ tools.push({ name: tool.tool, ...(version !== undefined && !EXPRESSION.test(String(version)) && { version: String(version) }) });
206
+ for (const extra of tool.also?.(step.with ?? {}) ?? []) tools.push(extra);
207
+ } else if (CI_ONLY.test(step.uses)) skipped.push({ step: name, why: "only needed on a CI machine" });
208
+ else untranslated.push({ step: name, why: `uses the action ${step.uses}` });
209
+ return;
210
+ }
211
+ if (!step.run) return;
212
+ if (/\$\{?GITHUB_(OUTPUT|ENV|PATH|STEP_SUMMARY)\b/.test(step.run)) {
213
+ skipped.push({ step: name, why: "only passes values between CI steps" });
214
+ return;
215
+ }
216
+ const target = cleanup ? remove : create;
217
+ // Flags such as --no-audit say nothing about what a step is for.
218
+ if (!cleanup && (checking || CHECK_STEP.test(`${step.name ?? ""} ${step.run}`.replace(/(^|\s)--?[\w-]+(=\S*)?/g, " ")))) {
219
+ checking = true;
220
+ checks++;
221
+ if (TEST_STEP.test(`${step.name ?? ""} ${step.run}`)) tests++;
222
+ skipped.push({ step: name, why: "checks the code (tests, lint, build); not setup" });
223
+ return;
224
+ }
225
+ if (EXPRESSION.test(step.run) || EXPRESSION.test(JSON.stringify(step.env ?? {}))) {
226
+ untranslated.push({ step: name, why: "uses a CI expression (${{ … }})" });
227
+ return;
228
+ }
229
+ // An install that CI skips on a cache hit or when no files changed: a new workspace has neither.
230
+ if (condition && !cleanup && onlyStepOutputs(condition) && isInstallCommand(step.run)) {
231
+ create.push({ name, run: String(step.run).trim(), ...(where(step) && { cwd: where(step) }) });
232
+ return;
233
+ }
234
+ if (condition && !cleanup) {
235
+ untranslated.push({ step: name, why: `runs only when ${condition}` });
236
+ return;
237
+ }
238
+ if (!isInstallCommand(step.run)) {
239
+ untranslated.push({ step: name, why: "not a dependency install; CI runs it on a throwaway machine, so check what it touches before adding it" });
240
+ return;
241
+ }
242
+ target.push({ name, run: String(step.run).trim(), ...(where(step) && { cwd: where(step) }), ...(step.env && { env: safeEnv(plainEnv(step.env, untranslated, name), name) }) });
243
+ });
244
+
245
+ // A step that runs a project's own package (pnpm exec playwright) needs its dependencies. When CI installs
246
+ // them inside an action nk cannot read (vuetify's vuetifyjs/setup-action), install them first, as a person would.
247
+ const hidden = untranslated.find((entry) => /^uses the action /.test(entry.why));
248
+ const runner = create.map((step) => /^(pnpm|npx|npm|yarn|bunx|bun)\b/.exec(step.run)?.[1]).find(Boolean);
249
+ if (hidden && runner && !create.some((step) => /^(pnpm|npm|yarn|bun)\s+(i|ci|install)\b|^yarn\s*$/.test(step.run))) {
250
+ const install = { pnpm: "pnpm install --frozen-lockfile", npx: "npm ci", npm: "npm ci", yarn: "yarn install", bunx: "bun install", bun: "bun install" }[runner];
251
+ create.unshift({ name: `${install} (CI installs in ${hidden.step})`, run: install, ...(where({}) && { cwd: where({}) }) });
252
+ }
253
+
254
+ // CI machines have corepack; Node 25 and later no longer include it, so check for it before anything starts.
255
+ if (create.some((step) => /^corepack\b/.test(step.run)) && !tools.some((tool) => tool.name === "corepack")) tools.push({ name: "corepack" });
256
+
257
+ return { source: file, job: jobName, tools, env, services, create, remove, skipped, untranslated, checks, tests };
258
+ }
259
+
260
+ /** Jobs that publish, release or maintain rather than set up and test the project. */
261
+ const NOT_SETUP = /release|publish|deploy|bump|\bdocs?\b|nightly|cron|mutation|bench|label|stale|codeql|security|dependabot|backport|sync|cleanup|lighthouse|breaking|changelog|translation|i18n|token|artifact|cache|schema|lint|format|\btypes?\b|audit|triage|preview|licen[sc]e|deprecation|docker|smoke|helm|mobile|\bios\b|android|windows|macos|submit|detect|merge|report|size|openapi|shellcheck|hlint|link|depsreview|dependency|guard|semantic|goreleaser|configuration|validat|devcontainer|image|enterprise|\bwin\b|feature-branch/i;
262
+ /** A check step that runs the tests, which is what a workspace is set up for. */
263
+ const TEST_STEP = /\b(test|tests|unittest|pytest|vitest|jest|e2e|rspec|phpunit|tox|nox)\b/i;
264
+
265
+ /**
266
+ * Steps of a local composite action (uses: ./.github/actions/setup) put in
267
+ * place of the step that uses it, so its installs are read like any other.
268
+ * Its inputs are filled from the step's with: or the input's default.
269
+ */
270
+ function expandLocalActions(repoPath, steps, depth = 0) {
271
+ return steps.flatMap((step) => {
272
+ if (depth > 3 || typeof step?.uses !== "string" || !step.uses.startsWith("./")) return [step];
273
+ const dir = join(repoPath, step.uses);
274
+ let action;
275
+ try {
276
+ const file = ["action.yml", "action.yaml"].map((name) => join(dir, name)).find((path) => existsSync(path));
277
+ action = file && parse(readFileSync(file, "utf8"));
278
+ } catch {
279
+ return [step];
280
+ }
281
+ if (action?.runs?.using !== "composite" || !Array.isArray(action.runs.steps)) return [step];
282
+ const inputs = {};
283
+ for (const [name, input] of Object.entries(action.inputs ?? {})) if (input?.default !== undefined) inputs[name] = String(input.default);
284
+ for (const [name, value] of Object.entries(step.with ?? {})) inputs[name] = String(value);
285
+ const fill = (value) =>
286
+ typeof value === "string"
287
+ ? value.replace(/\$\{\{\s*inputs\.([\w-]+)\s*\}\}/g, (whole, name) => (inputs[name] !== undefined && !EXPRESSION.test(inputs[name]) ? inputs[name] : whole))
288
+ : value && typeof value === "object"
289
+ ? Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, fill(inner)]))
290
+ : value;
291
+ const inner = action.runs.steps.map((child) => {
292
+ const filled = fill(child);
293
+ // A step's condition holds for every step of the action.
294
+ return step.if !== undefined && filled.if === undefined ? { ...filled, if: step.if } : filled;
295
+ });
296
+ return expandLocalActions(repoPath, inner, depth + 1);
297
+ });
298
+ }
299
+
300
+ /** Whether a workflow runs on pull requests or pushes, as the main CI does. */
301
+ function onChanges(on) {
302
+ const events = typeof on === "string" ? [on] : Array.isArray(on) ? on : Object.keys(on ?? {});
303
+ return events.some((event) => event === "pull_request" || event === "push" || event === "pull_request_target" || event === "merge_group" || event === "workflow_call");
304
+ }
305
+
306
+ /** Workflows that only answer repository events (issues, releases, comments) and never touch the code. */
307
+ function onlyRepositoryEvents(on) {
308
+ const events = typeof on === "string" ? [on] : Array.isArray(on) ? on : Object.keys(on ?? {});
309
+ return events.length > 0 && events.every((event) => /^(issues|issue_comment|discussion|discussion_comment|release|label|milestone|project|project_card|project_column|fork|watch|create|delete|status|check_run|check_suite|registry_package|page_build|deployment|deployment_status|gollum|public|pull_request_review|pull_request_review_comment)$/.test(event));
310
+ }
311
+
312
+ /**
313
+ * A job that needs others often starts from what they installed, such as a
314
+ * downloaded artifact; when it downloads one, their installs and tools are its own.
315
+ */
316
+ function planWithNeeds(repoPath, file, workflow, jobName, job, knownPorts) {
317
+ const plan = planJob(file, jobName, { defaults: workflow.defaults, ...job }, workflow.env, knownPorts);
318
+ if (!job.steps.some((step) => /^actions\/download-artifact@/.test(step.uses ?? ""))) return plan;
319
+ const seen = new Set([jobName]);
320
+ const queue = [job.needs ?? []].flat();
321
+ const before = [];
322
+ while (queue.length) {
323
+ const name = queue.shift();
324
+ if (seen.has(name) || !workflow.jobs[name]?.steps) continue;
325
+ seen.add(name);
326
+ const needed = workflow.jobs[name];
327
+ before.unshift(planJob(file, name, { defaults: workflow.defaults, ...needed, steps: expandLocalActions(repoPath, needed.steps) }, workflow.env, knownPorts));
328
+ queue.push(...[needed.needs ?? []].flat());
329
+ }
330
+ for (const other of before.reverse()) {
331
+ const runs = new Set(plan.create.map((step) => step.run));
332
+ plan.create.unshift(...other.create.filter((step) => !runs.has(step.run)));
333
+ const tools = new Set(plan.tools.map((tool) => tool.name));
334
+ plan.tools.push(...other.tools.filter((tool) => !tools.has(tool.name)));
335
+ }
336
+ return plan;
337
+ }
338
+
339
+ /**
340
+ * The job most like "set up and test the project": it runs on pull requests,
341
+ * runs tests, and has the most setup steps and services. knownPorts are the
342
+ * host ports of Compose services nk starts, for when the job has none of its own.
343
+ */
344
+ export function planFromCi(repoPath, knownPorts = []) {
345
+ const files = workflowFiles(repoPath);
346
+ const plans = [];
347
+ const sources = new Map();
348
+ for (const file of files) {
349
+ let workflow;
350
+ try {
351
+ workflow = parse(readFileSync(join(repoPath, file), "utf8"));
352
+ } catch (error) {
353
+ plans.push({ source: file, error: error.message });
354
+ continue;
355
+ }
356
+ for (let [jobName, job] of Object.entries(workflow?.jobs ?? {})) {
357
+ if (!job?.steps) continue;
358
+ job = { ...job, steps: expandLocalActions(repoPath, job.steps) };
359
+ const plan = planWithNeeds(repoPath, file, workflow, jobName, job);
360
+ sources.set(plan, { file, jobName, job, workflow, onChanges: onChanges(workflow.on) });
361
+ plans.push(plan);
362
+ }
363
+ }
364
+ const score = (plan) => {
365
+ const { onChanges: main } = sources.get(plan);
366
+ return plan.create.length + plan.tools.length + 3 * plan.services.length + (plan.checks ? 4 : 0) + (plan.tests ? 3 : 0) + (main ? 3 : -5) - (NOT_SETUP.test(`${plan.source} ${plan.job}`) ? 10 : 0);
367
+ };
368
+ const ranked = plans.filter((plan) => !plan.error).sort((a, b) => score(b) - score(a));
369
+ // A job that scores below zero, or neither runs on code changes nor tests the code, only maintains the repository (issues, releases, licences).
370
+ const useful = (plan) => score(plan) >= 0 && (sources.get(plan).onChanges || (plan.tests > 0 && !onlyRepositoryEvents(sources.get(plan).workflow.on)));
371
+ let best = ranked[0] && useful(ranked[0]) ? ranked[0] : null;
372
+ // Without services of its own, the job's addresses may point at the Compose services nk starts.
373
+ if (best && !best.services.length && knownPorts.length) {
374
+ const { file, jobName, job, workflow } = sources.get(best);
375
+ best = planWithNeeds(repoPath, file, workflow, jobName, job, knownPorts);
376
+ }
377
+ return { files, best, others: ranked.slice(1).map((plan) => `${plan.source} › ${plan.job}`), errors: plans.filter((plan) => plan.error) };
378
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,151 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseArgs } from "node:util";
3
+ import { createContext } from "./context.mjs";
4
+ import { NookError } from "./errors.mjs";
5
+ import { doctor } from "./commands/doctor.mjs";
6
+ import { init } from "./commands/init.mjs";
7
+ import { createPrompter } from "./prompt.mjs";
8
+ import { line, useColor } from "./ui.mjs";
9
+ import { cleanWorkspaces } from "./commands/clean.mjs";
10
+ import { listWorkspaces } from "./commands/ls.mjs";
11
+ import { removeWorkspace } from "./commands/rm.mjs";
12
+ import { createWorkspace, openWorkspace } from "./commands/workspace.mjs";
13
+ import { writeHooks } from "./commands/write-hooks.mjs";
14
+ import { listHooks } from "./commands/hooks.mjs";
15
+ import { testHooks } from "./commands/check-hooks.mjs";
16
+ import { forgetRepo } from "./commands/forget.mjs";
17
+
18
+ const HELP = `nk - one home for your git worktrees
19
+
20
+ Usage:
21
+ nk init [--yes] [--allow-hooks] [--name <short-name>] register this repository (once per repository)
22
+ nk -b <name> [agent] [-p <prompt>] [-y] create a workspace; start the agent if given
23
+ (-y: keep the converted name without asking;
24
+ --untested: start even if hooks or CI changed
25
+ since nk hooks test last passed)
26
+ nk <name> [agent] [-p <prompt>] open a workspace; start the agent if given
27
+ nk ls [--json] list workspaces: changes, commits, PR and CI
28
+ nk rm <name>... [-n] [-y] back up and remove workspaces
29
+ nk clean [-n] [-y] remove workspaces whose work is merged, and leftover folders
30
+ nk hooks the repository's .nook hooks: allowed? tested?
31
+ nk hooks write [agent] write the hooks from the repository's CI (GitHub Actions)
32
+ and Compose file; with an agent: then starts it in the
33
+ same folder to finish what they could not tell
34
+ nk hooks test run this folder's hooks in a temporary workspace;
35
+ fails if remove leaves anything behind
36
+ nk forget [<short-name>] [-y] stop knowing a repository: its registration and shared
37
+ files go; the repository folder and backups stay
38
+ nk doctor check nk's tools: node, git, gh, docker, agents
39
+
40
+ Options:
41
+ --repo <short-name> act on another registered repository (otherwise: the one you are in)
42
+ -- <args...> extra arguments for the agent
43
+ Home folder: ~/nook (set NOOK_HOME to move it)`;
44
+
45
+ function version() {
46
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
47
+ }
48
+
49
+ /** Splits argv at "--": everything after it goes to the agent untouched. */
50
+ function splitExtra(argv) {
51
+ const index = argv.indexOf("--");
52
+ return index === -1 ? [argv, []] : [argv.slice(0, index), argv.slice(index + 1)];
53
+ }
54
+
55
+ function parse(argv, options) {
56
+ try {
57
+ return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
58
+ } catch (error) {
59
+ throw new NookError(`${error.message}\nRun nk --help for usage`);
60
+ }
61
+ }
62
+
63
+ export async function run(argv, io = {}) {
64
+ const [args, extra] = splitExtra(argv);
65
+ const [first] = args;
66
+ if (!args.length || first === "help" || first === "--help" || first === "-h") {
67
+ (io.out ?? process.stdout).write(`${HELP}\n`);
68
+ return 0;
69
+ }
70
+ if (first === "version" || first === "--version" || first === "-v") {
71
+ (io.out ?? process.stdout).write(`${version()}\n`);
72
+ return 0;
73
+ }
74
+ const ctx = createContext(io);
75
+
76
+ switch (first) {
77
+ case "init": {
78
+ const { values } = parse(args.slice(1), { yes: { type: "boolean", short: "y" }, "allow-hooks": { type: "boolean" }, name: { type: "string" } });
79
+ return init(ctx, { yes: values.yes, allowHooks: values["allow-hooks"], name: values.name });
80
+ }
81
+ case "ls": {
82
+ const { values } = parse(args.slice(1), { repo: { type: "string" }, json: { type: "boolean" } });
83
+ return listWorkspaces(ctx, values);
84
+ }
85
+ case "rm": {
86
+ const { values, positionals } = parse(args.slice(1), {
87
+ "dry-run": { type: "boolean", short: "n" },
88
+ yes: { type: "boolean", short: "y" },
89
+ repo: { type: "string" },
90
+ });
91
+ if (!positionals.length) throw new NookError("Usage: nk rm <name>... [-n] [-y]");
92
+ return removeWorkspace(ctx, { names: positionals, repo: values.repo, dryRun: values["dry-run"], yes: values.yes });
93
+ }
94
+ case "hooks": {
95
+ const { values, positionals } = parse(args.slice(1), { repo: { type: "string" }, yes: { type: "boolean", short: "y" } });
96
+ const [action, agent, ...rest] = positionals;
97
+ if (!action && !agent) return listHooks(ctx, values);
98
+ if (action === "write" && !rest.length) return writeHooks(ctx, { repo: values.repo, agent, extra });
99
+ if (action === "test" && !agent) return testHooks(ctx, { repo: values.repo });
100
+ throw new NookError("Usage: nk hooks | nk hooks write [agent] | nk hooks test");
101
+ }
102
+ case "forget": {
103
+ const { values, positionals } = parse(args.slice(1), { yes: { type: "boolean", short: "y" }, repo: { type: "string" } });
104
+ if (positionals.length > 1) throw new NookError("Usage: nk forget [<short-name>] [-y]");
105
+ return forgetRepo(ctx, { name: positionals[0] ?? values.repo, yes: values.yes });
106
+ }
107
+ case "doctor":
108
+ parse(args.slice(1), {});
109
+ return doctor(ctx);
110
+ case "clean": {
111
+ const { values } = parse(args.slice(1), {
112
+ "dry-run": { type: "boolean", short: "n" },
113
+ yes: { type: "boolean", short: "y" },
114
+ repo: { type: "string" },
115
+ });
116
+ return cleanWorkspaces(ctx, { repo: values.repo, dryRun: values["dry-run"], yes: values.yes });
117
+ }
118
+ default: {
119
+ const { values, positionals } = parse(args, {
120
+ b: { type: "string", short: "b" },
121
+ prompt: { type: "string", short: "p" },
122
+ repo: { type: "string" },
123
+ yes: { type: "boolean", short: "y" },
124
+ untested: { type: "boolean" },
125
+ });
126
+ const create = values.b !== undefined;
127
+ const name = create ? values.b : positionals.shift();
128
+ if (!name || positionals.length > 1) throw new NookError("Usage: nk [-b] <name> [agent] [-p <prompt>]");
129
+ const options = { name, repo: values.repo, agent: positionals[0], prompt: values.prompt, extra };
130
+ return create ? createWorkspace(ctx, { ...options, untested: values.untested, prompter: createPrompter({ yes: values.yes }) }) : openWorkspace(ctx, options);
131
+ }
132
+ }
133
+ }
134
+
135
+ export async function main(argv, io = {}) {
136
+ try {
137
+ return await run(argv, io);
138
+ } catch (error) {
139
+ if (error instanceof NookError) {
140
+ const err = io.err ?? process.stderr;
141
+ const color = useColor(err, io.env ?? process.env);
142
+ // First line says what failed; later lines say why and what to do next.
143
+ const [first, ...rest] = error.message.split("\n");
144
+ const lines = [line("fail", first, color)];
145
+ for (const text of rest) lines.push(/^\s/.test(text) ? line("item", text.trim(), color) : line("info", text, color));
146
+ err.write(`${lines.join("\n")}\n`);
147
+ return error.code;
148
+ }
149
+ throw error;
150
+ }
151
+ }
@@ -0,0 +1,128 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { NookError } from "../errors.mjs";
4
+ import { exists } from "../home.mjs";
5
+ import { git, gitTry } from "../git.mjs";
6
+ import { findHooks, hookHash, runHook } from "../hooks.mjs";
7
+ import { allocatePort } from "../ports.mjs";
8
+ import { copyShared } from "../shared.mjs";
9
+ import { resolveRepo, workspaceEnv } from "../context.mjs";
10
+ import { describeOccupants, findOccupants, stopProcesses } from "../occupancy.mjs";
11
+ import { ciHash } from "../ci.mjs";
12
+ import { dockerCli, dockerSnapshot, findComposeProjects, removeComposeProject, snapshotDiff } from "../compose.mjs";
13
+ import { deleteFolder } from "./rm.mjs";
14
+
15
+ const CHECK_NAME = "nook-hooks-test";
16
+
17
+ /** Hooks of the main checkout that changed since they last passed a check. */
18
+ export function uncheckedHooks(repo) {
19
+ return findHooks(repo.path).filter((hook) => repo.checked?.[hook.name] !== hookHash(hook.file));
20
+ }
21
+
22
+ /** CI changed since the hooks last passed a test (only known once a test has passed). */
23
+ export function ciChanged(repo) {
24
+ return Boolean(repo.checked_ci) && ciHash(repo.path) !== repo.checked_ci;
25
+ }
26
+
27
+ /**
28
+ * What changed since nk hooks test last passed, as short phrases; empty when
29
+ * nothing did. `tested` says whether a test ever passed for this repository.
30
+ */
31
+ export function setupChanges(repo) {
32
+ const changes = uncheckedHooks(repo).map((hook) => `.nook/${hook.name}`);
33
+ if (ciChanged(repo)) changes.push("the CI configuration");
34
+ return { tested: Boolean(repo.checked), changes };
35
+ }
36
+
37
+ /**
38
+ * nk hooks test: runs the hooks of the folder you are in (an agent's
39
+ * branch, say) in a throwaway workspace: create, open twice, then remove.
40
+ * Passes when the hooks succeed, open can repeat, git sees no changed files,
41
+ * and Docker is back to what it was. Hooks that pass are recorded, so doctor and rm can tell when a hook
42
+ * changed without being checked again.
43
+ */
44
+ export async function testHooks(ctx, { repo: repoFlag, docker = dockerCli, scan = findOccupants, stop = stopProcesses }) {
45
+ const { name: repoName, repo } = resolveRepo(ctx, repoFlag);
46
+ const top = gitTry(["rev-parse", "--show-toplevel"], { cwd: ctx.cwd });
47
+ const source = top.ok ? top.out : repo.path;
48
+ const hooks = findHooks(source);
49
+ if (!hooks.length) throw new NookError(`No hooks in ${join(source, ".nook")}. Write them first: nk hooks write <agent>`);
50
+ const byEvent = Object.fromEntries(hooks.map((hook) => [hook.event, hook]));
51
+
52
+ const path = join(ctx.paths.workspaces, repoName, CHECK_NAME);
53
+ if (exists(path)) throw new NookError(`${path} is left from an earlier check. Delete it, then run the check again`);
54
+ ctx.info(`Checking ${hooks.map((hook) => `.nook/${hook.name}`).join(", ")} from ${source} in a temporary workspace`);
55
+ const before = dockerSnapshot(docker);
56
+ if (!before) ctx.info("Docker is not available; only the hooks' exit codes are checked");
57
+
58
+ mkdirSync(join(ctx.paths.workspaces, repoName), { recursive: true });
59
+ // Recorded while the check runs, so an nk -b at the same time does not take the same ports.
60
+ const port = await ctx.update(async (config) => {
61
+ const taken = await allocatePort(config);
62
+ config.repos[repoName].workspaces[CHECK_NAME] = { path, port: taken, created: new Date().toISOString() };
63
+ return taken;
64
+ });
65
+ try {
66
+ git(["worktree", "add", "--detach", path, git(["rev-parse", "HEAD"], { cwd: source })], { cwd: repo.path });
67
+ } catch (error) {
68
+ delete repo.workspaces[CHECK_NAME];
69
+ ctx.save();
70
+ throw error;
71
+ }
72
+ const target = { repoName, repo, name: CHECK_NAME, ws: { path, port } };
73
+ copyShared(join(ctx.paths.shared, repoName), path);
74
+
75
+ // A file git already shows as changed in a fresh checkout (line endings the repository's .gitattributes
76
+ // would change, as awesome-kotlin's gradlew.bat) is the repository's, not the hooks'.
77
+ const status = () => {
78
+ const result = gitTry(["status", "--porcelain", "--untracked-files=all"], { cwd: path });
79
+ return result.ok ? result.out.split(/\r?\n/).filter((line) => line.trim()) : [];
80
+ };
81
+ const fresh = new Set(status());
82
+ const problems = [];
83
+ const run = async (event, label = event) => {
84
+ const hook = byEvent[event];
85
+ if (!hook) return true;
86
+ const result = await runHook(hook, { cwd: path, env: workspaceEnv(ctx, target), logsDir: ctx.paths.logs, label: `${repoName}-${CHECK_NAME}`, output: ctx.err });
87
+ if (result.status === 0) return true;
88
+ problems.push(`${label} failed (exit ${result.status}). Log: ${result.log}`);
89
+ return false;
90
+ };
91
+ try {
92
+ if (await run("create")) if (await run("open")) await run("open", "open, run a second time,");
93
+ // Hooks set up the workspace; they must not change what git tracks, or every commit would carry it.
94
+ const changed = status().filter((line) => !fresh.has(line));
95
+ if (changed.length)
96
+ problems.push(`The hooks changed files in the workspace that git would commit (ignore them in .gitignore, or do not write them):\n${changed.slice(0, 10).map((line) => ` ${line.trim()}`).join("\n")}`);
97
+ } finally {
98
+ await run("remove");
99
+ const running = scan(path).processes ?? [];
100
+ if (running.length) {
101
+ problems.push(`Programs were still running in the workspace after remove:\n${describeOccupants(running)}`);
102
+ await stop(running);
103
+ }
104
+ const after = before && dockerSnapshot(docker);
105
+ if (after)
106
+ for (const item of snapshotDiff(before, after)) problems.push(`remove left a Docker ${item.kind}: ${item.name}`);
107
+ // Clean up whatever is left, so the check itself leaves nothing behind.
108
+ for (const project of findComposeProjects(path, docker).projects) removeComposeProject(project, docker);
109
+ gitTry(["worktree", "remove", "--force", path], { cwd: repo.path });
110
+ delete repo.workspaces[CHECK_NAME];
111
+ ctx.save();
112
+ try {
113
+ deleteFolder(path);
114
+ } catch (error) {
115
+ problems.push(error.message);
116
+ }
117
+ gitTry(["worktree", "prune"], { cwd: repo.path });
118
+ }
119
+
120
+ if (problems.length) throw new NookError(`The hooks did not pass:\n${problems.map((text) => ` ${text}`).join("\n")}`);
121
+ repo.checked = Object.fromEntries(hooks.map((hook) => [hook.name, hookHash(hook.file)]));
122
+ const ci = ciHash(source);
123
+ if (ci) repo.checked_ci = ci;
124
+ else delete repo.checked_ci;
125
+ ctx.save();
126
+ ctx.ok(`The hooks passed: create, open twice and remove ran, and ${before ? "Docker is as it was" : "all exited with 0"}`);
127
+ return 0;
128
+ }