@takodotid/circuit 0.19.0 → 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.
- package/README.md +2 -2
- package/dist/chunks/index-jtzfw679.js +936 -0
- package/dist/chunks/{index-dczpxzzm.js → index-xm9nma2t.js} +20 -1
- package/dist/chunks/server-5shmsmse.js +246 -0
- package/dist/cli/index.js +544 -487
- package/dist/index.js +1 -1
- package/dist/presets/index.js +1 -1
- package/dist/types/adapters/devices/derive.d.ts +2 -0
- package/dist/types/core/across.d.ts +4 -0
- package/dist/types/core/addr.d.ts +10 -0
- package/dist/types/core/addresses.d.ts +46 -0
- package/dist/types/core/files.d.ts +6 -0
- package/dist/types/core/graph.d.ts +31 -1
- package/dist/types/core/schema-docs.d.ts +16 -0
- package/package.json +1 -1
- package/src/adapters/devices/derive.ts +32 -0
- package/src/adapters/devices/raisecom-ros/index.ts +23 -3
- package/src/adapters/devices/routeros/index.ts +10 -0
- package/src/adapters/devices/vrp/index.ts +10 -1
- package/src/cli/graph.ts +1 -15
- package/src/cli/index.ts +154 -20
- package/src/cli/rename.ts +129 -0
- package/src/core/across.ts +322 -0
- package/src/core/addr.ts +29 -0
- package/src/core/addresses.ts +316 -0
- package/src/core/files.ts +30 -0
- package/src/core/graph.ts +27 -6
- package/src/core/schema-docs.ts +166 -0
- package/src/core/validate.ts +2 -52
- package/src/studio/data.ts +255 -0
- package/src/studio/git.ts +119 -0
- package/src/studio/public/app.js +147 -0
- package/src/studio/public/compare.js +243 -0
- package/src/studio/public/diff.js +97 -0
- package/src/studio/public/dom.js +108 -0
- package/src/studio/public/evaluate.js +648 -0
- package/src/studio/public/export.js +122 -0
- package/src/studio/public/favicon.svg +6 -0
- package/src/studio/public/graph.js +914 -0
- package/src/studio/public/index.html +67 -0
- package/src/studio/public/net.js +128 -0
- package/src/studio/public/style.css +693 -0
- package/src/studio/public/tables.js +1223 -0
- package/src/studio/public/tools.js +578 -0
- package/src/studio/server.ts +218 -0
- package/templates/project/AGENTS.md +2 -0
- package/dist/chunks/studio-femhydvd.js +0 -764
- package/src/cli/studio-page.ts +0 -876
- package/src/cli/studio.ts +0 -126
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// `circuit studio`: the network in the browser, on this machine or on a server for a team. The page is the files in public/, served as they are. What it shows comes from /data: gathered by `circuit studio --json` in a new process, so the config is always read fresh, and gathered again each time a file beside the config changes.
|
|
2
|
+
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
5
|
+
import { createServer, type ServerResponse } from "node:http";
|
|
6
|
+
import { extname, join } from "node:path";
|
|
7
|
+
import { packageRoot, type StudioData } from "./data";
|
|
8
|
+
import { commitOf, history, networkAt, references } from "./git";
|
|
9
|
+
|
|
10
|
+
export type StudioOptions = {
|
|
11
|
+
/** The config file. */
|
|
12
|
+
configPath: string;
|
|
13
|
+
/** The directory watched for changes: the config's own. */
|
|
14
|
+
root: string;
|
|
15
|
+
port: number;
|
|
16
|
+
/** Open the browser once the server listens. */
|
|
17
|
+
open: boolean;
|
|
18
|
+
/** The address to listen on: 127.0.0.1 on a machine of one's own, or a server's address. */
|
|
19
|
+
host: string;
|
|
20
|
+
/** Names besides this machine's own that requests may be addressed to, such as `studio.example.com` behind a proxy. */
|
|
21
|
+
allowHosts: string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Whether an address to listen on is this machine's own, which nothing else on the network can reach. */
|
|
25
|
+
export const isLoopback = (host: string) => host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a request's Host header names Studio, so a web page elsewhere cannot read the network through a name it points here.
|
|
29
|
+
*
|
|
30
|
+
* Studio answers to this machine's own names at its port, and to each name it was told to, with or without a port.
|
|
31
|
+
*/
|
|
32
|
+
export function hostAllowed(header: string | undefined, port: number, allowHosts: readonly string[]): boolean {
|
|
33
|
+
if (!header) return false;
|
|
34
|
+
if (header === `127.0.0.1:${port}` || header === `localhost:${port}` || header === `[::1]:${port}`) return true;
|
|
35
|
+
|
|
36
|
+
const name = header.replace(/:\d+$/, "").toLowerCase();
|
|
37
|
+
return allowHosts.some((allowed) => {
|
|
38
|
+
const wanted = allowed.toLowerCase();
|
|
39
|
+
return wanted === header.toLowerCase() || wanted === name;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Directories whose changes never change what Studio shows. */
|
|
44
|
+
const IGNORED = /(^|[\\/])(node_modules|\.git|\.circuit[\\/]cache)([\\/]|$)/;
|
|
45
|
+
|
|
46
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
47
|
+
".html": "text/html; charset=utf-8",
|
|
48
|
+
".css": "text/css; charset=utf-8",
|
|
49
|
+
".js": "text/javascript; charset=utf-8",
|
|
50
|
+
".svg": "image/svg+xml",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** What Studio shows, as JSON, from `circuit studio --json` run as a new process, or what stopped it. */
|
|
54
|
+
function gather(configPath: string): Promise<string> {
|
|
55
|
+
const cli = process.argv[1]!;
|
|
56
|
+
const args = [...process.execArgv, cli, "studio", "--json", `--config=${configPath}`];
|
|
57
|
+
|
|
58
|
+
return new Promise((done) => {
|
|
59
|
+
execFile(process.execPath, args, { maxBuffer: 256 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
60
|
+
if (!error) return done(stdout);
|
|
61
|
+
|
|
62
|
+
const message = (stderr || error.message).trim();
|
|
63
|
+
done(JSON.stringify({ error: message }));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Open the address in the browser. A machine without one only misses the convenience. */
|
|
69
|
+
function openBrowser(url: string): void {
|
|
70
|
+
const [command, args] =
|
|
71
|
+
process.platform === "win32"
|
|
72
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
73
|
+
: process.platform === "darwin"
|
|
74
|
+
? ["open", [url]]
|
|
75
|
+
: ["xdg-open", [url]];
|
|
76
|
+
|
|
77
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
78
|
+
child.on("error", () => {});
|
|
79
|
+
child.unref();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function studio(options: StudioOptions): Promise<void> {
|
|
83
|
+
const { configPath, root, port } = options;
|
|
84
|
+
|
|
85
|
+
// The page's files ship in the package's sources, which Bun runs as they are and Node's build leaves in place.
|
|
86
|
+
const installed = packageRoot();
|
|
87
|
+
if (!installed) throw new Error("cannot find where Circuit is installed, so Studio has no page to serve");
|
|
88
|
+
const pages = join(installed, "src", "studio", "public");
|
|
89
|
+
|
|
90
|
+
let data = await gather(configPath);
|
|
91
|
+
|
|
92
|
+
// Another commit's network is read once: a commit does not change. Each is kept as it is being read, so two asks share one read.
|
|
93
|
+
const past = new Map<string, Promise<string>>();
|
|
94
|
+
|
|
95
|
+
/** Answer with JSON, or with an error the page shows. */
|
|
96
|
+
const answer = async (response: ServerResponse, work: () => Promise<unknown>) => {
|
|
97
|
+
let body: string;
|
|
98
|
+
try {
|
|
99
|
+
const result = await work();
|
|
100
|
+
body = typeof result === "string" ? result : JSON.stringify(result);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
body = JSON.stringify({ error: (error as Error).message });
|
|
103
|
+
}
|
|
104
|
+
response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(body);
|
|
105
|
+
};
|
|
106
|
+
const listeners = new Set<ServerResponse>();
|
|
107
|
+
const tell = (message: string) => {
|
|
108
|
+
for (const listener of listeners) listener.write(`data: ${message}\n\n`);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const server = createServer((request, response) => {
|
|
112
|
+
if (!hostAllowed(request.headers.host, port, options.allowHosts)) {
|
|
113
|
+
response.writeHead(403).end();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const url = new URL(request.url ?? "/", "http://studio");
|
|
118
|
+
const path = url.pathname;
|
|
119
|
+
|
|
120
|
+
// The commits that changed a device: its file and its snapshot.
|
|
121
|
+
if (path === "/history") {
|
|
122
|
+
void answer(response, async () => {
|
|
123
|
+
const name = url.searchParams.get("device") ?? "";
|
|
124
|
+
const files = (JSON.parse(data) as StudioData).files?.[name];
|
|
125
|
+
if (!files) throw new Error(`no device ${name}`);
|
|
126
|
+
return history(
|
|
127
|
+
root,
|
|
128
|
+
[files.source, files.snapshot].filter((file): file is string => Boolean(file))
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// What Studio can compare with: branches, tags and the latest commits.
|
|
135
|
+
if (path === "/refs") {
|
|
136
|
+
void answer(response, () => references(root));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The network as it was at a branch, tag or commit.
|
|
141
|
+
if (path === "/compare") {
|
|
142
|
+
void answer(response, async () => {
|
|
143
|
+
const asked = url.searchParams.get("ref") ?? "";
|
|
144
|
+
const commit = await commitOf(root, asked);
|
|
145
|
+
if (!commit) throw new Error(`${asked} names no commit here`);
|
|
146
|
+
|
|
147
|
+
if (!past.has(commit.commit)) past.set(commit.commit, networkAt(root, configPath, commit.commit, gather));
|
|
148
|
+
const network = await past.get(commit.commit)!;
|
|
149
|
+
if (network.startsWith('{"error"')) past.delete(commit.commit);
|
|
150
|
+
|
|
151
|
+
return `{"commit":${JSON.stringify(commit)},"network":${network}}`;
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (path === "/data") {
|
|
157
|
+
response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(data);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Changes are told as they happen; a proxy such as nginx is asked not to hold them back.
|
|
162
|
+
if (path === "/events") {
|
|
163
|
+
response.writeHead(200, {
|
|
164
|
+
"content-type": "text/event-stream",
|
|
165
|
+
"cache-control": "no-store",
|
|
166
|
+
connection: "keep-alive",
|
|
167
|
+
"x-accel-buffering": "no",
|
|
168
|
+
});
|
|
169
|
+
response.write(": connected\n\n");
|
|
170
|
+
|
|
171
|
+
listeners.add(response);
|
|
172
|
+
request.on("close", () => listeners.delete(response));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Only a file directly in public/, by a plain name, is served: nothing above it can be reached.
|
|
177
|
+
const name = path === "/" ? "index.html" : path.slice(1);
|
|
178
|
+
const type = CONTENT_TYPES[extname(name)];
|
|
179
|
+
const file = join(pages, name);
|
|
180
|
+
|
|
181
|
+
if (!/^[a-z-]+\.[a-z]+$/.test(name) || !type || !existsSync(file)) {
|
|
182
|
+
response.writeHead(404).end();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
response.writeHead(200, { "content-type": type, "cache-control": "no-store" }).end(readFileSync(file));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Several files often change at once, such as on a save or a checkout: the config is read again once they settle.
|
|
190
|
+
let timer: NodeJS.Timeout | undefined;
|
|
191
|
+
|
|
192
|
+
watch(root, { recursive: true }, (_event, filename) => {
|
|
193
|
+
if (filename && IGNORED.test(filename)) return;
|
|
194
|
+
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
timer = setTimeout(async () => {
|
|
197
|
+
data = await gather(configPath);
|
|
198
|
+
tell("changed");
|
|
199
|
+
console.log(`${new Date().toLocaleTimeString()} ${filename ?? "a file"} changed, read again`);
|
|
200
|
+
}, 300);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// A change to the page's own files, while working on Circuit, loads the page again.
|
|
204
|
+
watch(pages, () => tell("page"));
|
|
205
|
+
|
|
206
|
+
server.on("error", (error: NodeJS.ErrnoException) => {
|
|
207
|
+
const message = error.code === "EADDRINUSE" ? `port ${port} is in use; choose another with --port=N` : error.message;
|
|
208
|
+
console.error(message);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
server.listen(port, options.host, () => {
|
|
213
|
+
const local = isLoopback(options.host);
|
|
214
|
+
const url = local ? `http://127.0.0.1:${port}` : `http://${options.allowHosts[0] ?? options.host}`;
|
|
215
|
+
console.log(`Circuit Studio at ${url}, listening on ${options.host}:${port}, watching ${root}. Stop it with Ctrl+C.`);
|
|
216
|
+
if (options.open && local) openBrowser(url);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
@@ -25,6 +25,8 @@ What each field means is written as a comment on the field, in `node_modules/@ta
|
|
|
25
25
|
| `npx circuit apply <device>` | Reads the device, then shows what would change on it | no |
|
|
26
26
|
| `npx circuit apply <device> --confirm` | Sends the change to the device | **yes** |
|
|
27
27
|
| `npx circuit secrets` | Lists every secret the config uses, and whether each one can be read | no |
|
|
28
|
+
| `npx circuit rename <device> <new-name>` | Shows every line a new device name changes; `--confirm` writes them | no |
|
|
29
|
+
| `npx circuit addresses` | Every address the config uses, by prefix, and what is still free | no |
|
|
28
30
|
| `npx circuit graph --layer=all --format=json` | The whole network as a graph: cables, VLANs, addresses, BGP, OSPF and tunnels | no |
|
|
29
31
|
|
|
30
32
|
## Making a change
|