@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/git.mjs CHANGED
@@ -1,86 +1,86 @@
1
- import { spawnSync } from "node:child_process";
2
- import { resolve } from "node:path";
3
- import { NookError } from "./errors.mjs";
4
-
5
- export function gitTry(args, { cwd, input } = {}) {
6
- const result = spawnSync("git", args, {
7
- cwd,
8
- input,
9
- encoding: "utf8",
10
- maxBuffer: 256 * 1024 * 1024,
11
- windowsHide: true,
12
- });
13
- if (result.error) throw new NookError(`Cannot run git: ${result.error.message}`);
14
- return { ok: result.status === 0, status: result.status, out: result.stdout.trimEnd(), err: result.stderr.trim() };
15
- }
16
-
17
- export function git(args, options) {
18
- const result = gitTry(args, options);
19
- if (!result.ok) throw new NookError(`git ${args.join(" ")} failed: ${result.err || `exit ${result.status}`}`);
20
- return result.out;
21
- }
22
-
23
- /** Parses `git worktree list --porcelain`. The first entry is the main worktree. */
24
- export function listWorktrees(repo) {
25
- const out = git(["worktree", "list", "--porcelain"], { cwd: repo });
26
- const items = [];
27
- let current = null;
28
- for (const line of out.split(/\r?\n/)) {
29
- if (line.startsWith("worktree ")) {
30
- current = { path: resolve(line.slice(9)), branch: null, head: null, bare: false, locked: false };
31
- items.push(current);
32
- } else if (current && line.startsWith("HEAD ")) current.head = line.slice(5);
33
- else if (current && line.startsWith("branch ")) current.branch = line.slice(7).replace(/^refs\/heads\//, "");
34
- else if (current && line === "bare") current.bare = true;
35
- else if (current && (line === "locked" || line.startsWith("locked "))) current.locked = true;
36
- }
37
- return items;
38
- }
39
-
40
- /** The main worktree of the repository that contains `cwd`. */
41
- export function mainWorktree(cwd) {
42
- const inside = gitTry(["rev-parse", "--is-inside-work-tree"], { cwd });
43
- if (!inside.ok || inside.out !== "true") throw new NookError("Not inside a git repository");
44
- const [main] = listWorktrees(cwd);
45
- if (!main || main.bare) throw new NookError("Bare repositories are not supported");
46
- return main.path;
47
- }
48
-
49
- export function remoteUrl(repo, remote = "origin") {
50
- const result = gitTry(["remote", "get-url", remote], { cwd: repo });
51
- return result.ok ? result.out : "";
52
- }
53
-
54
- export function refExists(repo, ref) {
55
- return gitTry(["show-ref", "--verify", "--quiet", ref], { cwd: repo }).ok;
56
- }
57
-
58
- export function defaultBranch(repo) {
59
- const head = gitTry(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repo });
60
- if (head.ok && head.out) return head.out.replace(/^origin\//, "");
61
- const current = gitTry(["symbolic-ref", "--short", "HEAD"], { cwd: repo });
62
- if (current.ok && current.out) return current.out;
63
- return "main";
64
- }
65
-
66
- /** The ref new workspaces start from: origin/<default> when it exists, else <default>. */
67
- export function baseRef(repo, branch) {
68
- return refExists(repo, `refs/remotes/origin/${branch}`) ? `origin/${branch}` : branch;
69
- }
70
-
71
- export function statusLines(path) {
72
- const out = git(["status", "--porcelain", "--untracked-files=all"], { cwd: path });
73
- return out ? out.split(/\r?\n/) : [];
74
- }
75
-
76
- /** Which of `paths` (relative to cwd) git ignores. */
77
- export function ignoredPaths(cwd, paths) {
78
- if (!paths.length) return new Set();
79
- const result = gitTry(["check-ignore", "-z", "--stdin"], { cwd, input: paths.join("\0") + "\0" });
80
- if (!result.ok && result.status !== 1) throw new NookError(`git check-ignore failed: ${result.err}`);
81
- return new Set(result.out.split("\0").filter(Boolean));
82
- }
83
-
84
- export function isTracked(cwd, path) {
85
- return gitTry(["ls-files", "--error-unmatch", "--", path], { cwd }).ok;
86
- }
1
+ import { spawnSync } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+ import { NookError } from "./errors.mjs";
4
+
5
+ export function gitTry(args, { cwd, input } = {}) {
6
+ const result = spawnSync("git", args, {
7
+ cwd,
8
+ input,
9
+ encoding: "utf8",
10
+ maxBuffer: 256 * 1024 * 1024,
11
+ windowsHide: true,
12
+ });
13
+ if (result.error) throw new NookError(`Cannot run git: ${result.error.message}`);
14
+ return { ok: result.status === 0, status: result.status, out: result.stdout.trimEnd(), err: result.stderr.trim() };
15
+ }
16
+
17
+ export function git(args, options) {
18
+ const result = gitTry(args, options);
19
+ if (!result.ok) throw new NookError(`git ${args.join(" ")} failed: ${result.err || `exit ${result.status}`}`);
20
+ return result.out;
21
+ }
22
+
23
+ /** Parses `git worktree list --porcelain`. The first entry is the main worktree. */
24
+ export function listWorktrees(repo) {
25
+ const out = git(["worktree", "list", "--porcelain"], { cwd: repo });
26
+ const items = [];
27
+ let current = null;
28
+ for (const line of out.split(/\r?\n/)) {
29
+ if (line.startsWith("worktree ")) {
30
+ current = { path: resolve(line.slice(9)), branch: null, head: null, bare: false, locked: false };
31
+ items.push(current);
32
+ } else if (current && line.startsWith("HEAD ")) current.head = line.slice(5);
33
+ else if (current && line.startsWith("branch ")) current.branch = line.slice(7).replace(/^refs\/heads\//, "");
34
+ else if (current && line === "bare") current.bare = true;
35
+ else if (current && (line === "locked" || line.startsWith("locked "))) current.locked = true;
36
+ }
37
+ return items;
38
+ }
39
+
40
+ /** The main worktree of the repository that contains `cwd`. */
41
+ export function mainWorktree(cwd) {
42
+ const inside = gitTry(["rev-parse", "--is-inside-work-tree"], { cwd });
43
+ if (!inside.ok || inside.out !== "true") throw new NookError("Not inside a git repository");
44
+ const [main] = listWorktrees(cwd);
45
+ if (!main || main.bare) throw new NookError("Bare repositories are not supported");
46
+ return main.path;
47
+ }
48
+
49
+ export function remoteUrl(repo, remote = "origin") {
50
+ const result = gitTry(["remote", "get-url", remote], { cwd: repo });
51
+ return result.ok ? result.out : "";
52
+ }
53
+
54
+ export function refExists(repo, ref) {
55
+ return gitTry(["show-ref", "--verify", "--quiet", ref], { cwd: repo }).ok;
56
+ }
57
+
58
+ export function defaultBranch(repo) {
59
+ const head = gitTry(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repo });
60
+ if (head.ok && head.out) return head.out.replace(/^origin\//, "");
61
+ const current = gitTry(["symbolic-ref", "--short", "HEAD"], { cwd: repo });
62
+ if (current.ok && current.out) return current.out;
63
+ return "main";
64
+ }
65
+
66
+ /** The ref new workspaces start from: origin/<default> when it exists, else <default>. */
67
+ export function baseRef(repo, branch) {
68
+ return refExists(repo, `refs/remotes/origin/${branch}`) ? `origin/${branch}` : branch;
69
+ }
70
+
71
+ export function statusLines(path) {
72
+ const out = git(["status", "--porcelain", "--untracked-files=all"], { cwd: path });
73
+ return out ? out.split(/\r?\n/) : [];
74
+ }
75
+
76
+ /** Which of `paths` (relative to cwd) git ignores. */
77
+ export function ignoredPaths(cwd, paths) {
78
+ if (!paths.length) return new Set();
79
+ const result = gitTry(["check-ignore", "-z", "--stdin"], { cwd, input: paths.join("\0") + "\0" });
80
+ if (!result.ok && result.status !== 1) throw new NookError(`git check-ignore failed: ${result.err}`);
81
+ return new Set(result.out.split("\0").filter(Boolean));
82
+ }
83
+
84
+ export function isTracked(cwd, path) {
85
+ return gitTry(["ls-files", "--error-unmatch", "--", path], { cwd }).ok;
86
+ }
package/src/home.mjs CHANGED
@@ -1,48 +1,48 @@
1
- import { existsSync, realpathSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join, relative, resolve, isAbsolute } from "node:path";
4
-
5
- /** Nook keeps everything in one folder. Only NOOK_HOME can move it. */
6
- export function nookHome(env = process.env) {
7
- return resolve(env.NOOK_HOME || join(homedir(), "nook"));
8
- }
9
-
10
- export function homePaths(home) {
11
- return {
12
- home,
13
- config: join(home, "config.toml"),
14
- workspaces: join(home, "workspace"),
15
- shared: join(home, "shared"),
16
- backup: join(home, "backup"),
17
- logs: join(home, "logs"),
18
- };
19
- }
20
-
21
- /** Resolves symlinks (macOS /var -> /private/var) when the path exists. */
22
- export function realPath(path) {
23
- const full = resolve(path);
24
- try {
25
- return realpathSync.native(full);
26
- } catch {
27
- return full;
28
- }
29
- }
30
-
31
- function comparable(path, platform) {
32
- const full = realPath(path);
33
- return platform === "win32" ? full.toLowerCase() : full;
34
- }
35
-
36
- export function samePath(a, b, platform = process.platform) {
37
- return comparable(a, platform) === comparable(b, platform);
38
- }
39
-
40
- /** True when `child` is `parent` or anywhere below it. */
41
- export function isInside(child, parent, platform = process.platform) {
42
- const rel = relative(comparable(parent, platform), comparable(child, platform));
43
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
44
- }
45
-
46
- export function exists(path) {
47
- return existsSync(path);
48
- }
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, relative, resolve, isAbsolute } from "node:path";
4
+
5
+ /** Nook keeps everything in one folder. Only NOOK_HOME can move it. */
6
+ export function nookHome(env = process.env) {
7
+ return resolve(env.NOOK_HOME || join(homedir(), "nook"));
8
+ }
9
+
10
+ export function homePaths(home) {
11
+ return {
12
+ home,
13
+ config: join(home, "config.toml"),
14
+ workspaces: join(home, "workspace"),
15
+ shared: join(home, "shared"),
16
+ backup: join(home, "backup"),
17
+ logs: join(home, "logs"),
18
+ };
19
+ }
20
+
21
+ /** Resolves symlinks (macOS /var -> /private/var) when the path exists. */
22
+ export function realPath(path) {
23
+ const full = resolve(path);
24
+ try {
25
+ return realpathSync.native(full);
26
+ } catch {
27
+ return full;
28
+ }
29
+ }
30
+
31
+ function comparable(path, platform) {
32
+ const full = realPath(path);
33
+ return platform === "win32" ? full.toLowerCase() : full;
34
+ }
35
+
36
+ export function samePath(a, b, platform = process.platform) {
37
+ return comparable(a, platform) === comparable(b, platform);
38
+ }
39
+
40
+ /** True when `child` is `parent` or anywhere below it. */
41
+ export function isInside(child, parent, platform = process.platform) {
42
+ const rel = relative(comparable(parent, platform), comparable(child, platform));
43
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
44
+ }
45
+
46
+ export function exists(path) {
47
+ return existsSync(path);
48
+ }
package/src/hooks.mjs CHANGED
@@ -1,135 +1,135 @@
1
- import { createHash } from "node:crypto";
2
- import { createWriteStream, mkdirSync, readdirSync, readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import spawn from "cross-spawn";
5
- import { which } from "./agents.mjs";
6
- import { NookError } from "./errors.mjs";
7
- import { line, useColor, indenter } from "./ui.mjs";
8
-
9
- export const HOOK_EVENTS = ["create", "open", "remove"];
10
-
11
- /** Extensions tried for a hook, in order. Platform scripts first, then portable ones. */
12
- export function hookExtensions(platform = process.platform) {
13
- return platform === "win32" ? [".ps1", ".cmd", ".mjs", ".py"] : [".sh", ".mjs", ".py"];
14
- }
15
-
16
- /** Every hook in `<dir>/.nook/` for this platform. */
17
- export function findHooks(dir, platform = process.platform) {
18
- return HOOK_EVENTS.map((event) => findHook(dir, event, platform)).filter(Boolean);
19
- }
20
-
21
- /** The hook file for `event` in `<dir>/.nook/`, or null. */
22
- export function findHook(dir, event, platform = process.platform) {
23
- let entries;
24
- try {
25
- entries = readdirSync(join(dir, ".nook"));
26
- } catch {
27
- return null;
28
- }
29
- for (const ext of hookExtensions(platform)) {
30
- const name = event + ext;
31
- if (entries.includes(name)) return { event, ext, name, file: join(dir, ".nook", name) };
32
- }
33
- return null;
34
- }
35
-
36
- /**
37
- * Fingerprint of a hook's content. Line endings are normalized, so a Windows
38
- * checkout (CRLF) and the same file with LF count as one version.
39
- */
40
- export function hookHash(file) {
41
- const text = readFileSync(file, "latin1").replace(/\r\n/g, "\n");
42
- return createHash("sha256").update(text, "latin1").digest("hex");
43
- }
44
-
45
- /** How to run a hook file: { command, args }. */
46
- export function hookCommand(hook, { platform = process.platform, find = which } = {}) {
47
- switch (hook.ext) {
48
- case ".ps1": {
49
- const shell = find("pwsh") ?? (platform === "win32" ? "powershell.exe" : "pwsh");
50
- return { command: shell, args: ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", hook.file] };
51
- }
52
- case ".cmd":
53
- return { command: hook.file, args: [] };
54
- case ".sh":
55
- return { command: find("bash") ?? "sh", args: [hook.file] };
56
- case ".mjs":
57
- return { command: process.execPath, args: [hook.file] };
58
- case ".py": {
59
- const python = find("python3") ?? find("python") ?? "python3";
60
- return { command: python, args: [hook.file] };
61
- }
62
- default:
63
- throw new NookError(`Unknown hook type: ${hook.name}`);
64
- }
65
- }
66
-
67
- /** "trusted", "new" or "changed" for a hook of a repository. */
68
- export function trustState(repoConfig, hook) {
69
- const saved = repoConfig.hooks?.[hook.name];
70
- if (!saved) return "new";
71
- return saved === hookHash(hook.file) ? "trusted" : "changed";
72
- }
73
-
74
- /**
75
- * Asks once before hooks run for the first time, and again after one changes,
76
- * listing every such hook of the repository (like worktrunk's approval prompt).
77
- * The file contents are not printed; the paths say where to read them.
78
- * `ui` is the command context (warn, info, item, note). Calls `save` after a yes.
79
- */
80
- export async function ensureTrusted({ repoName, repoConfig, hooks, prompter, save, ui }) {
81
- const pending = hooks.filter((hook) => trustState(repoConfig, hook) !== "trusted");
82
- if (!pending.length) return;
83
- const changed = pending.some((hook) => trustState(repoConfig, hook) === "changed");
84
- const count = pending.length === 1 ? "1 hook" : `${pending.length} hooks`;
85
- ui.warn(`${repoName} needs approval to run ${count}${changed ? " (changed since you allowed them)" : ""}:`);
86
- for (const hook of pending)
87
- ui.item(`${hook.event}: .nook/${hook.name}${trustState(repoConfig, hook) === "changed" ? " (changed)" : ""}`);
88
- ui.info(`Read them in ${dirname(pending[0].file)}`);
89
- let ok;
90
- try {
91
- ok = await prompter.confirm("Allow and remember?", false);
92
- } catch {
93
- throw new NookError(`Hooks of ${repoName} need your OK. Run nk in a terminal once, or nk init --allow-hooks after reading them`);
94
- }
95
- if (!ok) throw new NookError("Hooks were not allowed; nothing ran. Allow them by answering yes in a terminal, or with nk init --allow-hooks after reading them");
96
- repoConfig.hooks ??= {};
97
- for (const hook of pending) repoConfig.hooks[hook.name] = hookHash(hook.file);
98
- save();
99
- }
100
-
101
- function stamp(date = new Date()) {
102
- const pad = (n) => String(n).padStart(2, "0");
103
- return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
104
- }
105
-
106
- /**
107
- * Runs a hook in the workspace. Output goes to the terminal and to a log file.
108
- * Resolves to { status, log }.
109
- */
110
- export function runHook(hook, { cwd, env, logsDir, label, output = process.stderr }) {
111
- mkdirSync(logsDir, { recursive: true });
112
- const log = join(logsDir, `${label}-${hook.event}-${stamp()}.log`);
113
- const stream = createWriteStream(log);
114
- const { command, args } = hookCommand(hook);
115
- output.write(`${line("step", `Running ${hook.event} hook .nook/${hook.name}`, useColor(output))}\n`);
116
- return new Promise((done) => {
117
- const child = spawn(command, args, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true });
118
- // Indented under the "Running … hook" line; the log keeps the output as it was.
119
- const forward = (indent) => (chunk) => {
120
- stream.write(chunk);
121
- output.write(indent(chunk));
122
- };
123
- child.stdout.on("data", forward(indenter()));
124
- child.stderr.on("data", forward(indenter()));
125
- let finished = false;
126
- const finish = (status, error) => {
127
- if (finished) return;
128
- finished = true;
129
- if (error) stream.write(`\n${error.message}\n`);
130
- stream.end(() => done({ status, log }));
131
- };
132
- child.on("error", (error) => finish(127, error));
133
- child.on("close", (code, signal) => finish(code ?? (signal ? 1 : 0)));
134
- });
135
- }
1
+ import { createHash } from "node:crypto";
2
+ import { createWriteStream, mkdirSync, readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import spawn from "cross-spawn";
5
+ import { which } from "./agents.mjs";
6
+ import { NookError } from "./errors.mjs";
7
+ import { line, useColor, indenter } from "./ui.mjs";
8
+
9
+ export const HOOK_EVENTS = ["create", "open", "remove"];
10
+
11
+ /** Extensions tried for a hook, in order. Platform scripts first, then portable ones. */
12
+ export function hookExtensions(platform = process.platform) {
13
+ return platform === "win32" ? [".ps1", ".cmd", ".mjs", ".py"] : [".sh", ".mjs", ".py"];
14
+ }
15
+
16
+ /** Every hook in `<dir>/.nook/` for this platform. */
17
+ export function findHooks(dir, platform = process.platform) {
18
+ return HOOK_EVENTS.map((event) => findHook(dir, event, platform)).filter(Boolean);
19
+ }
20
+
21
+ /** The hook file for `event` in `<dir>/.nook/`, or null. */
22
+ export function findHook(dir, event, platform = process.platform) {
23
+ let entries;
24
+ try {
25
+ entries = readdirSync(join(dir, ".nook"));
26
+ } catch {
27
+ return null;
28
+ }
29
+ for (const ext of hookExtensions(platform)) {
30
+ const name = event + ext;
31
+ if (entries.includes(name)) return { event, ext, name, file: join(dir, ".nook", name) };
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Fingerprint of a hook's content. Line endings are normalized, so a Windows
38
+ * checkout (CRLF) and the same file with LF count as one version.
39
+ */
40
+ export function hookHash(file) {
41
+ const text = readFileSync(file, "latin1").replace(/\r\n/g, "\n");
42
+ return createHash("sha256").update(text, "latin1").digest("hex");
43
+ }
44
+
45
+ /** How to run a hook file: { command, args }. */
46
+ export function hookCommand(hook, { platform = process.platform, find = which } = {}) {
47
+ switch (hook.ext) {
48
+ case ".ps1": {
49
+ const shell = find("pwsh") ?? (platform === "win32" ? "powershell.exe" : "pwsh");
50
+ return { command: shell, args: ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", hook.file] };
51
+ }
52
+ case ".cmd":
53
+ return { command: hook.file, args: [] };
54
+ case ".sh":
55
+ return { command: find("bash") ?? "sh", args: [hook.file] };
56
+ case ".mjs":
57
+ return { command: process.execPath, args: [hook.file] };
58
+ case ".py": {
59
+ const python = find("python3") ?? find("python") ?? "python3";
60
+ return { command: python, args: [hook.file] };
61
+ }
62
+ default:
63
+ throw new NookError(`Unknown hook type: ${hook.name}`);
64
+ }
65
+ }
66
+
67
+ /** "trusted", "new" or "changed" for a hook of a repository. */
68
+ export function trustState(repoConfig, hook) {
69
+ const saved = repoConfig.hooks?.[hook.name];
70
+ if (!saved) return "new";
71
+ return saved === hookHash(hook.file) ? "trusted" : "changed";
72
+ }
73
+
74
+ /**
75
+ * Asks once before hooks run for the first time, and again after one changes,
76
+ * listing every such hook of the repository (like worktrunk's approval prompt).
77
+ * The file contents are not printed; the paths say where to read them.
78
+ * `ui` is the command context (warn, info, item, note). Calls `save` after a yes.
79
+ */
80
+ export async function ensureTrusted({ repoName, repoConfig, hooks, prompter, save, ui }) {
81
+ const pending = hooks.filter((hook) => trustState(repoConfig, hook) !== "trusted");
82
+ if (!pending.length) return;
83
+ const changed = pending.some((hook) => trustState(repoConfig, hook) === "changed");
84
+ const count = pending.length === 1 ? "1 hook" : `${pending.length} hooks`;
85
+ ui.warn(`${repoName} needs approval to run ${count}${changed ? " (changed since you allowed them)" : ""}:`);
86
+ for (const hook of pending)
87
+ ui.item(`${hook.event}: .nook/${hook.name}${trustState(repoConfig, hook) === "changed" ? " (changed)" : ""}`);
88
+ ui.info(`Read them in ${dirname(pending[0].file)}`);
89
+ let ok;
90
+ try {
91
+ ok = await prompter.confirm("Allow and remember?", false);
92
+ } catch {
93
+ throw new NookError(`Hooks of ${repoName} need your OK. Run nk in a terminal once, or nk init --allow-hooks after reading them`);
94
+ }
95
+ if (!ok) throw new NookError("Hooks were not allowed; nothing ran. Allow them by answering yes in a terminal, or with nk init --allow-hooks after reading them");
96
+ repoConfig.hooks ??= {};
97
+ for (const hook of pending) repoConfig.hooks[hook.name] = hookHash(hook.file);
98
+ save();
99
+ }
100
+
101
+ function stamp(date = new Date()) {
102
+ const pad = (n) => String(n).padStart(2, "0");
103
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
104
+ }
105
+
106
+ /**
107
+ * Runs a hook in the workspace. Output goes to the terminal and to a log file.
108
+ * Resolves to { status, log }.
109
+ */
110
+ export function runHook(hook, { cwd, env, logsDir, label, output = process.stderr }) {
111
+ mkdirSync(logsDir, { recursive: true });
112
+ const log = join(logsDir, `${label}-${hook.event}-${stamp()}.log`);
113
+ const stream = createWriteStream(log);
114
+ const { command, args } = hookCommand(hook);
115
+ output.write(`${line("step", `Running ${hook.event} hook .nook/${hook.name}`, useColor(output))}\n`);
116
+ return new Promise((done) => {
117
+ const child = spawn(command, args, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true });
118
+ // Indented under the "Running … hook" line; the log keeps the output as it was.
119
+ const forward = (indent) => (chunk) => {
120
+ stream.write(chunk);
121
+ output.write(indent(chunk));
122
+ };
123
+ child.stdout.on("data", forward(indenter()));
124
+ child.stderr.on("data", forward(indenter()));
125
+ let finished = false;
126
+ const finish = (status, error) => {
127
+ if (finished) return;
128
+ finished = true;
129
+ if (error) stream.write(`\n${error.message}\n`);
130
+ stream.end(() => done({ status, log }));
131
+ };
132
+ child.on("error", (error) => finish(127, error));
133
+ child.on("close", (code, signal) => finish(code ?? (signal ? 1 : 0)));
134
+ });
135
+ }