@takodotid/circuit 0.9.0 → 0.11.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 (48) hide show
  1. package/README.md +3 -2
  2. package/dist/chunks/index-ex1wcst7.js +109 -0
  3. package/dist/chunks/index-fq57hya4.js +20 -0
  4. package/dist/chunks/new-7n8qvtjd.js +168 -0
  5. package/dist/cli/index.js +4079 -0
  6. package/dist/index.js +48 -0
  7. package/dist/presets/index.js +269 -0
  8. package/dist/types/adapters/devices/catalog.d.ts +58 -0
  9. package/dist/types/adapters/devices/derive.d.ts +5 -0
  10. package/dist/types/adapters/devices/index.d.ts +3 -0
  11. package/dist/types/adapters/devices/lines.d.ts +56 -0
  12. package/dist/types/adapters/devices/raisecom-ros/index.d.ts +23 -0
  13. package/dist/types/adapters/devices/raisecom-ros/models.d.ts +14 -0
  14. package/dist/types/adapters/devices/routeros/export.d.ts +39 -0
  15. package/dist/types/adapters/devices/routeros/index.d.ts +17 -0
  16. package/dist/types/adapters/devices/routeros/models.d.ts +14 -0
  17. package/dist/types/adapters/devices/routeros/plan.d.ts +4 -0
  18. package/dist/types/adapters/devices/routeros/render.d.ts +96 -0
  19. package/dist/types/adapters/devices/types.d.ts +34 -0
  20. package/dist/types/adapters/devices/vrp/index.d.ts +24 -0
  21. package/dist/types/adapters/devices/vrp/models.d.ts +12 -0
  22. package/dist/types/adapters/registries/index.d.ts +3 -0
  23. package/dist/types/adapters/registries/peeringdb.d.ts +16 -0
  24. package/dist/types/core/addr.d.ts +24 -0
  25. package/dist/types/core/define.d.ts +50 -0
  26. package/dist/types/core/merge.d.ts +4 -0
  27. package/dist/types/core/secrets.d.ts +21 -0
  28. package/dist/types/core/time.d.ts +2 -0
  29. package/dist/types/core/validate.d.ts +2 -0
  30. package/dist/types/index.d.ts +6 -0
  31. package/dist/types/presets/index.d.ts +101 -0
  32. package/dist/types/schema/common.d.ts +24 -0
  33. package/dist/types/schema/device.d.ts +80 -0
  34. package/dist/types/schema/firewall.d.ts +118 -0
  35. package/dist/types/schema/index.d.ts +7 -0
  36. package/dist/types/schema/interfaces.d.ts +181 -0
  37. package/dist/types/schema/policy.d.ts +88 -0
  38. package/dist/types/schema/routing.d.ts +115 -0
  39. package/dist/types/schema/system.d.ts +186 -0
  40. package/dist/types/transport/ssh.d.ts +22 -0
  41. package/package.json +19 -5
  42. package/src/cli/index.ts +10 -2
  43. package/src/cli/new.ts +60 -12
  44. package/src/core/secrets.ts +7 -9
  45. package/src/presets/index.ts +17 -3
  46. package/templates/project/AGENTS.md +10 -10
  47. package/templates/project/README.md +7 -7
  48. package/templates/project/tsconfig.json +1 -1
package/src/cli/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
  // The command line. `new` starts a project; every other command loads the network from `circuit.config.ts` in the working directory, or the file `--config` names.
3
3
 
4
4
  import { createPrivateKey, createPublicKey } from "node:crypto";
