@takodotid/circuit 0.18.5 → 0.20.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.
Files changed (47) hide show
  1. package/README.md +18 -0
  2. package/dist/chunks/index-jtzfw679.js +936 -0
  3. package/dist/chunks/{index-ex1wcst7.js → index-xm9nma2t.js} +20 -1
  4. package/dist/chunks/server-5shmsmse.js +246 -0
  5. package/dist/cli/index.js +701 -106
  6. package/dist/index.js +1 -1
  7. package/dist/presets/index.js +1 -1
  8. package/dist/types/adapters/devices/derive.d.ts +2 -0
  9. package/dist/types/core/across.d.ts +4 -0
  10. package/dist/types/core/addr.d.ts +10 -0
  11. package/dist/types/core/addresses.d.ts +46 -0
  12. package/dist/types/core/files.d.ts +6 -0
  13. package/dist/types/core/graph.d.ts +74 -0
  14. package/dist/types/core/schema-docs.d.ts +16 -0
  15. package/package.json +1 -1
  16. package/src/adapters/devices/derive.ts +32 -0
  17. package/src/adapters/devices/raisecom-ros/index.ts +23 -3
  18. package/src/adapters/devices/routeros/index.ts +10 -0
  19. package/src/adapters/devices/vrp/index.ts +10 -1
  20. package/src/cli/graph.ts +89 -0
  21. package/src/cli/index.ts +205 -2
  22. package/src/cli/rename.ts +129 -0
  23. package/src/core/across.ts +322 -0
  24. package/src/core/addr.ts +29 -0
  25. package/src/core/addresses.ts +316 -0
  26. package/src/core/files.ts +30 -0
  27. package/src/core/graph.ts +634 -0
  28. package/src/core/schema-docs.ts +166 -0
  29. package/src/core/validate.ts +2 -52
  30. package/src/studio/data.ts +255 -0
  31. package/src/studio/git.ts +119 -0
  32. package/src/studio/public/app.js +147 -0
  33. package/src/studio/public/compare.js +243 -0
  34. package/src/studio/public/diff.js +97 -0
  35. package/src/studio/public/dom.js +108 -0
  36. package/src/studio/public/evaluate.js +648 -0
  37. package/src/studio/public/export.js +122 -0
  38. package/src/studio/public/favicon.svg +6 -0
  39. package/src/studio/public/graph.js +914 -0
  40. package/src/studio/public/index.html +67 -0
  41. package/src/studio/public/net.js +128 -0
  42. package/src/studio/public/style.css +693 -0
  43. package/src/studio/public/tables.js +1223 -0
  44. package/src/studio/public/tools.js +578 -0
  45. package/src/studio/server.ts +218 -0
  46. package/templates/project/AGENTS.md +12 -9
  47. package/templates/project/README.md +1 -0
