@ree-auz/nook 0.0.0-stage → 0.1.1
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 -0
- package/README.md +421 -2
- package/bin/nk.mjs +4 -0
- package/docs/setup-hooks.md +107 -0
- package/integrations/linear/README.md +84 -0
- package/integrations/linear/coding-tools.json +7 -0
- package/integrations/linear/open-issue.mjs +147 -0
- package/package.json +32 -4
- package/skills/nook/SKILL.md +71 -0
- package/src/agents.mjs +92 -0
- package/src/backup.mjs +74 -0
- package/src/ci.mjs +378 -0
- package/src/cli.mjs +151 -0
- package/src/commands/check-hooks.mjs +128 -0
- package/src/commands/clean.mjs +175 -0
- package/src/commands/doctor.mjs +100 -0
- package/src/commands/forget.mjs +57 -0
- package/src/commands/hooks.mjs +34 -0
- package/src/commands/init.mjs +154 -0
- package/src/commands/ls.mjs +107 -0
- package/src/commands/rm.mjs +190 -0
- package/src/commands/workspace.mjs +163 -0
- package/src/commands/write-hooks.mjs +185 -0
- package/src/compose-plan.mjs +228 -0
- package/src/compose.mjs +123 -0
- package/src/config.mjs +115 -0
- package/src/context.mjs +189 -0
- package/src/errors.mjs +8 -0
- package/src/forge.mjs +75 -0
- package/src/generate-hooks.mjs +367 -0
- package/src/git.mjs +86 -0
- package/src/home.mjs +48 -0
- package/src/hooks.mjs +135 -0
- package/src/names.mjs +65 -0
- package/src/occupancy-windows.ps1 +59 -0
- package/src/occupancy.mjs +131 -0
- package/src/ports.mjs +89 -0
- package/src/prompt.mjs +100 -0
- package/src/shared.mjs +97 -0
- package/src/status.mjs +30 -0
- package/src/tools.mjs +23 -0
- package/src/ui.mjs +60 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { rmSync } 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 { baseRef, gitTry, listWorktrees, remoteUrl } from "../git.mjs";
|
|
6
|
+
import { githubPrs } from "../forge.mjs";
|
|
7
|
+
import { findHook } from "../hooks.mjs";
|
|
8
|
+
import { backupDir, createBackup, inspectWorkspace, needsBackup } from "../backup.mjs";
|
|
9
|
+
import { describeOccupants, findOccupants, stopProcesses } from "../occupancy.mjs";
|
|
10
|
+
import { createPrompter } from "../prompt.mjs";
|
|
11
|
+
import { branchOf, resolveWorkspace } from "../context.mjs";
|
|
12
|
+
import { toName } from "../names.mjs";
|
|
13
|
+
import { approveHooks, hook } from "./workspace.mjs";
|
|
14
|
+
import { listFiles } from "../shared.mjs";
|
|
15
|
+
import { setupChanges } from "./check-hooks.mjs";
|
|
16
|
+
import { describeProject, dockerCli, findComposeProjects, removeComposeProject } from "../compose.mjs";
|
|
17
|
+
|
|
18
|
+
function occupants(ctx, path, scan) {
|
|
19
|
+
const result = scan(path);
|
|
20
|
+
if (result.unsupported) ctx.warn(`Could not check for running programs: ${result.unsupported}`);
|
|
21
|
+
return result.processes;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Backs up what would be lost, runs the remove hook, deletes the folder and
|
|
28
|
+
* the branch, and forgets the workspace. Programs must already be stopped.
|
|
29
|
+
*/
|
|
30
|
+
export async function deleteWorkspace(ctx, target, { state, base, backupRoot, prompter, scan = findOccupants, docker = dockerCli }) {
|
|
31
|
+
const { repo, ws, name } = target;
|
|
32
|
+
const branchName = branchOf(name, ws);
|
|
33
|
+
if (needsBackup(state)) {
|
|
34
|
+
const dir = createBackup({ repo: repo.path, path: ws.path, branch: branchName, base, state, dir: backupDir(backupRoot, name) });
|
|
35
|
+
ctx.ok(`Backed up changes to ${dir}`);
|
|
36
|
+
}
|
|
37
|
+
if (state.present) {
|
|
38
|
+
try {
|
|
39
|
+
await hook(ctx, target, "remove", { prompter });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error instanceof NookError) error.message += "\nStopped; nothing was deleted";
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// What the workspace started with Docker Compose goes too, so no remove hook is needed for it.
|
|
46
|
+
// Checked again after the hook, which may have removed some of it already.
|
|
47
|
+
for (const project of findComposeProjects(ws.path, docker).projects) {
|
|
48
|
+
try {
|
|
49
|
+
removeComposeProject(project, docker);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new NookError(`${error.message}\nStopped; the folder was not deleted`);
|
|
52
|
+
}
|
|
53
|
+
ctx.ok(`Removed ${describeProject(project)}`);
|
|
54
|
+
}
|
|
55
|
+
if (state.present) {
|
|
56
|
+
const after = occupants(ctx, ws.path, scan);
|
|
57
|
+
if (after.length) throw new NookError(`Programs started running in ${ws.path}:\n${describeOccupants(after)}\nNothing was deleted`);
|
|
58
|
+
// Changes are backed up by now, so the folder goes even when git cannot remove the worktree
|
|
59
|
+
// (a damaged one, say); prune below then drops git's record of it.
|
|
60
|
+
const removed = gitTry(["worktree", "remove", "--force", ws.path], { cwd: repo.path });
|
|
61
|
+
if (!removed.ok) ctx.warn(`git worktree remove failed (${removed.err.split("\n")[0]}); deleting the folder instead`);
|
|
62
|
+
// git can leave ignored folders behind on Windows (node_modules, .venv), even when it reports success.
|
|
63
|
+
deleteFolder(ws.path);
|
|
64
|
+
}
|
|
65
|
+
gitTry(["worktree", "prune"], { cwd: repo.path });
|
|
66
|
+
if (listWorktrees(repo.path).some((tree) => samePath(tree.path, ws.path)))
|
|
67
|
+
throw new NookError(`git still lists ${ws.path} as a worktree. Run git worktree prune in ${repo.path}, then nk rm ${name} again`);
|
|
68
|
+
if (branchName) {
|
|
69
|
+
const branch = gitTry(["branch", "-D", branchName], { cwd: repo.path });
|
|
70
|
+
if (!branch.ok && !/not found/i.test(branch.err)) ctx.warn(`Branch ${branchName} was kept: ${branch.err}`);
|
|
71
|
+
}
|
|
72
|
+
delete repo.workspaces[name];
|
|
73
|
+
ctx.save();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Folders a project rebuilds from its own files, and files nobody keeps: losing them loses nothing.
|
|
77
|
+
const REBUILT = new Set(["node_modules", ".venv", "venv", "vendor", "dist", "build", "out", "target", "coverage", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".next", ".nuxt", ".svelte-kit", ".turbo", ".cache", ".parcel-cache", ".gradle", ".tox", "_build", "deps", "tmp", "log", "logs", ".idea", ".vscode", ".DS_Store"]);
|
|
78
|
+
|
|
79
|
+
/** Ignored files and folders in a workspace that hold more than a rebuild or a shared copy: a local database, say. */
|
|
80
|
+
export function ignoredData(path, sharedDir) {
|
|
81
|
+
const listed = gitTry(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--no-empty-directory"], { cwd: path });
|
|
82
|
+
if (!listed.ok) return [];
|
|
83
|
+
const shared = new Set(listFiles(sharedDir));
|
|
84
|
+
return listed.out
|
|
85
|
+
.split(/\r?\n/)
|
|
86
|
+
.filter((entry) => entry && !shared.has(entry) && !/\.(log|pyc)$/.test(entry) && !entry.split("/").some((part) => REBUILT.has(part)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Deletes a folder and everything in it, retrying while Windows still holds a file open. */
|
|
90
|
+
export function deleteFolder(path) {
|
|
91
|
+
if (!exists(path)) return;
|
|
92
|
+
try {
|
|
93
|
+
rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// A container that mounts the workspace and runs as root leaves files this user cannot delete.
|
|
96
|
+
if (error.code === "EACCES" || (error.code === "EPERM" && process.platform !== "win32"))
|
|
97
|
+
throw new NookError(`Could not delete ${path}: ${error.message}\nA container probably wrote these files as another user (often root). Delete the folder with sudo rm -rf, or have the remove hook delete them from inside the container`);
|
|
98
|
+
throw new NookError(`Could not delete ${path}: ${error.message}\nClose what uses it and run the command again`);
|
|
99
|
+
}
|
|
100
|
+
if (exists(path)) throw new NookError(`Could not delete ${path}; close what uses it and run the command again`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What removing one workspace involves, checked before anything changes. */
|
|
104
|
+
async function planRemoval(ctx, input, { repoFlag, scan, prs, docker }) {
|
|
105
|
+
const name = toName(input);
|
|
106
|
+
const target = resolveWorkspace(ctx, name, repoFlag);
|
|
107
|
+
const { repo, ws } = target;
|
|
108
|
+
if (target.main) throw new NookError(`${ws.path} is the main checkout of ${target.repoName}; git cannot remove it`);
|
|
109
|
+
if (ws.locked) throw new NookError(`${ws.path} is locked. Unlock it first: git worktree unlock ${ws.path}`);
|
|
110
|
+
if (exists(ws.path) && isInside(ctx.cwd, ws.path))
|
|
111
|
+
throw new NookError(`You are inside ${ws.path}. Change to another folder first, e.g. cd ${repo.path}`);
|
|
112
|
+
|
|
113
|
+
const base = baseRef(repo.path, repo.default_branch);
|
|
114
|
+
const branchName = branchOf(target.name, ws);
|
|
115
|
+
const state = inspectWorkspace({ repo: repo.path, path: ws.path, branch: branchName ?? "", base });
|
|
116
|
+
const backupRoot = join(ctx.paths.backup, target.repoName);
|
|
117
|
+
const removeHook = state.present ? findHook(repo.path, "remove") : null;
|
|
118
|
+
|
|
119
|
+
ctx.info(`Removing ${target.name} @ ${ws.path}${state.present ? "" : " (folder already missing)"}${!branchName ? "" : branchName === target.name ? " and its branch" : ` and its branch ${branchName}`}`);
|
|
120
|
+
if (removeHook) ctx.info(`Runs .nook/${removeHook.name} first`);
|
|
121
|
+
const changed = setupChanges(repo).changes.filter((what) => what.startsWith(".nook/"));
|
|
122
|
+
if (state.present && changed.length) ctx.warn(`${changed.join(", ")} changed since nk hooks test last passed; remove may leave things behind`);
|
|
123
|
+
const compose = findComposeProjects(ws.path, docker);
|
|
124
|
+
for (const project of compose.projects) ctx.info(`Removes ${describeProject(project)}`);
|
|
125
|
+
for (const name of compose.shared) ctx.warn(`Compose project ${name} also has containers outside this workspace; it is kept`);
|
|
126
|
+
if (compose.unavailable && compose.unavailable !== "not installed") ctx.warn(`Could not check for Docker containers: ${compose.unavailable}`);
|
|
127
|
+
if (needsBackup(state)) ctx.info(`Backs up changes to ${backupRoot}`);
|
|
128
|
+
const lost = state.present ? ignoredData(ws.path, join(ctx.paths.shared, target.repoName)) : [];
|
|
129
|
+
if (lost.length) ctx.warn(`Not backed up, since git ignores them: ${lost.slice(0, 8).join(", ")}${lost.length > 8 ? ` and ${lost.length - 8} more` : ""}`);
|
|
130
|
+
if (state.changes.length) {
|
|
131
|
+
ctx.warn(`${plural(state.changes.length, "uncommitted change")}:`);
|
|
132
|
+
for (const change of state.changes.slice(0, 10)) ctx.item(change);
|
|
133
|
+
if (state.changes.length > 10) ctx.item(`... ${state.changes.length - 10} more`);
|
|
134
|
+
}
|
|
135
|
+
if (state.unpushed.length) ctx.warn(`${plural(state.unpushed.length, "commit")} not pushed`);
|
|
136
|
+
if (branchName && !state.merged) {
|
|
137
|
+
// A squash merge leaves the branch outside the base; the PR says whether it was merged.
|
|
138
|
+
const pr = remoteUrl(repo.path) ? (await prs(repo.path, ctx.env)).prs?.get(branchName) : null;
|
|
139
|
+
if (pr?.state === "merged") ctx.info(`PR #${pr.number} is merged`);
|
|
140
|
+
else ctx.warn(`Branch ${branchName} is not merged into ${base}`);
|
|
141
|
+
}
|
|
142
|
+
const running = state.present ? occupants(ctx, ws.path, scan) : [];
|
|
143
|
+
return { target, state, base, backupRoot, removeHook, running };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** nk rm <name>... [-n] [-y]: checks every workspace first, asks once, then removes them in turn. */
|
|
147
|
+
export async function removeWorkspace(ctx, { name, names = [name], repo: repoFlag, dryRun = false, yes = false, scan = findOccupants, stop = stopProcesses, prompter = createPrompter(), prs = githubPrs, docker = dockerCli }) {
|
|
148
|
+
const plans = [];
|
|
149
|
+
for (const input of [...new Set(names.map(toName))]) plans.push(await planRemoval(ctx, input, { repoFlag, scan, prs, docker }));
|
|
150
|
+
if (dryRun) {
|
|
151
|
+
ctx.info("Dry run; nothing was removed");
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const running = plans.flatMap((plan) => plan.running);
|
|
156
|
+
if (running.length) {
|
|
157
|
+
ctx.warn(`${plural(running.length, "program")} running in ${plans.length === 1 ? "the workspace" : "these workspaces"}:`);
|
|
158
|
+
for (const item of running) ctx.item(`${item.pid} ${item.name}`);
|
|
159
|
+
}
|
|
160
|
+
// Hook approval comes before anything is stopped or deleted.
|
|
161
|
+
const approved = new Set();
|
|
162
|
+
for (const { target, removeHook } of plans)
|
|
163
|
+
if (removeHook && !approved.has(target.repoName)) {
|
|
164
|
+
approved.add(target.repoName);
|
|
165
|
+
await approveHooks(ctx, target, prompter);
|
|
166
|
+
}
|
|
167
|
+
if (!yes) {
|
|
168
|
+
const what = plans.map((plan) => plan.target.name).join(", ");
|
|
169
|
+
const question = running.length ? `Stop ${running.length === 1 ? "it" : "them"} and remove ${what}?` : `Remove ${what}?`;
|
|
170
|
+
let ok;
|
|
171
|
+
try {
|
|
172
|
+
ok = await prompter.confirm(question, false);
|
|
173
|
+
} catch {
|
|
174
|
+
throw new NookError("Confirmation needs a terminal; pass -y to remove without asking");
|
|
175
|
+
}
|
|
176
|
+
if (!ok) throw new NookError("Nothing was removed");
|
|
177
|
+
}
|
|
178
|
+
if (running.length) {
|
|
179
|
+
await stop(running);
|
|
180
|
+
const left = plans.flatMap((plan) => (plan.state.present ? occupants(ctx, plan.target.ws.path, scan) : []));
|
|
181
|
+
if (left.length) throw new NookError(`Could not stop:\n${describeOccupants(left)}\nNothing was removed`);
|
|
182
|
+
ctx.ok(`Stopped ${plural(running.length, "program")}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const { target, state, base, backupRoot } of plans) {
|
|
186
|
+
await deleteWorkspace(ctx, target, { state, base, backupRoot, prompter, scan, docker });
|
|
187
|
+
ctx.ok(`Removed ${target.name}`);
|
|
188
|
+
}
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { agentCommand, launch } from "../agents.mjs";
|
|
4
|
+
import { NookError } from "../errors.mjs";
|
|
5
|
+
import { exists } from "../home.mjs";
|
|
6
|
+
import { baseRef, git, gitTry, refExists, remoteUrl } from "../git.mjs";
|
|
7
|
+
import { ensureTrusted, findHook, findHooks, runHook } from "../hooks.mjs";
|
|
8
|
+
import { nameProblem, normalizeName, toName } from "../names.mjs";
|
|
9
|
+
import { allocatePort } from "../ports.mjs";
|
|
10
|
+
import { createPrompter } from "../prompt.mjs";
|
|
11
|
+
import { copyShared } from "../shared.mjs";
|
|
12
|
+
import { resolveRepo, resolveWorkspace, workspaceEnv } from "../context.mjs";
|
|
13
|
+
import { setupChanges } from "./check-hooks.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Before work starts: hooks or CI that changed since nk hooks test last passed
|
|
17
|
+
* stop a new workspace (they may no longer set it up or clean it up), unless
|
|
18
|
+
* `untested` is passed; an existing workspace only gets a warning.
|
|
19
|
+
*/
|
|
20
|
+
function checkSetup(ctx, repo, { stop, untested }) {
|
|
21
|
+
const { tested, changes } = setupChanges(repo);
|
|
22
|
+
if (!changes.length) return;
|
|
23
|
+
if (!tested) {
|
|
24
|
+
if (findHooks(repo.path).length) ctx.warn("The hooks have never passed nk hooks test");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const what = `${changes.join(" and ")} changed since nk hooks test last passed`;
|
|
28
|
+
if (stop && !untested) throw new NookError(`${what}\nRun nk hooks test first, or pass --untested to start anyway`);
|
|
29
|
+
ctx.warn(`${what} (run: nk hooks test)`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Asks once for hooks that are new or changed; nothing when all are allowed. */
|
|
33
|
+
export function approveHooks(ctx, { repoName, repo }, prompter = createPrompter()) {
|
|
34
|
+
return ensureTrusted({ repoName, repoConfig: repo, hooks: findHooks(repo.path), prompter, save: () => ctx.save(), ui: ctx });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runs one hook of the repository in the workspace. Hooks are read from the
|
|
39
|
+
* main checkout, like worktrunk reads its project config from where the
|
|
40
|
+
* command runs, so a branch made before the hooks existed still gets them.
|
|
41
|
+
* Throws on a non-zero exit.
|
|
42
|
+
*/
|
|
43
|
+
export async function hook(ctx, target, event, { prompter = createPrompter() } = {}) {
|
|
44
|
+
const found = findHook(target.repo.path, event);
|
|
45
|
+
if (!found) return null;
|
|
46
|
+
await approveHooks(ctx, target, prompter);
|
|
47
|
+
const result = await runHook(found, {
|
|
48
|
+
cwd: target.ws.path,
|
|
49
|
+
env: workspaceEnv(ctx, target),
|
|
50
|
+
logsDir: ctx.paths.logs,
|
|
51
|
+
label: `${target.repoName}-${target.name}`,
|
|
52
|
+
output: ctx.err,
|
|
53
|
+
});
|
|
54
|
+
if (result.status !== 0)
|
|
55
|
+
throw new NookError(`.nook/${found.name} failed (exit ${result.status}). Log: ${result.log}`, { code: result.status || 1 });
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function finish(ctx, target, { agent, prompt, extra }) {
|
|
60
|
+
if (!agent) {
|
|
61
|
+
if (prompt || extra.length) throw new NookError("-p and extra arguments need an agent, e.g. nk <name> <agent> -p ...");
|
|
62
|
+
// On a terminal the path is a line like the others; captured (cd "$(nk …)"), it is the bare path.
|
|
63
|
+
if (ctx.out.isTTY) ctx.ok(`Ready at ${target.ws.path}`);
|
|
64
|
+
else ctx.print(target.ws.path);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
const command = agentCommand(ctx.config, agent, { prompt, extra }, ctx.env);
|
|
68
|
+
ctx.step(`Starting ${agent} in ${target.ws.path}`);
|
|
69
|
+
return launch(command, { cwd: target.ws.path, env: workspaceEnv(ctx, target) });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** An existing local or origin branch spelled exactly like `input`, or null. */
|
|
73
|
+
function existingBranch(repoPath, input) {
|
|
74
|
+
if (!gitTry(["check-ref-format", "--branch", input], { cwd: repoPath }).ok) return null;
|
|
75
|
+
return refExists(repoPath, `refs/heads/${input}`) || refExists(repoPath, `refs/remotes/origin/${input}`) ? input : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function addWorktree(repoPath, name, path, defaultBranch) {
|
|
79
|
+
if (refExists(repoPath, `refs/heads/${name}`)) git(["worktree", "add", path, name], { cwd: repoPath });
|
|
80
|
+
else if (refExists(repoPath, `refs/remotes/origin/${name}`))
|
|
81
|
+
git(["worktree", "add", "--track", "-b", name, path, `origin/${name}`], { cwd: repoPath });
|
|
82
|
+
else git(["worktree", "add", "--no-track", "-b", name, path, baseRef(repoPath, defaultBranch)], { cwd: repoPath });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The name for a new workspace. When nk had to change what was passed in and
|
|
87
|
+
* there is a terminal, the converted name is shown ready to edit.
|
|
88
|
+
*/
|
|
89
|
+
async function pickName(ctx, input, prompter) {
|
|
90
|
+
let name = toName(input);
|
|
91
|
+
if (name === input) return name;
|
|
92
|
+
if (!prompter.interactive) {
|
|
93
|
+
ctx.info(`Using the name ${name}`);
|
|
94
|
+
return name;
|
|
95
|
+
}
|
|
96
|
+
for (;;) {
|
|
97
|
+
const edited = normalizeName(await prompter.edit("Workspace name (edit or press Enter)", name));
|
|
98
|
+
const problem = nameProblem(edited);
|
|
99
|
+
if (!problem) return edited;
|
|
100
|
+
ctx.warn(problem);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** nk -b <name> [agent] */
|
|
105
|
+
export async function createWorkspace(ctx, { name: input, repo: repoFlag, agent, prompt, extra = [], prompter = createPrompter(), untested = false }) {
|
|
106
|
+
const { name: repoName, repo } = resolveRepo(ctx, repoFlag);
|
|
107
|
+
checkSetup(ctx, repo, { stop: true, untested });
|
|
108
|
+
// Asked before anything is made, so hooks that are not allowed leave no half-made workspace.
|
|
109
|
+
await approveHooks(ctx, { repoName, repo }, prompter);
|
|
110
|
+
if (remoteUrl(repo.path)) {
|
|
111
|
+
const fetched = gitTry(["fetch", "--quiet", "origin", repo.default_branch], { cwd: repo.path });
|
|
112
|
+
if (!fetched.ok) ctx.warn(`Could not fetch origin/${repo.default_branch}; using what is local`);
|
|
113
|
+
}
|
|
114
|
+
// A branch that already exists keeps its own name, "/" and all; the folder uses the converted name.
|
|
115
|
+
const existing = existingBranch(repo.path, String(input));
|
|
116
|
+
const name = existing ? toName(existing) : await pickName(ctx, input, prompter);
|
|
117
|
+
if (existing && existing !== name) ctx.info(`Using the name ${name} for branch ${existing}`);
|
|
118
|
+
const branch = existing ?? name;
|
|
119
|
+
const dir = join(ctx.paths.workspaces, repoName);
|
|
120
|
+
const path = join(dir, name);
|
|
121
|
+
// The name and ports are taken under the lock, so two nk -b at once never get the same ones.
|
|
122
|
+
const ws = await ctx.update(async (config) => {
|
|
123
|
+
const fresh = config.repos[repoName];
|
|
124
|
+
if (fresh.workspaces[name]) throw new NookError(`Workspace "${name}" already exists. Open it with: nk ${name}`);
|
|
125
|
+
if (exists(path)) throw new NookError(`${path} already exists`);
|
|
126
|
+
const port = await allocatePort(config);
|
|
127
|
+
fresh.workspaces[name] = { path, port, created: new Date().toISOString(), ...(branch !== name && { branch }) };
|
|
128
|
+
return fresh.workspaces[name];
|
|
129
|
+
});
|
|
130
|
+
const { port } = ws;
|
|
131
|
+
mkdirSync(dir, { recursive: true });
|
|
132
|
+
try {
|
|
133
|
+
addWorktree(repo.path, branch, path, repo.default_branch);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
delete repo.workspaces[name];
|
|
136
|
+
ctx.save();
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
const target = { repoName, repo, name, ws };
|
|
140
|
+
ctx.ok(`Created workspace ${name} @ ${path} (ports ${port}–${port + 9})`);
|
|
141
|
+
|
|
142
|
+
const shared = copyShared(join(ctx.paths.shared, repoName), path);
|
|
143
|
+
if (shared.copied.length) ctx.info(`Copied shared files: ${shared.copied.join(", ")}`);
|
|
144
|
+
if (shared.skipped.length) ctx.warn(`Not copied (git does not ignore them here): ${shared.skipped.join(", ")}`);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await hook(ctx, target, "create");
|
|
148
|
+
await hook(ctx, target, "open");
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error instanceof NookError) error.message += `\nThe workspace is kept at ${path}; remove it with: nk rm ${name}`;
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
return finish(ctx, target, { agent, prompt, extra });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** nk <name> [agent] */
|
|
157
|
+
export async function openWorkspace(ctx, { name, repo: repoFlag, agent, prompt, extra = [] }) {
|
|
158
|
+
const target = resolveWorkspace(ctx, toName(name), repoFlag);
|
|
159
|
+
if (!exists(target.ws.path)) throw new NookError(`${target.ws.path} is missing. Run nk doctor`);
|
|
160
|
+
if (!target.main) checkSetup(ctx, target.repo, { stop: false });
|
|
161
|
+
await hook(ctx, target, "open");
|
|
162
|
+
return finish(ctx, target, { agent, prompt, extra });
|
|
163
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { parseEnv } from "node:util";
|
|
4
|
+
import { resolveRepo } from "../context.mjs";
|
|
5
|
+
import { exists } from "../home.mjs";
|
|
6
|
+
import { gitTry } from "../git.mjs";
|
|
7
|
+
import { HOOK_EVENTS, findHooks } from "../hooks.mjs";
|
|
8
|
+
import { NookError } from "../errors.mjs";
|
|
9
|
+
import { planFromCi } from "../ci.mjs";
|
|
10
|
+
import { planCompose } from "../compose-plan.mjs";
|
|
11
|
+
import { GENERATED_MARK, hookFiles, sources } from "../generate-hooks.mjs";
|
|
12
|
+
import { listFiles } from "../shared.mjs";
|
|
13
|
+
import { agentCommand, launch } from "../agents.mjs";
|
|
14
|
+
|
|
15
|
+
/** Files that say how a project is installed, run or served. */
|
|
16
|
+
const MARKERS = new Set([
|
|
17
|
+
"package.json", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb", "pnpm-workspace.yaml",
|
|
18
|
+
"pyproject.toml", "uv.lock", "poetry.lock", "requirements.txt", "Pipfile.lock",
|
|
19
|
+
"Gemfile.lock", "go.mod", "Cargo.toml", "composer.lock", "mix.lock",
|
|
20
|
+
"compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml", "Dockerfile", "devcontainer.json",
|
|
21
|
+
"Makefile", "justfile", "Taskfile.yml", "Procfile", "mise.toml", ".tool-versions", ".nvmrc", ".python-version",
|
|
22
|
+
]);
|
|
23
|
+
const DOC = "https://github.com/ree-auz/nook/blob/main/docs/setup-hooks.md";
|
|
24
|
+
|
|
25
|
+
/** CI configuration: it already lists, step by step, how a clean machine gets the project running. */
|
|
26
|
+
const CI_FILE = /^(\.github\/workflows\/[^/]+\.ya?ml|\.gitlab-ci\.yml|\.circleci\/config\.yml|azure-pipelines\.yml|bitbucket-pipelines\.yml|\.buildkite\/pipeline\.ya?ml|\.woodpecker\.ya?ml|\.woodpecker\/[^/]+\.ya?ml)$/;
|
|
27
|
+
|
|
28
|
+
function ciFiles(repoPath) {
|
|
29
|
+
const listed = gitTry(["ls-files"], { cwd: repoPath });
|
|
30
|
+
return listed.ok ? listed.out.split(/\r?\n/).filter((file) => CI_FILE.test(file)) : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Tracked marker files up to two folders deep, skipping vendored code. */
|
|
34
|
+
function markerFiles(repoPath) {
|
|
35
|
+
const listed = gitTry(["ls-files"], { cwd: repoPath });
|
|
36
|
+
if (!listed.ok) return [];
|
|
37
|
+
return listed.out
|
|
38
|
+
.split(/\r?\n/)
|
|
39
|
+
.filter((file) => file.split("/").length <= 3 && !/(^|\/)(node_modules|vendor)\//.test(file) && MARKERS.has(basename(file)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Names of the shared files, and keys in them left empty. Values are never read out. */
|
|
43
|
+
function sharedSummary(sharedDir) {
|
|
44
|
+
if (!exists(sharedDir)) return [];
|
|
45
|
+
return listFiles(sharedDir).map((file) => {
|
|
46
|
+
if (!/(^|\/)\.env[^/]*$/.test(file)) return file;
|
|
47
|
+
try {
|
|
48
|
+
const values = parseEnv(readFileSync(join(sharedDir, file), "utf8"));
|
|
49
|
+
const empty = Object.keys(values).filter((key) => values[key] === "");
|
|
50
|
+
return empty.length ? `${file} (empty: ${empty.join(", ")})` : file;
|
|
51
|
+
} catch {
|
|
52
|
+
return file;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Hook files in `<dir>/.nook`, with whether nk generated them. */
|
|
58
|
+
function existingHooks(dir) {
|
|
59
|
+
const folder = join(dir, ".nook");
|
|
60
|
+
if (!exists(folder)) return [];
|
|
61
|
+
return readdirSync(folder)
|
|
62
|
+
.filter((file) => HOOK_EVENTS.some((event) => file.startsWith(`${event}.`)))
|
|
63
|
+
.map((file) => ({ file, generated: readFileSync(join(folder, file), "utf8").startsWith(GENERATED_MARK) }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Writes hooks into `<dir>/.nook` from the repository's CI. Never replaces a
|
|
68
|
+
* hook someone wrote by hand. Returns { plan, written } or { reason }.
|
|
69
|
+
*/
|
|
70
|
+
export function generateHooks(dir) {
|
|
71
|
+
// Compose services nk would start, so CI's addresses that point at them count as the workspace's own.
|
|
72
|
+
const candidate = planCompose(dir);
|
|
73
|
+
const known = candidate ? [...Object.values(candidate.remap).flat().map((entry) => entry.host), ...Object.values(candidate.ports).filter((port) => /^\d+$/.test(port))] : [];
|
|
74
|
+
const ci = planFromCi(dir, known);
|
|
75
|
+
// CI that starts its own services says what the project needs; the Compose file is then for other uses.
|
|
76
|
+
const ciPorts = ci.best?.services.flatMap((service) => service.ports).length ?? 0;
|
|
77
|
+
const compose = candidate && (ci.best?.services.length ? planCompose(dir, { start: false, reserved: ciPorts }) : candidate);
|
|
78
|
+
if (!ci.files.length && !compose) return { reason: "no GitHub Actions workflow (.github/workflows/*.yml) and no Compose file" };
|
|
79
|
+
if (!ci.best && !compose) return { reason: `could not read ${ci.errors.map((e) => `${e.source} (${e.error.split("\n")[0]})`).join(", ")}` };
|
|
80
|
+
const base = ci.best ?? { source: compose.file, job: null, tools: [], env: {}, services: [], create: [], remove: [], skipped: [], untranslated: [] };
|
|
81
|
+
const plan = compose ? { ...base, compose, untranslated: [...base.untranslated, ...compose.untranslated] } : base;
|
|
82
|
+
const handWritten = existingHooks(dir).filter((hook) => !hook.generated);
|
|
83
|
+
if (handWritten.length) return { reason: `.nook already has hooks written by hand: ${handWritten.map((h) => h.file).join(", ")}`, plan };
|
|
84
|
+
const files = hookFiles(plan);
|
|
85
|
+
const folder = join(dir, ".nook");
|
|
86
|
+
mkdirSync(folder, { recursive: true });
|
|
87
|
+
for (const hook of existingHooks(dir)) if (!files[hook.file]) rmSync(join(folder, hook.file));
|
|
88
|
+
for (const [file, text] of Object.entries(files)) writeFileSync(join(folder, file), text);
|
|
89
|
+
return { plan, written: Object.keys(files), others: ci.others };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function report(ctx, result) {
|
|
93
|
+
const { plan } = result;
|
|
94
|
+
ctx.ok(`Wrote ${result.written.map((file) => `.nook/${file}`).join(", ")} from ${sources(plan)}`);
|
|
95
|
+
if (result.others.length) ctx.info(`Other CI jobs not used: ${result.others.slice(0, 5).join(", ")}${result.others.length > 5 ? ` and ${result.others.length - 5} more` : ""}`);
|
|
96
|
+
if (plan.tools.length) ctx.info(`Checks the tools CI installs: ${plan.tools.map((t) => (t.version ? `${t.name} ${t.version}` : t.name)).join(", ")}`);
|
|
97
|
+
if (plan.services.length) ctx.info(`Starts for each workspace, on its own ports: ${plan.services.map((s) => `${s.name} (${s.image.split("@")[0]})`).join(", ")}`);
|
|
98
|
+
if (plan.compose) {
|
|
99
|
+
const { file, start, ports, notStarted } = plan.compose;
|
|
100
|
+
if (start.length) ctx.info(`Starts from ${file} in each workspace's own Compose project: ${start.join(", ")}`);
|
|
101
|
+
if (Object.keys(ports).length) ctx.info(`Gives each workspace its own ports in .env: ${Object.keys(ports).join(", ")}`);
|
|
102
|
+
const moved = Object.entries(plan.compose.remap).map(([name, entries]) => `${name} ${entries.map((e) => e.host).join(", ")}`);
|
|
103
|
+
if (moved.length) ctx.info(`Moves fixed host ports to each workspace's own: ${moved.join("; ")}`);
|
|
104
|
+
if (plan.compose.ownNetwork?.length) ctx.info(`Runs on each workspace's own network instead of the host's: ${plan.compose.ownNetwork.join(", ")}`);
|
|
105
|
+
if (notStarted.length) ctx.info(`Not started (start them yourself when needed): ${notStarted.map((s) => `${s.name} (${s.why})`).join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
if (Object.keys(plan.env).length) ctx.info(`Adds to .env: ${Object.keys(plan.env).join(", ")}`);
|
|
108
|
+
if (plan.create.length) {
|
|
109
|
+
ctx.info("Setup steps (create):");
|
|
110
|
+
for (const step of plan.create) ctx.item(step.run.split("\n")[0]);
|
|
111
|
+
}
|
|
112
|
+
if (plan.remove.length) {
|
|
113
|
+
ctx.info("Cleanup steps (remove):");
|
|
114
|
+
for (const step of plan.remove) ctx.item(step.run.split("\n")[0]);
|
|
115
|
+
}
|
|
116
|
+
if (plan.skipped.length) ctx.info(`Left out ${plan.skipped.length} CI-only steps (checkout, tests, lint, build, uploads)`);
|
|
117
|
+
if (plan.untranslated.length) {
|
|
118
|
+
ctx.warn("Not added; review these and add what is safe by hand (or nk hooks write <agent>):");
|
|
119
|
+
for (const item of plan.untranslated) ctx.item(`${item.step}: ${item.why}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The prompt for an agent that writes or finishes this repository's hooks, filled with what nk knows. */
|
|
124
|
+
export function setupPrompt(ctx, { repoName, repo, dir = repo.path, generated = null }) {
|
|
125
|
+
const markers = markerFiles(dir);
|
|
126
|
+
const ci = ciFiles(dir);
|
|
127
|
+
const shared = sharedSummary(join(ctx.paths.shared, repoName));
|
|
128
|
+
const hooks = findHooks(dir).map((hook) => `.nook/${hook.name}`);
|
|
129
|
+
const list = (items, none) => (items.length ? items.map((item) => `- ${item}`).join("\n") : `- ${none}`);
|
|
130
|
+
const start = generated?.written
|
|
131
|
+
? `nk already wrote ${generated.written.map((f) => `.nook/${f}`).join(", ")} from ${sources(generated.plan)}, without AI. What it could not translate:
|
|
132
|
+
${list(generated.plan.untranslated.map((i) => `${i.step}: ${i.why}`), "nothing")}
|
|
133
|
+
|
|
134
|
+
Your task, in this folder:
|
|
135
|
+
1. Read the generated hooks and the CI file. Add what was not translated, adapted to a workspace (its own ports from NOOK_PORT, its own container and project names, started again by open, cleaned up by remove). Keep the rest as generated. Once you edit a hook, remove its first line, so nk hooks write will not replace your work.`
|
|
136
|
+
: `Your task, in this folder:
|
|
137
|
+
1. Start from the CI configuration: take its setup steps (runtime versions, dependency install, services such as databases, environment variables, migrations or seed data) and leave out what only CI needs (tests, lint, builds, releases, caches, uploads). Then adapt it to a workspace, which differs from a CI machine in three ways: several workspaces run at once, so ports and container or Compose project names must be this workspace's own; a workspace is kept and reopened, so services must start again; and nothing is thrown away afterwards, so remove has to clean up. Without CI, use the files below and the README.
|
|
138
|
+
Write .nook/create.mjs and .nook/remove.mjs as a pair, and .nook/open.mjs if services must start again when the workspace is reopened (it runs every time, so it must be safe to repeat).`;
|
|
139
|
+
return `Set up Nook hooks for the repository ${repoName}. Nook (nk) gives each task its own git worktree (a workspace) and runs scripts from .nook/ in it: create once, open on every open, remove before deletion. Rules and examples: ${DOC}
|
|
140
|
+
|
|
141
|
+
${start}
|
|
142
|
+
2. Never use the main checkout's .env or services, never print secrets, and never run docker compose config.
|
|
143
|
+
3. Run nk hooks test here. It runs create, open twice and remove in a temporary workspace and lists whatever remove left behind. Fix the hooks until it passes.
|
|
144
|
+
4. Do not commit or push. Tell me what each hook runs and what I must fill in once in the shared files.
|
|
145
|
+
|
|
146
|
+
What nk knows about this repository:
|
|
147
|
+
|
|
148
|
+
CI configuration:
|
|
149
|
+
${list(ci, "none found")}
|
|
150
|
+
|
|
151
|
+
Other files that show how it is set up:
|
|
152
|
+
${list(markers, "none found; read the README")}
|
|
153
|
+
|
|
154
|
+
Shared files, copied into every new workspace before create runs (from ~/nook/shared/${repoName}/):
|
|
155
|
+
${list(shared, "none")}
|
|
156
|
+
|
|
157
|
+
Hooks there now:
|
|
158
|
+
${list(hooks, "none")}
|
|
159
|
+
|
|
160
|
+
What nk does itself: it copies the shared files and gives the workspace ten free ports (NOOK_PORT to NOOK_PORT + 9). On removal it stops programs running in the folder and, as a safety net, removes Docker Compose projects started in it.`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* nk hooks write [agent]. nk translates the repository's CI into hooks in the
|
|
165
|
+
* folder you are in (usually the main checkout). With an agent, it then starts
|
|
166
|
+
* the agent in the same folder to finish what was not translated.
|
|
167
|
+
*/
|
|
168
|
+
export async function writeHooks(ctx, { repo: repoFlag, agent, extra = [] }) {
|
|
169
|
+
const target = resolveRepo(ctx, repoFlag);
|
|
170
|
+
const top = gitTry(["rev-parse", "--show-toplevel"], { cwd: ctx.cwd });
|
|
171
|
+
const dir = top.ok ? top.out : target.repo.path;
|
|
172
|
+
const result = generateHooks(dir);
|
|
173
|
+
if (!agent) {
|
|
174
|
+
if (!result.written)
|
|
175
|
+
throw new NookError(`Cannot write hooks from CI: ${result.reason}\nTo have an agent write them instead: nk hooks write <agent>`);
|
|
176
|
+
report(ctx, result);
|
|
177
|
+
ctx.info(`Next: nk hooks test${result.plan.untranslated.length ? `, or nk hooks write <agent> to have an agent finish them` : ""}`);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
if (result.written) report(ctx, result);
|
|
181
|
+
else ctx.info(`The agent writes the hooks itself (${result.reason})`);
|
|
182
|
+
const prompt = setupPrompt(ctx, { repoName: target.name, repo: target.repo, dir, generated: result });
|
|
183
|
+
ctx.info(`Starting ${agent} in ${dir}`);
|
|
184
|
+
return launch(agentCommand(ctx.config, agent, { prompt, extra }, ctx.env), { cwd: dir, env: ctx.env });
|
|
185
|
+
}
|