knodin 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,508 @@
1
+ import child_process from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { parseDocument, stringify } from "yaml";
7
+ import { loadSystemConfiguration, RELATIONSHIP_TYPES, } from "./system-config.js";
8
+ function fileDigest(filePath) {
9
+ try {
10
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
11
+ }
12
+ catch (error) {
13
+ if (error.code === "ENOENT")
14
+ return null;
15
+ throw error;
16
+ }
17
+ }
18
+ function protectPlan(plan) {
19
+ const protectedPlan = {
20
+ ...plan,
21
+ status: "planned",
22
+ files: plan.files.map((file) => ({ ...file, expectedDigest: fileDigest(file.path) })),
23
+ };
24
+ return {
25
+ ...protectedPlan,
26
+ planDigest: crypto.createHash("sha256").update(JSON.stringify(protectedPlan)).digest("hex"),
27
+ };
28
+ }
29
+ function configHome(value) {
30
+ return value ?? process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
31
+ }
32
+ function safeId(value, field) {
33
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value))
34
+ throw new Error(`${field} must contain only letters, digits, dot, underscore, and hyphen`);
35
+ return value;
36
+ }
37
+ function checkoutId(checkout) {
38
+ const value = path
39
+ .basename(path.resolve(checkout))
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9._-]+/g, "-");
42
+ return safeId(value, "derived repository id");
43
+ }
44
+ function repositoryOverride(overrides, checkout) {
45
+ return (overrides[checkout] ?? overrides[path.resolve(checkout)] ?? overrides[path.basename(checkout)]);
46
+ }
47
+ function assertCheckout(checkout) {
48
+ const resolved = path.resolve(checkout);
49
+ if (!fs.statSync(resolved).isDirectory())
50
+ throw new Error(`${resolved}: repository is not a directory`);
51
+ return resolved;
52
+ }
53
+ function yamlForSystem(systemId, repositories, relationships = []) {
54
+ return stringify({
55
+ schemaVersion: 1,
56
+ repositories: repositories.map(({ id }) => ({ id, role: "source" })),
57
+ systems: [
58
+ {
59
+ id: systemId,
60
+ components: repositories.map(({ id, componentId, componentType }) => ({
61
+ id: componentId,
62
+ repository: id,
63
+ type: componentType,
64
+ })),
65
+ },
66
+ ],
67
+ relationships,
68
+ }, { lineWidth: 0 });
69
+ }
70
+ function teamYamlForSystem(filePath, systemId, repositories) {
71
+ let document;
72
+ try {
73
+ document = parseDocument(fs.readFileSync(filePath, "utf8"), { uniqueKeys: true });
74
+ }
75
+ catch (error) {
76
+ if (error.code !== "ENOENT")
77
+ throw error;
78
+ document = parseDocument("schemaVersion: 1\n");
79
+ }
80
+ if (document.errors.length)
81
+ throw new Error(`${filePath}: ${document.errors[0]?.message}`);
82
+ if (document.get("schemaVersion") === undefined)
83
+ document.set("schemaVersion", 1);
84
+ if (document.get("schemaVersion") !== 1)
85
+ throw new Error(`${filePath}: schemaVersion must be 1`);
86
+ const root = document.toJS();
87
+ const existingRepositories = new Map((root.repositories ?? []).map((repository) => [String(repository.id ?? ""), repository]));
88
+ for (const repository of repositories)
89
+ if (!existingRepositories.has(repository.id))
90
+ existingRepositories.set(repository.id, { id: repository.id, role: "source" });
91
+ const declaration = {
92
+ id: systemId,
93
+ components: repositories.map(({ id, componentId, componentType }) => ({
94
+ id: componentId,
95
+ repository: id,
96
+ type: componentType,
97
+ })),
98
+ };
99
+ const systems = root.systems ?? [];
100
+ const systemIndex = systems.findIndex(({ id }) => id === systemId);
101
+ if (systemIndex >= 0)
102
+ systems[systemIndex] = { ...systems[systemIndex], ...declaration };
103
+ else
104
+ systems.push(declaration);
105
+ document.set("repositories", [...existingRepositories.values()]);
106
+ document.set("systems", systems);
107
+ return document.toString({ lineWidth: 0 });
108
+ }
109
+ function mappingsYaml(repositories, existingPath, system) {
110
+ let document;
111
+ try {
112
+ document = parseDocument(fs.readFileSync(existingPath, "utf8"), { uniqueKeys: true });
113
+ }
114
+ catch (error) {
115
+ if (error.code !== "ENOENT")
116
+ throw error;
117
+ document = parseDocument("schemaVersion: 1\nrepositories: {}\n");
118
+ }
119
+ if (document.errors.length)
120
+ throw new Error(`${existingPath}: ${document.errors[0]?.message}`);
121
+ if (!document.hasIn(["repositories"]))
122
+ document.setIn(["repositories"], {});
123
+ for (const repository of repositories)
124
+ document.setIn(["repositories", repository.id], repository.path);
125
+ if (system)
126
+ document.setIn(["systemManifests", system.id], {
127
+ layout: system.layout,
128
+ manifests: system.manifests,
129
+ });
130
+ return document.toString({ lineWidth: 0 });
131
+ }
132
+ export function planSystemLink(options) {
133
+ const systemId = safeId(options.systemId, "system id");
134
+ if (options.repositories.length === 0)
135
+ throw new Error("system link requires at least one repository");
136
+ const overrides = options.repositoryIds ?? {};
137
+ const repositories = options.repositories.map((value) => {
138
+ const checkout = assertCheckout(value);
139
+ const id = safeId(repositoryOverride(overrides, value) ?? checkoutId(checkout), "repository id");
140
+ const component = options.components?.[id] ?? {};
141
+ return {
142
+ id,
143
+ path: checkout,
144
+ componentId: safeId(component.id ?? id, "component id"),
145
+ componentType: component.type ?? "component",
146
+ };
147
+ });
148
+ const collisions = repositories.filter((repository, index) => repositories.findIndex(({ id }) => id === repository.id) !== index);
149
+ if (collisions.length)
150
+ throw new Error(`repository id collision for ${collisions[0]?.id}; provide an explicit override`);
151
+ const scope = options.scope ?? "personal";
152
+ const files = [];
153
+ if (scope === "personal") {
154
+ const root = path.join(configHome(options.xdgConfigHome), "knodin");
155
+ files.push({
156
+ path: path.join(root, "systems", `${systemId}.yaml`),
157
+ content: yamlForSystem(systemId, repositories),
158
+ change: `declare personal system ${systemId}`,
159
+ });
160
+ const mappings = path.join(root, "repositories.yaml");
161
+ files.push({
162
+ path: mappings,
163
+ content: mappingsYaml(repositories, mappings),
164
+ change: "register personal checkout paths",
165
+ });
166
+ }
167
+ else if (options.distributed) {
168
+ for (const repository of repositories)
169
+ files.push({
170
+ path: path.join(repository.path, "knodin.yaml"),
171
+ content: teamYamlForSystem(path.join(repository.path, "knodin.yaml"), systemId, repositories),
172
+ change: `declare distributed fragment ${systemId}`,
173
+ });
174
+ const registry = path.join(configHome(options.xdgConfigHome), "knodin", "repositories.yaml");
175
+ files.push({
176
+ path: registry,
177
+ content: mappingsYaml(repositories, registry, {
178
+ id: systemId,
179
+ layout: "distributed",
180
+ manifests: repositories.map(({ path: checkout }) => path.join(checkout, "knodin.yaml")),
181
+ }),
182
+ change: `register distributed team system ${systemId}`,
183
+ });
184
+ }
185
+ else {
186
+ const central = repositories[0];
187
+ if (!central)
188
+ throw new Error("system link requires a central repository");
189
+ const target = path.join(central.path, "knodin.yaml");
190
+ files.push({
191
+ path: target,
192
+ content: teamYamlForSystem(target, systemId, repositories),
193
+ change: `declare central team system ${systemId}`,
194
+ });
195
+ const registry = path.join(configHome(options.xdgConfigHome), "knodin", "repositories.yaml");
196
+ files.push({
197
+ path: registry,
198
+ content: mappingsYaml(repositories, registry, {
199
+ id: systemId,
200
+ layout: "central",
201
+ manifests: [target],
202
+ }),
203
+ change: `register central team system ${systemId}`,
204
+ });
205
+ }
206
+ return protectPlan({
207
+ applied: false,
208
+ action: "link",
209
+ systemId,
210
+ files,
211
+ changes: files.map(({ change }) => change),
212
+ });
213
+ }
214
+ function assertSafeWrite(filePath) {
215
+ // Refuse the mutable destination and its managed parent. Platform paths may
216
+ // legitimately contain aliases above the caller-selected config/repository
217
+ // root (for example macOS /var -> /private/var), so do not reject ancestors
218
+ // outside knodin's ownership boundary.
219
+ try {
220
+ if (fs.lstatSync(path.dirname(filePath)).isSymbolicLink())
221
+ throw new Error(`${filePath}: refusing symbolic-link target`);
222
+ }
223
+ catch (error) {
224
+ if (error.code !== "ENOENT")
225
+ throw error;
226
+ }
227
+ try {
228
+ if (fs.lstatSync(filePath).isSymbolicLink())
229
+ throw new Error(`${filePath}: refusing symbolic-link target`);
230
+ }
231
+ catch (error) {
232
+ if (error.code !== "ENOENT")
233
+ throw error;
234
+ }
235
+ }
236
+ export function applySystemPlan(plan) {
237
+ for (const file of plan.files)
238
+ assertSafeWrite(file.path);
239
+ for (const file of plan.files)
240
+ if (file.expectedDigest !== undefined && fileDigest(file.path) !== file.expectedDigest)
241
+ throw new Error(`${file.path}: changed after preview; create a new plan`);
242
+ for (const file of plan.files) {
243
+ if (file.remove) {
244
+ fs.rmSync(file.path, { force: true });
245
+ continue;
246
+ }
247
+ fs.mkdirSync(path.dirname(file.path), { recursive: true });
248
+ const temporary = `${file.path}.tmp-${process.pid}-${crypto.randomUUID()}`;
249
+ fs.writeFileSync(temporary, file.content, { encoding: "utf8", mode: 0o600, flag: "wx" });
250
+ fs.renameSync(temporary, file.path);
251
+ }
252
+ return { ...plan, status: "applied", applied: true };
253
+ }
254
+ function parseEvidence(value) {
255
+ const match = /^([^:]+):(.+):(\d+)$/.exec(value);
256
+ if (!match)
257
+ throw new Error("evidence must be <repo-id>:<repository-relative-path>:<line>");
258
+ const line = Number(match[3]);
259
+ if (!Number.isSafeInteger(line) || line < 1)
260
+ throw new Error("evidence line must be a positive integer");
261
+ const repository = match[1];
262
+ const file = match[2];
263
+ if (!repository || !file)
264
+ throw new Error("evidence must name a repository and path");
265
+ if (path.isAbsolute(file) || file.split(/[\\/]/).includes(".."))
266
+ throw new Error("evidence path must be repository-relative");
267
+ return { repository: safeId(repository, "evidence repository id"), file, line };
268
+ }
269
+ function tracked(checkout, relative) {
270
+ const result = child_process.spawnSync("git", ["-C", checkout, "ls-files", "--error-unmatch", "--", relative], { stdio: "ignore" });
271
+ return result.status === 0;
272
+ }
273
+ export function planSystemRelate(options) {
274
+ if (!RELATIONSHIP_TYPES.includes(options.type))
275
+ throw new Error(`unsupported relationship type: ${options.type}`);
276
+ const evidence = parseEvidence(options.evidence);
277
+ const root = path.resolve(options.repoPath);
278
+ const config = loadSystemConfiguration(root, { xdgConfigHome: options.xdgConfigHome });
279
+ const system = config.systems.find(({ id }) => id === options.systemId);
280
+ if (!system)
281
+ throw new Error(`system was not found: ${options.systemId}`);
282
+ const evidenceRepository = config.repositories.find(({ id }) => id === evidence.repository);
283
+ if (!evidenceRepository)
284
+ throw new Error(`evidence repository is not a system member: ${evidence.repository}`);
285
+ const evidenceRoot = path.resolve(evidenceRepository.path ?? root);
286
+ const evidencePath = path.join(evidenceRoot, evidence.file);
287
+ const relative = path.relative(evidenceRoot, evidencePath);
288
+ if (relative.startsWith("..") || path.isAbsolute(relative))
289
+ throw new Error("evidence escapes repository");
290
+ const lines = fs.readFileSync(evidencePath, "utf8").split(/\r?\n/);
291
+ if (evidence.line > lines.length || (evidence.line === lines.length && lines.at(-1) === ""))
292
+ throw new Error(`evidence line ${evidence.line} does not exist in ${evidence.file}`);
293
+ if (!tracked(evidenceRoot, evidence.file))
294
+ throw new Error(`evidence file is not tracked: ${evidence.file}`);
295
+ const componentIds = new Set(system.components.map(({ id }) => id));
296
+ if (!componentIds.has(options.from) || !componentIds.has(options.to))
297
+ throw new Error("relationship direction must reference two declared components");
298
+ const personalTarget = personalSystemPath(options.systemId, options.xdgConfigHome);
299
+ const evidenceFragment = path.join(evidenceRoot, "knodin.yaml");
300
+ let target = path.join(root, "knodin.yaml");
301
+ if (fs.existsSync(personalTarget))
302
+ target = personalTarget;
303
+ else if (evidenceRoot !== root && fs.existsSync(evidenceFragment)) {
304
+ const fragment = parseDocument(fs.readFileSync(evidenceFragment, "utf8"), { uniqueKeys: true });
305
+ const fragmentSystems = fragment.toJS().systems ?? [];
306
+ if (fragmentSystems.some(({ id }) => id === options.systemId))
307
+ target = evidenceFragment;
308
+ }
309
+ const document = parseDocument(fs.readFileSync(target, "utf8"), { uniqueKeys: true });
310
+ if (document.errors.length)
311
+ throw new Error(`${target}: ${document.errors[0]?.message}`);
312
+ const relationships = document.get("relationships", true);
313
+ const relation = {
314
+ type: options.type,
315
+ from: safeId(options.from, "from component"),
316
+ to: safeId(options.to, "to component"),
317
+ evidence: { repository: evidence.repository, path: evidence.file, line: evidence.line },
318
+ };
319
+ if (relationships)
320
+ relationships.add(relation);
321
+ else
322
+ document.set("relationships", [relation]);
323
+ return protectPlan({
324
+ applied: false,
325
+ action: "relate",
326
+ systemId: options.systemId,
327
+ files: [
328
+ {
329
+ path: target,
330
+ content: document.toString({ lineWidth: 0 }),
331
+ change: `${options.type} ${options.from} -> ${options.to}`,
332
+ },
333
+ ],
334
+ changes: [
335
+ `add ${options.type} ${options.from} -> ${options.to} evidenced by ${options.evidence}`,
336
+ ],
337
+ });
338
+ }
339
+ function personalSystemPath(systemId, xdgConfigHome) {
340
+ return path.join(configHome(xdgConfigHome), "knodin", "systems", `${safeId(systemId, "system id")}.yaml`);
341
+ }
342
+ function writableSystemPath(systemId, repoPath, xdgConfigHome) {
343
+ const personal = personalSystemPath(systemId, xdgConfigHome);
344
+ if (fs.existsSync(personal) || !repoPath)
345
+ return personal;
346
+ return path.join(path.resolve(repoPath), "knodin.yaml");
347
+ }
348
+ export function planSystemUnlink(options) {
349
+ const target = writableSystemPath(options.systemId, options.repoPath, options.xdgConfigHome);
350
+ const personal = target === personalSystemPath(options.systemId, options.xdgConfigHome);
351
+ const document = parseDocument(fs.readFileSync(target, "utf8"), { uniqueKeys: true });
352
+ if (document.errors.length)
353
+ throw new Error(`${target}: ${document.errors[0]?.message}`);
354
+ const selected = new Set(options.repositories ?? []);
355
+ if (selected.size === 0) {
356
+ if (!personal) {
357
+ const root = document.toJS();
358
+ const removed = root.systems?.find(({ id }) => id === options.systemId);
359
+ if (!removed)
360
+ throw new Error(`system was not found: ${options.systemId}`);
361
+ const removedComponents = new Set((removed.components ?? []).map(({ id }) => id));
362
+ root.systems = (root.systems ?? []).filter(({ id }) => id !== options.systemId);
363
+ root.relationships = (root.relationships ?? []).filter(({ from, to }) => !removedComponents.has(from) && !removedComponents.has(to));
364
+ const usedRepositories = new Set(root.systems.flatMap(({ components }) => (components ?? []).flatMap(({ repository }) => (repository ? [repository] : []))));
365
+ root.repositories = (root.repositories ?? []).filter(({ id }) => id !== undefined && usedRepositories.has(id));
366
+ document.set("systems", root.systems);
367
+ document.set("relationships", root.relationships);
368
+ document.set("repositories", root.repositories);
369
+ return protectPlan({
370
+ applied: false,
371
+ action: "unlink",
372
+ systemId: options.systemId,
373
+ files: [
374
+ {
375
+ path: target,
376
+ content: document.toString({ lineWidth: 0 }),
377
+ change: `remove team system ${options.systemId}`,
378
+ },
379
+ ],
380
+ changes: [`remove team system ${options.systemId}`],
381
+ });
382
+ }
383
+ return protectPlan({
384
+ applied: false,
385
+ action: "unlink",
386
+ systemId: options.systemId,
387
+ files: [
388
+ {
389
+ path: target,
390
+ content: "",
391
+ change: `remove personal system ${options.systemId}`,
392
+ remove: true,
393
+ },
394
+ ],
395
+ changes: [`remove personal system ${options.systemId}`],
396
+ });
397
+ }
398
+ const root = document.toJS();
399
+ const system = (root.systems ?? []).find(({ id }) => id === options.systemId);
400
+ if (!system)
401
+ throw new Error(`system was not found: ${options.systemId}`);
402
+ const removedComponents = new Set((system.components ?? [])
403
+ .filter(({ repository }) => repository !== undefined && selected.has(repository))
404
+ .flatMap(({ id }) => (id ? [id] : [])));
405
+ system.components = (system.components ?? []).filter(({ repository }) => repository === undefined || !selected.has(repository));
406
+ root.relationships = (root.relationships ?? []).filter(({ from, to }) => (from === undefined || !removedComponents.has(from)) &&
407
+ (to === undefined || !removedComponents.has(to)));
408
+ const usedRepositories = new Set((root.systems ?? []).flatMap(({ components }) => (components ?? []).flatMap(({ repository }) => (repository ? [repository] : []))));
409
+ root.repositories = (root.repositories ?? []).filter(({ id }) => id !== undefined && usedRepositories.has(id));
410
+ document.set("repositories", root.repositories);
411
+ document.set("systems", root.systems ?? []);
412
+ document.set("relationships", root.relationships ?? []);
413
+ return protectPlan({
414
+ applied: false,
415
+ action: "unlink",
416
+ systemId: options.systemId,
417
+ files: [
418
+ {
419
+ path: target,
420
+ content: document.toString({ lineWidth: 0 }),
421
+ change: `unlink ${[...selected].join(", ")}`,
422
+ },
423
+ ],
424
+ changes: [`unlink repositories ${[...selected].join(", ")}`],
425
+ });
426
+ }
427
+ export function planSystemUnrelate(options) {
428
+ const target = writableSystemPath(options.systemId, options.repoPath, options.xdgConfigHome);
429
+ const document = parseDocument(fs.readFileSync(target, "utf8"), { uniqueKeys: true });
430
+ const root = document.toJS();
431
+ const before = root.relationships ?? [];
432
+ const after = before.filter((relationship) => relationship.type !== options.type ||
433
+ relationship.from !== options.from ||
434
+ relationship.to !== options.to);
435
+ if (after.length === before.length)
436
+ throw new Error("relationship was not found");
437
+ document.set("relationships", after);
438
+ return protectPlan({
439
+ applied: false,
440
+ action: "unrelate",
441
+ systemId: options.systemId,
442
+ files: [
443
+ {
444
+ path: target,
445
+ content: document.toString({ lineWidth: 0 }),
446
+ change: `remove ${options.type} ${options.from} -> ${options.to}`,
447
+ },
448
+ ],
449
+ changes: [`remove ${options.type} ${options.from} -> ${options.to}`],
450
+ });
451
+ }
452
+ export function planSystemImport(options) {
453
+ const source = path.resolve(options.manifestPath);
454
+ const document = parseDocument(fs.readFileSync(source, "utf8"), { uniqueKeys: true });
455
+ if (document.errors.length)
456
+ throw new Error(`${source}: ${document.errors[0]?.message}`);
457
+ const systems = document.toJS().systems ?? [];
458
+ if (systems.length !== 1 || !systems[0]?.id)
459
+ throw new Error("system import requires exactly one named system");
460
+ const systemId = safeId(systems[0].id, "system id");
461
+ const target = options.scope === "team"
462
+ ? path.join(path.resolve(options.repoPath), "knodin.yaml")
463
+ : personalSystemPath(systemId, options.xdgConfigHome);
464
+ let content = document.toString({ lineWidth: 0 });
465
+ if (options.scope === "team") {
466
+ let targetDocument;
467
+ try {
468
+ targetDocument = parseDocument(fs.readFileSync(target, "utf8"), { uniqueKeys: true });
469
+ }
470
+ catch (error) {
471
+ if (error.code !== "ENOENT")
472
+ throw error;
473
+ targetDocument = parseDocument("schemaVersion: 1\n");
474
+ }
475
+ if (targetDocument.errors.length)
476
+ throw new Error(`${target}: ${targetDocument.errors[0]?.message}`);
477
+ const imported = document.toJS();
478
+ const current = targetDocument.toJS();
479
+ const mergeByIdentity = (existing, incoming, identity) => {
480
+ const values = new Map(existing.map((value) => [identity(value), value]));
481
+ for (const value of incoming) {
482
+ const key = identity(value);
483
+ const prior = values.get(key);
484
+ if (prior && JSON.stringify(prior) !== JSON.stringify(value))
485
+ throw new Error(`${target}: conflicting imported declaration ${key}`);
486
+ values.set(key, value);
487
+ }
488
+ return [...values.values()];
489
+ };
490
+ targetDocument.set("repositories", mergeByIdentity(current.repositories ?? [], imported.repositories ?? [], (value) => String(value.id)));
491
+ targetDocument.set("systems", mergeByIdentity(current.systems ?? [], imported.systems ?? [], (value) => String(value.id)));
492
+ targetDocument.set("relationships", mergeByIdentity(current.relationships ?? [], imported.relationships ?? [], (value) => `${value.type}:${value.from}:${value.to}`));
493
+ content = targetDocument.toString({ lineWidth: 0 });
494
+ }
495
+ return protectPlan({
496
+ applied: false,
497
+ action: "import",
498
+ systemId,
499
+ files: [
500
+ {
501
+ path: target,
502
+ content,
503
+ change: `import system ${systemId}`,
504
+ },
505
+ ],
506
+ changes: [`import system ${systemId} from ${source}`],
507
+ });
508
+ }