@@ -0,0 +1,166 @@
1
+ // The schema's own comments, read as data: each type, what it is, and each field with its type and description. The schema reference page and Studio both show them.
2
+
3
+ /** The schema files in reading order, with the title each gets. */
4
+ export const SCHEMA_FILES = [
5
+ ["device.ts", "Device"],
6
+ ["interfaces.ts", "Ports and interfaces"],
7
+ ["routing.ts", "Routing"],
8
+ ["policy.ts", "Route policies"],
9
+ ["firewall.ts", "Firewall and ACLs"],
10
+ ["system.ts", "System and management"],
11
+ ["common.ts", "Common values"],
12
+ ] as const;
13
+
14
+ export type Field = { path: string; type: string; description: string };
15
+ export type TypeDoc = { name: string; description: string; definition?: string; includes?: string[]; fields: Field[] };
16
+
17
+ /** The text of a JSDoc comment, without its markers, as one paragraph. */
18
+ function commentText(lines: string[]): string {
19
+ return lines
20
+ .map((line) => line.replace(/^\s*\/\*\*\s?|\s*\*\/\s*$|^\s*\*\s?/g, "").trim())
21
+ .filter(Boolean)
22
+ .join(" ");
23
+ }
24
+
25
+ /** What a type parameter stands for, where it is not one of the device's names. */
26
+ const PARAMETER_MEANING: Record<string, string> = { Pl: "platform", M: "model" };
27
+
28
+ /** A type as a reader needs it: "name" for each of the device's names, and no parameter list on the schema's own types. */
29
+ function withoutGenerics(type: string, generics: Set<string>): string {
30
+ let result = type;
31
+ while (/NoInfer<([^<>]*)>/.test(result)) {
32
+ result = result.replace(/NoInfer<([^<>]*)>/g, (_, inner: string) => (inner.includes("|") ? `(${inner})` : inner));
33
+ }
34
+
35
+ for (const name of generics) {
36
+ result = result.replace(new RegExp(`\\b${name}<[^<>]*(?:<[^<>]*>[^<>]*)*>`, "g"), name);
37
+ }
38
+
39
+ return result
40
+ .replace(/\b(Pl|M|N|V|I|P|S|G|A|AS|U|F|C|L|K)\b/g, (parameter) => PARAMETER_MEANING[parameter] ?? "name")
41
+ .replace(/\bname(?: \| name)+\b/g, "name")
42
+ .replace(/\(name\)/g, "name");
43
+ }
44
+
45
+ function parse(source: string, generics: Set<string>): TypeDoc[] {
46
+ const lines = source.split("\n");
47
+ const types: TypeDoc[] = [];
48
+
49
+ let comment: string[] = [];
50
+ let inComment = false;
51
+ let current: TypeDoc | undefined;
52
+ let depth = 0;
53
+ const parents: string[] = [];
54
+ let header: string | undefined;
55
+
56
+ /** Reads a whole declaration: a type it aliases, the types whose fields it also has, and whether a body opens. */
57
+ function finishHeader(): void {
58
+ const text = header!;
59
+ header = undefined;
60
+
61
+ // The part after `=`, with the generic parameter list before it left out.
62
+ const body = text.slice(text.indexOf("> =") >= 0 ? text.indexOf("> =") + 3 : text.indexOf("=") + 1).trim();
63
+
64
+ if (!body.includes("{")) {
65
+ current!.definition = body.replace(/;$/, "").replace(/^\|\s*/, "");
66
+ } else {
67
+ const before = body.slice(0, body.indexOf("{"));
68
+ const included = before
69
+ .split("&")
70
+ .map((part) => part.trim())
71
+ .filter((part) => /^\w/.test(part));
72
+ if (included.length) current!.includes = included;
73
+ }
74
+
75
+ depth = (text.match(/{/g) ?? []).length - (text.match(/}/g) ?? []).length;
76
+ }
77
+
78
+ for (const line of lines) {
79
+ const trimmed = line.trim();
80
+
81
+ if (trimmed.startsWith("/**")) {
82
+ comment = [trimmed];
83
+ inComment = !trimmed.endsWith("*/");
84
+ continue;
85
+ }
86
+
87
+ if (inComment) {
88
+ comment.push(trimmed);
89
+ if (trimmed.endsWith("*/")) inComment = false;
90
+ continue;
91
+ }
92
+
93
+ // A declaration can span lines before its body opens or it ends.
94
+ if (header !== undefined) {
95
+ header += ` ${trimmed}`;
96
+ if (trimmed.endsWith("{") || trimmed.endsWith(";")) finishHeader();
97
+ continue;
98
+ }
99
+
100
+ // A type the schema does not export is documented too, since exported types take its fields.
101
+ const declaration = /^(?:export )?type (\w+)(<)?/.exec(trimmed);
102
+ if (declaration && depth === 0) {
103
+ if (declaration[2]) generics.add(declaration[1]!);
104
+
105
+ current = { name: declaration[1]!, description: commentText(comment), fields: [] };
106
+ types.push(current);
107
+ comment = [];
108
+
109
+ header = trimmed;
110
+ if (trimmed.endsWith("{") || trimmed.endsWith(";")) finishHeader();
111
+ continue;
112
+ }
113
+
114
+ if (!current) continue;
115
+
116
+ const field = /^(?:readonly\s+)?("?[\w-]+"?)(\?)?:\s*(.*)$/.exec(trimmed);
117
+ if (field && depth >= 1) {
118
+ const [, name, optional, rest] = field;
119
+ const path = [...parents, name!.replace(/"/g, "")].join(".");
120
+ const opensObject = rest!.trim().endsWith("{");
121
+ const type = opensObject ? "object" : rest!.replace(/;$/, "").trim();
122
+
123
+ current.fields.push({ path: `${path}${optional ? "?" : ""}`, type, description: commentText(comment) });
124
+ comment = [];
125
+
126
+ if (opensObject) {
127
+ parents.push(name!.replace(/"/g, ""));
128
+ depth++;
129
+ }
130
+ continue;
131
+ }
132
+
133
+ const opens = (trimmed.match(/{/g) ?? []).length;
134
+ const closes = (trimmed.match(/}/g) ?? []).length;
135
+
136
+ for (let index = 0; index < closes - opens; index++) {
137
+ if (parents.length >= depth - 1 && parents.length > 0) parents.pop();
138
+ }
139
+
140
+ depth += opens - closes;
141
+ if (depth <= 0) {
142
+ depth = 0;
143
+ parents.length = 0;
144
+ }
145
+
146
+ comment = [];
147
+ }
148
+
149
+ return types;
150
+ }
151
+
152
+ /** Every type in each schema file, in the order given. Every file is read first, so a type is known to take parameters wherever it is used. */
153
+ export function schemaTypes(sources: readonly string[]): TypeDoc[][] {
154
+ const generics = new Set<string>();
155
+ const parsed = sources.map((source) => parse(source, generics));
156
+
157
+ for (const types of parsed) {
158
+ for (const type of types) {
159
+ if (type.definition) type.definition = withoutGenerics(type.definition, generics);
160
+ if (type.includes) type.includes = type.includes.map((name) => withoutGenerics(name, generics));
161
+ for (const field of type.fields) field.type = withoutGenerics(field.type, generics);
162
+ }
163
+ }
164
+
165
+ return parsed;
166
+ }
@@ -4,6 +4,7 @@ import { X509Certificate } from "node:crypto";
4
4
  import { adapters } from "../adapters/devices";
5
5
  import { portsOf } from "../adapters/devices/catalog";
6
6
  import type { Device } from "../schema";
7
+ import { checkAcross } from "./across";
7
8
  import { contains, familyOf, isAddress, isCidr } from "./addr";
8
9
  import type { Finding, Network } from "./define";
9
10
 
@@ -28,7 +29,7 @@ export function validate(network: Network): Finding[] {
28
29
  else for (const field of adapter.unsupported(device)) report(`${device.platform} cannot express ${field}`);
29
30
  }
30
31
 
31
- findings.push(...checkLinks(network.devices));
32
+ findings.push(...checkAcross(network.devices));
32
33
  for (const check of network.checks ?? []) findings.push(...check(network.devices));
33
34
  return findings;
34
35
  }
@@ -327,54 +328,3 @@ function checkCertificates(device: Device, names: Names, report: Report): void {
327
328
  }
328
329
  }
329
330
  }
330
-
331
- /** The VLAN ids a port carries, by how it carries them, so two ends can be compared across devices. */
332
- function carried(device: Device, port: NonNullable<NonNullable<Device["ports"]>[string]>) {
333
- const id = (vlan: string | undefined) => (vlan ? device.vlans?.[vlan]?.id : undefined);
334
- return {
335
- access: id(port.access_vlan),
336
- native: id(port.native_vlan),
337
- trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0)),
338
- };
339
- }
340
-
341
- /** A link names a device and port that exist, the far end links back, and both ends carry the same VLANs the same way. */
342
- function checkLinks(devices: readonly Device[]): Finding[] {
343
- const findings: Finding[] = [];
344
- const byName = new Map(devices.map((device) => [device.name, device]));
345
-
346
- for (const device of devices) {
347
- for (const [portName, port] of Object.entries(device.ports ?? {})) {
348
- const link = port?.link;
349
- if (!port || !link) continue;
350
-
351
- const report = (message: string) =>
352
- findings.push({
353
- level: "error",
354
- device: device.name,
355
- message: `port ${portName} link: ${message}`,
356
- });
357
-
358
- const far = byName.get(link.device);
359
- if (!far) {
360
- report(`device ${link.device} is not in the network`);
361
- continue;
362
- }
363
-
364
- const farPort = far.ports?.[link.port];
365
- if (!farPort) {
366
- report(`${link.device} does not declare port ${link.port}`);
367
- continue;
368
- }
369
-
370
- const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
371
- if (!linksBack) report(`${link.device} ${link.port} does not link back to it`);
372
-
373
- const here = JSON.stringify(carried(device, port));
374
- const there = JSON.stringify(carried(far, farPort));
375
- if (here !== there) report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
376
- }
377
- }
378
-
379
- return findings;
380
- }
@@ -0,0 +1,255 @@
1
+ // What Studio shows, gathered from the config in one pass: the graph with its findings and drift, each device as the config holds it, what each would run, the address plan, every BGP session and cable, and the schema's descriptions of each field.
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { portsOf } from "../adapters/devices/catalog";
7
+ import { familyOf, host } from "../core/addr";
8
+ import { type AddressReport, addressesOf } from "../core/addresses";
9
+ import { type Annotated, type Holder, holdersOf, keyOf } from "../core/graph";
10
+ import { SCHEMA_FILES, schemaTypes } from "../core/schema-docs";
11
+ import type { Device } from "../schema";
12
+
13
+ /** One BGP session, with its group's settings filled in, and what is at its far end. */
14
+ export type Session = {
15
+ device: string;
16
+ name: string;
17
+ address: string;
18
+ group?: string;
19
+ remoteAs?: number;
20
+ role?: string;
21
+ import?: string;
22
+ export?: string;
23
+ description?: string;
24
+ /** One of our devices, by name, or another network, as `AS64501`. */
25
+ far?: string;
26
+ /** The far device has no session back. */
27
+ oneSided?: true;
28
+ };
29
+
30
+ /** One cable, from the end that names it first. */
31
+ export type Cable = {
32
+ device: string;
33
+ port: string;
34
+ farDevice: string;
35
+ farPort: string;
36
+ /** The LAG each end's port is in. */
37
+ lag?: string;
38
+ farLag?: string;
39
+ description?: string;
40
+ /** VLANs carried, as `name (id)`, from the port or its LAG. */
41
+ vlans: string[];
42
+ /** The far end does not name this cable back. */
43
+ oneSided?: true;
44
+ };
45
+
46
+ /** A schema type's description and its fields' descriptions, by field path. */
47
+ export type TypeDocs = {
48
+ description: string;
49
+ includes: string[];
50
+ fields: Record<string, { type: string; description: string }>;
51
+ };
52
+
53
+ export type StudioData = {
54
+ graph: Annotated;
55
+ devices: readonly Device[];
56
+ /** Every port each device's model has, in order, so Studio can show the ones the config leaves shut. */
57
+ ports: Record<string, string[]>;
58
+ /** What each device would run, as `circuit build` prints it, or why it cannot be rendered. */
59
+ rendered: Record<string, string>;
60
+ /** What each device ran when Circuit last read it, secrets removed, for the devices with a snapshot. */
61
+ snapshots: Record<string, string>;
62
+ /** Each device's file and snapshot, relative to the config, for its history. */
63
+ files: Record<string, DeviceFiles>;
64
+ addresses: AddressReport;
65
+ sessions: Session[];
66
+ cables: Cable[];
67
+ /** By type name. */
68
+ docs: Record<string, TypeDocs>;
69
+ };
70
+
71
+ function sessionsOf(devices: readonly Device[], owners: Map<string, Holder>): Session[] {
72
+ const all = devices.flatMap((device) => {
73
+ const bgp = device.routing?.bgp;
74
+
75
+ return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]): Session & { deviceObject: Device } => {
76
+ const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
77
+ const settings = { ...group, ...neighbor };
78
+
79
+ return {
80
+ deviceObject: device,
81
+ device: device.name,
82
+ name,
83
+ address: neighbor.address,
84
+ group: neighbor.group,
85
+ remoteAs: settings.remote_as,
86
+ role: settings.role,
87
+ import: settings.import,
88
+ export: settings.export,
89
+ description: settings.description,
90
+ };
91
+ });
92
+ });
93
+
94
+ const ownerOf = (address: string) => owners.get(keyOf(address) ?? "")?.device;
95
+
96
+ return all.map(({ deviceObject, ...session }) => {
97
+ const far = ownerOf(session.address);
98
+
99
+ if (far && far !== deviceObject) {
100
+ // The session back is one of the same family: a v4 session back does not answer a v6 session.
101
+ const back = all.some(
102
+ (other) =>
103
+ other.deviceObject === far &&
104
+ ownerOf(other.address) === deviceObject &&
105
+ familyOf(other.address) === familyOf(session.address)
106
+ );
107
+ return { ...session, far: far.name, ...(back ? {} : { oneSided: true as const }) };
108
+ }
109
+
110
+ return { ...session, ...(session.remoteAs ? { far: `AS${session.remoteAs}` } : {}) };
111
+ });
112
+ }
113
+
114
+ function cablesOf(devices: readonly Device[]): Cable[] {
115
+ const byName = new Map(devices.map((device) => [device.name, device]));
116
+ const drawn = new Set<string>();
117
+ const cables: Cable[] = [];
118
+
119
+ const vlansOf = (device: Device, portName: string) => {
120
+ const port = device.ports?.[portName];
121
+ const lag = port?.lag ? device.interfaces?.[port.lag] : undefined;
122
+ const carrier = lag?.type === "lag" ? lag : port;
123
+ const names = [carrier?.access_vlan, carrier?.native_vlan, ...(carrier?.trunk_vlans ?? [])].filter((name) => name !== undefined);
124
+
125
+ return [...new Set(names)].map((name) => `${name} (${device.vlans?.[name]?.id ?? "?"})`);
126
+ };
127
+
128
+ for (const device of devices) {
129
+ for (const [portName, port] of Object.entries(device.ports ?? {})) {
130
+ const link = port?.link;
131
+ if (!port || !link) continue;
132
+
133
+ const here = `${device.name} ${portName}`;
134
+ const there = `${link.device} ${link.port}`;
135
+ if (drawn.has(`${there}|${here}`)) continue;
136
+ drawn.add(`${here}|${there}`);
137
+
138
+ const farPort = byName.get(link.device)?.ports?.[link.port];
139
+ const linksBack = farPort?.link?.device === device.name && farPort.link.port === portName;
140
+
141
+ cables.push({
142
+ device: device.name,
143
+ port: portName,
144
+ farDevice: link.device,
145
+ farPort: link.port,
146
+ ...(port.lag ? { lag: port.lag } : {}),
147
+ ...(farPort?.lag ? { farLag: farPort.lag } : {}),
148
+ ...(port.description ? { description: port.description } : {}),
149
+ vlans: vlansOf(device, portName),
150
+ ...(linksBack ? {} : { oneSided: true as const }),
151
+ });
152
+ }
153
+ }
154
+
155
+ return cables;
156
+ }
157
+
158
+ /** The directory Circuit is installed in: the nearest one up with its package.json. Its sources ship with it, in src: the schema, and the files of Studio's page. */
159
+ export function packageRoot(): string | undefined {
160
+ let directory = dirname(fileURLToPath(import.meta.url));
161
+
162
+ while (true) {
163
+ const manifest = join(directory, "package.json");
164
+ if (existsSync(manifest) && JSON.parse(readFileSync(manifest, "utf8")).name === "@takodotid/circuit") return directory;
165
+
166
+ const parent = dirname(directory);
167
+ if (parent === directory) return undefined;
168
+ directory = parent;
169
+ }
170
+ }
171
+
172
+ /** The schema's descriptions, or none when its sources cannot be found. */
173
+ function schemaDocs(): Record<string, TypeDocs> {
174
+ const root = packageRoot();
175
+ if (!root) return {};
176
+
177
+ const sources = SCHEMA_FILES.map(([file]) => readFileSync(join(root, "src", "schema", file), "utf8"));
178
+ const docs: Record<string, TypeDocs> = {};
179
+
180
+ for (const type of schemaTypes(sources).flat()) {
181
+ docs[type.name] = {
182
+ description: type.description,
183
+ includes: (type.includes ?? []).map((name) => name.replace(/<.*$/, "")),
184
+ fields: Object.fromEntries(
185
+ type.fields.map((field) => [field.path.replace(/\?$/, ""), { type: field.type, description: field.description }])
186
+ ),
187
+ };
188
+ }
189
+
190
+ return docs;
191
+ }
192
+
193
+ /** Where a device is kept, relative to the config: the file that declares it, and its snapshot. Either is absent when it cannot be found. */
194
+ export type DeviceFiles = { source?: string; snapshot?: string };
195
+
196
+ /** How Studio learns what a device would run and what it ran, from the caller, which knows the adapters and the state directory. */
197
+ export type DeviceTexts = {
198
+ /** What the device would run, as `circuit build` prints it. */
199
+ render: (device: Device) => string;
200
+ /** Its last snapshot, or undefined when it has none. */
201
+ snapshot: (device: Device) => string | undefined;
202
+ /** Its file and its snapshot's file. */
203
+ files: (device: Device) => DeviceFiles;
204
+ };
205
+
206
+ /** Everything Studio shows. `graph` comes ready from the caller, with each device compared with its snapshot. */
207
+ export function studioData(devices: readonly Device[], graph: Annotated, texts: DeviceTexts): StudioData {
208
+ const owners = new Map<string, Holder>();
209
+ for (const holder of holdersOf(devices)) {
210
+ const key = keyOf(host(holder.address))!;
211
+ if (!owners.has(key)) owners.set(key, holder);
212
+ }
213
+
214
+ const attempt = <T>(work: () => T, otherwise: (error: Error) => T) => {
215
+ try {
216
+ return work();
217
+ } catch (error) {
218
+ return otherwise(error as Error);
219
+ }
220
+ };
221
+
222
+ return {
223
+ graph,
224
+ devices,
225
+ ports: Object.fromEntries(
226
+ devices.map((device) => [
227
+ device.name,
228
+ attempt(
229
+ () => portsOf(device.platform, device.model),
230
+ () => []
231
+ ),
232
+ ])
233
+ ),
234
+ rendered: Object.fromEntries(
235
+ devices.map((device) => [
236
+ device.name,
237
+ attempt(
238
+ () => texts.render(device),
239
+ (error) => `Cannot be rendered: ${error.message}`
240
+ ),
241
+ ])
242
+ ),
243
+ snapshots: Object.fromEntries(
244
+ devices.flatMap((device) => {
245
+ const text = texts.snapshot(device);
246
+ return text === undefined ? [] : [[device.name, text]];
247
+ })
248
+ ),
249
+ files: Object.fromEntries(devices.map((device) => [device.name, texts.files(device)])),
250
+ addresses: addressesOf(devices),
251
+ sessions: sessionsOf(devices, owners),
252
+ cables: cablesOf(devices),
253
+ docs: schemaDocs(),
254
+ };
255
+ }
@@ -0,0 +1,119 @@
1
+ // Git, for Studio's history and comparisons: the commits that changed a device, and the network as it was at another commit or branch.
2
+
3
+ import { execFile } from "node:child_process";
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { basename, join } from "node:path";
6
+
7
+ /** One commit, as Studio lists it. */
8
+ export type Commit = {
9
+ commit: string;
10
+ short: string;
11
+ author: string;
12
+ /** ISO 8601, with the author's time zone. */
13
+ date: string;
14
+ subject: string;
15
+ };
16
+
17
+ /** The environment without git's own variables, such as GIT_DIR in a hook, which would point git at another repository than the directory's. */
18
+ const environment = () => Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_")));
19
+
20
+ /** Run git in a directory, and give what it printed, or fail with what it said. */
21
+ function git(cwd: string, args: string[]): Promise<string> {
22
+ return new Promise((done, fail) => {
23
+ execFile("git", args, { cwd, env: environment(), maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
24
+ if (error) fail(new Error((stderr || error.message).trim()));
25
+ else done(stdout);
26
+ });
27
+ });
28
+ }
29
+
30
+ const FORMAT = "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s";
31
+
32
+ function commitsOf(text: string): Commit[] {
33
+ return text
34
+ .split("\n")
35
+ .filter(Boolean)
36
+ .map((line) => {
37
+ const [commit = "", short = "", author = "", date = "", subject = ""] = line.split("\x1f");
38
+ return { commit, short, author, date, subject };
39
+ });
40
+ }
41
+
42
+ /** The top of the git repository the directory is in, or undefined when it is in none, or git is missing. */
43
+ export async function gitTop(directory: string): Promise<string | undefined> {
44
+ try {
45
+ return (await git(directory, ["rev-parse", "--show-toplevel"])).trim();
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ /** The latest commits that changed any of these files, relative to the directory, newest first. */
52
+ export async function history(directory: string, files: string[], limit = 50): Promise<Commit[]> {
53
+ if (!files.length) return [];
54
+ return commitsOf(await git(directory, ["log", `-n${limit}`, FORMAT, "--", ...files]));
55
+ }
56
+
57
+ /** Branches and tags to compare with, and the latest commits. */
58
+ export async function references(directory: string): Promise<{ names: string[]; commits: Commit[] }> {
59
+ const names = (
60
+ await git(directory, [
61
+ "for-each-ref",
62
+ "--count=100",
63
+ "--sort=-committerdate",
64
+ "--format=%(refname:short)",
65
+ "refs/heads",
66
+ "refs/remotes",
67
+ "refs/tags",
68
+ ])
69
+ )
70
+ .split("\n")
71
+ .filter((name) => name && !name.endsWith("/HEAD"));
72
+
73
+ return { names, commits: commitsOf(await git(directory, ["log", "-n30", FORMAT])) };
74
+ }
75
+
76
+ /** A branch, tag or commit, as the commit it names, or undefined when it names none. Only the characters a git name can hold are accepted. */
77
+ export async function commitOf(directory: string, name: string): Promise<Commit | undefined> {
78
+ if (!/^[A-Za-z0-9][\w./@^~-]*$/.test(name)) return undefined;
79
+
80
+ try {
81
+ const [commit] = commitsOf(await git(directory, ["log", "-n1", FORMAT, `${name}^{commit}`, "--"]));
82
+ return commit;
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Read the network as it was at a commit: check that commit out beside the project, in `.circuit/cache/`, which git ignores, gather it there with `gather`, then remove the checkout. The config is the file of that name in the same directory, `configDirectory`.
90
+ *
91
+ * The checkout sits inside the project, so its imports of Circuit resolve to the version installed now.
92
+ */
93
+ export async function networkAt(
94
+ configDirectory: string,
95
+ configPath: string,
96
+ commit: string,
97
+ gather: (configPath: string) => Promise<string>
98
+ ): Promise<string> {
99
+ const top = await gitTop(configDirectory);
100
+ if (!top) throw new Error("the config is not in a git repository");
101
+
102
+ const cache = join(configDirectory, ".circuit", "cache");
103
+ mkdirSync(cache, { recursive: true });
104
+ writeFileSync(join(cache, ".gitignore"), "# Written by Circuit Studio: checkouts of other commits, removed once read.\n*\n");
105
+
106
+ // A checkout left behind by a run that stopped is forgotten first.
107
+ await git(top, ["worktree", "prune"]);
108
+
109
+ const checkout = join(cache, commit.slice(0, 12));
110
+ await git(top, ["worktree", "add", "--detach", "--force", checkout, commit]);
111
+
112
+ try {
113
+ // Where the config sits inside the repository, as git says: comparing paths would trip on two spellings of one directory, such as Windows' short names.
114
+ const prefix = (await git(configDirectory, ["rev-parse", "--show-prefix"])).trim();
115
+ return await gather(join(checkout, prefix, basename(configPath)));
116
+ } finally {
117
+ await git(top, ["worktree", "remove", "--force", checkout]).catch(() => {});
118
+ }
119
+ }