@takodotid/circuit 0.6.0 → 0.8.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/README.md CHANGED
@@ -1,195 +1,35 @@
1
1
  # Circuit
2
2
 
3
- Declarative, vendor-neutral network configuration. Describe each device in TypeScript, and Circuit renders it for the platform, compares it with what the device runs, and makes the device match.
3
+ Write how each router and switch should be configured, in one TypeScript file per device. Circuit shows you what would change, then makes the device match.
4
4
 
5
5
  Documentation: [circuit.tako.id](https://circuit.tako.id)
6
6
 
7
7
  > [!WARNING]
8
- > Circuit is in alpha. It runs a production network, but it is not yet stable: the schema, the CLI and the adapters can still change in breaking ways between releases. Pin an exact version and read the release notes before upgrading.
8
+ > Circuit is in alpha. It runs a production network, but the way you write config, the commands and the supported devices can still change between releases. Install an exact version, and read the release notes before you upgrade.
9
9
 
10
- ## Principles
10
+ ## Start
11
11
 
12
- 1. **The config is the whole truth.** Whatever a device runs that its config does not say is removed, or returned to its default. There are no exceptions and nothing is left alone.
13
- 2. **Absent means off.** A field is written when it has a value. An undeclared port is shut down, an undeclared service is disabled, an undeclared feature is not running. There is no `enabled: false` and no placeholder.
14
- 3. **Neutral words only.** The schema says `access_vlan`, `https`, `hardware_offload`. Each adapter translates, and a field a platform cannot express fails validation instead of being skipped.
15
- 4. **Names are checked.** A device declares its VLANs, interfaces, policies and the rest; every reference to one is type-checked, so a typo does not compile and the editor suggests the names that exist. Port names come from the hardware model.
16
- 5. **Secrets are references.** `secret("NAME")` is resolved from the environment or `.env.local`, and `secretFile("path")` from a file kept out of git, only when a command is sent. Rendered config, plans and snapshots never hold a secret.
17
-
18
- ## Install
19
-
20
- Circuit runs on [Bun](https://bun.sh) and ships as TypeScript source, on npm and GitHub Packages.
21
-
22
- ```bash
23
- bun add --exact @takodotid/circuit
24
- ```
25
-
26
- Add `"net": "circuit"` to the project's `scripts` to run it as `bun net`. Every release is on the [releases page](https://github.com/takodotid/circuit/releases); release candidates are tagged `-rc.N` and published under the `rc` dist-tag.
27
-
28
- ## A device
29
-
30
- ```ts
31
- import { defineDevice, secret } from "@takodotid/circuit";
32
-
33
- export default defineDevice({
34
- name: "sw1",
35
- platform: "vrp",
36
- model: "CE6855-48S6Q-HI",
37
- connection: { host: "10.0.0.2", user: "operator" },
38
- users: { operator: { role: "admin", password: secret("SW1_PASSWORD") } },
39
- management: { allow: ["10.0.0.0/24"], ssh: {} },
40
- vlans: { mgmt: { id: 99 }, servers: { id: 100 } },
41
- ports: {
42
- "10g-1": { description: "server-1", access_vlan: "servers" },
43
- "40g-1": { description: "uplink", trunk_vlans: ["mgmt", "servers"] },
44
- },
45
- interfaces: { mgmt: { type: "vlan", vlan: "mgmt", addresses: ["10.0.0.2/24"] } },
46
- routing: { static: [{ prefix: "0.0.0.0/0", via: "10.0.0.1" }] },
47
- });
48
- ```
49
-
50
- Ports are named `<speed>-<position>`: `10g-1` is the first 10G cage. The model decides which exist; the adapter knows what the platform calls each one. Everything a device can hold is in `src/schema/`, with a comment on every field.
51
-
52
- A network is a list of devices in `circuit.config.ts` at the root of the project, or any file named with `--config <path>`:
53
-
54
- ```ts
55
- import { defineNetwork } from "@takodotid/circuit";
56
-
57
- export default defineNetwork({ devices: [sw1, r1], checks, state: "state" });
58
- ```
59
-
60
- `checks` are rules of your own design, run by `validate` beside Circuit's. `state` is where device snapshots are kept, committed, so history shows what each device ran. The CLI writes a `README.md` there saying the directory is generated.
61
-
62
- Config is plain data. Shared values are ordinary imports, and anything repetitive can be a function; the result is still plain data.
63
-
64
- ## Commands
65
-
66
- | Command | What it does | Touches a device |
67
- | -------------------------------- | ------------------------------------------------------------------------ | ---------------------- |
68
- | `validate` | Checks the config and every rule in `checks` | no |
69
- | `build <device>` | Prints the whole configuration the device should run | no |
70
- | `diff [device...]` | The plan against the last snapshot | no |
71
- | `snapshot [device...]` | Reads what each device runs into the state directory | reads |
72
- | `apply <device>` | Reads the device live and prints the plan; `--confirm` sends it | yes, with `--confirm` |
73
- | `refresh <device>` | Fills prefix sets fetched from a registry; `--confirm` sends | yes, with `--confirm` |
74
- | `wireguard <device> <if> <peer>` | A client config for one peer, its private key left for the peer to fill | no |
75
- | `communities` | The network's BGP communities, for a looking glass or bgp.tools | no |
76
- | `peeringdb` | Brings PeeringDB's exchange records in line with the config; `--confirm` | PeeringDB, `--confirm` |
77
-
78
- `apply` also takes `--secrets`, to send every secret when rotating one, and `--rollback=N`, the minutes before a device that can restore itself does, 10 by default. Secrets cannot be read back, so without `--secrets` they are sent only when the object holding them is new.
79
-
80
- A change goes:
81
-
82
- 1. Edit the device's file.
83
- 2. `validate`, then `diff <device>` to see the change against the last snapshot.
84
- 3. `apply <device>` and read every line of the plan. A `~ set` on a settings menu may turn something off.
85
- 4. `apply <device> --confirm`.
86
- 5. Commit the config with the snapshot `apply` wrote.
87
-
88
- ## Publishing the network
89
-
90
- Some of the config describes the network to others rather than configuring a device. Devices ignore it.
91
-
92
- ```ts
93
- interfaces: {
94
- "ix-lan": {
95
- type: "vlan",
96
- vlan: "ix",
97
- addresses: ["192.0.2.10/24", "2001:db8:1::10/64"],
98
- exchange: { name: "Example IX", speed: 10_000, peeringdb_ixlan: 1234 },
99
- },
100
- },
101
- ```
102
-
103
- ```ts
104
- export default defineNetwork({
105
- devices,
106
- state: "state",
107
- asn: 64500,
108
- communities: [{ community: "64500:0:nnn", description: "Do not announce to AS$0" }],
109
- peeringdb: { api_key: secret("PEERINGDB_API_KEY") },
110
- });
111
- ```
112
-
113
- `peeringdb` compares every interface with a `peeringdb_ixlan` against PeeringDB's records for `asn`: speed, the interface's globally reachable addresses, and whether a BGP neighbor on it is a route server. A record PeeringDB holds that the config does not is reported, never deleted.
114
-
115
- `communities` prints one per line, `community,description`, in the format the NLNOG Ring looking glass defines and bgp.tools reads: `nnn` is any number, `x` one digit, `a-b` a range, and `$0`, `$1` in the description are what each wildcard matched.
116
-
117
- ## How a device is converged
118
-
119
- Each adapter guards the apply its platform allows:
120
-
121
- | Platform | Commit | If it goes wrong |
122
- | -------------- | -------------------------- | ------------------------------------------------------------------------- |
123
- | `routeros` | each command as sent | backup saved, a scheduler restores it unless a fresh login disarms it |
124
- | `vrp` | `commit`, then `save` | nothing applies until commit; save only after a fresh login proves access |
125
- | `raisecom-ros` | each command, then `write` | write only after a fresh login proves access |
126
-
127
- Plans run in two phases: additions and changes in dependency order, then removals in reverse, so nothing is removed while something still uses it. An ordered list such as a firewall chain is replaced whole, the old rules deciding until the new ones are in place.
128
-
129
- ## Platforms
130
-
131
- `src/adapters/devices/<platform>/` holds the model catalog, the renderer, the reader and the apply. `TRAPS.md` beside each lists the platform behaviour it works around.
132
-
133
- Not every platform can express every field. What one cannot is reported by `validate`, never skipped:
134
-
135
- | Feature | routeros | vrp | raisecom-ros |
136
- | ----------------------------------------- | --------------------- | --------------------- | ----------------------- |
137
- | VLANs, access and trunk ports, links | yes | yes | yes |
138
- | Per-port STP, storm control, LLDP | yes, storm in percent | yes, storm in percent | off only, broadcast pps |
139
- | LAGs | yes | yes | yes |
140
- | Routed ports, loopbacks, tunnels | yes | VLAN interfaces only | VLAN interfaces only |
141
- | VRRP, VRFs, DHCP relay | yes | IPv4 | VRRP, IPv4 |
142
- | Static routes | yes | yes | IPv4 next hop |
143
- | OSPF and BFD | yes | IPv4 | OSPF, IPv4 |
144
- | BGP, policies, prefix sets, RPKI | yes | no | no |
145
- | Firewall, NAT, hardware ACLs, DHCP server | yes | no | no |
146
- | Flow export | NetFlow 9, IPFIX | sFlow | no |
147
- | SNMP | v2c, v3 with SHA1 | v2c, v3 | v2c, v3 with SHA1 |
148
- | Certificates, web and API | yes | no | no |
149
-
150
- `examples/` holds a device per platform using every field its adapter supports. `bun run test` validates them and checks each plans nothing against its own render. Their commands were compiled on real devices without being run.
151
-
152
- ## Versions
153
-
154
- An adapter renders for one major version of its platform and refuses another when it connects. `routeros` is RouterOS 7; RouterOS 6 has a different BGP and filter language and would be an adapter of its own. Within a major version, a property the device does not know is caught before anything is sent: RouterOS compiles every command first, and VRP stages them in a candidate that is cleared on the first refusal. A behaviour that differs between minor versions belongs in the adapter, with a note in its `TRAPS.md`.
155
-
156
- Circuit follows semantic versioning. A release is a git tag, `v<major>.<minor>.<patch>`.
157
-
158
- ## Developing Circuit beside a network
159
-
160
- Link the working copy into the network's project, so a change here is live there without a release:
12
+ You need [Bun](https://bun.sh). Then:
161
13
 
162
14
  ```bash
163
- cd circuit && bun link
164
- cd ../my-network && bun link @takodotid/circuit
15
+ bunx @takodotid/circuit new
165
16
  ```
166
17
 
167
- `bun install` in the network's project returns it to the pinned release.
168
-
169
- ## Adding a platform
18
+ It asks which kind of network you have, creates a project with example devices, and installs Circuit. [Getting started](https://circuit.tako.id/guide/getting-started) walks through the first run.
170
19
 
171
- 1. `src/adapters/devices/<platform>/models.ts`: each model's ports per speed class and the platform's spelling.
172
- 2. An adapter implementing `DeviceAdapter` in `src/adapters/devices/types.ts`. `lines.ts` converges block-structured configurations; `routeros/` shows a command-structured one.
173
- 3. Register it in `src/adapters/devices/index.ts` and the catalog in `src/adapters/devices/catalog.ts`.
174
- 4. `unsupported()` lists every field it cannot express. Silence is not an option.
175
- 5. An example in `examples/` using every field it supports.
20
+ Circuit supports MikroTik RouterOS 7, Huawei VRP and Raisecom ROS. Releases are on the [releases page](https://github.com/takodotid/circuit/releases), on npm and on GitHub Packages.
176
21
 
177
- ## Documentation site
178
-
179
- `docs/` is the site at [circuit.tako.id](https://circuit.tako.id), built with VitePress: `bun run docs:dev` serves it locally. It is not part of the package.
180
-
181
- ## Releases
22
+ ## Working on Circuit
182
23
 
183
24
  ```bash
184
- bun run release patch | minor | major
185
- bun run release rc [patch | minor | major]
186
- bun run release stable
25
+ bun install
26
+ bun run typecheck
27
+ bun run test # every example and template validates, and plans nothing against its own render
28
+ bun run docs:dev # the documentation site, from docs/
187
29
  ```
188
30
 
189
- The script commits the version and pushes a tag; the tag publishes the package and a GitHub release.
31
+ [Contributing](https://circuit.tako.id/contributing) explains the layout, how to add a platform, and how releases are cut. `AGENTS.md` has the rules for changing the code.
190
32
 
191
33
  ## License
192
34
 
193
- [Business Source License 1.1](LICENSE). Free in production for an organization whose yearly revenue and funding, with its affiliates, are each under US$100,000; above that, production use needs a commercial license: [legal@tako.id](mailto:legal@tako.id). Each version becomes Apache License 2.0 four years after it is published.
194
-
195
- Maintained by the Tako Network Engineering Team. Copyright 2026 PT Hobimu Jadi Cuan.
35
+ [Business Source License 1.1](LICENSE): free in production for an organization whose yearly revenue and funding, with its affiliates, are each under US$100,000. Above that, production use needs a commercial license from [legal@tako.id](mailto:legal@tako.id). Each version becomes Apache License 2.0 four years after it is published. See [License](https://circuit.tako.id/license).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -47,6 +47,7 @@
47
47
  },
48
48
  "files": [
49
49
  "src",
50
+ "templates",
50
51
  "LICENSE",
51
52
  "README.md"
52
53
  ],
@@ -874,12 +874,13 @@ export const RPKI_GROUP = "rpki";
874
874
  /** The chain a session without a policy uses, so that it learns or announces nothing. */
875
875
  export const REJECT_ALL = "REJECT-ALL";
876
876
 
877
+ // RouterOS takes our own role. The config states the neighbor's, so each becomes its opposite: a neighbor that is our provider makes us its customer.
877
878
  const ROLES = {
878
- provider: "ebgp-provider",
879
- customer: "ebgp-customer",
879
+ provider: "ebgp-customer",
880
+ customer: "ebgp-provider",
880
881
  peer: "ebgp-peer",
881
- rs: "ebgp-rs",
882
- "rs-client": "ebgp-rs-client",
882
+ rs: "ebgp-rs-client",
883
+ "rs-client": "ebgp-rs",
883
884
  } as const;
884
885
 
885
886
  type AnyRule = PolicyRule<string, string>;
@@ -1097,7 +1098,7 @@ function bgpConnections(out: Output, ctx: Context): void {
1097
1098
  const addressFamily = familyOf(neighbor.address);
1098
1099
  const internal = settings.remote_as === bgp.asn;
1099
1100
  const originates = bgp.networks?.some((prefix) => familyOf(host(prefix)) === family);
1100
- const role = settings.local_role ? ROLES[settings.local_role] : internal ? "ibgp" : "ebgp";
1101
+ const role = settings.role ? ROLES[settings.role] : internal ? "ibgp" : "ebgp";
1101
1102
 
1102
1103
  out.add(
1103
1104
  "/routing bgp connection",
@@ -56,8 +56,8 @@ function peersWithRouteServer(device: Device, iface: AnyInterface): boolean {
56
56
 
57
57
  return Object.values(bgp?.neighbors ?? {}).some((neighbor) => {
58
58
  const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
59
- const role = neighbor.local_role ?? group?.local_role;
60
- return role === "rs-client" && subnets.some((subnet) => contains(subnet, neighbor.address));
59
+ const role = neighbor.role ?? group?.role;
60
+ return role === "rs" && subnets.some((subnet) => contains(subnet, neighbor.address));
61
61
  });
62
62
  }
63
63
 
package/src/cli/index.ts CHANGED
@@ -1,21 +1,22 @@
1
1
  #!/usr/bin/env bun
2
- // The command line. Loads `circuit.config.ts` from the working directory.
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";
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { dirname, join, resolve } from "node:path";
5
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { basename, dirname, join, resolve } from "node:path";
7
7
  import { adapters } from "../adapters/devices";
8
8
  import type { ApplyOptions, DeviceAdapter, Plan } from "../adapters/devices/types";
9
9
  import { registries } from "../adapters/registries";
10
10
  import { planPeeringDb, sendPeeringDb } from "../adapters/registries/peeringdb";
11
11
  import { network as prefixOf } from "../core/addr";
12
12
  import type { Network } from "../core/define";
13
- import { resolve as reveal } from "../core/secrets";
13
+ import { isSecret, referenceOf, resolve as reveal, sourceOf, useRoot } from "../core/secrets";
14
14
  import { validate } from "../core/validate";
15
15
  import type { Device } from "../schema";
16
16
 
17
17
  const HELP = `usage: circuit <command> [device...] [options]
18
18
 
19
+ new [directory] start a project from a pattern, asking what it needs
19
20
  validate check the config, then every rule in checks
20
21
  build <device> print the configuration the device should run, without contacting it
21
22
  diff [device...] what apply would send, planned against the last snapshot, offline
@@ -28,6 +29,7 @@ const HELP = `usage: circuit <command> [device...] [options]
28
29
  a client config for one peer, its private key left for the peer to fill
29
30
  communities the network's BGP communities, one per line, for a looking glass or bgp.tools
30
31
  peeringdb bring PeeringDB's exchange records in line with the config, with --confirm
32
+ secrets every secret the config uses, and whether each can be read
31
33
 
32
34
  --config <path> the network's config file, circuit.config.ts in the working directory by default`;
33
35
 
@@ -35,6 +37,17 @@ const DEFAULT_CONFIG = "circuit.config.ts";
35
37
 
36
38
  const [command, ...args] = process.argv.slice(2);
37
39
 
40
+ if (command === "new") {
41
+ const { create } = await import("./new");
42
+ await create(args);
43
+ process.exit(0);
44
+ }
45
+
46
+ if (!command || command === "help" || command === "--help" || command === "-h") {
47
+ console.log(HELP);
48
+ process.exit(0);
49
+ }
50
+
38
51
  // `--config <path>` and `--config=<path>` both name the config file; neither is a device name.
39
52
  const configAt = args.indexOf("--config");
40
53
  const configArgument = configAt >= 0 ? args[configAt + 1] : args.find((arg) => arg.startsWith("--config="))?.slice("--config=".length);
@@ -49,8 +62,9 @@ if (!existsSync(configPath)) {
49
62
  process.exit(1);
50
63
  }
51
64
 
52
- /** The directory of the config file. The state directory is relative to it. */
65
+ /** The directory of the config file. `.circuit/`, `.env` and secret files are found from here. */
53
66
  const root = dirname(configPath);
67
+ useRoot(root);
54
68
 
55
69
  const network = (await import(configPath)).default as Network;
56
70
 
@@ -83,20 +97,22 @@ function one(): Device {
83
97
  }
84
98
 
85
99
  const adapterOf = (device: Device): DeviceAdapter => adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
86
- const stateDirectory = join(root, network.state);
100
+ /** What Circuit writes, beside the config. */
101
+ const generated = join(root, ".circuit");
102
+ const stateDirectory = join(generated, "state");
87
103
  const statePath = (device: Device) => join(stateDirectory, device.name + adapterOf(device).extension);
88
104
 
89
- const STATE_README = `# Do not edit
105
+ const GENERATED_README = `# Written by Circuit
90
106
 
91
- Written by \`circuit snapshot\` and \`circuit apply\`: what each device ran when it was last read, with secrets removed.
107
+ Do not edit anything here: an edit changes nothing on a device, and Circuit overwrites it. Change the config instead.
92
108
 
93
- An edit here changes nothing on a device and is overwritten on the next read. Change the config instead.
109
+ \`state/\` holds what each device ran when Circuit last read it, with secrets removed. \`circuit snapshot\` and \`circuit apply\` write it, and \`circuit diff\` plans against it. Commit it, so the history of this directory is the history of the network.
94
110
  `;
95
111
 
96
112
  /** Record what a device runs, beside a note that the directory is generated. */
97
113
  function writeState(device: Device, text: string): void {
98
114
  mkdirSync(stateDirectory, { recursive: true });
99
- writeFileSync(join(stateDirectory, "README.md"), STATE_README);
115
+ writeFileSync(join(generated, "README.md"), GENERATED_README);
100
116
  writeFileSync(statePath(device), text);
101
117
  }
102
118
 
@@ -168,6 +184,17 @@ async function snapshotCommand(): Promise<void> {
168
184
  writeState(device, text);
169
185
  console.log(`${device.name}: ${text.split("\n").length} lines`);
170
186
  }
187
+
188
+ // With every device read, a snapshot of a device the config no longer has, such as one renamed, is removed.
189
+ if (names.length || !existsSync(stateDirectory)) return;
190
+
191
+ const current = new Set(network.devices.map((device) => basename(statePath(device))));
192
+ for (const file of readdirSync(stateDirectory)) {
193
+ if (current.has(file)) continue;
194
+
195
+ rmSync(join(stateDirectory, file));
196
+ console.log(`${file}: removed, no such device in the config`);
197
+ }
171
198
  }
172
199
 
173
200
  async function applyCommand(): Promise<void> {
@@ -290,6 +317,36 @@ async function peeringdbCommand(): Promise<void> {
290
317
  await sendPeeringDb(network, plan.changes);
291
318
  }
292
319
 
320
+ /** Every secret reference in a value, however deep. */
321
+ function secretsIn(value: unknown, found = new Set<string>()): Set<string> {
322
+ if (isSecret(value)) found.add(referenceOf(value));
323
+ else if (Array.isArray(value)) for (const item of value) secretsIn(item, found);
324
+ else if (typeof value === "object" && value !== null) for (const item of Object.values(value)) secretsIn(item, found);
325
+
326
+ return found;
327
+ }
328
+
329
+ function secretsCommand(): void {
330
+ const references = [...secretsIn([network.devices, network.peeringdb])].sort();
331
+ let missing = 0;
332
+
333
+ for (const reference of references) {
334
+ const name = reference.replace(/^file:/, "");
335
+
336
+ try {
337
+ reveal(reference);
338
+ console.log(` ok ${name}, from ${sourceOf(reference)}`);
339
+ } catch (error) {
340
+ missing++;
341
+ console.log(` missing ${name}: ${(error as Error).message}`);
342
+ }
343
+ }
344
+
345
+ console.log(`
346
+ ${references.length} secret(s), ${missing} missing.`);
347
+ process.exit(missing ? 1 : 0);
348
+ }
349
+
293
350
  const commands: Record<string, () => void | Promise<void>> = {
294
351
  validate: validateCommand,
295
352
  build: buildCommand,
@@ -300,8 +357,12 @@ const commands: Record<string, () => void | Promise<void>> = {
300
357
  wireguard: wireguardCommand,
301
358
  communities: communitiesCommand,
302
359
  peeringdb: peeringdbCommand,
360
+ secrets: secretsCommand,
303
361
  };
304
362
 
305
- const run = commands[command ?? ""];
363
+ const run = commands[command];
306
364
  if (run) await run();
307
- else console.log(HELP);
365
+ else
366
+ fail(`no command ${command}
367
+
368
+ ${HELP}`);
package/src/cli/new.ts ADDED
@@ -0,0 +1,153 @@
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
+
3
+ import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { basename, join, relative, resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const TEMPLATES = fileURLToPath(new URL("../../templates", import.meta.url));
9
+ const circuit = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
10
+
11
+ const PATTERNS = {
12
+ "single-site": { title: "A single site", fits: "a home, an office or an internal network: a router and a switch, no BGP", asn: false },
13
+ "edge-router": { title: "An edge router", fits: "your own AS number, with an IP transit and an internet exchange", asn: true },
14
+ colocation: {
15
+ title: "Colocation with tenants",
16
+ fits: "a provider whose customers each get their own VLANs, addresses and ports",
17
+ asn: true,
18
+ },
19
+ } as const;
20
+
21
+ type Pattern = keyof typeof PATTERNS;
22
+
23
+ /** The AS number the templates use, reserved for documentation by RFC 5398. */
24
+ const EXAMPLE_ASN = "64500";
25
+
26
+ const USAGE = `usage: circuit new [directory] [options]
27
+
28
+ --pattern <name> single-site, edge-router or colocation
29
+ --asn <number> your AS number, for edge-router and colocation
30
+ --1password write .env with 1Password references instead of empty values
31
+ --no-install do not run bun install`;
32
+
33
+ function fail(message: string): never {
34
+ console.error(message);
35
+ process.exit(1);
36
+ }
37
+
38
+ /** The value of `--name value` or `--name=value`. */
39
+ function option(args: string[], name: string): string | undefined {
40
+ const at = args.indexOf(`--${name}`);
41
+ if (at >= 0) return args[at + 1];
42
+ return args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
43
+ }
44
+
45
+ /** Every file under a directory, relative to it. */
46
+ function filesIn(directory: string): string[] {
47
+ return readdirSync(directory, { recursive: true, withFileTypes: true })
48
+ .filter((entry) => entry.isFile())
49
+ .map((entry) => relative(directory, join(entry.parentPath, entry.name)));
50
+ }
51
+
52
+ export async function create(args: string[]): Promise<void> {
53
+ if (args.includes("--help") || args.includes("-h")) {
54
+ console.log(USAGE);
55
+ return;
56
+ }
57
+
58
+ const valued = new Set(["--pattern", "--asn"]);
59
+ const positional = args.filter((arg, index) => !arg.startsWith("--") && !valued.has(args[index - 1] ?? ""));
60
+
61
+ const interactive = process.stdin.isTTY === true;
62
+ const terminal = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
63
+
64
+ // The answer typed, or the fallback when nothing is typed or nobody is there to type.
65
+ const ask = async (question: string, fallback: string) => {
66
+ if (!terminal) return fallback;
67
+ const answer = (await terminal.question(`${question} (${fallback}): `)).trim();
68
+ return answer || fallback;
69
+ };
70
+
71
+ const directory = positional[0] ?? (await ask("Directory for the project", "my-network"));
72
+ const target = resolve(process.cwd(), directory);
73
+ if (existsSync(target) && readdirSync(target).length) fail(`${target} is not empty`);
74
+
75
+ let pattern = option(args, "pattern") as Pattern | undefined;
76
+ if (!pattern) {
77
+ const names = Object.keys(PATTERNS) as Pattern[];
78
+ if (terminal) {
79
+ console.log("\nWhich is closest to your network?");
80
+ names.forEach((name, index) => console.log(` ${index + 1}. ${PATTERNS[name].title}: ${PATTERNS[name].fits}`));
81
+ }
82
+ pattern = names[Number(await ask("Number", "1")) - 1];
83
+ }
84
+ if (!pattern || !(pattern in PATTERNS)) fail(`no pattern ${pattern}; there are ${Object.keys(PATTERNS).join(", ")}`);
85
+
86
+ let asn = option(args, "asn");
87
+ if (!asn && PATTERNS[pattern].asn) {
88
+ asn = await ask("Your AS number, or press enter to keep the documentation example", EXAMPLE_ASN);
89
+ }
90
+ if (asn && !/^\d+$/.test(asn)) fail(`${asn} is not an AS number`);
91
+
92
+ const onePassword =
93
+ args.includes("--1password") || (await ask("Keep the secrets in 1Password? y or n", "n")).toLowerCase().startsWith("y");
94
+ terminal?.close();
95
+
96
+ // The pattern's files, then what every project has. npm leaves out a file named .gitignore, so it travels without the dot.
97
+ cpSync(join(TEMPLATES, pattern), target, { recursive: true });
98
+ cpSync(join(TEMPLATES, "project"), target, { recursive: true });
99
+ renameSync(join(target, "gitignore"), join(target, ".gitignore"));
100
+
101
+ const sources = filesIn(target).filter((file) => file.endsWith(".ts"));
102
+ const secrets = new Set<string>();
103
+
104
+ for (const file of sources) {
105
+ const path = join(target, file);
106
+ let text = readFileSync(path, "utf8");
107
+
108
+ if (asn && asn !== EXAMPLE_ASN) text = text.replaceAll(new RegExp(`\\b${EXAMPLE_ASN}\\b`, "g"), asn);
109
+ for (const [, name] of text.matchAll(/secret\("([A-Z0-9_]+)"\)/g)) secrets.add(name!);
110
+
111
+ writeFileSync(path, text);
112
+ }
113
+
114
+ const env = [
115
+ "# Secrets for Circuit. Never commit this file.",
116
+ "# A value is the secret itself, or a 1Password reference, op://vault/item/field, read with the 1Password CLI.",
117
+ ...[...secrets].sort().map((name) => (onePassword ? `${name}=op://Network/${name}/password` : `${name}=`)),
118
+ "",
119
+ ];
120
+ writeFileSync(join(target, ".env"), env.join("\n"));
121
+
122
+ const name = basename(target)
123
+ .toLowerCase()
124
+ .replace(/[^a-z0-9-]+/g, "-");
125
+ const packageJson = {
126
+ name,
127
+ private: true,
128
+ type: "module",
129
+ dependencies: { "@takodotid/circuit": circuit.version },
130
+ devDependencies: { "@types/bun": circuit.devDependencies["@types/bun"] },
131
+ };
132
+ writeFileSync(join(target, "package.json"), JSON.stringify(packageJson, null, 4) + "\n");
133
+
134
+ 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.");
137
+ }
138
+
139
+ const steps = [
140
+ `cd ${directory}`,
141
+ 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",
144
+ "Change the names, addresses and ports in the device files to your own.",
145
+ ...(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",
148
+ ];
149
+
150
+ console.log(`\nCreated ${directory} from the pattern "${PATTERNS[pattern].title}".\n\nNext:`);
151
+ steps.forEach((step, index) => console.log(` ${index + 1}. ${step}`));
152
+ console.log(`\nWhat each file does: https://circuit.tako.id/patterns/${pattern}`);
153
+ }
@@ -1,10 +1,10 @@
1
1
  import type { ModelName, PlatformName, PortName } from "../adapters/devices/catalog";
2
2
  import type { Device, Secret } from "../schema";
3
3
 
4
- /** A value resolved by name when a device is applied, from the environment or `.env.local`. Never the value itself. */
4
+ /** A value resolved by name when a device is applied, from the environment or `.env` beside the config, and through 1Password when that value is an `op://` reference. Never the value itself. */
5
5
  export const secret = (name: string): Secret => ({ secret: name });
6
6
 
7
- /** A value read from a file when a device is applied, such as a private key in PEM. The path is relative to the project root; keep the file out of git. */
7
+ /** A value read from a file when a device is applied, such as a private key in PEM. The path is relative to the config file; keep the file out of git. */
8
8
  export const secretFile = (path: string): Secret => {
9
9
  if (!/^[A-Za-z0-9_./-]+$/.test(path)) throw new Error(`secret file path ${path} may hold only letters, digits, _ . / and -`);
10
10
  return { secret_file: path };
@@ -77,8 +77,6 @@ export type Network = {
77
77
  devices: readonly Device[];
78
78
  /** Rules of your own design. */
79
79
  checks?: readonly Check[];
80
- /** Where snapshots of what each device runs are kept, relative to the config file. */
81
- state: string;
82
80
  /** The AS the network operates, for what is published about it. */
83
81
  asn?: number;
84
82
  /** The BGP communities the network defines, printed by `circuit communities` for a looking glass or bgp.tools. */