@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.
- package/LICENSE +21 -0
- package/README.md +410 -3
- 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 +34 -6
- 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 +91 -0
- package/src/commands/forget.mjs +57 -0
- package/src/commands/hooks.mjs +27 -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 +45 -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,228 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import { exists } from "./home.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads the repository's Docker Compose file for what a workspace needs from
|
|
8
|
+
* it: the services it uses as they are (a database, a queue), and the
|
|
9
|
+
* variables that choose host ports. Services the repository builds itself are
|
|
10
|
+
* the app; a workspace starts those when the developer wants.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The files Compose itself looks for, in its order. */
|
|
14
|
+
export const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"];
|
|
15
|
+
|
|
16
|
+
export function composeFile(repoPath) {
|
|
17
|
+
return COMPOSE_FILES.find((file) => exists(join(repoPath, file))) ?? null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The override file Compose merges in by default: compose.override.yaml next to compose.yaml, and so on. */
|
|
21
|
+
export function overrideFile(repoPath, file = composeFile(repoPath)) {
|
|
22
|
+
if (!file) return null;
|
|
23
|
+
const stem = file.replace(/\.ya?ml$/, "");
|
|
24
|
+
const override = [`${stem}.override.yaml`, `${stem}.override.yml`].find((name) => exists(join(repoPath, name)));
|
|
25
|
+
if (override) return override;
|
|
26
|
+
// A base file that cannot run alone (a service with neither image nor build, as coolify's) is meant to be
|
|
27
|
+
// combined with its development file: docker compose -f docker-compose.yml -f docker-compose.dev.yml.
|
|
28
|
+
let services = {};
|
|
29
|
+
try {
|
|
30
|
+
services = parse(readFileSync(join(repoPath, file), "utf8"))?.services ?? {};
|
|
31
|
+
} catch {}
|
|
32
|
+
if (!Object.values(services).some((service) => service && typeof service === "object" && !service.image && !service.build && !service.extends)) return null;
|
|
33
|
+
return [".dev.yaml", ".dev.yml", ".development.yaml", ".development.yml"].map((end) => stem + end).find((name) => exists(join(repoPath, name))) ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Networks and volumes the files give a fixed name (networks: { coolify: { name: coolify } }): every
|
|
38
|
+
* workspace and the main checkout would share them, data too. external ones are shared on purpose.
|
|
39
|
+
*/
|
|
40
|
+
function fixedNames(repoPath, files) {
|
|
41
|
+
const found = { networks: [], volumes: [] };
|
|
42
|
+
for (const file of files)
|
|
43
|
+
for (const kind of ["networks", "volumes"])
|
|
44
|
+
for (const [key, value] of Object.entries(parse(readFileSync(join(repoPath, file), "utf8"))?.[kind] ?? {}))
|
|
45
|
+
if (value?.name && !value.external && !found[kind].includes(key)) found[kind].push(key);
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Both files, as `docker compose` reads them with no -f: ports add up, other keys are replaced. */
|
|
50
|
+
function readServices(repoPath, file, override) {
|
|
51
|
+
const services = { ...(parse(readFileSync(join(repoPath, file), "utf8"))?.services ?? {}) };
|
|
52
|
+
if (override)
|
|
53
|
+
for (const [name, extra] of Object.entries(parse(readFileSync(join(repoPath, override), "utf8"))?.services ?? {})) {
|
|
54
|
+
const base = services[name] ?? {};
|
|
55
|
+
services[name] = { ...base, ...extra, ports: [...(base.ports ?? []), ...(extra?.ports ?? [])] };
|
|
56
|
+
}
|
|
57
|
+
return services;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** "${PORT:-3001}" → { name: "PORT", fallback: "3001" }; a literal port → null. */
|
|
61
|
+
function portVariable(text) {
|
|
62
|
+
const match = /^\$\{(\w+)(?::?-([^}]*))?\}$/.exec(text.trim());
|
|
63
|
+
return match ? { name: match[1], fallback: match[2] ?? "" } : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A ports entry as { host, target }: "127.0.0.1:${P:-80}:80" → { host: "${P:-80}", target: "80" },
|
|
68
|
+
* "5432:5432/tcp" → { host: "5432", target: "5432/tcp" }. host is null when Compose picks one.
|
|
69
|
+
*/
|
|
70
|
+
function portEntry(entry) {
|
|
71
|
+
if (typeof entry === "object" && entry) {
|
|
72
|
+
const target = `${entry.target}${entry.protocol && entry.protocol !== "tcp" ? `/${entry.protocol}` : ""}`;
|
|
73
|
+
return { host: entry.published === undefined ? null : String(entry.published), target };
|
|
74
|
+
}
|
|
75
|
+
// Split on ":" outside ${...}.
|
|
76
|
+
const parts = [];
|
|
77
|
+
let depth = 0;
|
|
78
|
+
let current = "";
|
|
79
|
+
for (const char of String(entry)) {
|
|
80
|
+
if (char === "{") depth++;
|
|
81
|
+
if (char === "}") depth--;
|
|
82
|
+
if (char === ":" && depth === 0) {
|
|
83
|
+
parts.push(current);
|
|
84
|
+
current = "";
|
|
85
|
+
} else current += char;
|
|
86
|
+
}
|
|
87
|
+
parts.push(current);
|
|
88
|
+
return { host: parts.length >= 2 ? parts.at(-2) : null, target: parts.at(-1) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Images a project uses as they are, beside its own code: databases, caches,
|
|
93
|
+
* queues, search, object storage, mail catchers, cloud emulators. An image the
|
|
94
|
+
* project publishes itself (its app) is not one of them.
|
|
95
|
+
*/
|
|
96
|
+
const BACKING = /(^|[/:_-])(postgres|postgis|pgvector|timescale(db)?|mysql|mariadb|percona|mongo(db)?|redis|valkey|keydb|dragonfly(db)?|memcached|rabbitmq|kafka|redpanda|zookeeper|nats|elasticsearch|opensearch|meilisearch|typesense|minio|clickhouse(-server)?|cassandra|scylla|neo4j|couchdb|cockroach(db)?|mssql(-server)?|azure-sql-edge|localstack|dynamodb-local|azurite|mailpit|mailhog|maildev|smtp4dev|qdrant|weaviate|milvus|chroma|influxdb|questdb|surrealdb|etcd|pubsub-emulator|firebase-emulator|spanner-emulator|garage|seaweedfs|valkey-bundle)($|[/:._@-]|\d)/i;
|
|
97
|
+
|
|
98
|
+
/** Variables a service needs set: ${NAME:?message} or ${NAME?message}. */
|
|
99
|
+
function requiredVariables(service) {
|
|
100
|
+
return [...JSON.stringify(service).matchAll(/\$\{(\w+):?\?/g)].map((match) => match[1]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* { file, files, start, ports, remap, required, notStarted, untranslated } for the
|
|
105
|
+
* repository's Compose file and its override file, or null without one.
|
|
106
|
+
*/
|
|
107
|
+
const PORTS = 10;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The repository paths a service mounts (./vendor/x.sh:/docker-entrypoint-initdb.d/x.sh). When one is
|
|
111
|
+
* missing, docker creates it as a root-owned folder, so the create hook waits for it instead.
|
|
112
|
+
*/
|
|
113
|
+
function bindMounts(service) {
|
|
114
|
+
const paths = [];
|
|
115
|
+
for (const volume of service.volumes ?? []) {
|
|
116
|
+
const source = typeof volume === "string" ? volume.split(":")[0] : volume?.type === "bind" ? volume.source : null;
|
|
117
|
+
if (typeof source === "string" && /^\.\.?\//.test(source) && !source.includes("$")) paths.push(source);
|
|
118
|
+
}
|
|
119
|
+
return paths;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** startAny false: start nothing (CI has its own services). reserved: ports CI's services take first. */
|
|
123
|
+
export function planCompose(repoPath, { start: startAny = true, reserved = 0 } = {}) {
|
|
124
|
+
const file = composeFile(repoPath);
|
|
125
|
+
if (!file) return null;
|
|
126
|
+
const untranslated = [];
|
|
127
|
+
const override = overrideFile(repoPath, file);
|
|
128
|
+
const where = override ? `${file} + ${override}` : file;
|
|
129
|
+
let services;
|
|
130
|
+
try {
|
|
131
|
+
services = readServices(repoPath, file, override);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
untranslated.push({ step: where, why: `could not be read (${error.message.split("\n")[0]})` });
|
|
134
|
+
return { file: where, files: [file, override].filter(Boolean), start: [], ports: {}, remap: {}, required: [], notStarted: [], untranslated };
|
|
135
|
+
}
|
|
136
|
+
const ports = {};
|
|
137
|
+
const remap = {};
|
|
138
|
+
const start = [];
|
|
139
|
+
const notStarted = [];
|
|
140
|
+
const required = new Set();
|
|
141
|
+
const mounts = {};
|
|
142
|
+
const named = [];
|
|
143
|
+
const ownNetwork = [];
|
|
144
|
+
for (const [name, service] of Object.entries(services)) {
|
|
145
|
+
if (!service || typeof service !== "object") continue;
|
|
146
|
+
// A fixed container_name (plane-redis) would clash between workspaces, even for a service you start yourself: each gets its own.
|
|
147
|
+
if (service.container_name) named.push(name);
|
|
148
|
+
const fixed = [];
|
|
149
|
+
// On the host's network a service listens on the host's own ports, the main checkout's too (forem's
|
|
150
|
+
// postgres and redis): a workspace runs it on its project network instead, its ports moved to its own.
|
|
151
|
+
const hostNetwork = service.network_mode === "host";
|
|
152
|
+
for (const entry of service.ports ?? []) {
|
|
153
|
+
const { host, target } = portEntry(entry);
|
|
154
|
+
if (hostNetwork && (host === null || host === "")) fixed.push({ host: target.split("/")[0], target });
|
|
155
|
+
if (host === null || host === "") continue;
|
|
156
|
+
const variable = portVariable(host);
|
|
157
|
+
if (variable) ports[variable.name] ??= variable.fallback;
|
|
158
|
+
else fixed.push({ host, target });
|
|
159
|
+
}
|
|
160
|
+
if (service.build) notStarted.push({ name, why: "built from this repository" });
|
|
161
|
+
else if (service.profiles?.length) notStarted.push({ name, why: `only with the profile ${service.profiles.join(", ")}` });
|
|
162
|
+
else if (!service.image) notStarted.push({ name, why: "no image" });
|
|
163
|
+
else if (!BACKING.test(service.image)) notStarted.push({ name, why: `${service.image} is not a database or similar service` });
|
|
164
|
+
else if (!startAny) notStarted.push({ name, why: "CI starts its own services" });
|
|
165
|
+
else if (hostNetwork && !fixed.length) notStarted.push({ name, why: "uses the host's network and names no ports, so it would share the main checkout's" });
|
|
166
|
+
else {
|
|
167
|
+
start.push(name);
|
|
168
|
+
for (const variable of requiredVariables(service)) required.add(variable);
|
|
169
|
+
const files = bindMounts(service);
|
|
170
|
+
if (files.length) mounts[name] = files;
|
|
171
|
+
// A fixed host port (5432:5432) would be the same for every workspace, and the main checkout may hold
|
|
172
|
+
// it: each workspace moves it to one of its own ports.
|
|
173
|
+
if (fixed.length) remap[name] = fixed;
|
|
174
|
+
if (hostNetwork) ownNetwork.push(name);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// A workspace has ten ports: services that do not fit are left out, last first.
|
|
178
|
+
const used = (extra = 0) => Object.keys(ports).length + Object.values(remap).flat().length + extra;
|
|
179
|
+
while (start.length && used() > PORTS - reserved) {
|
|
180
|
+
const name = start.pop();
|
|
181
|
+
delete remap[name];
|
|
182
|
+
if (ownNetwork.includes(name)) ownNetwork.splice(ownNetwork.indexOf(name), 1);
|
|
183
|
+
notStarted.push({ name, why: "no port left of the workspace's ten" });
|
|
184
|
+
untranslated.push({ step: `${where} › ${name}`, why: "not started: the workspace's ten ports are taken by the other services" });
|
|
185
|
+
}
|
|
186
|
+
return { file: where, files: [file, override].filter(Boolean), start, ports, remap, required: [...required], mounts, named, ownNetwork, fixed: fixedNames(repoPath, [file, override].filter(Boolean)), notStarted, untranslated };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Where the Compose services a workspace starts are reached from this machine:
|
|
191
|
+
* { "postgres:5432": "127.0.0.1:5432" }. A template written for apps running
|
|
192
|
+
* inside Docker (DATABASE_URL=...@postgres:5432) is rewritten with it, since
|
|
193
|
+
* here the app runs on the machine; each workspace then moves the port to its own.
|
|
194
|
+
* start false: CI's own services are started instead, so there is nothing to point at.
|
|
195
|
+
*/
|
|
196
|
+
export function hostAddresses(repoPath, { start = true } = {}) {
|
|
197
|
+
const plan = planCompose(repoPath, { start });
|
|
198
|
+
if (!plan?.start.length) return {};
|
|
199
|
+
const services = readServices(repoPath, plan.files[0], plan.files[1]);
|
|
200
|
+
const addresses = {};
|
|
201
|
+
for (const name of plan.start)
|
|
202
|
+
for (const entry of services[name]?.ports ?? []) {
|
|
203
|
+
const { host, target } = portEntry(entry);
|
|
204
|
+
if (!host) continue;
|
|
205
|
+
const variable = portVariable(host);
|
|
206
|
+
const port = variable ? variable.fallback : host;
|
|
207
|
+
if (/^\d+$/.test(port)) addresses[`${name}:${target.split("/")[0]}`] = `127.0.0.1:${port}`;
|
|
208
|
+
}
|
|
209
|
+
return addresses;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Rewrites service addresses in an env file's values; returns { text, keys } with the keys it changed. */
|
|
213
|
+
export function pointAtHost(text, addresses) {
|
|
214
|
+
const keys = [];
|
|
215
|
+
const lines = text.split(/(\r?\n)/).map((line) => {
|
|
216
|
+
const match = /^(\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=)(.*)$/.exec(line);
|
|
217
|
+
if (!match) return line;
|
|
218
|
+
let value = match[3];
|
|
219
|
+
for (const [from, to] of Object.entries(addresses)) {
|
|
220
|
+
const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
221
|
+
value = value.replace(new RegExp(`(^|[\\s"'=/@,])${escaped}(?!\\d)`, "g"), `$1${to}`);
|
|
222
|
+
}
|
|
223
|
+
if (value === match[3]) return line;
|
|
224
|
+
keys.push(match[2]);
|
|
225
|
+
return match[1] + value;
|
|
226
|
+
});
|
|
227
|
+
return { text: lines.join(""), keys };
|
|
228
|
+
}
|
package/src/compose.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/context.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { existsSync, renameSync } from "node:fs";
|
|
2
|
+
import { basename, join, relative } from "node:path";
|
|
3
|
+
import { applyChanges, loadConfig, saveConfig, syncInPlace, withLock, withLockSync } from "./config.mjs";
|
|
4
|
+
import { NookError } from "./errors.mjs";
|
|
5
|
+
import { homePaths, isInside, nookHome, samePath } from "./home.mjs";
|
|
6
|
+
import { normalizeName } from "./names.mjs";
|
|
7
|
+
import { gitTry, listWorktrees } from "./git.mjs";
|
|
8
|
+
import { line, useColor } from "./ui.mjs";
|
|
9
|
+
|
|
10
|
+
/** Shared state for one command run. */
|
|
11
|
+
export function createContext({ env = process.env, cwd = process.cwd(), out = process.stdout, err = process.stderr } = {}) {
|
|
12
|
+
const paths = homePaths(nookHome(env));
|
|
13
|
+
const config = loadConfig(paths.config);
|
|
14
|
+
const moveFailed = moveOldWorkspaceFolder(paths, config);
|
|
15
|
+
// What this run changed is config minus base; on save it goes onto what is on disk now,
|
|
16
|
+
// so changes another nk made meanwhile are kept.
|
|
17
|
+
let base = structuredClone(config);
|
|
18
|
+
const merge = () => {
|
|
19
|
+
const disk = applyChanges(base, config, loadConfig(paths.config));
|
|
20
|
+
saveConfig(paths.config, disk);
|
|
21
|
+
syncInPlace(config, disk);
|
|
22
|
+
base = structuredClone(config);
|
|
23
|
+
};
|
|
24
|
+
const color = useColor(err, env);
|
|
25
|
+
const say = (kind) => (text) => err.write(`${line(kind, text, color)}\n`);
|
|
26
|
+
if (moveFailed)
|
|
27
|
+
say("warn")(`Could not rename ${paths.workspaces} to ${join(paths.home, "workspace")} (${moveFailed}); still using the old folder. Close programs open in it (terminals, editors, dev servers) and run nk again.`);
|
|
28
|
+
return {
|
|
29
|
+
env,
|
|
30
|
+
cwd,
|
|
31
|
+
paths,
|
|
32
|
+
config,
|
|
33
|
+
out,
|
|
34
|
+
err,
|
|
35
|
+
save() {
|
|
36
|
+
withLockSync(paths.config, merge);
|
|
37
|
+
},
|
|
38
|
+
/**
|
|
39
|
+
* Brings config up to date with disk, runs fn on it and saves, all under the lock: for choices
|
|
40
|
+
* that must not clash with another nk running at the same time, such as a workspace's ports.
|
|
41
|
+
*/
|
|
42
|
+
async update(fn) {
|
|
43
|
+
return withLock(paths.config, async () => {
|
|
44
|
+
merge();
|
|
45
|
+
const result = await fn(config);
|
|
46
|
+
merge();
|
|
47
|
+
return result;
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
print(text = "") {
|
|
51
|
+
out.write(`${text}\n`);
|
|
52
|
+
},
|
|
53
|
+
note(text = "") {
|
|
54
|
+
err.write(`${text}\n`);
|
|
55
|
+
},
|
|
56
|
+
step: say("step"),
|
|
57
|
+
ok: say("ok"),
|
|
58
|
+
warn: say("warn"),
|
|
59
|
+
info: say("info"),
|
|
60
|
+
item: say("item"),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Before 0.1 the folder was called "workspaces". Renames it once and tells git
|
|
66
|
+
* where the workspaces went. When it cannot (a file is open on Windows), keeps
|
|
67
|
+
* using the old folder and returns why.
|
|
68
|
+
*/
|
|
69
|
+
function moveOldWorkspaceFolder(paths, config) {
|
|
70
|
+
const old = join(paths.home, "workspaces");
|
|
71
|
+
if (!existsSync(old) || existsSync(paths.workspaces)) return;
|
|
72
|
+
const moves = [];
|
|
73
|
+
for (const repo of Object.values(config.repos ?? {}))
|
|
74
|
+
for (const ws of Object.values(repo.workspaces ?? {}))
|
|
75
|
+
if (isInside(ws.path, old)) moves.push({ repo, ws, path: join(paths.workspaces, relative(old, ws.path)) });
|
|
76
|
+
try {
|
|
77
|
+
renameSync(old, paths.workspaces);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
paths.workspaces = old;
|
|
80
|
+
return error.code ?? error.message;
|
|
81
|
+
}
|
|
82
|
+
for (const move of moves) move.ws.path = move.path;
|
|
83
|
+
for (const repo of new Set(moves.map((move) => move.repo)))
|
|
84
|
+
gitTry(["worktree", "repair", ...moves.filter((move) => move.repo === repo).map((move) => move.path)], { cwd: repo.path });
|
|
85
|
+
if (moves.length) saveConfig(paths.config, config);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The registered repository `cwd` belongs to, or null. */
|
|
90
|
+
export function repoFromCwd(ctx) {
|
|
91
|
+
for (const [name, repo] of Object.entries(ctx.config.repos)) {
|
|
92
|
+
if (isInside(ctx.cwd, join(ctx.paths.workspaces, name))) return name;
|
|
93
|
+
if (isInside(ctx.cwd, repo.path)) return name;
|
|
94
|
+
for (const ws of Object.values(repo.workspaces)) if (isInside(ctx.cwd, ws.path)) return name;
|
|
95
|
+
}
|
|
96
|
+
const top = gitTry(["rev-parse", "--show-toplevel"], { cwd: ctx.cwd });
|
|
97
|
+
if (top.ok) {
|
|
98
|
+
try {
|
|
99
|
+
const [main] = listWorktrees(ctx.cwd);
|
|
100
|
+
for (const [name, repo] of Object.entries(ctx.config.repos)) if (main && isInside(main.path, repo.path)) return name;
|
|
101
|
+
} catch {
|
|
102
|
+
/* Not a usable repository. */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function requireRepo(ctx, name) {
|
|
109
|
+
const repo = ctx.config.repos[name];
|
|
110
|
+
if (!repo) throw new NookError(`Unknown repository "${name}". Registered: ${Object.keys(ctx.config.repos).join(", ") || "none"}`);
|
|
111
|
+
return { name, repo };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The repository a command acts on: --repo, else the one the current folder
|
|
116
|
+
* belongs to (its main checkout or one of its workspaces). Like git and
|
|
117
|
+
* worktrunk, nothing is guessed outside a repository.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveRepo(ctx, flag) {
|
|
120
|
+
if (flag) return requireRepo(ctx, flag);
|
|
121
|
+
const fromCwd = repoFromCwd(ctx);
|
|
122
|
+
if (fromCwd) return requireRepo(ctx, fromCwd);
|
|
123
|
+
if (!Object.keys(ctx.config.repos).length) throw new NookError("No repository yet. Run nk init inside a repository first");
|
|
124
|
+
throw new NookError(
|
|
125
|
+
`Not inside a registered repository. Run nk in the repository or one of its workspaces, or pass --repo (${Object.keys(ctx.config.repos).join(", ")})`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Every worktree of a repository, as git lists them, joined with what Nook
|
|
131
|
+
* recorded (port, creation time). Git is the source of truth, like worktrunk:
|
|
132
|
+
* worktrees made by other tools show up too, without a port. The main
|
|
133
|
+
* checkout comes first with `main: true`; workspaces Nook recorded whose
|
|
134
|
+
* folder is gone come last.
|
|
135
|
+
*/
|
|
136
|
+
export function repoWorkspaces(repoName, repo) {
|
|
137
|
+
let trees = [];
|
|
138
|
+
try {
|
|
139
|
+
trees = listWorktrees(repo.path);
|
|
140
|
+
} catch {
|
|
141
|
+
/* Not a usable repository; nk doctor reports it. */
|
|
142
|
+
}
|
|
143
|
+
const items = [];
|
|
144
|
+
const recorded = new Set();
|
|
145
|
+
const taken = new Set(Object.keys(repo.workspaces));
|
|
146
|
+
trees.forEach((tree, index) => {
|
|
147
|
+
if (tree.bare) return;
|
|
148
|
+
const found = Object.entries(repo.workspaces).find(([, ws]) => samePath(ws.path, tree.path));
|
|
149
|
+
if (found) {
|
|
150
|
+
recorded.add(found[0]);
|
|
151
|
+
items.push({ repoName, repo, name: found[0], ws: { ...found[1], locked: tree.locked }, main: false, managed: true });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
let name = normalizeName(tree.branch ?? basename(tree.path));
|
|
155
|
+
if (index > 0 && (!name || taken.has(name))) name = normalizeName(basename(tree.path));
|
|
156
|
+
taken.add(name);
|
|
157
|
+
const ws = { path: tree.path, branch: tree.branch, locked: tree.locked };
|
|
158
|
+
items.push({ repoName, repo, name, ws, main: index === 0, managed: false });
|
|
159
|
+
});
|
|
160
|
+
for (const [name, ws] of Object.entries(repo.workspaces))
|
|
161
|
+
if (!recorded.has(name)) items.push({ repoName, repo, name, ws, main: false, managed: true });
|
|
162
|
+
return items;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** An existing workspace of the current repository (or --repo) by name: its branch or folder name, converted the same way. */
|
|
166
|
+
export function resolveWorkspace(ctx, name, flag) {
|
|
167
|
+
const { name: repoName, repo } = resolveRepo(ctx, flag);
|
|
168
|
+
const items = repoWorkspaces(repoName, repo);
|
|
169
|
+
const hit = items.find((item) => item.name === name) ?? items.find((item) => normalizeName(basename(item.ws.path)) === name);
|
|
170
|
+
if (hit) return hit;
|
|
171
|
+
throw new NookError(`No workspace named "${name}" in ${repoName}. Create it with: nk -b ${name}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The git branch of a workspace: its name, unless the branch had characters a name cannot use. */
|
|
175
|
+
export function branchOf(name, ws) {
|
|
176
|
+
return "branch" in ws ? ws.branch : name;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Variables hooks and agents see. */
|
|
180
|
+
export function workspaceEnv(ctx, { repoName, repo, name, ws }) {
|
|
181
|
+
return {
|
|
182
|
+
...ctx.env,
|
|
183
|
+
NOOK_WORKSPACE: name,
|
|
184
|
+
NOOK_PATH: ws.path,
|
|
185
|
+
NOOK_REPO: repo.path,
|
|
186
|
+
NOOK_SHARED: join(ctx.paths.shared, repoName),
|
|
187
|
+
NOOK_PORT: String(ws.port ?? ""),
|
|
188
|
+
};
|
|
189
|
+
}
|