@ree-auz/nook 0.1.0 → 0.1.2

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.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +422 -410
  3. package/bin/nk.mjs +3 -3
  4. package/docs/setup-hooks.md +107 -107
  5. package/integrations/linear/README.md +94 -84
  6. package/integrations/linear/coding-tools.json +7 -7
  7. package/integrations/linear/open-issue.mjs +193 -147
  8. package/package.json +34 -34
  9. package/skills/nook/SKILL.md +71 -71
  10. package/src/agents.mjs +92 -92
  11. package/src/backup.mjs +74 -74
  12. package/src/ci.mjs +378 -378
  13. package/src/cli.mjs +151 -151
  14. package/src/commands/check-hooks.mjs +128 -128
  15. package/src/commands/clean.mjs +175 -175
  16. package/src/commands/doctor.mjs +100 -91
  17. package/src/commands/forget.mjs +57 -57
  18. package/src/commands/hooks.mjs +34 -27
  19. package/src/commands/init.mjs +154 -154
  20. package/src/commands/ls.mjs +107 -107
  21. package/src/commands/rm.mjs +190 -190
  22. package/src/commands/workspace.mjs +163 -163
  23. package/src/commands/write-hooks.mjs +185 -185
  24. package/src/compose-plan.mjs +228 -228
  25. package/src/compose.mjs +123 -123
  26. package/src/config.mjs +115 -115
  27. package/src/context.mjs +189 -189
  28. package/src/errors.mjs +8 -8
  29. package/src/forge.mjs +75 -75
  30. package/src/generate-hooks.mjs +367 -367
  31. package/src/git.mjs +86 -86
  32. package/src/home.mjs +48 -48
  33. package/src/hooks.mjs +135 -135
  34. package/src/names.mjs +65 -65
  35. package/src/occupancy-windows.ps1 +59 -59
  36. package/src/occupancy.mjs +131 -131
  37. package/src/ports.mjs +89 -45
  38. package/src/prompt.mjs +100 -100
  39. package/src/shared.mjs +97 -97
  40. package/src/status.mjs +30 -30
  41. package/src/tools.mjs +23 -23
  42. package/src/ui.mjs +60 -60
package/src/cli.mjs CHANGED
@@ -1,151 +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
- }
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
+ }
@@ -1,128 +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
- }
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
+ }