@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.
- 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 +94 -84
- package/integrations/linear/coding-tools.json +7 -7
- package/integrations/linear/open-issue.mjs +193 -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/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
|
+
}
|