@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
@@ -1,175 +1,175 @@
1
- import { readdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { NookError } from "../errors.mjs";
4
- import { exists, isInside, samePath } from "../home.mjs";
5
- import { gitTry, listWorktrees, remoteUrl } from "../git.mjs";
6
- import { githubPrs } from "../forge.mjs";
7
- import { inspectWorkspace, isMerged } from "../backup.mjs";
8
- import { describeOccupants, findOccupants, stopProcesses } from "../occupancy.mjs";
9
- import { createPrompter } from "../prompt.mjs";
10
- import { branchTip, contains } from "../status.mjs";
11
- import { resolveRepo } from "../context.mjs";
12
- import { collect } from "./ls.mjs";
13
- import { deleteFolder, deleteWorkspace, ignoredData } from "./rm.mjs";
14
- import { dockerCli, findComposeProjects, removeComposeProject } from "../compose.mjs";
15
- import { approveHooks } from "./workspace.mjs";
16
- import { findHook } from "../hooks.mjs";
17
-
18
- const DAY = 24 * 60 * 60 * 1000;
19
- const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
20
-
21
- /**
22
- * Why a workspace can go, or null. A merged PR counts when the local branch
23
- * has nothing the PR did not have (this covers squash merges). Without a PR,
24
- * a branch already in the base counts once the workspace is a day old, so a
25
- * fresh workspace that has no commits yet is kept (worktrunk's --min-age).
26
- */
27
- export function mergedReason(item, now = Date.now()) {
28
- const { branch, repo, base, pr, ws } = item;
29
- if (pr?.state === "merged") {
30
- const tip = branchTip(repo.path, branch);
31
- if (!tip || (pr.head && contains(repo.path, pr.head, tip))) return { ok: `PR #${pr.number} merged` };
32
- return { skip: `has commits that are not in PR #${pr.number}` };
33
- }
34
- if (pr) return null;
35
- const age = now - (ws.created ? Date.parse(ws.created) : createdAt(ws.path));
36
- if (!(age >= DAY)) return null;
37
- if (branchTip(repo.path, branch) && isMerged(repo.path, branch, base)) return { ok: `merged into ${base}` };
38
- return null;
39
- }
40
-
41
- /** When a folder was made, for worktrees Nook did not record; NaN when unknown. */
42
- function createdAt(path) {
43
- try {
44
- const stat = statSync(path);
45
- return stat.birthtimeMs || stat.ctimeMs;
46
- } catch {
47
- return Number.NaN;
48
- }
49
- }
50
-
51
- /**
52
- * Folders in the repository's workspaces folder that are neither a workspace
53
- * nor a git worktree: what a removal left halfway, such as node_modules that
54
- * Windows would not let go of.
55
- */
56
- export function leftoverFolders(ctx, repoName, repo) {
57
- const dir = join(ctx.paths.workspaces, repoName);
58
- if (!exists(dir)) return [];
59
- const worktrees = listWorktrees(repo.path);
60
- return readdirSync(dir, { withFileTypes: true })
61
- .filter((entry) => entry.isDirectory() && !repo.workspaces[entry.name])
62
- .map((entry) => join(dir, entry.name))
63
- .filter((path) => !worktrees.some((w) => samePath(w.path, path)));
64
- }
65
-
66
- /** nk clean [-n] [-y]: remove workspaces whose work is merged. */
67
- export async function cleanWorkspaces(ctx, { repo: repoFlag, dryRun = false, yes = false, prs = githubPrs, scan = findOccupants, stop = stopProcesses, prompter = createPrompter(), now = Date.now(), docker = dockerCli } = {}) {
68
- const { name, repo } = resolveRepo(ctx, repoFlag);
69
- if (remoteUrl(repo.path)) {
70
- const fetched = gitTry(["fetch", "--quiet", "origin", repo.default_branch], { cwd: repo.path });
71
- if (!fetched.ok) ctx.warn(`Could not fetch origin/${repo.default_branch} for ${name}; using what is local`);
72
- }
73
- const { items, problems } = await collect(ctx, { repo: name, prs });
74
- for (const problem of problems) ctx.warn(`Only checks merges in git: ${problem}`);
75
-
76
- const remove = [];
77
- const skipped = [];
78
- let lost;
79
- for (const item of items) {
80
- if (item.main || !item.branch) continue;
81
- const reason = mergedReason(item, now);
82
- if (!reason) continue;
83
- const label = `${item.name} (${item.repoName})`;
84
- if (item.ws.locked) skipped.push(`${label}: locked (git worktree unlock to allow)`);
85
- else if (reason.skip) skipped.push(`${label}: ${reason.skip}`);
86
- else if (isInside(ctx.cwd, item.ws.path)) skipped.push(`${label}: you are inside it`);
87
- else if (item.changes) skipped.push(`${label}: ${plural(item.changes, "uncommitted change")}`);
88
- // Files git ignores (a local database, say) are not backed up, so clean leaves them to nk rm, which lists them.
89
- else if ((lost = item.changes === null ? [] : ignoredData(item.ws.path, join(ctx.paths.shared, item.repoName))).length)
90
- skipped.push(`${label}: files git ignores would be lost: ${lost.slice(0, 3).join(", ")}${lost.length > 3 ? ` and ${lost.length - 3} more` : ""} (nk rm ${item.name} to remove anyway)`);
91
- else remove.push({ item, label, reason: reason.ok, running: item.changes === null ? [] : scan(item.ws.path).processes });
92
- }
93
-
94
- const leftovers = [];
95
- for (const path of leftoverFolders(ctx, name, repo)) {
96
- if (isInside(ctx.cwd, path)) skipped.push(`${path}: you are inside it`);
97
- else if (scan(path).processes?.length) skipped.push(`${path}: programs are running in it`);
98
- else leftovers.push(path);
99
- }
100
-
101
- if (skipped.length) {
102
- ctx.warn(`Kept ${skipped.length}:`);
103
- for (const text of skipped) ctx.item(text);
104
- }
105
- if (!remove.length && !leftovers.length) {
106
- ctx.info("Nothing to clean");
107
- return 0;
108
- }
109
- if (remove.length) {
110
- ctx.info(`${remove.length === 1 ? "1 workspace is" : `${remove.length} workspaces are`} merged:`);
111
- const width = Math.max(...remove.map((entry) => entry.label.length));
112
- for (const entry of remove) ctx.item(`${entry.label.padEnd(width)} ${entry.reason}`);
113
- }
114
- if (leftovers.length) {
115
- ctx.info(`Left from earlier removals (not workspaces any more):`);
116
- for (const path of leftovers) ctx.item(path);
117
- }
118
- const running = remove.flatMap((entry) => entry.running);
119
- if (running.length) {
120
- // Same as nk rm: programs inside are listed, and stopped after one yes.
121
- ctx.warn(`${plural(running.length, "program")} running in them:`);
122
- for (const entry of remove) for (const p of entry.running) ctx.item(`${p.pid} ${p.name} (${entry.item.name})`);
123
- }
124
- if (dryRun) {
125
- ctx.info("Dry run; nothing was removed");
126
- return 0;
127
- }
128
- // Hook approval comes before anything is stopped or deleted.
129
- const repos = new Map(remove.map(({ item }) => [item.repoName, item]));
130
- for (const item of repos.values()) if (findHook(item.repo.path, "remove")) await approveHooks(ctx, item, prompter);
131
- if (!yes) {
132
- let ok;
133
- try {
134
- const count = remove.length + leftovers.length;
135
- const what = count === 1 ? "it" : `these ${count}`;
136
- ok = await prompter.confirm(running.length ? `Stop the programs and remove ${what}?` : `Remove ${what}?`, false);
137
- } catch {
138
- throw new NookError("Confirmation needs a terminal; pass -y to remove without asking");
139
- }
140
- if (!ok) throw new NookError("Nothing was removed");
141
- }
142
-
143
- let failed = 0;
144
- for (const { item, running: inside } of remove) {
145
- const target = { repoName: item.repoName, repo: item.repo, name: item.name, ws: item.ws };
146
- const state = inspectWorkspace({ repo: item.repo.path, path: item.ws.path, branch: item.branch, base: item.base });
147
- try {
148
- if (inside.length) {
149
- await stop(inside);
150
- const left = scan(item.ws.path).processes;
151
- if (left.length) throw new NookError(`Could not stop:\n${describeOccupants(left)}`);
152
- ctx.ok(`Stopped ${plural(inside.length, "program")} in ${item.name}`);
153
- }
154
- await deleteWorkspace(ctx, target, { state, base: item.base, backupRoot: join(ctx.paths.backup, item.repoName), prompter, scan });
155
- ctx.ok(`Removed ${item.name}`);
156
- } catch (error) {
157
- if (!(error instanceof NookError)) throw error;
158
- failed++;
159
- ctx.warn(`${item.name} was not removed: ${error.message.split("\n").join("; ")}`);
160
- }
161
- }
162
- for (const path of leftovers) {
163
- try {
164
- // Compose projects started there go too, as nk rm does for a workspace.
165
- for (const project of findComposeProjects(path, docker).projects) removeComposeProject(project, docker);
166
- deleteFolder(path);
167
- ctx.ok(`Deleted ${path}`);
168
- } catch (error) {
169
- failed++;
170
- ctx.warn(`${path} was not deleted: ${error.message.split("\n").join("; ")}`);
171
- }
172
- }
173
- if (failed) throw new NookError(`${failed} of ${remove.length + leftovers.length} not removed; see the messages above`);
174
- return 0;
175
- }
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { NookError } from "../errors.mjs";
4
+ import { exists, isInside, samePath } from "../home.mjs";
5
+ import { gitTry, listWorktrees, remoteUrl } from "../git.mjs";
6
+ import { githubPrs } from "../forge.mjs";
7
+ import { inspectWorkspace, isMerged } from "../backup.mjs";
8
+ import { describeOccupants, findOccupants, stopProcesses } from "../occupancy.mjs";
9
+ import { createPrompter } from "../prompt.mjs";
10
+ import { branchTip, contains } from "../status.mjs";
11
+ import { resolveRepo } from "../context.mjs";
12
+ import { collect } from "./ls.mjs";
13
+ import { deleteFolder, deleteWorkspace, ignoredData } from "./rm.mjs";
14
+ import { dockerCli, findComposeProjects, removeComposeProject } from "../compose.mjs";
15
+ import { approveHooks } from "./workspace.mjs";
16
+ import { findHook } from "../hooks.mjs";
17
+
18
+ const DAY = 24 * 60 * 60 * 1000;
19
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
20
+
21
+ /**
22
+ * Why a workspace can go, or null. A merged PR counts when the local branch
23
+ * has nothing the PR did not have (this covers squash merges). Without a PR,
24
+ * a branch already in the base counts once the workspace is a day old, so a
25
+ * fresh workspace that has no commits yet is kept (worktrunk's --min-age).
26
+ */
27
+ export function mergedReason(item, now = Date.now()) {
28
+ const { branch, repo, base, pr, ws } = item;
29
+ if (pr?.state === "merged") {
30
+ const tip = branchTip(repo.path, branch);
31
+ if (!tip || (pr.head && contains(repo.path, pr.head, tip))) return { ok: `PR #${pr.number} merged` };
32
+ return { skip: `has commits that are not in PR #${pr.number}` };
33
+ }
34
+ if (pr) return null;
35
+ const age = now - (ws.created ? Date.parse(ws.created) : createdAt(ws.path));
36
+ if (!(age >= DAY)) return null;
37
+ if (branchTip(repo.path, branch) && isMerged(repo.path, branch, base)) return { ok: `merged into ${base}` };
38
+ return null;
39
+ }
40
+
41
+ /** When a folder was made, for worktrees Nook did not record; NaN when unknown. */
42
+ function createdAt(path) {
43
+ try {
44
+ const stat = statSync(path);
45
+ return stat.birthtimeMs || stat.ctimeMs;
46
+ } catch {
47
+ return Number.NaN;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Folders in the repository's workspaces folder that are neither a workspace
53
+ * nor a git worktree: what a removal left halfway, such as node_modules that
54
+ * Windows would not let go of.
55
+ */
56
+ export function leftoverFolders(ctx, repoName, repo) {
57
+ const dir = join(ctx.paths.workspaces, repoName);
58
+ if (!exists(dir)) return [];
59
+ const worktrees = listWorktrees(repo.path);
60
+ return readdirSync(dir, { withFileTypes: true })
61
+ .filter((entry) => entry.isDirectory() && !repo.workspaces[entry.name])
62
+ .map((entry) => join(dir, entry.name))
63
+ .filter((path) => !worktrees.some((w) => samePath(w.path, path)));
64
+ }
65
+
66
+ /** nk clean [-n] [-y]: remove workspaces whose work is merged. */
67
+ export async function cleanWorkspaces(ctx, { repo: repoFlag, dryRun = false, yes = false, prs = githubPrs, scan = findOccupants, stop = stopProcesses, prompter = createPrompter(), now = Date.now(), docker = dockerCli } = {}) {
68
+ const { name, repo } = resolveRepo(ctx, repoFlag);
69
+ if (remoteUrl(repo.path)) {
70
+ const fetched = gitTry(["fetch", "--quiet", "origin", repo.default_branch], { cwd: repo.path });
71
+ if (!fetched.ok) ctx.warn(`Could not fetch origin/${repo.default_branch} for ${name}; using what is local`);
72
+ }
73
+ const { items, problems } = await collect(ctx, { repo: name, prs });
74
+ for (const problem of problems) ctx.warn(`Only checks merges in git: ${problem}`);
75
+
76
+ const remove = [];
77
+ const skipped = [];
78
+ let lost;
79
+ for (const item of items) {
80
+ if (item.main || !item.branch) continue;
81
+ const reason = mergedReason(item, now);
82
+ if (!reason) continue;
83
+ const label = `${item.name} (${item.repoName})`;
84
+ if (item.ws.locked) skipped.push(`${label}: locked (git worktree unlock to allow)`);
85
+ else if (reason.skip) skipped.push(`${label}: ${reason.skip}`);
86
+ else if (isInside(ctx.cwd, item.ws.path)) skipped.push(`${label}: you are inside it`);
87
+ else if (item.changes) skipped.push(`${label}: ${plural(item.changes, "uncommitted change")}`);
88
+ // Files git ignores (a local database, say) are not backed up, so clean leaves them to nk rm, which lists them.
89
+ else if ((lost = item.changes === null ? [] : ignoredData(item.ws.path, join(ctx.paths.shared, item.repoName))).length)
90
+ skipped.push(`${label}: files git ignores would be lost: ${lost.slice(0, 3).join(", ")}${lost.length > 3 ? ` and ${lost.length - 3} more` : ""} (nk rm ${item.name} to remove anyway)`);
91
+ else remove.push({ item, label, reason: reason.ok, running: item.changes === null ? [] : scan(item.ws.path).processes });
92
+ }
93
+
94
+ const leftovers = [];
95
+ for (const path of leftoverFolders(ctx, name, repo)) {
96
+ if (isInside(ctx.cwd, path)) skipped.push(`${path}: you are inside it`);
97
+ else if (scan(path).processes?.length) skipped.push(`${path}: programs are running in it`);
98
+ else leftovers.push(path);
99
+ }
100
+
101
+ if (skipped.length) {
102
+ ctx.warn(`Kept ${skipped.length}:`);
103
+ for (const text of skipped) ctx.item(text);
104
+ }
105
+ if (!remove.length && !leftovers.length) {
106
+ ctx.info("Nothing to clean");
107
+ return 0;
108
+ }
109
+ if (remove.length) {
110
+ ctx.info(`${remove.length === 1 ? "1 workspace is" : `${remove.length} workspaces are`} merged:`);
111
+ const width = Math.max(...remove.map((entry) => entry.label.length));
112
+ for (const entry of remove) ctx.item(`${entry.label.padEnd(width)} ${entry.reason}`);
113
+ }
114
+ if (leftovers.length) {
115
+ ctx.info(`Left from earlier removals (not workspaces any more):`);
116
+ for (const path of leftovers) ctx.item(path);
117
+ }
118
+ const running = remove.flatMap((entry) => entry.running);
119
+ if (running.length) {
120
+ // Same as nk rm: programs inside are listed, and stopped after one yes.
121
+ ctx.warn(`${plural(running.length, "program")} running in them:`);
122
+ for (const entry of remove) for (const p of entry.running) ctx.item(`${p.pid} ${p.name} (${entry.item.name})`);
123
+ }
124
+ if (dryRun) {
125
+ ctx.info("Dry run; nothing was removed");
126
+ return 0;
127
+ }
128
+ // Hook approval comes before anything is stopped or deleted.
129
+ const repos = new Map(remove.map(({ item }) => [item.repoName, item]));
130
+ for (const item of repos.values()) if (findHook(item.repo.path, "remove")) await approveHooks(ctx, item, prompter);
131
+ if (!yes) {
132
+ let ok;
133
+ try {
134
+ const count = remove.length + leftovers.length;
135
+ const what = count === 1 ? "it" : `these ${count}`;
136
+ ok = await prompter.confirm(running.length ? `Stop the programs and remove ${what}?` : `Remove ${what}?`, false);
137
+ } catch {
138
+ throw new NookError("Confirmation needs a terminal; pass -y to remove without asking");
139
+ }
140
+ if (!ok) throw new NookError("Nothing was removed");
141
+ }
142
+
143
+ let failed = 0;
144
+ for (const { item, running: inside } of remove) {
145
+ const target = { repoName: item.repoName, repo: item.repo, name: item.name, ws: item.ws };
146
+ const state = inspectWorkspace({ repo: item.repo.path, path: item.ws.path, branch: item.branch, base: item.base });
147
+ try {
148
+ if (inside.length) {
149
+ await stop(inside);
150
+ const left = scan(item.ws.path).processes;
151
+ if (left.length) throw new NookError(`Could not stop:\n${describeOccupants(left)}`);
152
+ ctx.ok(`Stopped ${plural(inside.length, "program")} in ${item.name}`);
153
+ }
154
+ await deleteWorkspace(ctx, target, { state, base: item.base, backupRoot: join(ctx.paths.backup, item.repoName), prompter, scan });
155
+ ctx.ok(`Removed ${item.name}`);
156
+ } catch (error) {
157
+ if (!(error instanceof NookError)) throw error;
158
+ failed++;
159
+ ctx.warn(`${item.name} was not removed: ${error.message.split("\n").join("; ")}`);
160
+ }
161
+ }
162
+ for (const path of leftovers) {
163
+ try {
164
+ // Compose projects started there go too, as nk rm does for a workspace.
165
+ for (const project of findComposeProjects(path, docker).projects) removeComposeProject(project, docker);
166
+ deleteFolder(path);
167
+ ctx.ok(`Deleted ${path}`);
168
+ } catch (error) {
169
+ failed++;
170
+ ctx.warn(`${path} was not deleted: ${error.message.split("\n").join("; ")}`);
171
+ }
172
+ }
173
+ if (failed) throw new NookError(`${failed} of ${remove.length + leftovers.length} not removed; see the messages above`);
174
+ return 0;
175
+ }
@@ -1,91 +1,100 @@
1
- import { readdirSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { which } from "../agents.mjs";
4
- import { exists, samePath } from "../home.mjs";
5
- import { gitTry, listWorktrees } from "../git.mjs";
6
- import { line, useColor } from "../ui.mjs";
7
- import { dockerCli, leftoverComposeProjects } from "../compose.mjs";
8
- import { ciChanged, uncheckedHooks } from "./check-hooks.mjs";
9
- import { HOOK_EVENTS, findHook, trustState } from "../hooks.mjs";
10
- import { gitVersion, toolStatus } from "../tools.mjs";
11
-
12
- /** nk doctor: checks only, changes nothing. Exit 1 when something is broken. */
13
- export function doctor(ctx, { docker = dockerCli } = {}) {
14
- let broken = 0;
15
- let warnings = 0;
16
- const color = useColor(ctx.out, ctx.env);
17
- const ok = (text) => ctx.print(` ${line("ok", text, color)}`);
18
- const warn = (text) => {
19
- warnings++;
20
- ctx.print(` ${line("warn", text, color)}`);
21
- };
22
- const bad = (text) => {
23
- broken++;
24
- ctx.print(` ${line("fail", text, color)}`);
25
- };
26
-
27
- ctx.print("Basics");
28
- const [major, minor] = process.versions.node.split(".").map(Number);
29
- const recent = major > 20 || (major === 20 && minor >= 12);
30
- (recent ? ok : bad)(`node ${process.versions.node}${recent ? "" : " (need 20.12 or newer)"}`);
31
- const gv = gitVersion();
32
- if (!gv) bad("git not found");
33
- else (gv.major > 2 || (gv.major === 2 && gv.minor >= 20) ? ok : bad)(gv.text);
34
- ok(`home ${ctx.paths.home}${ctx.env.NOOK_HOME ? " (from NOOK_HOME)" : ""}`);
35
- (exists(ctx.paths.config) ? ok : warn)(`config ${ctx.paths.config}${exists(ctx.paths.config) ? "" : " (missing; run nk init)"}`);
36
- const tools = toolStatus(ctx.env);
37
- (tools.gh === "logged in" ? ok : warn)(`gh ${tools.gh}`);
38
- (tools.docker === "running" ? ok : warn)(`docker ${tools.docker}`);
39
-
40
- ctx.print("Agents");
41
- const agents = Object.entries(ctx.config.agents);
42
- if (!agents.length) warn("none recorded (nk init records them)");
43
- for (const [name, agent] of agents) {
44
- const found = agent.path && exists(agent.path) ? agent.path : which(name, ctx.env);
45
- (found ? ok : warn)(`${name} ${found ?? "not found"}`);
46
- }
47
-
48
- for (const [repoName, repo] of Object.entries(ctx.config.repos)) {
49
- ctx.print(`Repository ${repoName}`);
50
- if (!exists(repo.path) || !gitTry(["rev-parse", "--git-dir"], { cwd: repo.path }).ok) {
51
- bad(`${repo.path} is not a git repository`);
52
- continue;
53
- }
54
- ok(repo.path);
55
- const shared = join(ctx.paths.shared, repoName);
56
- (exists(shared) ? ok : warn)(`shared folder ${shared}${exists(shared) ? "" : " (missing)"}`);
57
- const hooks = HOOK_EVENTS.map((event) => findHook(repo.path, event)).filter(Boolean);
58
- const notAllowed = hooks.filter((hook) => trustState(repo, hook) !== "trusted").length;
59
- const untested = uncheckedHooks(repo).length;
60
- const issues = [notAllowed && `${notAllowed} not allowed yet`, untested && `${untested} not tested since changed`, ciChanged(repo) && "CI changed since they were tested"].filter(Boolean);
61
- if (!hooks.length) ok("no hooks (nk hooks write <agent> has an agent write them)");
62
- else (issues.length ? warn : ok)(`hooks ${hooks.map((hook) => hook.event).join(", ")}${issues.length ? `: ${issues.join(", ")} (see nk hooks)` : ""}`);
63
- const worktrees = listWorktrees(repo.path);
64
- for (const [name, ws] of Object.entries(repo.workspaces)) {
65
- const known = worktrees.find((w) => samePath(w.path, ws.path));
66
- if (!exists(ws.path)) bad(`workspace ${name}: ${ws.path} is missing (nk rm ${name} cleans up)`);
67
- else if (!known) bad(`workspace ${name}: ${ws.path} is not a git worktree`);
68
- else if (known.branch !== (ws.branch ?? name)) warn(`workspace ${name}: checked out ${known.branch ?? "detached HEAD"}`);
69
- else ok(`workspace ${name}`);
70
- }
71
- const dir = join(ctx.paths.workspaces, repoName);
72
- if (tools.docker === "running")
73
- for (const project of leftoverComposeProjects(dir, worktrees.map((w) => w.path), docker))
74
- warn(`Compose project ${project.name} was started in ${project.dir}, which is no longer a workspace (remove it with: docker compose -p ${project.name} down --volumes)`);
75
- if (exists(dir))
76
- for (const entry of readdirSync(dir)) {
77
- if (repo.workspaces[entry]) continue;
78
- const path = join(dir, entry);
79
- // A git worktree nk did not create, or a folder left behind when a removal failed halfway.
80
- warn(
81
- worktrees.some((w) => samePath(w.path, path))
82
- ? `${path} is a git worktree nk did not create (remove it with: git worktree remove)`
83
- : `${path} is not a workspace, only a leftover folder (nk clean deletes it)`,
84
- );
85
- }
86
- }
87
-
88
- const notes = warnings ? `, ${warnings} warning(s)` : "";
89
- ctx.print(broken ? `${broken} problem(s) found${notes}` : `No problems found${notes}`);
90
- return broken ? 1 : 0;
91
- }
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { which } from "../agents.mjs";
4
+ import { exists, samePath } from "../home.mjs";
5
+ import { gitTry, listWorktrees } from "../git.mjs";
6
+ import { line, useColor } from "../ui.mjs";
7
+ import { dockerCli, leftoverComposeProjects } from "../compose.mjs";
8
+ import { ciChanged, uncheckedHooks } from "./check-hooks.mjs";
9
+ import { HOOK_EVENTS, findHook, trustState } from "../hooks.mjs";
10
+ import { gitVersion, toolStatus } from "../tools.mjs";
11
+ import { DEFAULT_PORT_BASE, ephemeralRange, inEphemeral } from "../ports.mjs";
12
+
13
+ /** nk doctor: checks only, changes nothing. Exit 1 when something is broken. */
14
+ export function doctor(ctx, { docker = dockerCli } = {}) {
15
+ let broken = 0;
16
+ let warnings = 0;
17
+ const color = useColor(ctx.out, ctx.env);
18
+ const ok = (text) => ctx.print(` ${line("ok", text, color)}`);
19
+ const warn = (text) => {
20
+ warnings++;
21
+ ctx.print(` ${line("warn", text, color)}`);
22
+ };
23
+ const bad = (text) => {
24
+ broken++;
25
+ ctx.print(` ${line("fail", text, color)}`);
26
+ };
27
+
28
+ ctx.print("Basics");
29
+ const [major, minor] = process.versions.node.split(".").map(Number);
30
+ const recent = major > 20 || (major === 20 && minor >= 12);
31
+ (recent ? ok : bad)(`node ${process.versions.node}${recent ? "" : " (need 20.12 or newer)"}`);
32
+ const gv = gitVersion();
33
+ if (!gv) bad("git not found");
34
+ else (gv.major > 2 || (gv.major === 2 && gv.minor >= 20) ? ok : bad)(gv.text);
35
+ ok(`home ${ctx.paths.home}${ctx.env.NOOK_HOME ? " (from NOOK_HOME)" : ""}`);
36
+ (exists(ctx.paths.config) ? ok : warn)(`config ${ctx.paths.config}${exists(ctx.paths.config) ? "" : " (missing; run nk init)"}`);
37
+ const tools = toolStatus(ctx.env);
38
+ (tools.gh === "logged in" ? ok : warn)(`gh ${tools.gh}`);
39
+ (tools.docker === "running" ? ok : warn)(`docker ${tools.docker}`);
40
+ const range = ephemeralRange();
41
+ const base = ctx.config.port_base ?? DEFAULT_PORT_BASE;
42
+ // A range that starts well above the workspace ports (Linux: 32768) leaves plenty of room below it.
43
+ if (range && range.end >= base && range.start < base + 1000) {
44
+ const fix = process.platform === "win32" ? "; Windows' default is 49152-65535 (as administrator: netsh int ipv4 set dynamicport tcp start=49152 num=16384, then restart)" : "";
45
+ warn(`outgoing connections take ports ${range.start}-${range.end}, which overlaps the workspace ports from ${base}; new workspaces avoid them where they can${fix}`);
46
+ } else if (range) ok(`outgoing connections take ports ${range.start}-${range.end}`);
47
+
48
+ ctx.print("Agents");
49
+ const agents = Object.entries(ctx.config.agents);
50
+ if (!agents.length) warn("none recorded (nk init records them)");
51
+ for (const [name, agent] of agents) {
52
+ const found = agent.path && exists(agent.path) ? agent.path : which(name, ctx.env);
53
+ (found ? ok : warn)(`${name} ${found ?? "not found"}`);
54
+ }
55
+
56
+ for (const [repoName, repo] of Object.entries(ctx.config.repos)) {
57
+ ctx.print(`Repository ${repoName}`);
58
+ if (!exists(repo.path) || !gitTry(["rev-parse", "--git-dir"], { cwd: repo.path }).ok) {
59
+ bad(`${repo.path} is not a git repository`);
60
+ continue;
61
+ }
62
+ ok(repo.path);
63
+ const shared = join(ctx.paths.shared, repoName);
64
+ (exists(shared) ? ok : warn)(`shared folder ${shared}${exists(shared) ? "" : " (missing)"}`);
65
+ const hooks = HOOK_EVENTS.map((event) => findHook(repo.path, event)).filter(Boolean);
66
+ const notAllowed = hooks.filter((hook) => trustState(repo, hook) !== "trusted").length;
67
+ const untested = uncheckedHooks(repo).length;
68
+ const issues = [notAllowed && `${notAllowed} not allowed yet`, untested && `${untested} not tested since changed`, ciChanged(repo) && "CI changed since they were tested"].filter(Boolean);
69
+ if (!hooks.length) ok("no hooks (nk hooks write <agent> has an agent write them)");
70
+ else (issues.length ? warn : ok)(`hooks ${hooks.map((hook) => hook.event).join(", ")}${issues.length ? `: ${issues.join(", ")} (see nk hooks)` : ""}`);
71
+ const worktrees = listWorktrees(repo.path);
72
+ for (const [name, ws] of Object.entries(repo.workspaces)) {
73
+ const known = worktrees.find((w) => samePath(w.path, ws.path));
74
+ if (!exists(ws.path)) bad(`workspace ${name}: ${ws.path} is missing (nk rm ${name} cleans up)`);
75
+ else if (!known) bad(`workspace ${name}: ${ws.path} is not a git worktree`);
76
+ else if (known.branch !== (ws.branch ?? name)) warn(`workspace ${name}: checked out ${known.branch ?? "detached HEAD"}`);
77
+ else if (inEphemeral(ws.port, range)) warn(`workspace ${name}: ports ${ws.port}-${ws.port + 9} are among those outgoing connections take, so one may be in use by another program`);
78
+ else ok(`workspace ${name}`);
79
+ }
80
+ const dir = join(ctx.paths.workspaces, repoName);
81
+ if (tools.docker === "running")
82
+ for (const project of leftoverComposeProjects(dir, worktrees.map((w) => w.path), docker))
83
+ warn(`Compose project ${project.name} was started in ${project.dir}, which is no longer a workspace (remove it with: docker compose -p ${project.name} down --volumes)`);
84
+ if (exists(dir))
85
+ for (const entry of readdirSync(dir)) {
86
+ if (repo.workspaces[entry]) continue;
87
+ const path = join(dir, entry);
88
+ // A git worktree nk did not create, or a folder left behind when a removal failed halfway.
89
+ warn(
90
+ worktrees.some((w) => samePath(w.path, path))
91
+ ? `${path} is a git worktree nk did not create (remove it with: git worktree remove)`
92
+ : `${path} is not a workspace, only a leftover folder (nk clean deletes it)`,
93
+ );
94
+ }
95
+ }
96
+
97
+ const notes = warnings ? `, ${warnings} warning(s)` : "";
98
+ ctx.print(broken ? `${broken} problem(s) found${notes}` : `No problems found${notes}`);
99
+ return broken ? 1 : 0;
100
+ }