@protoboardai/cli 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/bin/protoboard.mjs +5 -0
- package/build-info.json +8 -0
- package/package.json +40 -4
- package/src/agents.ts +235 -0
- package/src/bom.ts +145 -0
- package/src/dev/build-worker.ts +63 -0
- package/src/dev/layout.ts +199 -0
- package/src/dev/page.ts +284 -0
- package/src/dev/parts.ts +356 -0
- package/src/dev/relay.d.ts +60 -0
- package/src/dev/relay.js +147 -0
- package/src/dev/server.ts +727 -0
- package/src/dev/sources.ts +120 -0
- package/src/gate.ts +102 -0
- package/src/index.ts +2024 -0
- package/src/layout.ts +166 -0
- package/src/mcp/instructions.ts +60 -0
- package/src/mcp/server.ts +215 -0
- package/src/mcp/tools.ts +392 -0
- package/src/openUrl.ts +71 -0
- package/src/setup.ts +298 -0
- package/src/stack-messages.ts +53 -0
- package/src/views-check.ts +267 -0
- package/README.md +0 -3
package/src/dev/page.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page `protoboard dev` serves: Protoboard's web viewer
|
|
3
|
+
* (`@protoboardai/web-viewer`, `mountWebViewer`: the browser shell over the
|
|
4
|
+
* viewer stack, docs/specs/viewer-stack.md) in a page of its own, with the
|
|
5
|
+
* Protoboard adapter around it:
|
|
6
|
+
*
|
|
7
|
+
* - the bundle and its artifacts come from the viewer's own handler
|
|
8
|
+
* (`/uhd-bundle.json`, `/artifacts/...`);
|
|
9
|
+
* - the dev session's findings (a failed rebuild, an unsaved layout) are the
|
|
10
|
+
* host diagnostics, next to the project's aggregate in the bundle;
|
|
11
|
+
* - layout edits go to the server, which writes them to display.json;
|
|
12
|
+
* - server-sent events push rebuilt bundles and display changes into the
|
|
13
|
+
* running viewer (`update`), so the view, drill level and selection stay;
|
|
14
|
+
* - in a frame (the VS Code extension's webview), the page relays the
|
|
15
|
+
* viewer's host messages between its parent and the viewer (relay.js),
|
|
16
|
+
* and answers `openSource` with the file and line from the build's source
|
|
17
|
+
* index (`/__protoboard/source`, ./sources.ts);
|
|
18
|
+
* - the views the project's extensions contribute become tabs (`views` in the
|
|
19
|
+
* state route); `?theme=` picks the theme (protoboard-dark, protoboard-light,
|
|
20
|
+
* vscode, …; default: the viewer's last choice, else Protoboard dark).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Routes of the dev session, apart from the viewer handler's own. */
|
|
24
|
+
export const DEV_ROUTES = {
|
|
25
|
+
page: "/",
|
|
26
|
+
script: "/__protoboard/dev.js",
|
|
27
|
+
events: "/__protoboard/events",
|
|
28
|
+
state: "/__protoboard/state",
|
|
29
|
+
layout: "/__protoboard/layout",
|
|
30
|
+
relay: "/__protoboard/relay.js",
|
|
31
|
+
source: "/__protoboard/source",
|
|
32
|
+
parts: "/__protoboard/parts/",
|
|
33
|
+
viewerJs: "/__protoboard/viewer/web-viewer.js",
|
|
34
|
+
viewerCss: "/__protoboard/viewer/web-viewer.css",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A route as the page refers to it: relative to the page, so the page also
|
|
39
|
+
* works behind a proxy that serves it under a path (code-server's
|
|
40
|
+
* `/proxy/<port>/`, PB-861). The page is always served at the root of the
|
|
41
|
+
* server.
|
|
42
|
+
*/
|
|
43
|
+
const rel = (route: string) => `.${route}`;
|
|
44
|
+
/** A route as dev.js imports it: relative to dev.js itself (`/__protoboard/dev.js`). */
|
|
45
|
+
const fromScript = (route: string) => `./${route.slice("/__protoboard/".length)}`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The Protoboard mark (as in the VS Code extension's media/protoboard.svg) as
|
|
49
|
+
* the tab's icon, in a neutral grey: no brand colour is hard-coded (PB-900).
|
|
50
|
+
* Once mounted, the web viewer redraws it in the theme's accent.
|
|
51
|
+
*/
|
|
52
|
+
export const FAVICON_SVG =
|
|
53
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">' +
|
|
54
|
+
'<path fill="#8a8a8a" d="M8.529 13.629 15.272 6.885C15.348 6.81 15.385 6.715 15.385 6.6C15.385 6.486 15.348 6.39 15.272 6.315L13.815 4.886C13.624 4.695 13.386 4.6 13.101 4.6H10.9C10.614 4.6 10.376 4.695 10.186 4.886L8.529 6.543C8.338 6.733 8.243 6.972 8.243 7.257V13.486C8.243 13.638 8.281 13.714 8.357 13.714C8.414 13.714 8.472 13.686 8.529 13.629ZM10.9 19.4H13.101C13.386 19.4 13.624 19.305 13.815 19.114L15.472 17.457C15.662 17.267 15.757 17.029 15.757 16.743V10.514C15.757 10.362 15.719 10.286 15.643 10.286C15.586 10.286 15.529 10.315 15.472 10.372L8.729 17.115C8.634 17.209 8.586 17.305 8.586 17.4C8.586 17.496 8.634 17.591 8.729 17.685L10.186 19.114C10.376 19.305 10.614 19.4 10.9 19.4ZM6.015 5.372 9.015 2.4C9.243 2.133 9.548 2 9.929 2H14.072C14.433 2 14.738 2.124 14.986 2.372L17.958 5.372C18.224 5.6 18.357 5.905 18.357 6.286V17.714C18.357 18.076 18.224 18.391 17.958 18.657L14.986 21.628C14.738 21.876 14.433 22 14.072 22H9.929C9.548 22 9.243 21.867 9.015 21.6L6.015 18.628C5.767 18.381 5.643 18.076 5.643 17.714V6.286C5.643 5.924 5.767 5.619 6.015 5.372Z"/></svg>';
|
|
55
|
+
/** The page's icon, inline: the browser then never asks for /favicon.ico (PB-873). */
|
|
56
|
+
const FAVICON_DATA_URL = `data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}`;
|
|
57
|
+
|
|
58
|
+
/** Header carrying the session token on writes (a page on another origin cannot set it without a preflight, which is refused). */
|
|
59
|
+
export const TOKEN_HEADER = "x-protoboard-dev";
|
|
60
|
+
|
|
61
|
+
const escapeHtml = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
|
62
|
+
|
|
63
|
+
export function devPage(config: { name: string; token: string; session: string; hostOrigins?: string[] }): string {
|
|
64
|
+
const json = JSON.stringify(config).replace(/</g, "\\u003c");
|
|
65
|
+
return `<!doctype html>
|
|
66
|
+
<html lang="en">
|
|
67
|
+
<head>
|
|
68
|
+
<meta charset="utf-8" />
|
|
69
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
70
|
+
<title>${escapeHtml(config.name)} · Protoboard dev</title>
|
|
71
|
+
<link rel="icon" href="${FAVICON_DATA_URL}" />
|
|
72
|
+
<link rel="stylesheet" href="${rel(DEV_ROUTES.viewerCss)}" />
|
|
73
|
+
<style>
|
|
74
|
+
/* until the web viewer mounts and puts its theme on the document, the system canvas in the
|
|
75
|
+
theme's scheme (no dark flash under a light theme) */
|
|
76
|
+
html { background: Canvas; color-scheme: dark; }
|
|
77
|
+
html[data-pb-scheme="light"] { color-scheme: light; }
|
|
78
|
+
html, body { margin: 0; height: 100%; }
|
|
79
|
+
#viewer { height: 100vh; }
|
|
80
|
+
#pb-dev {
|
|
81
|
+
--pb-surface: #262626; --pb-text: #e5e5e5; --pb-text-muted: #a3a3a3; --pb-border: #3a3a3a; --pb-accent: currentColor;
|
|
82
|
+
--pb-ok: #22c55e; --pb-warning: #eed202; --pb-error: #ef4444; --pb-shadow: 0 6px 24px rgb(0 0 0 / 0.35); --pb-font: ui-monospace, monospace;
|
|
83
|
+
/* above the viewer's bottom toolbar, and only while it has something to say: once a build or a
|
|
84
|
+
save is done it fades out, so it never sits over the diagram (PB-861); errors stay */
|
|
85
|
+
position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%); z-index: 50; pointer-events: none;
|
|
86
|
+
max-width: min(640px, calc(100vw - 32px)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
87
|
+
display: flex; align-items: center; gap: 8px; padding: 6px 10px;
|
|
88
|
+
/* the viewer's tokens (the theme is applied on the viewer's root, so read them from there) */
|
|
89
|
+
background: var(--pb-surface); color: var(--pb-text-muted);
|
|
90
|
+
border: 1px solid var(--pb-border); border-radius: 8px;
|
|
91
|
+
box-shadow: var(--pb-shadow);
|
|
92
|
+
font: 500 11px/1.4 var(--pb-font); letter-spacing: 0.04em; text-transform: uppercase;
|
|
93
|
+
transition: opacity 0.3s;
|
|
94
|
+
}
|
|
95
|
+
#pb-dev .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pb-ok); flex: none; }
|
|
96
|
+
#pb-dev[data-state="busy"] .dot { background: var(--pb-warning); }
|
|
97
|
+
#pb-dev[data-state="error"] .dot { background: var(--pb-error); }
|
|
98
|
+
#pb-dev[data-state="error"] { color: var(--pb-text); }
|
|
99
|
+
#pb-dev[data-quiet="1"] { opacity: 0; }
|
|
100
|
+
#pb-dev .sep { width: 1px; align-self: stretch; background: var(--pb-border); }
|
|
101
|
+
#pb-dev .brand { color: var(--pb-accent); }
|
|
102
|
+
</style>
|
|
103
|
+
<script>
|
|
104
|
+
try {
|
|
105
|
+
const t = new URLSearchParams(location.search).get("theme") ?? localStorage.getItem("protoboard-web:theme") ?? "";
|
|
106
|
+
if (t.endsWith("light") || (t.endsWith("system") && matchMedia("(prefers-color-scheme: light)").matches)) document.documentElement.dataset.pbScheme = "light";
|
|
107
|
+
} catch {}
|
|
108
|
+
</script>
|
|
109
|
+
<script type="application/json" id="protoboard-dev">${json}</script>
|
|
110
|
+
</head>
|
|
111
|
+
<body>
|
|
112
|
+
<div id="viewer"></div>
|
|
113
|
+
<div id="pb-dev" data-state="busy"><span class="dot"></span><span class="brand">Protoboard dev</span><span class="sep"></span><span id="pb-dev-text">loading</span></div>
|
|
114
|
+
<script type="module" src="${rel(DEV_ROUTES.script)}"></script>
|
|
115
|
+
</body>
|
|
116
|
+
</html>
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The page's script: mounts the viewer and connects it to the dev server. */
|
|
121
|
+
export const DEV_SCRIPT = `import { createDevPartsData, mountWebViewer } from "${fromScript(DEV_ROUTES.viewerJs)}";
|
|
122
|
+
import { createRelay } from "${fromScript(DEV_ROUTES.relay)}";
|
|
123
|
+
|
|
124
|
+
const config = JSON.parse(document.getElementById("protoboard-dev").textContent);
|
|
125
|
+
const client = crypto.randomUUID();
|
|
126
|
+
const empty = () => ({ positions: {}, links: {} });
|
|
127
|
+
const badge = document.getElementById("pb-dev");
|
|
128
|
+
const text = document.getElementById("pb-dev-text");
|
|
129
|
+
let quiet;
|
|
130
|
+
function status(state, message) {
|
|
131
|
+
badge.dataset.state = state;
|
|
132
|
+
badge.dataset.quiet = "0";
|
|
133
|
+
text.textContent = message;
|
|
134
|
+
clearTimeout(quiet);
|
|
135
|
+
if (state === "ok") quiet = setTimeout(() => (badge.dataset.quiet = "1"), 4000);
|
|
136
|
+
}
|
|
137
|
+
const counts = (c) => (c ? c.error + " errors · " + c.warning + " warnings" : "");
|
|
138
|
+
|
|
139
|
+
// the badge sits outside the viewer: it takes the viewer's theme tokens from the viewer's root whenever they change
|
|
140
|
+
const BADGE_TOKENS = { surface: "surface", text: "text", "text-muted": "text-muted", border: "border", accent: "accent", ok: "severity-ok", warning: "severity-warning", error: "severity-error", shadow: "shadow-lg", font: "font-ui" };
|
|
141
|
+
let themed;
|
|
142
|
+
function syncBadge() {
|
|
143
|
+
const style = getComputedStyle(themed);
|
|
144
|
+
for (const [k, t] of Object.entries(BADGE_TOKENS)) {
|
|
145
|
+
const v = style.getPropertyValue("--uhd-" + t).trim();
|
|
146
|
+
if (v) badge.style.setProperty("--pb-" + k, v);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
new MutationObserver(() => {
|
|
150
|
+
const root = document.querySelector("#viewer > .uhd-root");
|
|
151
|
+
if (!root || root === themed) return;
|
|
152
|
+
themed = root;
|
|
153
|
+
new MutationObserver(syncBadge).observe(root, { attributes: true, attributeFilter: ["style", "data-uhd-theme"] });
|
|
154
|
+
syncBadge();
|
|
155
|
+
}).observe(document.getElementById("viewer"), { childList: true });
|
|
156
|
+
|
|
157
|
+
// every URL relative to the page, so it works behind a path-prefixing proxy (a browser-hosted VS Code)
|
|
158
|
+
const here = (path) => new URL(path, document.baseURI).href;
|
|
159
|
+
const bundleUrl = here("uhd-bundle.json");
|
|
160
|
+
|
|
161
|
+
async function fetchBundle() {
|
|
162
|
+
const res = await fetch(bundleUrl, { cache: "no-store" });
|
|
163
|
+
if (!res.ok) throw new Error("bundle: " + res.status);
|
|
164
|
+
const bundle = await res.json();
|
|
165
|
+
// the server names artifact bases by the host it was asked on (http://host/artifacts/…); a proxy in
|
|
166
|
+
// front of it (another scheme, host or path) reaches them relative to this page instead
|
|
167
|
+
for (const [id, base] of Object.entries(bundle.artifactBases ?? {})) {
|
|
168
|
+
const m = /^https?:\\/\\/[^/]+\\/(artifacts\\/.*)$/.exec(base);
|
|
169
|
+
if (m) bundle.artifactBases[id] = here(m[1]);
|
|
170
|
+
}
|
|
171
|
+
return bundle;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Where every node and edge of the diagram on screen is drawn (absolute canvas coordinates). */
|
|
175
|
+
function snapshot() {
|
|
176
|
+
const flow = document.querySelector(".react-flow");
|
|
177
|
+
if (!flow) return undefined;
|
|
178
|
+
const nodes = {};
|
|
179
|
+
for (const el of flow.querySelectorAll(".react-flow__node[data-id]")) {
|
|
180
|
+
const m = /translate\\((-?[\\d.]+)px,\\s*(-?[\\d.]+)px\\)/.exec(el.style.transform);
|
|
181
|
+
if (m) nodes[el.dataset.id] = { x: Number(m[1]), y: Number(m[2]) };
|
|
182
|
+
}
|
|
183
|
+
const edges = [...flow.querySelectorAll(".react-flow__edge[data-id]")].map((el) => el.dataset.id);
|
|
184
|
+
return { nodes, edges };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function save(layout) {
|
|
188
|
+
status("busy", "saving layout");
|
|
189
|
+
try {
|
|
190
|
+
const res = await fetch("${rel(DEV_ROUTES.layout)}", {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: { "content-type": "application/json", "${TOKEN_HEADER}": config.token },
|
|
193
|
+
body: JSON.stringify({ client, layout, snapshot: snapshot() }),
|
|
194
|
+
});
|
|
195
|
+
const body = await res.json();
|
|
196
|
+
if (!res.ok) throw new Error(body.error ?? res.status);
|
|
197
|
+
if (body.skipped?.length) status("error", "layout not saved: " + body.skipped[0].reason);
|
|
198
|
+
else status("ok", body.levels.length ? "layout saved · " + body.file : "layout unchanged");
|
|
199
|
+
} catch (e) {
|
|
200
|
+
status("error", "layout not saved: " + e.message);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// a framing host (the VS Code extension) talks to the viewer through this page
|
|
205
|
+
const relay = createRelay({
|
|
206
|
+
window,
|
|
207
|
+
origins: config.hostOrigins,
|
|
208
|
+
async resolveSource(request) {
|
|
209
|
+
const query = new URLSearchParams({ defId: request.defId, ...(request.instancePath ? { instancePath: request.instancePath } : {}) });
|
|
210
|
+
const res = await fetch("${rel(DEV_ROUTES.source)}?" + query, { cache: "no-store" });
|
|
211
|
+
return res.ok ? res.json() : undefined;
|
|
212
|
+
},
|
|
213
|
+
onLocal(event) {
|
|
214
|
+
const at = event.source ?? event.instanceSource;
|
|
215
|
+
status("ok", at ? "source · " + at.file + (at.line ? ":" + at.line : "") : "no source found for " + event.defId);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const state = await (await fetch("${rel(DEV_ROUTES.state)}", { cache: "no-store" })).json();
|
|
220
|
+
const themeChoice = new URLSearchParams(location.search).get("theme") ?? undefined;
|
|
221
|
+
const viewer = mountWebViewer(document.getElementById("viewer"), {
|
|
222
|
+
bundle: await fetchBundle(),
|
|
223
|
+
bundleUrl,
|
|
224
|
+
themeChoice,
|
|
225
|
+
views: state.views ?? [],
|
|
226
|
+
// every definition's part status (PB-932): the shell marks the modules with it
|
|
227
|
+
partStatus: state.partStatus,
|
|
228
|
+
// the library and bom tabs ask the server (writes carry the session token)
|
|
229
|
+
partsData: createDevPartsData({ base: here("${DEV_ROUTES.parts.slice(1)}"), token: config.token, tokenHeader: "${TOKEN_HEADER}" }),
|
|
230
|
+
diagnostics: state.diagnostics,
|
|
231
|
+
layout: empty(),
|
|
232
|
+
onLayoutChange: save,
|
|
233
|
+
...relay.callbacks,
|
|
234
|
+
});
|
|
235
|
+
relay.attach(viewer);
|
|
236
|
+
window.protoboardDev = { viewer, relay, client, version: state.version, updates: 0 };
|
|
237
|
+
status(state.building ? "busy" : state.failed ? "error" : "ok", state.message);
|
|
238
|
+
|
|
239
|
+
const events = new EventSource("${rel(DEV_ROUTES.events)}");
|
|
240
|
+
events.addEventListener("hello", (e) => {
|
|
241
|
+
// a restarted server: its bundle and session are new
|
|
242
|
+
if (JSON.parse(e.data).session !== config.session) location.reload();
|
|
243
|
+
});
|
|
244
|
+
events.addEventListener("status", (e) => {
|
|
245
|
+
const s = JSON.parse(e.data);
|
|
246
|
+
status(s.state, s.message);
|
|
247
|
+
});
|
|
248
|
+
events.addEventListener("bundle", async (e) => {
|
|
249
|
+
const msg = JSON.parse(e.data);
|
|
250
|
+
try {
|
|
251
|
+
const bundle = await fetchBundle();
|
|
252
|
+
// the layout on disk changed under the viewer (an edit, a checkout, another tab): show it as saved
|
|
253
|
+
const reset = msg.reason === "display" || (msg.reason === "layout" && msg.client !== client);
|
|
254
|
+
viewer.update({ bundle, diagnostics: msg.diagnostics, ...(reset ? { layout: empty() } : {}), ...(msg.partStatus ? { partStatus: msg.partStatus } : {}) });
|
|
255
|
+
window.protoboardDev.version = msg.version;
|
|
256
|
+
window.protoboardDev.updates++;
|
|
257
|
+
status(msg.failed ? "error" : "ok", msg.message);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
status("error", err.message);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
// a dropped stream reconnects by itself (a laptop that slept, a network that changed): say so only while it is down
|
|
263
|
+
let lost = false;
|
|
264
|
+
events.onerror = () => {
|
|
265
|
+
lost = true;
|
|
266
|
+
status("error", "dev server unreachable; retrying");
|
|
267
|
+
};
|
|
268
|
+
events.onopen = async () => {
|
|
269
|
+
if (!lost) return;
|
|
270
|
+
lost = false;
|
|
271
|
+
try {
|
|
272
|
+
const now = await (await fetch("${rel(DEV_ROUTES.state)}", { cache: "no-store" })).json();
|
|
273
|
+
if (now.session !== config.session) return location.reload();
|
|
274
|
+
if (now.version !== window.protoboardDev.version) {
|
|
275
|
+
viewer.update({ bundle: await fetchBundle(), diagnostics: now.diagnostics });
|
|
276
|
+
window.protoboardDev.version = now.version;
|
|
277
|
+
window.protoboardDev.updates++;
|
|
278
|
+
}
|
|
279
|
+
status(now.building ? "busy" : now.failed ? "error" : "ok", now.message);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
status("error", err.message);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
`;
|
package/src/dev/parts.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts routes of `protoboard dev` (PB-912): what the web viewer's
|
|
3
|
+
* `library` and `bom` tabs show, computed the way the VS Code extension
|
|
4
|
+
* computes them (@protoboardai/views/parts-data, the CLI's own commands).
|
|
5
|
+
*
|
|
6
|
+
* - `GET parts/library?text&category&domain&protocol&sort&cursor`: a search over
|
|
7
|
+
* the project's libraries (the list from `protoboard libraries`, the
|
|
8
|
+
* client and the merging from @protoboardai/core/library-client), as the
|
|
9
|
+
* Library's model; thumbnails by `parts/blob`.
|
|
10
|
+
* - `GET parts/part?key=<library>/<id>&revision`: a part's detail, with its pages.
|
|
11
|
+
* - `GET parts/pages?key=<library>/<id>&revision`: a part's pages, for the
|
|
12
|
+
* cards' text carousel (PB-918): asked for when a card first shows one.
|
|
13
|
+
* - `GET parts/prices?key=<library>/<id>`: the part's prices by source, as
|
|
14
|
+
* its library serves them to the signed-in user (`partPrices`).
|
|
15
|
+
* - `GET parts/blob?library&sha&size&type`: a thumbnail or model, verified
|
|
16
|
+
* against its address by the library client, cached under
|
|
17
|
+
* .protoboard/cache/library.
|
|
18
|
+
* - `GET parts/project[?check=1]`: `protoboard parts list [--check]`, each
|
|
19
|
+
* library part with its picture (the library's thumbnail, by `parts/blob`).
|
|
20
|
+
* - `GET parts/bom`: `protoboard bom`.
|
|
21
|
+
* - `POST parts/offers`: `protoboard parts offers`: the library's current
|
|
22
|
+
* offers for the project's parts into parts/offers.json; the page's
|
|
23
|
+
* session token, like every write. The answer says how many came back.
|
|
24
|
+
* - `POST parts/bom`: keep the units to build (`{ units }`) or a line's
|
|
25
|
+
* chosen offer (`{ line, offer }`, `offer: null` for the best one) in
|
|
26
|
+
* parts/bom.json; the page's session token, like every write.
|
|
27
|
+
* - `POST parts/vendor {key, kind, revision}` (the session token): `protoboard
|
|
28
|
+
* parts add|update`, as in a terminal.
|
|
29
|
+
*
|
|
30
|
+
* The CLI runs in a child process for anything that loads the project, as
|
|
31
|
+
* the build does; only the library client runs here.
|
|
32
|
+
*/
|
|
33
|
+
import { spawn } from "node:child_process";
|
|
34
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
35
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { fileURLToPath } from "node:url";
|
|
38
|
+
import { readOrigins } from "@protoboardai/core";
|
|
39
|
+
import { accountTokensFor } from "@protoboardai/core/account";
|
|
40
|
+
import { connectLibrary, explainCloudError, libraryTaxonomy, markShadowed, partPrices, searchLibraries, type LibrariesTaxonomy, type LibraryConnection } from "@protoboardai/core/library-client";
|
|
41
|
+
import { categoryNamer, detailModel, drillDown, itemModel, libraryViewModel, pageImages, parseLibraryQuery, partPages, previewModel, previewTrust, type LibraryQuery, type Usage } from "@protoboardai/views/parts-data";
|
|
42
|
+
import { parseVerificationRecord } from "@protoboardai/core/part-trust";
|
|
43
|
+
|
|
44
|
+
export const PARTS_PREFIX = "/__protoboard/parts/";
|
|
45
|
+
const CLI = fileURLToPath(new URL("../../bin/protoboard.mjs", import.meta.url));
|
|
46
|
+
const PAGE = 30;
|
|
47
|
+
const SHA = /^[0-9a-f]{64}$/;
|
|
48
|
+
|
|
49
|
+
/** `protoboard <args> --format json` in the project, its JSON or its error. */
|
|
50
|
+
export function runCliJson<T>(root: string, args: string[], timeoutMs = 180_000): Promise<T> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const child = spawn(process.execPath, [CLI, ...args, "--format", "json"], { cwd: root, stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
53
|
+
let out = "";
|
|
54
|
+
let err = "";
|
|
55
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
56
|
+
child.stdout.on("data", (d) => (out += d));
|
|
57
|
+
child.stderr.on("data", (d) => (err += d));
|
|
58
|
+
child.on("close", (code) => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
try {
|
|
61
|
+
const json = JSON.parse(out) as T & { error?: string };
|
|
62
|
+
if (code !== 0 && json && typeof json === "object" && "error" in json && json.error) return reject(new Error(json.error));
|
|
63
|
+
resolve(json);
|
|
64
|
+
} catch {
|
|
65
|
+
reject(new Error((err || out).trim().split("\n").pop() || `protoboard ${args[0]} exited ${code}`));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface LibraryInfo {
|
|
72
|
+
id: string;
|
|
73
|
+
url: string;
|
|
74
|
+
authEnv?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PartsRoutes {
|
|
78
|
+
handle(req: IncomingMessage, res: ServerResponse, url: URL, write: { authorised: boolean }): Promise<void>;
|
|
79
|
+
/** The project's files changed (a rebuild): forget what was read from them. */
|
|
80
|
+
changed(): void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function partsRoutes(root: string): PartsRoutes {
|
|
84
|
+
let libs: Promise<LibraryConnection[]> | undefined;
|
|
85
|
+
let taxonomy: { key: string; value: Promise<LibrariesTaxonomy | undefined> } | undefined;
|
|
86
|
+
const cacheDir = join(root, ".protoboard", "cache", "library");
|
|
87
|
+
|
|
88
|
+
const connections = () =>
|
|
89
|
+
(libs ??= (async () => {
|
|
90
|
+
const out = await runCliJson<{ libraries: LibraryInfo[] }>(root, ["libraries"]);
|
|
91
|
+
const signedIn = await accountTokensFor(out.libraries.map((l) => l.url)).catch(() => ({ tokens: new Map<string, string>(), problems: [] }));
|
|
92
|
+
return out.libraries.map((l) => connectLibrary(l, { accountTokens: signedIn.tokens }));
|
|
93
|
+
})().catch((e) => {
|
|
94
|
+
libs = undefined;
|
|
95
|
+
throw e;
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
const usage = (): Map<string, Usage> => {
|
|
99
|
+
const out = new Map<string, Usage>();
|
|
100
|
+
try {
|
|
101
|
+
for (const [id, rec] of Object.entries(readOrigins(root).definitions)) {
|
|
102
|
+
const library = rec.library ?? (rec.provider === "protoboard-cloud" ? "protoboard" : undefined);
|
|
103
|
+
out.set(id, { ...(typeof rec.revision === "number" ? { revision: rec.revision } : {}), ...(library ? { library } : {}), ...(rec.tier ? { tier: rec.tier } : {}), file: rec.file });
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// no origins yet
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const blobUrl = (library: string, b: { sha256: string; size: number; mediaType: string }) => `${PARTS_PREFIX.slice(1)}blob?${new URLSearchParams({ library, sha: b.sha256, size: String(b.size), type: b.mediaType })}`;
|
|
112
|
+
|
|
113
|
+
const json = (res: ServerResponse, status: number, body: unknown) => {
|
|
114
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
115
|
+
res.end(JSON.stringify(body));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
async function library(url: URL) {
|
|
119
|
+
const conns = await connections();
|
|
120
|
+
const p = url.searchParams;
|
|
121
|
+
const q: LibraryQuery = parseLibraryQuery({ text: p.get("text") ?? "", category: p.get("category") ?? "", domain: p.get("domain") ?? "", protocol: p.get("protocol") ?? "", sort: p.get("sort") ?? undefined });
|
|
122
|
+
const key = JSON.stringify([q.text, q.domain, q.protocol]);
|
|
123
|
+
if (taxonomy?.key !== key) taxonomy = { key, value: libraryTaxonomy(conns, { q: q.text || undefined, domain: q.domain || undefined, protocol: q.protocol || undefined }).catch(() => undefined) };
|
|
124
|
+
const cursor = p.get("cursor");
|
|
125
|
+
const [r, tax] = await Promise.all([
|
|
126
|
+
searchLibraries(conns, { q: q.text || undefined, taxonomy: q.category || undefined, domain: q.domain || undefined, protocol: q.protocol || undefined, sort: q.sort, limit: PAGE, facets: !cursor, ...(cursor ? { cursor } : {}) }),
|
|
127
|
+
taxonomy.value,
|
|
128
|
+
]);
|
|
129
|
+
const used = usage();
|
|
130
|
+
const hits = markShadowed(r.parts, conns.map((c) => c.id));
|
|
131
|
+
const thumbs = Object.fromEntries(hits.filter((h) => h.thumbnail).map((h) => [h.thumbnail!.sha256, blobUrl(h.library, h.thumbnail!)]));
|
|
132
|
+
const model = libraryViewModel({
|
|
133
|
+
query: q,
|
|
134
|
+
...(tax ? { drill: drillDown(tax.nodes, q.category, { facets: r.facets?.taxonomy, total: tax.total }), top: tax.nodes } : {}),
|
|
135
|
+
items: hits.map((h) => itemModel(h, used)),
|
|
136
|
+
total: r.total,
|
|
137
|
+
// the taxonomy counts each part once; it is the whole catalog while no text, domain or protocol narrows it
|
|
138
|
+
...(tax && !q.text && !q.domain && !q.protocol ? { catalogTotal: tax.total } : {}),
|
|
139
|
+
more: !!r.nextCursor,
|
|
140
|
+
sources: r.libraries.map((l) => ({ id: l.id, ...(l.total !== undefined ? { total: l.total } : {}), ...(l.error ? { error: l.error } : {}) })),
|
|
141
|
+
thumbs,
|
|
142
|
+
loading: false,
|
|
143
|
+
...(tax ? { categoryName: categoryNamer(tax.nodes) } : {}),
|
|
144
|
+
sorts: r.sorts,
|
|
145
|
+
});
|
|
146
|
+
const { loading: _l, loadingMore: _m, ...rest } = model;
|
|
147
|
+
return { model: rest, ...(r.nextCursor ? { cursor: r.nextCursor } : {}) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The library and part id of a `<library>/<id>` key. */
|
|
151
|
+
async function keyed(url: URL) {
|
|
152
|
+
const key = url.searchParams.get("key") ?? "";
|
|
153
|
+
const [libraryId, ...rest] = key.split("/");
|
|
154
|
+
const partId = rest.join("/");
|
|
155
|
+
const conn = (await connections()).find((c) => c.id === libraryId);
|
|
156
|
+
if (!conn || !partId) throw new Error(`no part ${key} in the project's libraries`);
|
|
157
|
+
return { conn, partId };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A revision's pictures as this server's URLs (verified and cached by `parts/blob`). */
|
|
161
|
+
const pictureUrls = (library: string, env: Parameters<typeof pageImages>[0]) => Object.fromEntries(pageImages(env).map((i) => [i.sha256, blobUrl(library, i)]));
|
|
162
|
+
|
|
163
|
+
/** A part's pages: its revision's specifications, dimensions, interfaces, CAD and artifacts (a revision never changes, so the client keeps them). */
|
|
164
|
+
async function pages(url: URL) {
|
|
165
|
+
const { conn, partId } = await keyed(url);
|
|
166
|
+
try {
|
|
167
|
+
const asked = Number(url.searchParams.get("revision"));
|
|
168
|
+
const revision = Number.isInteger(asked) && asked > 0 ? asked : await conn.client.getPart(partId).then((r) => r.part.recommendedRevision ?? r.part.latestRevision);
|
|
169
|
+
const envelope = await conn.client.getRevision(partId, revision);
|
|
170
|
+
return partPages(envelope, { urls: pictureUrls(conn.id, envelope) });
|
|
171
|
+
} catch (e) {
|
|
172
|
+
throw explainCloudError(e, conn, partId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The part's prices by source: what its library serves, nothing else. A refusal comes back in `unavailable`, with what to do. */
|
|
177
|
+
async function prices(url: URL) {
|
|
178
|
+
const { conn, partId } = await keyed(url);
|
|
179
|
+
return partPrices(conn, partId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function part(url: URL) {
|
|
183
|
+
const { conn, partId } = await keyed(url);
|
|
184
|
+
try {
|
|
185
|
+
const { part, revisions } = await conn.client.getPart(partId);
|
|
186
|
+
const asked = Number(url.searchParams.get("revision"));
|
|
187
|
+
const revision = Number.isInteger(asked) && asked > 0 ? asked : (part.recommendedRevision ?? part.latestRevision);
|
|
188
|
+
const envelope = await conn.client.getRevision(partId, revision);
|
|
189
|
+
const tax = await taxonomy?.value;
|
|
190
|
+
const preview = previewModel({ part, revisions, envelope, library: { id: conn.id, url: conn.url }, usage: usage().get(partId), ...(tax ? { categoryName: categoryNamer(tax.nodes) } : {}) });
|
|
191
|
+
// the verification record among the evidence says the part's status (PB-932); one that cannot be read reads as unverified
|
|
192
|
+
const record = preview.verification
|
|
193
|
+
? await conn.client.downloadBlob(preview.verification.sha256, { size: preview.verification.size }).then(
|
|
194
|
+
(bytes) => parseVerificationRecord(Buffer.from(bytes).toString("utf8")),
|
|
195
|
+
() => null,
|
|
196
|
+
)
|
|
197
|
+
: undefined;
|
|
198
|
+
return detailModel(preview, {
|
|
199
|
+
pages: partPages(envelope, { urls: pictureUrls(conn.id, envelope) }),
|
|
200
|
+
hasPrices: true,
|
|
201
|
+
...previewTrust(preview, record),
|
|
202
|
+
...(part.thumbnail ? { thumb: blobUrl(conn.id, part.thumbnail) } : {}),
|
|
203
|
+
...(preview.glb ? { model: blobUrl(conn.id, { sha256: preview.glb.sha256, size: preview.glb.size, mediaType: "model/gltf-binary" }) } : {}),
|
|
204
|
+
});
|
|
205
|
+
} catch (e) {
|
|
206
|
+
throw explainCloudError(e, conn, partId);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Library parts' thumbnails by `library/id` (null: the library has none), kept until the project changes. */
|
|
211
|
+
const pictures = new Map<string, Promise<string | null>>();
|
|
212
|
+
|
|
213
|
+
/** `parts list`, each library part with its picture from its library, as the Project Parts page shows it. */
|
|
214
|
+
async function project(check: boolean) {
|
|
215
|
+
const report = await runCliJson<{ parts: { id: string; tier: string; library?: string; thumbnail?: string }[] }>(root, ["parts", "list", ...(check ? ["--check"] : [])]);
|
|
216
|
+
const conns = await connections().catch(() => [] as LibraryConnection[]);
|
|
217
|
+
await Promise.all(
|
|
218
|
+
report.parts.map(async (p) => {
|
|
219
|
+
const conn = p.tier === "catalog" && p.library ? conns.find((c) => c.id === p.library) : undefined;
|
|
220
|
+
if (!conn) return;
|
|
221
|
+
const key = `${conn.id}/${p.id}`;
|
|
222
|
+
if (!pictures.has(key)) {
|
|
223
|
+
pictures.set(
|
|
224
|
+
key,
|
|
225
|
+
conn.client.getPart(p.id).then(
|
|
226
|
+
(r) => (r.part.thumbnail ? blobUrl(conn.id, r.part.thumbnail) : null),
|
|
227
|
+
() => {
|
|
228
|
+
pictures.delete(key); // the library did not answer: ask again next time
|
|
229
|
+
return null;
|
|
230
|
+
},
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const url = await pictures.get(key);
|
|
235
|
+
if (url) p.thumbnail = url;
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
return report;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function blob(url: URL, res: ServerResponse) {
|
|
242
|
+
const p = url.searchParams;
|
|
243
|
+
const sha = p.get("sha") ?? "";
|
|
244
|
+
const type = p.get("type") ?? "application/octet-stream";
|
|
245
|
+
if (!SHA.test(sha) || !/^(image\/(png|jpeg|webp|svg\+xml)|model\/gltf-binary)$/.test(type)) return json(res, 400, { error: "not a part file" });
|
|
246
|
+
const file = join(cacheDir, sha);
|
|
247
|
+
if (!existsSync(file)) {
|
|
248
|
+
const conn = (await connections()).find((c) => c.id === p.get("library"));
|
|
249
|
+
if (!conn) return json(res, 404, { error: `no library ${p.get("library")}` });
|
|
250
|
+
const size = Number(p.get("size"));
|
|
251
|
+
const bytes = await conn.client.downloadBlob(sha, Number.isInteger(size) && size > 0 ? { size } : {});
|
|
252
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
253
|
+
const tmp = `${file}.${process.pid}.part`;
|
|
254
|
+
writeFileSync(tmp, bytes);
|
|
255
|
+
renameSync(tmp, file);
|
|
256
|
+
}
|
|
257
|
+
// an SVG from a library is shown as an image only, never as a page
|
|
258
|
+
res.writeHead(200, { "content-type": type, "cache-control": "max-age=31536000, immutable", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'", "x-content-type-options": "nosniff" });
|
|
259
|
+
res.end(readFileSync(file));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function body(req: IncomingMessage): Promise<unknown> {
|
|
263
|
+
let text = "";
|
|
264
|
+
for await (const chunk of req) {
|
|
265
|
+
text += chunk;
|
|
266
|
+
if (text.length > 64 << 10) throw new Error("request too large");
|
|
267
|
+
}
|
|
268
|
+
return JSON.parse(text);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function vendor(req: IncomingMessage) {
|
|
272
|
+
const m = (await body(req)) as { key?: unknown; kind?: unknown; revision?: unknown };
|
|
273
|
+
const key = typeof m.key === "string" ? m.key : "";
|
|
274
|
+
const [library, ...rest] = key.split("/");
|
|
275
|
+
const partId = rest.join("/");
|
|
276
|
+
const revision = Number(m.revision);
|
|
277
|
+
if (!library || !/^[a-z0-9][a-z0-9._-]*$/i.test(partId) || !Number.isInteger(revision) || revision < 1) throw new Error("give a part key and a revision");
|
|
278
|
+
const args = m.kind === "add" ? ["parts", "add", `${partId}@${revision}`, "--library", library] : ["parts", "update", partId, "--to", String(revision), "--library", library];
|
|
279
|
+
const out = await runCliJson<{ results: { id: string; status: string; revision?: number; message?: string }[] }>(root, args);
|
|
280
|
+
taxonomy = undefined;
|
|
281
|
+
const self = out.results.find((x) => x.id === partId);
|
|
282
|
+
const conflict = out.results.find((x) => x.status === "conflict");
|
|
283
|
+
if (conflict) throw new Error(`${conflict.id}: ${conflict.message ?? "conflict"}`);
|
|
284
|
+
return { message: `${partId}@${self?.revision ?? revision} ${self?.status ?? "added"} from ${library}` };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Keep the units to build, or a line's chosen offer, in the project (parts/bom.json), through the CLI. */
|
|
288
|
+
async function saveBom(req: IncomingMessage) {
|
|
289
|
+
const m = (await body(req)) as { units?: unknown; line?: unknown; offer?: unknown };
|
|
290
|
+
if (typeof m.units === "number") {
|
|
291
|
+
if (!Number.isInteger(m.units) || m.units < 1 || m.units > 1_000_000) throw new Error("units is a whole number from 1 to 1,000,000");
|
|
292
|
+
return runCliJson(root, ["bom", "units", String(m.units)]);
|
|
293
|
+
}
|
|
294
|
+
// a line key is a part id, with an ordering code after "#"; an offer id never starts with a dash
|
|
295
|
+
if (typeof m.line !== "string" || !m.line || m.line.startsWith("-") || m.line.length > 300) throw new Error("give units, or a line and an offer");
|
|
296
|
+
if (m.offer !== null && (typeof m.offer !== "string" || !m.offer || m.offer.startsWith("-") || m.offer === "auto" || m.offer.length > 300)) throw new Error("give the offer's id, or null for the best offer");
|
|
297
|
+
return runCliJson(root, ["bom", "choose", m.line, m.offer ?? "auto"]);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Ask the library for its current offers for the project's parts (parts/offers.json), through the CLI. Says what came back, not how. */
|
|
301
|
+
async function refreshOffers() {
|
|
302
|
+
const r = await runCliJson<{ id: string; status: string; offers?: number }[]>(root, ["parts", "offers"]);
|
|
303
|
+
const got = r.filter((x) => x.status === "vendored");
|
|
304
|
+
const offers = got.reduce((n, x) => n + (x.offers ?? 0), 0);
|
|
305
|
+
return { message: got.length ? `The library gave ${offers} offer${offers === 1 ? "" : "s"} for ${got.length} part${got.length === 1 ? "" : "s"}.` : "The library holds no offers for this project's parts." };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
changed() {
|
|
310
|
+
libs = undefined;
|
|
311
|
+
taxonomy = undefined;
|
|
312
|
+
pictures.clear();
|
|
313
|
+
},
|
|
314
|
+
async handle(req, res, url, write) {
|
|
315
|
+
const route = url.pathname.slice(PARTS_PREFIX.length);
|
|
316
|
+
try {
|
|
317
|
+
if (route === "vendor") {
|
|
318
|
+
if (req.method !== "POST") return json(res, 405, { error: "POST only" });
|
|
319
|
+
if (!write.authorised) return json(res, 403, { error: "not this page's session" });
|
|
320
|
+
return json(res, 200, await vendor(req));
|
|
321
|
+
}
|
|
322
|
+
if (route === "bom" && req.method === "POST") {
|
|
323
|
+
if (!write.authorised) return json(res, 403, { error: "not this page's session" });
|
|
324
|
+
return json(res, 200, await saveBom(req));
|
|
325
|
+
}
|
|
326
|
+
if (route === "offers") {
|
|
327
|
+
if (req.method !== "POST") return json(res, 405, { error: "POST only" });
|
|
328
|
+
if (!write.authorised) return json(res, 403, { error: "not this page's session" });
|
|
329
|
+
return json(res, 200, await refreshOffers());
|
|
330
|
+
}
|
|
331
|
+
if (req.method !== "GET" && req.method !== "HEAD") return json(res, 405, { error: "GET only" });
|
|
332
|
+
switch (route) {
|
|
333
|
+
case "library":
|
|
334
|
+
return json(res, 200, await library(url));
|
|
335
|
+
case "part":
|
|
336
|
+
return json(res, 200, await part(url));
|
|
337
|
+
case "pages":
|
|
338
|
+
return json(res, 200, await pages(url));
|
|
339
|
+
case "prices":
|
|
340
|
+
return json(res, 200, await prices(url));
|
|
341
|
+
case "blob":
|
|
342
|
+
return await blob(url, res);
|
|
343
|
+
case "project":
|
|
344
|
+
return json(res, 200, await project(!!url.searchParams.get("check")));
|
|
345
|
+
case "bom":
|
|
346
|
+
return json(res, 200, await runCliJson(root, ["bom"]));
|
|
347
|
+
default:
|
|
348
|
+
return json(res, 404, { error: `no parts route ${route}` });
|
|
349
|
+
}
|
|
350
|
+
} catch (e) {
|
|
351
|
+
if (!res.headersSent) json(res, 502, { error: (e as Error).message });
|
|
352
|
+
else res.end();
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|