@ree-auz/nook 0.1.0 → 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.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +422 -410
  3. package/bin/nk.mjs +3 -3
  4. package/docs/setup-hooks.md +107 -107
  5. package/integrations/linear/README.md +84 -84
  6. package/integrations/linear/coding-tools.json +7 -7
  7. package/integrations/linear/open-issue.mjs +147 -147
  8. package/package.json +34 -34
  9. package/skills/nook/SKILL.md +71 -71
  10. package/src/agents.mjs +92 -92
  11. package/src/backup.mjs +74 -74
  12. package/src/ci.mjs +378 -378
  13. package/src/cli.mjs +151 -151
  14. package/src/commands/check-hooks.mjs +128 -128
  15. package/src/commands/clean.mjs +175 -175
  16. package/src/commands/doctor.mjs +100 -91
  17. package/src/commands/forget.mjs +57 -57
  18. package/src/commands/hooks.mjs +34 -27
  19. package/src/commands/init.mjs +154 -154
  20. package/src/commands/ls.mjs +107 -107
  21. package/src/commands/rm.mjs +190 -190
  22. package/src/commands/workspace.mjs +163 -163
  23. package/src/commands/write-hooks.mjs +185 -185
  24. package/src/compose-plan.mjs +228 -228
  25. package/src/compose.mjs +123 -123
  26. package/src/config.mjs +115 -115
  27. package/src/context.mjs +189 -189
  28. package/src/errors.mjs +8 -8
  29. package/src/forge.mjs +75 -75
  30. package/src/generate-hooks.mjs +367 -367
  31. package/src/git.mjs +86 -86
  32. package/src/home.mjs +48 -48
  33. package/src/hooks.mjs +135 -135
  34. package/src/names.mjs +65 -65
  35. package/src/occupancy-windows.ps1 +59 -59
  36. package/src/occupancy.mjs +131 -131
  37. package/src/ports.mjs +89 -45
  38. package/src/prompt.mjs +100 -100
  39. package/src/shared.mjs +97 -97
  40. package/src/status.mjs +30 -30
  41. package/src/tools.mjs +23 -23
  42. package/src/ui.mjs +60 -60