@@ -66,7 +66,15 @@ if (!existsSync(configPath)) {
66
66
  const root = dirname(configPath);
67
67
  useRoot(root);
68
68
 
69
- const network = (await import(configPath)).default as Network;
69
+ /** The config, a TypeScript file. Bun imports it as it is; Node needs jiti, which also resolves imports written without an extension. */
70
+ async function load(path: string): Promise<Network> {
71
+ if (process.versions.bun) return (await import(path)).default;
72
+
73
+ const { createJiti } = await import("jiti");
74
+ return createJiti(import.meta.url).import(path, { default: true });
75
+ }
76
+
77
+ const network = await load(configPath);
70
78
 
71
79
  const flags = rest.filter((arg) => arg.startsWith("--"));
72
80
  const names = rest.filter((arg) => !arg.startsWith("--"));
package/src/cli/new.ts CHANGED
@@ -1,12 +1,44 @@
1
1
  // `circuit new`: start a project from one of the patterns in templates/. In a terminal it asks what it needs; the same answers can be given as options, for a script or an AI agent.
2
2
 
3
+ import { spawnSync } from "node:child_process";
3
4
  import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
- import { basename, join, relative, resolve } from "node:path";
5
+ import { basename, dirname, join, relative, resolve } from "node:path";
5
6
  import { createInterface } from "node:readline/promises";
6
7
  import { fileURLToPath } from "node:url";
7
8
 
8
- const TEMPLATES = fileURLToPath(new URL("../../templates", import.meta.url));
9
- const circuit = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
9
+ /** Circuit's own directory: this file runs from src/cli/ in Bun, and from dist/ once built. */
10
+ function packageRoot(): string {
11
+ let directory = dirname(fileURLToPath(import.meta.url));
12
+
13
+ while (!existsSync(join(directory, "templates"))) {
14
+ const parent = dirname(directory);
15
+ if (parent === directory) throw new Error("Circuit's templates are missing from its package");
16
+ directory = parent;
17
+ }
18
+
19
+ return directory;
20
+ }
21
+
22
+ const ROOT = packageRoot();
23
+ const TEMPLATES = join(ROOT, "templates");
24
+ const circuit = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
25
+
26
+ /** How each package manager installs and runs. The templates are written for npm; the others are swapped in. */
27
+ const MANAGERS = {
28
+ npm: { install: "npm install", run: "npx circuit", types: "node" },
29
+ pnpm: { install: "pnpm install", run: "pnpm circuit", types: "node" },
30
+ bun: { install: "bun install", run: "bun circuit", types: "bun" },
31
+ } as const;
32
+
33
+ type Manager = keyof typeof MANAGERS;
34
+
35
+ /** The package manager that started this, from what it says about itself, such as `pnpm/9.1.0 npm/? node/v22`. */
36
+ function detectManager(): Manager {
37
+ const agent = process.env.npm_config_user_agent ?? "";
38
+ if (agent.startsWith("pnpm/")) return "pnpm";
39
+ if (agent.startsWith("bun/") || process.versions.bun) return "bun";
40
+ return "npm";
41
+ }
10
42
 
11
43
  const PATTERNS = {
12
44
  "single-site": { title: "A single site", fits: "a home, an office or an internal network: a router and a switch, no BGP", asn: false },
@@ -28,7 +60,8 @@ const USAGE = `usage: circuit new [directory] [options]
28
60
  --pattern <name> single-site, edge-router or colocation
29
61
  --asn <number> your AS number, for edge-router and colocation
30
62
  --1password write .env with 1Password references instead of empty values
31
- --no-install do not run bun install`;
63
+ --use <manager> npm, pnpm or bun; the one that ran this by default
64
+ --no-install do not install the dependencies`;
32
65
 
33
66
  function fail(message: string): never {
34
67
  console.error(message);
@@ -55,7 +88,7 @@ export async function create(args: string[]): Promise<void> {
55
88
  return;
56
89
  }
57
90
 
58
- const valued = new Set(["--pattern", "--asn"]);
91
+ const valued = new Set(["--pattern", "--asn", "--use"]);
59
92
  const positional = args.filter((arg, index) => !arg.startsWith("--") && !valued.has(args[index - 1] ?? ""));
60
93
 
61
94
  const interactive = process.stdin.isTTY === true;
@@ -89,6 +122,10 @@ export async function create(args: string[]): Promise<void> {
89
122
  }
90
123
  if (asn && !/^\d+$/.test(asn)) fail(`${asn} is not an AS number`);
91
124
 
125
+ const manager = (option(args, "use") ?? detectManager()) as Manager;
126
+ if (!(manager in MANAGERS)) fail(`no package manager ${manager}; there are ${Object.keys(MANAGERS).join(", ")}`);
127
+ const { install, run, types } = MANAGERS[manager];
128
+
92
129
  const onePassword =
93
130
  args.includes("--1password") || (await ask("Keep the secrets in 1Password? y or n", "n")).toLowerCase().startsWith("y");
94
131
  terminal?.close();
@@ -98,6 +135,16 @@ export async function create(args: string[]): Promise<void> {
98
135
  cpSync(join(TEMPLATES, "project"), target, { recursive: true });
99
136
  renameSync(join(target, "gitignore"), join(target, ".gitignore"));
100
137
 
138
+ // What the templates say about npm, said for the package manager in use.
139
+ for (const file of ["AGENTS.md", "README.md", "tsconfig.json"]) {
140
+ const path = join(target, file);
141
+ const text = readFileSync(path, "utf8")
142
+ .replaceAll("npx circuit", run)
143
+ .replaceAll("npm install", install)
144
+ .replaceAll('"types": ["node"]', `"types": ["${types}"]`);
145
+ writeFileSync(path, text);
146
+ }
147
+
101
148
  const sources = filesIn(target).filter((file) => file.endsWith(".ts"));
102
149
  const secrets = new Set<string>();
103
150
 
@@ -127,24 +174,25 @@ export async function create(args: string[]): Promise<void> {
127
174
  private: true,
128
175
  type: "module",
129
176
  dependencies: { "@takodotid/circuit": circuit.version },
130
- devDependencies: { "@types/bun": circuit.devDependencies["@types/bun"] },
177
+ devDependencies: { [`@types/${types}`]: circuit.devDependencies[`@types/${types}`] },
131
178
  };
132
179
  writeFileSync(join(target, "package.json"), JSON.stringify(packageJson, null, 4) + "\n");
133
180
 
134
181
  if (!args.includes("--no-install")) {
135
- const install = Bun.spawnSync(["bun", "install"], { cwd: target, stdout: "inherit", stderr: "inherit" });
136
- if (install.exitCode !== 0) console.log("bun install failed; run it again in the project.");
182
+ // npm and pnpm are scripts on Windows, which only a shell runs.
183
+ const result = spawnSync(install, { cwd: target, stdio: "inherit", shell: true });
184
+ if (result.status !== 0) console.log(`${install} failed; run it again in the project.`);
137
185
  }
138
186
 
139
187
  const steps = [
140
188
  `cd ${directory}`,
141
189
  onePassword
142
- ? "Point each line of .env at the item that holds it in 1Password, then run: bun circuit secrets"
143
- : "Fill in each password in .env, then run: bun circuit secrets",
190
+ ? `Point each line of .env at the item that holds it in 1Password, then run: ${run} secrets`
191
+ : `Fill in each password in .env, then run: ${run} secrets`,
144
192
  "Change the names, addresses and ports in the device files to your own.",
145
193
  ...(PATTERNS[pattern].asn && (!asn || asn === EXAMPLE_ASN) ? ["Put your own AS number in routing.ts."] : []),
146
- "bun circuit validate",
147
- "bun circuit snapshot, then bun circuit diff, to see what would change on each device",
194
+ `${run} validate`,
195
+ `${run} snapshot, then ${run} diff, to see what would change on each device`,
148
196
  ];
149
197
 
150
198
  console.log(`\nCreated ${directory} from the pattern "${PATTERNS[pattern].title}".\n\nNext:`);
@@ -1,5 +1,6 @@
1
1
  // Secrets are references in the config and values only at the moment a command is sent. A reference names a variable, set in the environment or in `.env` beside the config, or a file kept out of the repository. A variable's value may itself be a 1Password reference, `op://vault/item/field`, read with the 1Password CLI.
2
2
 
3
+ import { spawnSync } from "node:child_process";
3
4
  import { existsSync, readFileSync } from "node:fs";
4
5
  import { join } from "node:path";
5
6
  import type { Secret } from "../schema";
@@ -44,19 +45,16 @@ function readOnePassword(name: string, reference: string): string {
44
45
  const cached = fromOnePassword.get(reference);
45
46
  if (cached !== undefined) return cached;
46
47
 
47
- let result: ReturnType<typeof Bun.spawnSync>;
48
- try {
49
- result = Bun.spawnSync(["op", "read", "--no-newline", reference], { stdout: "pipe", stderr: "pipe" });
50
- } catch {
51
- throw new Error(`secret ${name} is a 1Password reference, and the 1Password CLI, op, is not installed`);
52
- }
48
+ const result = spawnSync("op", ["read", "--no-newline", reference], { encoding: "utf8" });
49
+
50
+ if (result.error) throw new Error(`secret ${name} is a 1Password reference, and the 1Password CLI, op, is not installed`);
53
51
 
54
- if (result.exitCode !== 0) {
55
- const reason = result.stderr?.toString().trim() || `op exited with ${result.exitCode}`;
52
+ if (result.status !== 0) {
53
+ const reason = result.stderr.trim() || `op exited with ${result.status}`;
56
54
  throw new Error(`secret ${name}: 1Password could not read ${reference}: ${reason}`);
57
55
  }
58
56
 
59
- const value = result.stdout?.toString() ?? "";
57
+ const value = result.stdout;
60
58
  fromOnePassword.set(reference, value);
61
59
  return value;
62
60
  }
@@ -239,7 +239,7 @@ const article = (word: string) => (/^[aeiou]/i.test(word) ? `an ${word}` : `a ${
239
239
  * Policy rules and a catalogue from one community scheme, so the communities your routers set, the ones they act on, and the ones you publish never disagree.
240
240
  *
241
241
  * - `tag(neighborClass, neighborAs, { site, keepActions })`: a rule for an import policy. It removes your communities a neighbor set, then adds where the route was learned. `site` is needed when the scheme has `learned_at`. With `keepActions`, for a customer, its action communities stay.
242
- * - `actions(neighborAs)`: rules for an export policy toward one neighbor. They act on a customer's action communities, then remove all of yours. Place them before the rules that accept.
242
+ * - `actions(neighborAs, { blackhole })`: rules for an export policy toward one neighbor. They act on a customer's action communities, then remove all of yours. Place them before the rules that accept. A blackholed route goes no further, unless `blackhole` names the neighbor's own blackhole community: then it is passed on with it, so the neighbor drops the traffic too.
243
243
  * - `blackhole(prefixSet)`: a rule for a customer's import policy, before anything that rejects long prefixes. It takes a route the customer asked to blackhole, but only inside `prefixSet`, its own space, and drops traffic to it here.
244
244
  * - `catalogue`: every community, for `communities` in `defineNetwork`.
245
245
  */
@@ -286,8 +286,8 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
286
286
  };
287
287
  };
288
288
 
289
- const actions = (neighborAs: number): PolicyRule<never, never>[] => [
290
- ...(scheme.blackhole !== undefined
289
+ const actions = (neighborAs: number, options: { blackhole?: string } = {}): PolicyRule<never, never>[] => [
290
+ ...(scheme.blackhole !== undefined && !options.blackhole
291
291
  ? [
292
292
  {
293
293
  description: "Blackholed here, never announced",
@@ -310,6 +310,20 @@ export function communityScheme<const Class extends string>(scheme: CommunitySch
310
310
  },
311
311
  ]
312
312
  : []),
313
+ ...(scheme.blackhole !== undefined && options.blackhole
314
+ ? [
315
+ {
316
+ description: `Blackhole passed on to AS${neighborAs}`,
317
+ match: { large_community: community(scheme.blackhole, 0) },
318
+ set: {
319
+ remove_communities: ours.standard,
320
+ remove_large_communities: ours.large,
321
+ add_communities: [options.blackhole],
322
+ },
323
+ action: "accept" as const,
324
+ },
325
+ ]
326
+ : []),
313
327
  ...PREPENDS.flatMap(([name, times]) => {
314
328
  const fn = scheme.prepend?.[name];
315
329
  if (fn === undefined) return [];
@@ -18,21 +18,21 @@ What each field means is written as a comment on the field, in `node_modules/@ta
18
18
 
19
19
  | Command | What it does | Changes a device |
20
20
  | -------------------------------------- | -------------------------------------------------------------------- | ---------------- |
21
- | `bun circuit validate` | Checks every file and every rule in the checks | no |
22
- | `bun circuit diff [device]` | Shows what would change, compared with the last snapshot | no |
23
- | `bun circuit build <device>` | Prints the full configuration the device should run | no |
24
- | `bun circuit snapshot [device]` | Reads what each device runs now into `.circuit/state/` | no, only reads |
25
- | `bun circuit apply <device>` | Reads the device, then shows what would change on it | no |
26
- | `bun circuit apply <device> --confirm` | Sends the change to the device | **yes** |
27
- | `bun circuit secrets` | Lists every secret the config uses, and whether each one can be read | no |
21
+ | `npx circuit validate` | Checks every file and every rule in the checks | no |
22
+ | `npx circuit diff [device]` | Shows what would change, compared with the last snapshot | no |
23
+ | `npx circuit build <device>` | Prints the full configuration the device should run | no |
24
+ | `npx circuit snapshot [device]` | Reads what each device runs now into `.circuit/state/` | no, only reads |
25
+ | `npx circuit apply <device>` | Reads the device, then shows what would change on it | no |
26
+ | `npx circuit apply <device> --confirm` | Sends the change to the device | **yes** |
27
+ | `npx circuit secrets` | Lists every secret the config uses, and whether each one can be read | no |
28
28
 
29
29
  ## Making a change
30
30
 
31
31
  1. Edit the device's file.
32
- 2. Run `bun circuit validate`. Fix every error.
33
- 3. Run `bun circuit diff <device>` and read the result.
32
+ 2. Run `npx circuit validate`. Fix every error.
33
+ 3. Run `npx circuit diff <device>` and read the result.
34
34
  4. Show the owner what will change and why, in plain words.
35
- 5. Run `bun circuit apply <device>` to see the plan against the live device.
35
+ 5. Run `npx circuit apply <device>` to see the plan against the live device.
36
36
  6. **Stop.** Only run `apply <device> --confirm` after the owner says yes to that exact plan. Never confirm on your own, even if you were told to finish the task.
37
37
  7. After the apply, commit the device file together with the updated `.circuit/state/`.
38
38
 
@@ -3,13 +3,13 @@
3
3
  This repository describes the network with [Circuit](https://circuit.tako.id). Each device has a file; Circuit makes the device match it.
4
4
 
5
5
  ```bash
6
- bun install # once
7
- bun circuit secrets # check every secret can be read
8
- bun circuit validate # check the files
9
- bun circuit snapshot # read what each device runs now
10
- bun circuit diff # see what would change
11
- bun circuit apply <device> # see the plan against the device
12
- bun circuit apply <device> --confirm
6
+ npm install # once
7
+ npx circuit secrets # check every secret can be read
8
+ npx circuit validate # check the files
9
+ npx circuit snapshot # read what each device runs now
10
+ npx circuit diff # see what would change
11
+ npx circuit apply <device> # see the plan against the device
12
+ npx circuit apply <device> --confirm
13
13
  ```
14
14
 
15
15
  Secrets go in `.env`, which is never committed. `.circuit/` is written by Circuit; commit it, but never edit it.
@@ -4,7 +4,7 @@
4
4
  "target": "ESNext",
5
5
  "module": "ESNext",
6
6
  "moduleResolution": "bundler",
7
- "types": ["bun"],
7
+ "types": ["node"],
8
8
  "strict": true,
9
9
  "noEmit": true,
10
10
  "skipLibCheck": true,