@ree-auz/nook 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +422 -410
- package/bin/nk.mjs +3 -3
- package/docs/setup-hooks.md +107 -107
- package/integrations/linear/README.md +94 -84
- package/integrations/linear/coding-tools.json +7 -7
- package/integrations/linear/open-issue.mjs +193 -147
- package/package.json +34 -34
- package/skills/nook/SKILL.md +71 -71
- package/src/agents.mjs +92 -92
- package/src/backup.mjs +74 -74
- package/src/ci.mjs +378 -378
- package/src/cli.mjs +151 -151
- package/src/commands/check-hooks.mjs +128 -128
- package/src/commands/clean.mjs +175 -175
- package/src/commands/doctor.mjs +100 -91
- package/src/commands/forget.mjs +57 -57
- package/src/commands/hooks.mjs +34 -27
- package/src/commands/init.mjs +154 -154
- package/src/commands/ls.mjs +107 -107
- package/src/commands/rm.mjs +190 -190
- package/src/commands/workspace.mjs +163 -163
- package/src/commands/write-hooks.mjs +185 -185
- package/src/compose-plan.mjs +228 -228
- package/src/compose.mjs +123 -123
- package/src/config.mjs +115 -115
- package/src/context.mjs +189 -189
- package/src/errors.mjs +8 -8
- package/src/forge.mjs +75 -75
- package/src/generate-hooks.mjs +367 -367
- package/src/git.mjs +86 -86
- package/src/home.mjs +48 -48
- package/src/hooks.mjs +135 -135
- package/src/names.mjs +65 -65
- package/src/occupancy-windows.ps1 +59 -59
- package/src/occupancy.mjs +131 -131
- package/src/ports.mjs +89 -45
- package/src/prompt.mjs +100 -100
- package/src/shared.mjs +97 -97
- package/src/status.mjs +30 -30
- package/src/tools.mjs +23 -23
- package/src/ui.mjs +60 -60
package/src/commands/forget.mjs
CHANGED
|
@@ -1,57 +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
|
-
}
|
|
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
|
+
}
|
package/src/commands/hooks.mjs
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 { readFileSync } from "node:fs";
|
|
5
|
+
import { ciChanged } from "./check-hooks.mjs";
|
|
6
|
+
import { GENERATED_MARK } from "../generate-hooks.mjs";
|
|
7
|
+
|
|
8
|
+
/** nk hooks: each hook of the repository, whether it is allowed, and whether it passed nk hooks test. */
|
|
9
|
+
export function listHooks(ctx, { repo: repoFlag }) {
|
|
10
|
+
const { name: repoName, repo } = resolveRepo(ctx, repoFlag);
|
|
11
|
+
const color = useColor(ctx.out, ctx.env);
|
|
12
|
+
const hooks = HOOK_EVENTS.map((event) => findHook(repo.path, event)).filter(Boolean);
|
|
13
|
+
ctx.print(`Hooks of ${repoName} (${repo.path}/.nook)`);
|
|
14
|
+
if (!hooks.length) {
|
|
15
|
+
ctx.print(` ${line("info", "none yet; to have an agent write them: nk hooks write <agent>", color)}`);
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
let untested = 0;
|
|
19
|
+
for (const hook of hooks) {
|
|
20
|
+
const trust = trustState(repo, hook);
|
|
21
|
+
const tested = repo.checked?.[hook.name] === hookHash(hook.file);
|
|
22
|
+
if (!tested) untested++;
|
|
23
|
+
const notes = [trust === "trusted" ? "allowed" : trust === "new" ? "not allowed yet" : "changed, will ask again", tested ? "tested" : "not tested since it changed"];
|
|
24
|
+
ctx.print(` ${line(tested && trust === "trusted" ? "ok" : "warn", `${hook.event.padEnd(6)} .nook/${hook.name} ${notes.join(", ")}`, color)}`);
|
|
25
|
+
}
|
|
26
|
+
if (ciChanged(repo)) {
|
|
27
|
+
// nk hooks write only replaces hooks it generated; hooks written by hand are updated by hand (or by an agent).
|
|
28
|
+
const generated = hooks.every((hook) => readFileSync(hook.file, "utf8").startsWith(GENERATED_MARK));
|
|
29
|
+
const update = generated ? "update them with nk hooks write" : "check whether the hooks need the change (nk hooks write <agent> can help)";
|
|
30
|
+
ctx.print(` ${line("warn", `CI changed since the hooks were tested; ${update}, then nk hooks test`, color)}`);
|
|
31
|
+
}
|
|
32
|
+
if (untested) ctx.print(` ${line("info", "Test them: nk hooks test", color)}`);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -1,154 +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
|
-
}
|
|
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
|
+
}
|