package/src/compose.mjs CHANGED
@@ -1,123 +1,123 @@
1
- import { homedir } from "node:os";
2
- import spawn from "cross-spawn";
3
- import { isInside } from "./home.mjs";
4
-
5
- /**
6
- * Docker Compose labels every container it starts with its project and the
7
- * folder it ran in (com.docker.compose.project.working_dir), and labels the
8
- * volumes, networks and built images of that project too. So nk can tell what
9
- * a workspace started without the repository writing a remove hook.
10
- */
11
- const PROJECT = "com.docker.compose.project";
12
- const WORKING_DIR = "com.docker.compose.project.working_dir";
13
-
14
- export function dockerCli(args) {
15
- // Run from the home folder, so Docker never holds a workspace folder open while it is being deleted.
16
- const result = spawn.sync("docker", args, { cwd: homedir(), encoding: "utf8", timeout: 60000, windowsHide: true });
17
- if (result.error) return { ok: false, out: "", err: result.error.code === "ENOENT" ? "not installed" : result.error.message };
18
- return { ok: result.status === 0, out: result.stdout ?? "", err: (result.stderr ?? "").trim() };
19
- }
20
-
21
- const rows = (text) => text.split(/\r?\n/).filter(Boolean);
22
-
23
- /**
24
- * Compose projects started inside `path`. A project that also has containers
25
- * started from another folder is reported in `shared` and never removed: it
26
- * may be the main checkout's (production) project under the same name.
27
- */
28
- export function findComposeProjects(path, docker = dockerCli) {
29
- const listed = docker(["container", "ls", "-a", "--filter", `label=${WORKING_DIR}`, "--format", `{{.ID}}\t{{.Label "${PROJECT}"}}\t{{.Label "${WORKING_DIR}"}}`]);
30
- if (!listed.ok) return { unavailable: listed.err || "docker is not running", projects: [], shared: [] };
31
- const byProject = new Map();
32
- for (const row of rows(listed.out)) {
33
- const [id, project, dir] = row.split("\t");
34
- if (!project) continue;
35
- const entry = byProject.get(project) ?? { inside: [], outside: [] };
36
- (dir && isInside(dir, path) ? entry.inside : entry.outside).push(id);
37
- byProject.set(project, entry);
38
- }
39
- const projects = [];
40
- const shared = [];
41
- for (const [name, { inside, outside }] of byProject) {
42
- if (!inside.length) continue;
43
- if (outside.length) {
44
- shared.push(name);
45
- continue;
46
- }
47
- const label = `label=${PROJECT}=${name}`;
48
- const ids = (kind) => {
49
- const result = docker([kind, "ls", "-q", "--filter", label]);
50
- return result.ok ? [...new Set(rows(result.out))] : [];
51
- };
52
- projects.push({ name, containers: inside, volumes: ids("volume"), networks: ids("network"), images: ids("image") });
53
- }
54
- return { projects, shared };
55
- }
56
-
57
- const count = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
58
-
59
- export function describeProject(project) {
60
- const parts = [count(project.containers.length, "container")];
61
- if (project.volumes.length) parts.push(count(project.volumes.length, "volume"));
62
- if (project.networks.length) parts.push(count(project.networks.length, "network"));
63
- if (project.images.length) parts.push(count(project.images.length, "built image"));
64
- return `Compose project ${project.name}: ${parts.join(", ")}`;
65
- }
66
-
67
- /** Removes containers (with their anonymous volumes), then volumes, networks and built images. */
68
- export function removeComposeProject(project, docker = dockerCli) {
69
- const steps = [
70
- project.containers.length && ["container", "rm", "-f", "-v", ...project.containers],
71
- project.volumes.length && ["volume", "rm", "-f", ...project.volumes],
72
- project.networks.length && ["network", "rm", ...project.networks],
73
- project.images.length && ["image", "rm", "-f", ...project.images],
74
- ].filter(Boolean);
75
- for (const args of steps) {
76
- const result = docker(args);
77
- if (!result.ok) throw new Error(`docker ${args.slice(0, 2).join(" ")} failed: ${result.err}`);
78
- }
79
- }
80
-
81
- /** Compose projects started somewhere under `root` whose folder is not one of `live` any more. */
82
- export function leftoverComposeProjects(root, live, docker = dockerCli) {
83
- const listed = docker(["container", "ls", "-a", "--filter", `label=${WORKING_DIR}`, "--format", `{{.Label "${PROJECT}"}}\t{{.Label "${WORKING_DIR}"}}`]);
84
- if (!listed.ok) return [];
85
- const found = new Map();
86
- for (const row of rows(listed.out)) {
87
- const [project, dir] = row.split("\t");
88
- if (project && dir && isInside(dir, root) && !live.some((path) => isInside(dir, path))) found.set(project, dir);
89
- }
90
- return [...found].map(([name, dir]) => ({ name, dir }));
91
- }
92
-
93
- /**
94
- * Every container, volume and network, and every image Compose built: what
95
- * `nk hooks test` compares before and after the hooks. Null without Docker.
96
- */
97
- export function dockerSnapshot(docker = dockerCli) {
98
- const list = (args) => {
99
- const result = docker(args);
100
- if (!result.ok) return null;
101
- return rows(result.out).map((row) => {
102
- const [id, name] = row.split("\t");
103
- return { id, name: name || id };
104
- });
105
- };
106
- const snapshot = {
107
- container: list(["container", "ls", "-a", "--format", "{{.ID}}\t{{.Names}}"]),
108
- volume: list(["volume", "ls", "--format", "{{.Name}}"]),
109
- network: list(["network", "ls", "--format", "{{.ID}}\t{{.Name}}"]),
110
- image: list(["image", "ls", "--filter", `label=${PROJECT}`, "--format", "{{.ID}}\t{{.Repository}}:{{.Tag}}"]),
111
- };
112
- return Object.values(snapshot).some((items) => items === null) ? null : snapshot;
113
- }
114
-
115
- /** What `after` has that `before` did not: [{ kind, id, name }]. */
116
- export function snapshotDiff(before, after) {
117
- const added = [];
118
- for (const kind of Object.keys(after)) {
119
- const known = new Set(before[kind].map((item) => item.id));
120
- for (const item of after[kind]) if (!known.has(item.id)) added.push({ kind, ...item });
121
- }
122
- return added;
123
- }
1
+ import { homedir } from "node:os";
2
+ import spawn from "cross-spawn";
3
+ import { isInside } from "./home.mjs";
4
+
5
+ /**
6
+ * Docker Compose labels every container it starts with its project and the
7
+ * folder it ran in (com.docker.compose.project.working_dir), and labels the
8
+ * volumes, networks and built images of that project too. So nk can tell what
9
+ * a workspace started without the repository writing a remove hook.
10
+ */
11
+ const PROJECT = "com.docker.compose.project";
12
+ const WORKING_DIR = "com.docker.compose.project.working_dir";
13
+
14
+ export function dockerCli(args) {
15
+ // Run from the home folder, so Docker never holds a workspace folder open while it is being deleted.
16
+ const result = spawn.sync("docker", args, { cwd: homedir(), encoding: "utf8", timeout: 60000, windowsHide: true });
17
+ if (result.error) return { ok: false, out: "", err: result.error.code === "ENOENT" ? "not installed" : result.error.message };
18
+ return { ok: result.status === 0, out: result.stdout ?? "", err: (result.stderr ?? "").trim() };
19
+ }
20
+
21
+ const rows = (text) => text.split(/\r?\n/).filter(Boolean);
22
+
23
+ /**
24
+ * Compose projects started inside `path`. A project that also has containers
25
+ * started from another folder is reported in `shared` and never removed: it
26
+ * may be the main checkout's (production) project under the same name.
27
+ */
28
+ export function findComposeProjects(path, docker = dockerCli) {
29
+ const listed = docker(["container", "ls", "-a", "--filter", `label=${WORKING_DIR}`, "--format", `{{.ID}}\t{{.Label "${PROJECT}"}}\t{{.Label "${WORKING_DIR}"}}`]);
30
+ if (!listed.ok) return { unavailable: listed.err || "docker is not running", projects: [], shared: [] };
31
+ const byProject = new Map();
32
+ for (const row of rows(listed.out)) {
33
+ const [id, project, dir] = row.split("\t");
34
+ if (!project) continue;
35
+ const entry = byProject.get(project) ?? { inside: [], outside: [] };
36
+ (dir && isInside(dir, path) ? entry.inside : entry.outside).push(id);
37
+ byProject.set(project, entry);
38
+ }
39
+ const projects = [];
40
+ const shared = [];
41
+ for (const [name, { inside, outside }] of byProject) {
42
+ if (!inside.length) continue;
43
+ if (outside.length) {
44
+ shared.push(name);
45
+ continue;
46
+ }
47
+ const label = `label=${PROJECT}=${name}`;
48
+ const ids = (kind) => {
49
+ const result = docker([kind, "ls", "-q", "--filter", label]);
50
+ return result.ok ? [...new Set(rows(result.out))] : [];
51
+ };
52
+ projects.push({ name, containers: inside, volumes: ids("volume"), networks: ids("network"), images: ids("image") });
53
+ }
54
+ return { projects, shared };
55
+ }
56
+
57
+ const count = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
58
+
59
+ export function describeProject(project) {
60
+ const parts = [count(project.containers.length, "container")];
61
+ if (project.volumes.length) parts.push(count(project.volumes.length, "volume"));
62
+ if (project.networks.length) parts.push(count(project.networks.length, "network"));
63
+ if (project.images.length) parts.push(count(project.images.length, "built image"));
64
+ return `Compose project ${project.name}: ${parts.join(", ")}`;
65
+ }
66
+
67
+ /** Removes containers (with their anonymous volumes), then volumes, networks and built images. */
68
+ export function removeComposeProject(project, docker = dockerCli) {
69
+ const steps = [
70
+ project.containers.length && ["container", "rm", "-f", "-v", ...project.containers],
71
+ project.volumes.length && ["volume", "rm", "-f", ...project.volumes],
72
+ project.networks.length && ["network", "rm", ...project.networks],
73
+ project.images.length && ["image", "rm", "-f", ...project.images],
74
+ ].filter(Boolean);
75
+ for (const args of steps) {
76
+ const result = docker(args);
77
+ if (!result.ok) throw new Error(`docker ${args.slice(0, 2).join(" ")} failed: ${result.err}`);
78
+ }
79
+ }
80
+
81
+ /** Compose projects started somewhere under `root` whose folder is not one of `live` any more. */
82
+ export function leftoverComposeProjects(root, live, docker = dockerCli) {
83
+ const listed = docker(["container", "ls", "-a", "--filter", `label=${WORKING_DIR}`, "--format", `{{.Label "${PROJECT}"}}\t{{.Label "${WORKING_DIR}"}}`]);
84
+ if (!listed.ok) return [];
85
+ const found = new Map();
86
+ for (const row of rows(listed.out)) {
87
+ const [project, dir] = row.split("\t");
88
+ if (project && dir && isInside(dir, root) && !live.some((path) => isInside(dir, path))) found.set(project, dir);
89
+ }
90
+ return [...found].map(([name, dir]) => ({ name, dir }));
91
+ }
92
+
93
+ /**
94
+ * Every container, volume and network, and every image Compose built: what
95
+ * `nk hooks test` compares before and after the hooks. Null without Docker.
96
+ */
97
+ export function dockerSnapshot(docker = dockerCli) {
98
+ const list = (args) => {
99
+ const result = docker(args);
100
+ if (!result.ok) return null;
101
+ return rows(result.out).map((row) => {
102
+ const [id, name] = row.split("\t");
103
+ return { id, name: name || id };
104
+ });
105
+ };
106
+ const snapshot = {
107
+ container: list(["container", "ls", "-a", "--format", "{{.ID}}\t{{.Names}}"]),
108
+ volume: list(["volume", "ls", "--format", "{{.Name}}"]),
109
+ network: list(["network", "ls", "--format", "{{.ID}}\t{{.Name}}"]),
110
+ image: list(["image", "ls", "--filter", `label=${PROJECT}`, "--format", "{{.ID}}\t{{.Repository}}:{{.Tag}}"]),
111
+ };
112
+ return Object.values(snapshot).some((items) => items === null) ? null : snapshot;
113
+ }
114
+
115
+ /** What `after` has that `before` did not: [{ kind, id, name }]. */
116
+ export function snapshotDiff(before, after) {
117
+ const added = [];
118
+ for (const kind of Object.keys(after)) {
119
+ const known = new Set(before[kind].map((item) => item.id));
120
+ for (const item of after[kind]) if (!known.has(item.id)) added.push({ kind, ...item });
121
+ }
122
+ return added;
123
+ }
package/src/config.mjs CHANGED
@@ -1,115 +1,115 @@
1
- import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
- import { dirname } from "node:path";
3
- import { parse, stringify } from "smol-toml";
4
- import { NookError } from "./errors.mjs";
5
-
6
- export function emptyConfig() {
7
- return { agents: {}, repos: {} };
8
- }
9
-
10
- export function loadConfig(file) {
11
- let text;
12
- try {
13
- text = readFileSync(file, "utf8");
14
- } catch (error) {
15
- if (error.code === "ENOENT") return emptyConfig();
16
- throw error;
17
- }
18
- let data;
19
- try {
20
- data = parse(text);
21
- } catch (error) {
22
- throw new NookError(`Cannot read ${file}: ${error.message}`);
23
- }
24
- data.agents ??= {};
25
- data.repos ??= {};
26
- for (const repo of Object.values(data.repos)) {
27
- repo.workspaces ??= {};
28
- repo.hooks ??= {};
29
- }
30
- return data;
31
- }
32
-
33
- export function saveConfig(file, config) {
34
- mkdirSync(dirname(file), { recursive: true });
35
- const temp = `${file}.${process.pid}.tmp`;
36
- writeFileSync(temp, stringify(config) + "\n");
37
- renameSync(temp, file);
38
- }
39
-
40
- // Several nk may run at once (an agent per Linear issue): config.toml changes under a lock, so no
41
- // two get the same ports and none writes over what another just recorded.
42
- const STALE_MS = 30_000;
43
- const WAIT_MS = 15_000;
44
-
45
- function tryLock(lock) {
46
- try {
47
- mkdirSync(lock);
48
- return true;
49
- } catch (error) {
50
- if (error.code !== "EEXIST") throw error;
51
- try {
52
- // A lock left by an nk that was killed: no update takes this long.
53
- if (Date.now() - statSync(lock).mtimeMs > STALE_MS) rmSync(lock, { recursive: true, force: true });
54
- } catch {}
55
- return false;
56
- }
57
- }
58
-
59
- const busy = (lock) => new NookError(`Another nk is updating its config and did not finish. If no nk is running, delete ${lock}`);
60
-
61
- /** Runs fn while holding the lock on `file`. */
62
- export function withLockSync(file, fn) {
63
- const lock = `${file}.lock`;
64
- mkdirSync(dirname(file), { recursive: true });
65
- const until = Date.now() + WAIT_MS;
66
- while (!tryLock(lock)) {
67
- if (Date.now() > until) throw busy(lock);
68
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
69
- }
70
- try {
71
- return fn();
72
- } finally {
73
- rmSync(lock, { recursive: true, force: true });
74
- }
75
- }
76
-
77
- /** Async form, for updates that wait on something (checking that ports are free). */
78
- export async function withLock(file, fn) {
79
- const lock = `${file}.lock`;
80
- mkdirSync(dirname(file), { recursive: true });
81
- const until = Date.now() + WAIT_MS;
82
- while (!tryLock(lock)) {
83
- if (Date.now() > until) throw busy(lock);
84
- await new Promise((done) => setTimeout(done, 50));
85
- }
86
- try {
87
- return await fn();
88
- } finally {
89
- rmSync(lock, { recursive: true, force: true });
90
- }
91
- }
92
-
93
- const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
94
- const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
95
-
96
- /** Applies to `target` what changed from `base` to `current`: keys added, removed or changed, at any depth. */
97
- export function applyChanges(base, current, target) {
98
- for (const key of new Set([...Object.keys(base), ...Object.keys(current)])) {
99
- if (!(key in current)) delete target[key];
100
- else if (!(key in base)) target[key] = structuredClone(current[key]);
101
- else if (isObject(base[key]) && isObject(current[key]) && isObject(target[key])) applyChanges(base[key], current[key], target[key]);
102
- else if (!same(base[key], current[key])) target[key] = structuredClone(current[key]);
103
- }
104
- return target;
105
- }
106
-
107
- /** Makes `target` equal to `source`, keeping the objects `target` already has, so references to them stay live. */
108
- export function syncInPlace(target, source) {
109
- for (const key of Object.keys(target)) if (!(key in source)) delete target[key];
110
- for (const [key, value] of Object.entries(source)) {
111
- if (isObject(target[key]) && isObject(value)) syncInPlace(target[key], value);
112
- else target[key] = structuredClone(value);
113
- }
114
- return target;
115
- }
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { parse, stringify } from "smol-toml";
4
+ import { NookError } from "./errors.mjs";
5
+
6
+ export function emptyConfig() {
7
+ return { agents: {}, repos: {} };
8
+ }
9
+
10
+ export function loadConfig(file) {
11
+ let text;
12
+ try {
13
+ text = readFileSync(file, "utf8");
14
+ } catch (error) {
15
+ if (error.code === "ENOENT") return emptyConfig();
16
+ throw error;
17
+ }
18
+ let data;
19
+ try {
20
+ data = parse(text);
21
+ } catch (error) {
22
+ throw new NookError(`Cannot read ${file}: ${error.message}`);
23
+ }
24
+ data.agents ??= {};
25
+ data.repos ??= {};
26
+ for (const repo of Object.values(data.repos)) {
27
+ repo.workspaces ??= {};
28
+ repo.hooks ??= {};
29
+ }
30
+ return data;
31
+ }
32
+
33
+ export function saveConfig(file, config) {
34
+ mkdirSync(dirname(file), { recursive: true });
35
+ const temp = `${file}.${process.pid}.tmp`;
36
+ writeFileSync(temp, stringify(config) + "\n");
37
+ renameSync(temp, file);
38
+ }
39
+
40
+ // Several nk may run at once (an agent per Linear issue): config.toml changes under a lock, so no
41
+ // two get the same ports and none writes over what another just recorded.
42
+ const STALE_MS = 30_000;
43
+ const WAIT_MS = 15_000;
44
+
45
+ function tryLock(lock) {
46
+ try {
47
+ mkdirSync(lock);
48
+ return true;
49
+ } catch (error) {
50
+ if (error.code !== "EEXIST") throw error;
51
+ try {
52
+ // A lock left by an nk that was killed: no update takes this long.
53
+ if (Date.now() - statSync(lock).mtimeMs > STALE_MS) rmSync(lock, { recursive: true, force: true });
54
+ } catch {}
55
+ return false;
56
+ }
57
+ }
58
+
59
+ const busy = (lock) => new NookError(`Another nk is updating its config and did not finish. If no nk is running, delete ${lock}`);
60
+
61
+ /** Runs fn while holding the lock on `file`. */
62
+ export function withLockSync(file, fn) {
63
+ const lock = `${file}.lock`;
64
+ mkdirSync(dirname(file), { recursive: true });
65
+ const until = Date.now() + WAIT_MS;
66
+ while (!tryLock(lock)) {
67
+ if (Date.now() > until) throw busy(lock);
68
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
69
+ }
70
+ try {
71
+ return fn();
72
+ } finally {
73
+ rmSync(lock, { recursive: true, force: true });
74
+ }
75
+ }
76
+
77
+ /** Async form, for updates that wait on something (checking that ports are free). */
78
+ export async function withLock(file, fn) {
79
+ const lock = `${file}.lock`;
80
+ mkdirSync(dirname(file), { recursive: true });
81
+ const until = Date.now() + WAIT_MS;
82
+ while (!tryLock(lock)) {
83
+ if (Date.now() > until) throw busy(lock);
84
+ await new Promise((done) => setTimeout(done, 50));
85
+ }
86
+ try {
87
+ return await fn();
88
+ } finally {
89
+ rmSync(lock, { recursive: true, force: true });
90
+ }
91
+ }
92
+
93
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
94
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
95
+
96
+ /** Applies to `target` what changed from `base` to `current`: keys added, removed or changed, at any depth. */
97
+ export function applyChanges(base, current, target) {
98
+ for (const key of new Set([...Object.keys(base), ...Object.keys(current)])) {
99
+ if (!(key in current)) delete target[key];
100
+ else if (!(key in base)) target[key] = structuredClone(current[key]);
101
+ else if (isObject(base[key]) && isObject(current[key]) && isObject(target[key])) applyChanges(base[key], current[key], target[key]);
102
+ else if (!same(base[key], current[key])) target[key] = structuredClone(current[key]);
103
+ }
104
+ return target;
105
+ }
106
+
107
+ /** Makes `target` equal to `source`, keeping the objects `target` already has, so references to them stay live. */
108
+ export function syncInPlace(target, source) {
109
+ for (const key of Object.keys(target)) if (!(key in source)) delete target[key];
110
+ for (const [key, value] of Object.entries(source)) {
111
+ if (isObject(target[key]) && isObject(value)) syncInPlace(target[key], value);
112
+ else target[key] = structuredClone(value);
113
+ }
114
+ return target;
115
+ }