@ree-auz/nook 0.0.0-stage → 0.1.0

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.
@@ -0,0 +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
+ }
@@ -0,0 +1,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
+
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
+ }
@@ -0,0 +1,57 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { NookError } from "../errors.mjs";
4
+ import { exists, isInside } from "../home.mjs";
5
+ import { resolveRepo } from "../context.mjs";
6
+ import { createPrompter } from "../prompt.mjs";
7
+ import { dockerCli, findComposeProjects, removeComposeProject } from "../compose.mjs";
8
+ import { deleteFolder } from "./rm.mjs";
9
+
10
+ /**
11
+ * nk forget [<short-name>]: nk stops knowing a repository. Its registration
12
+ * and shared files go; the repository folder and backups stay. Workspaces are
13
+ * removed first with nk rm, which backs up their work, unless the repository
14
+ * folder itself is gone: then git cannot remove them and they go with it.
15
+ */
16
+ export async function forgetRepo(ctx, { name: input, yes = false, prompter = createPrompter(), docker = dockerCli } = {}) {
17
+ const { name, repo } = resolveRepo(ctx, input);
18
+ const workspaces = Object.keys(repo.workspaces);
19
+ const present = exists(repo.path);
20
+ if (workspaces.length && present)
21
+ throw new NookError(`${name} still has workspaces: ${workspaces.join(", ")}\nRemove them first, which backs up their work: nk rm ${workspaces.join(" ")} --repo ${name}`);
22
+
23
+ const shared = join(ctx.paths.shared, name);
24
+ const dir = join(ctx.paths.workspaces, name);
25
+ const leftovers = exists(dir) ? readdirSync(dir).map((entry) => join(dir, entry)) : [];
26
+ ctx.info(`Forgetting ${name} (${repo.path}${present ? "" : ", which no longer exists"})`);
27
+ if (exists(shared)) ctx.warn(`Deletes the shared files ${shared}, with the values you filled in`);
28
+ if (leftovers.length) {
29
+ ctx.warn(`Deletes what is left in ${dir}${present ? "" : " (git cannot remove these without the repository)"}:`);
30
+ for (const path of leftovers) ctx.item(path);
31
+ }
32
+ ctx.info(`Keeps the repository folder${exists(join(ctx.paths.backup, name)) ? ` and the backups in ${join(ctx.paths.backup, name)}` : ""}`);
33
+ if (!yes) {
34
+ let ok;
35
+ try {
36
+ ok = await prompter.confirm(`Forget ${name}?`, false);
37
+ } catch {
38
+ throw new NookError("Confirmation needs a terminal; pass -y to forget without asking");
39
+ }
40
+ if (!ok) throw new NookError("Nothing was changed");
41
+ }
42
+
43
+ // What was started from those folders goes with them: Compose projects (containers, volumes, networks,
44
+ // built images) and the service containers generated hooks start (labelled with their workspace).
45
+ if (exists(dir)) {
46
+ for (const project of findComposeProjects(dir, docker).projects) removeComposeProject(project, docker);
47
+ const labelled = docker(["container", "ls", "-a", "--filter", "label=dev.nook.workspace", "--format", '{{.ID}}\t{{.Label "dev.nook.workspace"}}']);
48
+ const ids = labelled.ok ? labelled.out.split(/\r?\n/).filter(Boolean).map((row) => row.split("\t")).filter(([, path]) => path && isInside(path, dir)).map(([id]) => id) : [];
49
+ if (ids.length) docker(["container", "rm", "-f", "-v", ...ids]);
50
+ }
51
+ deleteFolder(dir);
52
+ deleteFolder(shared);
53
+ delete ctx.config.repos[name];
54
+ ctx.save();
55
+ ctx.ok(`Forgot ${name}. Register it again with nk init`);
56
+ return 0;
57
+ }
@@ -0,0 +1,27 @@
1
+ import { resolveRepo } from "../context.mjs";
2
+ import { useColor, line } from "../ui.mjs";
3
+ import { HOOK_EVENTS, findHook, hookHash, trustState } from "../hooks.mjs";
4
+ import { ciChanged } from "./check-hooks.mjs";
5
+
6
+ /** nk hooks: each hook of the repository, whether it is allowed, and whether it passed nk hooks test. */
7
+ export function listHooks(ctx, { repo: repoFlag }) {
8
+ const { name: repoName, repo } = resolveRepo(ctx, repoFlag);
9
+ const color = useColor(ctx.out, ctx.env);
10
+ const hooks = HOOK_EVENTS.map((event) => findHook(repo.path, event)).filter(Boolean);
11
+ ctx.print(`Hooks of ${repoName} (${repo.path}/.nook)`);
12
+ if (!hooks.length) {
13
+ ctx.print(` ${line("info", "none yet; to have an agent write them: nk hooks write <agent>", color)}`);
14
+ return 0;
15
+ }
16
+ let untested = 0;
17
+ for (const hook of hooks) {
18
+ const trust = trustState(repo, hook);
19
+ const tested = repo.checked?.[hook.name] === hookHash(hook.file);
20
+ if (!tested) untested++;
21
+ const notes = [trust === "trusted" ? "allowed" : trust === "new" ? "not allowed yet" : "changed, will ask again", tested ? "tested" : "not tested since it changed"];
22
+ ctx.print(` ${line(tested && trust === "trusted" ? "ok" : "warn", `${hook.event.padEnd(6)} .nook/${hook.name} ${notes.join(", ")}`, color)}`);
23
+ }
24
+ if (ciChanged(repo)) ctx.print(` ${line("warn", "CI changed since the hooks were tested; update them with nk hooks write, then nk hooks test", color)}`);
25
+ if (untested) ctx.print(` ${line("info", "Test them: nk hooks test", color)}`);
26
+ return 0;
27
+ }
@@ -0,0 +1,154 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import spawn from "cross-spawn";
4
+ import { detectAgents, which } from "../agents.mjs";
5
+ import { NookError } from "../errors.mjs";
6
+ import { isInside, samePath } from "../home.mjs";
7
+ import { defaultBranch, listWorktrees, mainWorktree, remoteUrl } from "../git.mjs";
8
+ import { ensureTrusted, findHooks, trustState } from "../hooks.mjs";
9
+ import { assertName, suggestRepoName } from "../names.mjs";
10
+ import { createPrompter, splitArgs } from "../prompt.mjs";
11
+ import { copyFile, includePatterns, knownSecretFiles, listFiles, sharedCandidates, templateFiles } from "../shared.mjs";
12
+ import { toolStatus } from "../tools.mjs";
13
+ import { hostAddresses, pointAtHost } from "../compose-plan.mjs";
14
+ import { planFromCi } from "../ci.mjs";
15
+
16
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
17
+
18
+ function openEditor(ctx, file, prompter) {
19
+ if (!prompter.interactive || prompter.yes) return false;
20
+ const editor = ctx.env.VISUAL || ctx.env.EDITOR;
21
+ let command;
22
+ if (editor) command = [...splitArgs(editor), file];
23
+ else if (process.platform === "win32") command = ["notepad", file];
24
+ else if (process.platform === "darwin") command = ["open", "-W", "-t", file];
25
+ else if (which("xdg-open", ctx.env)) command = ["xdg-open", file];
26
+ else return false;
27
+ spawn.sync(command[0], command.slice(1), { stdio: "inherit" });
28
+ return true;
29
+ }
30
+
31
+ /**
32
+ * Fills the shared folder. A file already there is never replaced, so running
33
+ * init again only adds what is new (a template added since, for example).
34
+ */
35
+ async function prepareShared(ctx, { repoPath, sharedDir, prompter }) {
36
+ mkdirSync(sharedDir, { recursive: true });
37
+ const present = new Set(listFiles(sharedDir));
38
+ const { patterns, source } = includePatterns(repoPath);
39
+ ctx.info(`Shared files follow ${source === "default" ? "the default pattern .env*" : ".worktreeinclude"}; only files git ignores are used`);
40
+ const candidates = sharedCandidates(repoPath, patterns);
41
+ const templates = templateFiles(repoPath);
42
+ const copies = [];
43
+ const fill = [];
44
+ for (const template of templates) {
45
+ if (present.has(template.to)) continue;
46
+ // A committed template is the safe start: the same file in the main checkout may be production.
47
+ copies.push({ from: join(repoPath, template.from), to: template.to });
48
+ fill.push(join(sharedDir, template.to));
49
+ ctx.info(`Starting shared ${template.to} from ${template.from}`);
50
+ }
51
+ const covered = new Set([...templates.map((template) => template.to), ...present]);
52
+ const others = candidates.filter((file) => !covered.has(file));
53
+ if (others.length && (await prompter.confirm(`Copy ${others.join(", ")} from the repository into the shared folder? (only development files, not production)`, false)))
54
+ for (const file of others) copies.push({ from: join(repoPath, file), to: file });
55
+
56
+ for (const file of knownSecretFiles(repoPath, [...candidates, ...covered]))
57
+ if (!present.has(file) && (await prompter.confirm(`Found ignored ${file}. Share it with new workspaces too?`, false)))
58
+ copies.push({ from: join(repoPath, file), to: file });
59
+
60
+ for (const copy of copies) copyFile(copy.from, join(sharedDir, copy.to));
61
+ // A template for running the app inside Docker names Compose services (postgres:5432); here the app runs on
62
+ // this machine, so point those values at where the services are published.
63
+ // When the CI job has services, those are started instead of Compose's (see nk hooks write).
64
+ const addresses = fill.length ? hostAddresses(repoPath, { start: !planFromCi(repoPath).best?.services.length }) : {};
65
+ if (Object.keys(addresses).length)
66
+ for (const file of fill) {
67
+ const { text, keys } = pointAtHost(readFileSync(file, "utf8"), addresses);
68
+ if (!keys.length) continue;
69
+ writeFileSync(file, text);
70
+ ctx.info(`Pointed ${keys.join(", ")} in ${basename(file)} at the Compose services on this machine (each workspace then uses its own ports)`);
71
+ }
72
+ if (present.size) ctx.info(`Kept shared files: ${[...present].join(", ")} (edit them in ${sharedDir})`);
73
+ if (copies.length) ctx.ok(`Shared files in ${sharedDir}: ${copies.map((c) => c.to).join(", ")}`);
74
+ for (const file of fill) {
75
+ if (openEditor(ctx, file, prompter)) ctx.ok(`Saved ${file}`);
76
+ else ctx.warn(`Fill in ${file} once; every new workspace copies it`);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Records the agents on PATH. Nothing is asked: each starts with its own
82
+ * default command, and `args` in config.toml can be edited later if wanted.
83
+ */
84
+ function recordAgents(ctx) {
85
+ const found = detectAgents(ctx.env);
86
+ ctx.info(found.length ? "Agents found (start one with nk <name> <agent>):" : "No known agents found on PATH");
87
+ for (const agent of found) {
88
+ ctx.item(`${agent.name} ${agent.version || "?"} ${agent.path}`);
89
+ const args = ctx.config.agents[agent.name]?.args ?? [];
90
+ ctx.config.agents[agent.name] = { path: agent.path, version: agent.version, args };
91
+ }
92
+ }
93
+
94
+ /** Worktrees other tools made need no adopting: nk ls, rm and clean read them from git. */
95
+ function listOtherWorktrees(ctx, { repoPath, repo }) {
96
+ const known = Object.values(repo.workspaces).map((ws) => ws.path);
97
+ const others = listWorktrees(repoPath)
98
+ .slice(1)
99
+ .filter((w) => !known.some((path) => samePath(path, w.path)));
100
+ if (!others.length) return;
101
+ ctx.info(`Other worktrees of this repository (nk ls, nk rm and nk clean include them):`);
102
+ for (const w of others) ctx.item(`${w.branch ?? "(detached)"} ${w.path}`);
103
+ }
104
+
105
+ /** nk init: run once per repository; safe to run again. */
106
+ export async function init(ctx, { yes = false, allowHooks = false, name: nameFlag } = {}) {
107
+ const prompter = createPrompter({ yes });
108
+ const repoPath = mainWorktree(ctx.cwd);
109
+ if (isInside(repoPath, ctx.paths.home)) throw new NookError("Run nk init in the main repository, not in a workspace");
110
+ const registered = Object.entries(ctx.config.repos).find(([, repo]) => samePath(repo.path, repoPath));
111
+ let name;
112
+ if (registered) {
113
+ name = registered[0];
114
+ if (nameFlag && nameFlag !== name) throw new NookError(`This repository is already registered as "${name}"`);
115
+ } else {
116
+ name = assertName(nameFlag ?? (await prompter.ask("Short name for this repository", suggestRepoName(basename(repoPath)))), "repository");
117
+ if (ctx.config.repos[name]) throw new NookError(`"${name}" is already used for ${ctx.config.repos[name].path}`);
118
+ }
119
+
120
+ const repo = (ctx.config.repos[name] ??= { workspaces: {}, hooks: {} });
121
+ repo.path = repoPath;
122
+ repo.remote = remoteUrl(repoPath);
123
+ repo.default_branch = defaultBranch(repoPath);
124
+ repo.workspaces ??= {};
125
+ repo.hooks ??= {};
126
+ ctx.ok(`Registered ${name} @ ${repoPath}`);
127
+ ctx.info(`Remote ${repo.remote || "(none)"}, default branch ${repo.default_branch}`);
128
+ mkdirSync(join(ctx.paths.workspaces, name), { recursive: true });
129
+
130
+ listOtherWorktrees(ctx, { repoPath, repo });
131
+ recordAgents(ctx);
132
+ const tools = toolStatus(ctx.env);
133
+ ctx.info(`gh: ${tools.gh}; docker: ${tools.docker}`);
134
+ await prepareShared(ctx, { repoPath, sharedDir: join(ctx.paths.shared, name), prompter });
135
+ ctx.save();
136
+
137
+ // Allowing hooks lets this repository's scripts run on this machine: --yes skips questions, but
138
+ // only --allow-hooks (or a person answering in a terminal) gives that.
139
+ const hooks = findHooks(repoPath);
140
+ const pending = hooks.filter((hook) => trustState(repo, hook) !== "trusted");
141
+ if (pending.length && !allowHooks && (yes || !prompter.interactive)) {
142
+ ctx.warn(`${plural(pending.length, "hook")} not allowed yet: ${pending.map((h) => `.nook/${h.name}`).join(", ")}`);
143
+ ctx.info(`Read them in ${join(repoPath, ".nook")}, then run nk init --allow-hooks, or nk init in a terminal`);
144
+ } else {
145
+ const hookPrompter = allowHooks ? { confirm: async () => true } : prompter;
146
+ await ensureTrusted({ repoName: name, repoConfig: repo, hooks, prompter: hookPrompter, save: () => ctx.save(), ui: ctx });
147
+ if (allowHooks && pending.length) ctx.info(`Allowed ${pending.map((h) => `.nook/${h.name}`).join(", ")} (--allow-hooks)`);
148
+ }
149
+ if (!hooks.length) ctx.info("No .nook/ hooks yet. To have an agent write them: nk hooks write <agent>");
150
+ ctx.save();
151
+ ctx.ok(`Done. Config: ${ctx.paths.config}`);
152
+ ctx.info("Next: nk -b <name> [agent]");
153
+ return 0;
154
+ }
@@ -0,0 +1,107 @@
1
+ import { NookError } from "../errors.mjs";
2
+ import { baseRef, remoteUrl } from "../git.mjs";
3
+ import { githubPrs } from "../forge.mjs";
4
+ import { aheadBehind, changeCount } from "../status.mjs";
5
+ import { useColor } from "../ui.mjs";
6
+ import { branchOf, repoFromCwd, repoWorkspaces } from "../context.mjs";
7
+ import { isInside } from "../home.mjs";
8
+
9
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
10
+
11
+ function table(rows) {
12
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)));
13
+ return rows.map((row) => row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))).join(" "));
14
+ }
15
+
16
+ /** Every workspace with its local state and, when gh can tell, its PR. */
17
+ export async function collect(ctx, { repo: repoFlag, prs: lookup = githubPrs } = {}) {
18
+ const only = repoFlag ?? repoFromCwd(ctx);
19
+ if (only && !ctx.config.repos[only]) throw new NookError(`Unknown repository "${only}"`);
20
+ const repos = Object.entries(ctx.config.repos).filter(([name]) => !only || name === only);
21
+ const fetched = await Promise.all(repos.map(([, repo]) => (remoteUrl(repo.path) ? lookup(repo.path, ctx.env) : { prs: null })));
22
+ const items = [];
23
+ const problems = new Set();
24
+ repos.forEach(([repoName, repo], i) => {
25
+ const { prs, error } = fetched[i];
26
+ if (error) problems.add(error);
27
+ const base = baseRef(repo.path, repo.default_branch);
28
+ for (const item of repoWorkspaces(repoName, repo)) {
29
+ const branch = branchOf(item.name, item.ws);
30
+ const pr = item.main || !branch ? null : (prs?.get(branch) ?? null);
31
+ const commits = item.main || !branch ? null : aheadBehind(repo.path, branch, base);
32
+ items.push({ ...item, branch, base, changes: changeCount(item.ws.path), commits, pr });
33
+ }
34
+ });
35
+ return { items, problems: [...problems] };
36
+ }
37
+
38
+ const portsText = (ws) => (Number.isInteger(ws.port) ? `${ws.port}-${ws.port + 9}` : "-");
39
+
40
+ function changesText(n) {
41
+ if (n === null) return "missing";
42
+ return n ? `${n} changed` : "clean";
43
+ }
44
+
45
+ function commitsText(c) {
46
+ if (!c) return "";
47
+ return [c.ahead && `↑${c.ahead}`, c.behind && `↓${c.behind}`].filter(Boolean).join(" ");
48
+ }
49
+
50
+ function prText(pr) {
51
+ if (!pr) return "";
52
+ return `#${pr.number} ${pr.state}${pr.ci ? ` ${pr.ci}` : ""}`;
53
+ }
54
+
55
+ /** nk ls [--json] */
56
+ export async function listWorkspaces(ctx, { repo: repoFlag, json = false, prs } = {}) {
57
+ const { items, problems } = await collect(ctx, { repo: repoFlag, prs });
58
+ if (json) {
59
+ const data = items.map(({ name, branch, repoName, ws, base, changes, commits, pr, main, managed }) => ({
60
+ name,
61
+ branch,
62
+ main,
63
+ managed,
64
+ locked: Boolean(ws.locked),
65
+ repo: repoName,
66
+ path: ws.path,
67
+ port: ws.port ?? null,
68
+ base,
69
+ changes,
70
+ ahead: commits?.ahead ?? null,
71
+ behind: commits?.behind ?? null,
72
+ pr,
73
+ }));
74
+ ctx.print(JSON.stringify(data, null, 2));
75
+ return 0;
76
+ }
77
+ if (!items.length) {
78
+ ctx.info("No repositories yet. Run nk init inside one");
79
+ return 0;
80
+ }
81
+ // Gutter as in worktrunk: ^ main checkout, @ where you are, + another worktree.
82
+ const mark = (item) => (isInside(ctx.cwd, item.ws.path) && !items.some((o) => o !== item && o.ws.path.length > item.ws.path.length && isInside(ctx.cwd, o.ws.path)) ? "@" : item.main ? "^" : "+");
83
+ const rows = [["", "NAME", "REPO", "PORTS", "CHANGES", "BASE", "PR", "PATH"]];
84
+ for (const item of items) {
85
+ const name = item.ws.locked ? `${item.name} (locked)` : item.name;
86
+ rows.push([mark(item), name, item.repoName, portsText(item.ws), changesText(item.changes), commitsText(item.commits), prText(item.pr), item.ws.path]);
87
+ }
88
+ const lines = table(rows);
89
+ // Merged rows are dimmed, as worktrunk does for branches that are safe to delete.
90
+ const dim = useColor(ctx.out, ctx.env);
91
+ items.forEach((item, i) => {
92
+ if (dim && item.pr?.state === "merged") lines[i + 1] = `\x1b[2m${lines[i + 1]}\x1b[0m`;
93
+ });
94
+ ctx.print(lines.join("\n"));
95
+
96
+ const workspaces = items.filter((item) => !item.main);
97
+ const changed = workspaces.filter((item) => item.changes).length;
98
+ const merged = workspaces.filter((item) => item.pr?.state === "merged").length;
99
+ const outside = workspaces.filter((item) => !item.managed).length;
100
+ const parts = [`Showing ${plural(workspaces.length, "workspace")} and the main checkout`];
101
+ if (outside) parts.push(`${outside} made by other tools`);
102
+ if (changed) parts.push(`${changed} with changes`);
103
+ if (merged) parts.push(`${merged} merged (nk clean removes them)`);
104
+ ctx.info(parts.join(", "));
105
+ for (const problem of problems) ctx.warn(`No PR status: ${problem}`);
106
+ return 0;
107
+ }