@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/src/setup.ts ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * `protoboard setup` (first builds review, D4 and item 8; PB-1047): the npm
3
+ * package sets up the rest of the machine. It finds the command-line launchers
4
+ * of VS Code, Cursor, Windsurf and VSCodium, installs the Protoboard extension
5
+ * into each with the user's yes (`<editor> --install-extension
6
+ * deltarobotics.vscode-protoboard --force`: VS Code installs it from the
7
+ * Marketplace, the others from Open VSX), offers to put the `protoboard`
8
+ * command on PATH, and then offers the agents' set-up (`agents setup`). None
9
+ * of it needs a sign-in.
10
+ *
11
+ * `--vsix <file>` installs that package instead, for a machine offline. An
12
+ * editor whose gallery does not have the extension (not published yet) is said
13
+ * in one line, and the PATH and the agents are still set up.
14
+ *
15
+ * It is a command a person (or their agent, with their yes) runs. It is never
16
+ * run from an npm install script: an install must not change an editor.
17
+ *
18
+ * The platform, the environment, the file system, the network and the
19
+ * programs it starts are arguments, so Windows and macOS are tested from Linux.
20
+ */
21
+ import { spawnSync } from "node:child_process";
22
+ import { existsSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { basename, join, resolve } from "node:path";
25
+ import { applyPathPlan, describePathPlan, lastingPath, onPathValue, pathPlan, stackHome, type PathPlan } from "@protoboardai/core";
26
+
27
+ export type EditorId = "code" | "cursor" | "windsurf" | "codium";
28
+
29
+ export interface Editor {
30
+ id: EditorId;
31
+ name: string;
32
+ /** Its command-line launcher, as a full path. */
33
+ launcher: string;
34
+ /** `path`: found on PATH. `known`: found where its installer puts it. */
35
+ from: "path" | "known";
36
+ }
37
+
38
+ export const EDITORS: readonly { id: EditorId; name: string }[] = [
39
+ { id: "code", name: "VS Code" },
40
+ { id: "cursor", name: "Cursor" },
41
+ { id: "windsurf", name: "Windsurf" },
42
+ { id: "codium", name: "VSCodium" },
43
+ ];
44
+
45
+ export interface Look {
46
+ platform?: NodeJS.Platform;
47
+ env?: NodeJS.ProcessEnv;
48
+ home?: string;
49
+ exists?: (file: string) => boolean;
50
+ }
51
+
52
+ /** Where each editor's installer puts its launcher, when it is not on PATH. */
53
+ function knownLaunchers(id: EditorId, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, home: string): string[] {
54
+ if (platform === "darwin") {
55
+ const app = { code: "Visual Studio Code.app", cursor: "Cursor.app", windsurf: "Windsurf.app", codium: "VSCodium.app" }[id];
56
+ return [`/Applications/${app}/Contents/Resources/app/bin/${id}`, `${home}/Applications/${app}/Contents/Resources/app/bin/${id}`];
57
+ }
58
+ if (platform === "win32") {
59
+ const local = env.LOCALAPPDATA ?? `${home}\\AppData\\Local`;
60
+ const files = env.ProgramFiles ?? "C:\\Program Files";
61
+ return {
62
+ code: [`${local}\\Programs\\Microsoft VS Code\\bin\\code.cmd`, `${files}\\Microsoft VS Code\\bin\\code.cmd`],
63
+ cursor: [`${local}\\Programs\\cursor\\resources\\app\\bin\\cursor.cmd`, `${files}\\cursor\\resources\\app\\bin\\cursor.cmd`],
64
+ windsurf: [`${local}\\Programs\\Windsurf\\bin\\windsurf.cmd`, `${files}\\Windsurf\\bin\\windsurf.cmd`],
65
+ codium: [`${local}\\Programs\\VSCodium\\bin\\codium.cmd`, `${files}\\VSCodium\\bin\\codium.cmd`],
66
+ }[id];
67
+ }
68
+ return {
69
+ code: ["/usr/bin/code", "/snap/bin/code", "/usr/share/code/bin/code", "/var/lib/flatpak/exports/bin/com.visualstudio.code"],
70
+ cursor: ["/usr/bin/cursor", `${home}/.local/bin/cursor`, "/opt/cursor/bin/cursor"],
71
+ windsurf: ["/usr/bin/windsurf", `${home}/.local/bin/windsurf`, "/opt/windsurf/bin/windsurf"],
72
+ codium: ["/usr/bin/codium", "/snap/bin/codium", "/usr/share/codium/bin/codium", "/var/lib/flatpak/exports/bin/com.vscodium.codium"],
73
+ }[id];
74
+ }
75
+
76
+ /** The editors whose command-line launcher is on this machine: on PATH first, else where the installer puts it. */
77
+ export function findEditors(look: Look = {}): Editor[] {
78
+ const platform = look.platform ?? process.platform;
79
+ const env = look.env ?? process.env;
80
+ const home = look.home ?? homedir();
81
+ const exists = look.exists ?? existsSync;
82
+ const win = platform === "win32";
83
+ const dirs = (env.PATH ?? env.Path ?? "")
84
+ .split(win ? ";" : ":")
85
+ .filter(Boolean)
86
+ .map((d) => d.replace(win ? /[\\/]+$/ : /\/+$/, ""));
87
+ const found: Editor[] = [];
88
+ for (const e of EDITORS) {
89
+ const names = win ? [`${e.id}.cmd`, `${e.id}.exe`] : [e.id];
90
+ let launcher: string | undefined;
91
+ for (const d of dirs) {
92
+ launcher = names.map((n) => (win ? `${d}\\${n}` : `${d}/${n}`)).find(exists);
93
+ if (launcher) break;
94
+ }
95
+ if (launcher) found.push({ ...e, launcher, from: "path" });
96
+ else {
97
+ const known = knownLaunchers(e.id, platform, env, home).find(exists);
98
+ if (known) found.push({ ...e, launcher: known, from: "known" });
99
+ }
100
+ }
101
+ return found;
102
+ }
103
+
104
+ export interface Command {
105
+ command: string;
106
+ args: string[];
107
+ /** Windows: the arguments are the command line as written (no quoting added). */
108
+ verbatim?: boolean;
109
+ }
110
+
111
+ /**
112
+ * `<editor> --install-extension <id | file> --force`, started without a shell
113
+ * where the launcher is a program or a script. On Windows the launcher is a
114
+ * `.cmd`, which only cmd runs: the line is given to cmd whole, each argument in
115
+ * double quotes (inside them cmd reads `&` and spaces as text).
116
+ */
117
+ export function installCommand(editor: Pick<Editor, "launcher">, extension: string, platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env): Command {
118
+ const args = ["--install-extension", extension, "--force"];
119
+ if (platform === "win32" && /\.(cmd|bat)$/i.test(editor.launcher)) {
120
+ for (const p of [editor.launcher, extension]) if (/["%]/.test(p)) throw new Error(`${p} has a " or a % in its path, which cmd would read as its own: move the file to a folder without one`);
121
+ return { command: env.ComSpec ?? `${env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`, args: ["/d", "/s", "/c", `""${editor.launcher}" --install-extension "${extension}" --force"`], verbatim: true };
122
+ }
123
+ return { command: editor.launcher, args };
124
+ }
125
+
126
+ export type Run = (c: Command) => { status: number | null; stdout: string; stderr: string; error?: Error };
127
+
128
+ const defaultRun: Run = (c) => {
129
+ // an editor's launcher started from VS Code's own runtime must not inherit "run as Node"
130
+ const { ELECTRON_RUN_AS_NODE: _e, ...env } = process.env;
131
+ const r = spawnSync(c.command, c.args, { encoding: "utf8", env, windowsHide: true, timeout: 5 * 60_000, ...(c.verbatim ? { windowsVerbatimArguments: true } : {}) });
132
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", ...(r.error ? { error: r.error } : {}) };
133
+ };
134
+
135
+ // ---------------------------------------------------------------- the extension
136
+
137
+ /** The extension's id on the Visual Studio Marketplace and on Open VSX (`<publisher>.<name>` of its manifest). */
138
+ export const EXTENSION_ID = "deltarobotics.vscode-protoboard";
139
+
140
+ /**
141
+ * What is installed: the extension by its id, from the editor's own gallery
142
+ * (VS Code's is the Marketplace; Cursor's, Windsurf's and VSCodium's is Open
143
+ * VSX), or a `.vsix` file given with `--vsix` (offline).
144
+ */
145
+ export type ExtensionSource = { from: "gallery"; id: string } | { from: "file"; file: string };
146
+
147
+ /** An editor's answer that its gallery has no extension of that id: not published there (yet). */
148
+ export const notPublished = (output: string) => /\bnot found\b|\b404\b/i.test(output);
149
+
150
+ // ---------------------------------------------------------------- the command
151
+
152
+ export interface SetupFlags {
153
+ vsix?: string;
154
+ yes?: boolean;
155
+ editor?: string[];
156
+ /** false: leave the agents alone (`--no-agents`). */
157
+ agents?: boolean;
158
+ }
159
+
160
+ export interface SetupIo {
161
+ json: boolean;
162
+ cwd: string;
163
+ out(data: unknown, text: () => string): void;
164
+ log(line: string): void;
165
+ /** Ask a yes/no question at the terminal; undefined when there is no terminal to ask at. */
166
+ confirm?: (question: string) => Promise<boolean>;
167
+ /** The agents' set-up, offered last (`protoboard agents setup`). */
168
+ agents?: () => Promise<void>;
169
+ }
170
+
171
+ export interface SetupDeps extends Look {
172
+ run?: Run;
173
+ /** The user's Protoboard folder (its `bin` holds the command). */
174
+ stackHome?: string;
175
+ /** Appends to a shell start file (PATH set-up). */
176
+ append?: (file: string, text: string) => void;
177
+ read?: (file: string) => string;
178
+ }
179
+
180
+ export interface EditorResult {
181
+ id: EditorId;
182
+ name: string;
183
+ launcher: string;
184
+ /** `unpublished`: the editor's gallery has no extension of that id yet (not a failure of the set-up). */
185
+ action: "installed" | "declined" | "failed" | "unpublished";
186
+ command: string;
187
+ message?: string;
188
+ }
189
+
190
+ export interface SetupResult {
191
+ editors: EditorResult[];
192
+ notFound: string[];
193
+ /** What was installed into the editors: the id from their galleries, or a `--vsix` file. */
194
+ extension?: ExtensionSource;
195
+ path?: { dir: string; action: "added" | "already" | "declined" | "failed" | "not-needed"; message: string };
196
+ agents: "offered" | "skipped" | "not-run";
197
+ }
198
+
199
+ const show = (c: Command) => (c.verbatim ? `${c.command} ${c.args.join(" ")}` : [c.command, ...c.args].map((a) => (/^[\w@%+=:,./\\-]+$/.test(a) ? a : JSON.stringify(a))).join(" "));
200
+
201
+ export async function setupCommand(flags: SetupFlags, io: SetupIo, deps: SetupDeps = {}): Promise<SetupResult> {
202
+ const env = deps.env ?? process.env;
203
+ const platform = deps.platform ?? process.platform;
204
+ // an install must not change an editor or a shell's start file: only a person's own command does
205
+ const lifecycle = env.npm_lifecycle_event;
206
+ if (lifecycle) throw new Error(`protoboard setup does not run from an npm script of an install (${lifecycle}): it changes editors and asks before each change. Run \`protoboard setup\` yourself, in a terminal.`);
207
+ if (!flags.yes && !io.confirm) throw new Error("there is no terminal to ask at, and protoboard setup changes nothing without a yes: run it in a terminal, or with --yes (installs the extension into every editor found; --editor <id> names one)");
208
+ const run = deps.run ?? defaultRun;
209
+ const wanted = flags.editor ?? [];
210
+ for (const id of wanted) if (!EDITORS.some((e) => e.id === id)) throw new Error(`no editor "${id}"; known: ${EDITORS.map((e) => e.id).join(", ")}`);
211
+ const all = findEditors(deps);
212
+ const editors = wanted.length ? all.filter((e) => wanted.includes(e.id)) : all;
213
+ const notFound = EDITORS.filter((e) => (wanted.length ? wanted.includes(e.id) : true) && !all.some((a) => a.id === e.id)).map((e) => e.name);
214
+ const result: SetupResult = { editors: [], notFound, agents: "not-run" };
215
+
216
+ // ---- the extension, into each editor found
217
+ if (editors.length) {
218
+ let source: ExtensionSource;
219
+ if (flags.vsix) {
220
+ const file = resolve(io.cwd, flags.vsix);
221
+ if (!(deps.exists ?? existsSync)(file)) throw new Error(`--vsix: there is no file ${file}`);
222
+ if (!/\.vsix$/i.test(file)) throw new Error(`--vsix: ${file} is not a .vsix file`);
223
+ source = { from: "file", file };
224
+ } else source = { from: "gallery", id: EXTENSION_ID };
225
+ result.extension = source;
226
+ const what = source.from === "file" ? source.file : source.id;
227
+ for (const e of editors) {
228
+ const c = installCommand(e, what, platform, env);
229
+ const shown = show(c);
230
+ if (!flags.yes && !(await io.confirm!(`Install the Protoboard extension into ${e.name}? This runs: ${e.launcher} --install-extension ${what} --force`))) {
231
+ result.editors.push({ id: e.id, name: e.name, launcher: e.launcher, action: "declined", command: shown });
232
+ continue;
233
+ }
234
+ io.log(`${e.name}: ${e.launcher} --install-extension ${source.from === "file" ? basename(source.file) : source.id} --force`);
235
+ const r = run(c);
236
+ const ok = !r.error && r.status === 0;
237
+ const said = `${r.stderr}\n${r.stdout}`;
238
+ // the gallery has no such extension: said once below, and the set-up goes on (PATH, agents)
239
+ const action = ok ? "installed" : !r.error && source.from === "gallery" && notPublished(said) ? "unpublished" : "failed";
240
+ result.editors.push({ id: e.id, name: e.name, launcher: e.launcher, action, command: shown, ...(action === "failed" ? { message: (r.error?.message ?? (r.stderr.trim() || r.stdout.trim()).split("\n").slice(-3).join(" ")) || `exit ${r.status}` } : {}) });
241
+ }
242
+ }
243
+
244
+ // ---- the command for terminals outside the editor: only when the extension put one in ~/.protoboard/bin and no `protoboard` is on PATH
245
+ const bin = join(deps.stackHome ?? stackHome(env), "bin");
246
+ const exists = deps.exists ?? existsSync;
247
+ const win = platform === "win32";
248
+ // what a terminal outside the editor has: in an editor's terminal the extension's own entry for `bin` lasts only there (PB-1037)
249
+ const lasting = lastingPath(bin, env, platform);
250
+ const hasCommand = (lasting ?? "")
251
+ .split(win ? ";" : ":")
252
+ .filter(Boolean)
253
+ .some((d) => (win ? ["protoboard.cmd", "protoboard.exe"] : ["protoboard"]).some((n) => exists(win ? `${d.replace(/[\\/]+$/, "")}\\${n}` : `${d.replace(/\/+$/, "")}/${n}`)));
254
+ if (!exists(join(bin, win ? "protoboard.cmd" : "protoboard"))) {
255
+ result.path = { dir: bin, action: "not-needed", message: hasCommand ? "The protoboard command is on PATH." : `The Protoboard extension writes the protoboard command into ${bin} the first time it starts; run \`protoboard setup\` again then to put it on PATH.` };
256
+ } else if (hasCommand || onPathValue(bin, lasting, platform)) {
257
+ result.path = { dir: bin, action: "already", message: "The protoboard command is on PATH." };
258
+ } else {
259
+ const plan: PathPlan = pathPlan(bin, { platform, env, ...(deps.home ? { home: deps.home } : {}), ...(deps.exists ? { exists: deps.exists } : {}), ...(deps.read ? { read: deps.read } : {}) });
260
+ if (plan.kind === "already") result.path = { dir: bin, action: "already", message: describePathPlan(plan) };
261
+ else if (!flags.yes && !(await io.confirm!(`Put the protoboard command on your PATH, for terminals outside the editor? ${describePathPlan(plan)}`))) result.path = { dir: bin, action: "declined", message: `Left as it is. The command is ${join(bin, win ? "protoboard.cmd" : "protoboard")}; the editor's own terminals have it.` };
262
+ else {
263
+ try {
264
+ const applied = applyPathPlan(plan, { ...(deps.append ? { append: deps.append } : {}), ...(deps.run ? { run: (command: string, args: string[]) => deps.run!({ command, args }) } : {}) });
265
+ result.path = { dir: bin, action: applied.changed ? "added" : "already", message: applied.message };
266
+ } catch (e) {
267
+ result.path = { dir: bin, action: "failed", message: (e as Error).message };
268
+ }
269
+ }
270
+ }
271
+
272
+ // ---- report, then the agents (their own questions, their own report)
273
+ const agentsNext = flags.agents === false ? "skipped" : io.agents && !io.json && io.confirm ? "offered" : "not-run";
274
+ result.agents = agentsNext;
275
+ const mark = { installed: "✓", declined: "·", failed: "✗", unpublished: "·" } as const;
276
+ const unpublished = result.editors.filter((e) => e.action === "unpublished").map((e) => e.name);
277
+ const gallery = (id: EditorId) => (id === "code" ? "the Marketplace" : "Open VSX");
278
+ io.out(result, () =>
279
+ [
280
+ ...(result.editors.length || result.notFound.length ? ["The Protoboard extension"] : []),
281
+ ...result.editors.map((e) => ` ${mark[e.action]} ${e.name}: ${e.action === "installed" ? `installed (${e.launcher})` : e.action === "declined" ? "left alone" : e.action === "unpublished" ? "not installed" : `not installed: ${e.message}`}${e.action === "installed" ? "\n Reload its windows (or restart it) to start the extension." : ""}`),
282
+ ...(unpublished.length ? [` ${EXTENSION_ID} is not published yet on ${[...new Set(result.editors.filter((e) => e.action === "unpublished").map((e) => gallery(e.id)))].join(" or ")}: install a .vsix with \`protoboard setup --vsix <file>\`.`] : []),
283
+ ...(result.notFound.length ? [` Not found here: ${result.notFound.join(", ")}${editors.length ? "" : `. In ${result.notFound[0]}, install it from the Extensions view: search for Protoboard (${EXTENSION_ID})${platform === "darwin" ? '; its command comes with "Shell Command: Install \'code\' command in PATH" from the Command Palette' : ""}.`}`] : []),
284
+ ...(result.extension?.from === "file" ? [` from ${result.extension.file}`] : result.extension && result.editors.some((e) => e.action === "installed") ? [` ${EXTENSION_ID}, from ${[...new Set(result.editors.filter((e) => e.action === "installed").map((e) => gallery(e.id)))].join(" and ")}`] : []),
285
+ ...(result.path && result.path.action !== "not-needed" ? ["", "The protoboard command", ` ${result.path.action === "failed" ? "✗" : result.path.action === "declined" ? "·" : "✓"} ${result.path.message}`] : result.path ? ["", result.path.message] : []),
286
+ ...(agentsNext === "not-run" ? ["", "Agents: `protoboard agents setup` connects your agents to Protoboard (it asks about each)."] : []),
287
+ ].join("\n"),
288
+ );
289
+ if (agentsNext === "offered") {
290
+ io.log("");
291
+ io.log("Agents");
292
+ await io.agents!();
293
+ }
294
+ return result;
295
+ }
296
+
297
+ /** The exit code of a set-up: 1 when an install that was agreed to failed. */
298
+ export const setupExitCode = (r: SetupResult) => (r.editors.some((e) => e.action === "failed") || r.path?.action === "failed" ? 1 : 0);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * What `protoboard stack link` says beyond the link summary (F04).
3
+ */
4
+ import { readdirSync, readFileSync, type Dirent } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ /**
8
+ * What `stack link` says of the packages package.json names that no stack copy
9
+ * provides (F04): which the project's own files import, and which nothing
10
+ * imports (leftovers, such as a tool's dependencies from before the project
11
+ * was a Protoboard one). Never "npm install": AGENTS.md tells agents not to
12
+ * run npm in a stack-linked project.
13
+ */
14
+ export function notProvidedLines(root: string, names: string[]): string[] {
15
+ if (!names.length) return [];
16
+ // the TypeScript pin (catalog parts' sources are checked against it) and type packages are kept on purpose
17
+ const tooling = names.filter((n) => n === "typescript" || n.startsWith("@types/"));
18
+ const rest = names.filter((n) => !tooling.includes(n));
19
+ const used = importedPackages(root, rest);
20
+ const unused = rest.filter((n) => !used.has(n));
21
+ return [
22
+ ...(tooling.length ? [`kept in package.json for type checking, not linked: ${tooling.join(", ")}`] : []),
23
+ ...(used.size ? [`not provided by the Protoboard stack, and imported by the project's files: ${[...used].sort().join(", ")}. Ask the person how the project should get them; do not run npm in a stack-linked project`] : []),
24
+ ...(unused.length ? [`not provided by the Protoboard stack, and imported by none of the project's files: ${unused.join(", ")}. Probably leftovers: remove them from package.json`] : []),
25
+ ];
26
+ }
27
+
28
+ /** Which of `names` a source file of the project imports or requires (node_modules, .protoboard, operations and release left out). */
29
+ export function importedPackages(root: string, names: string[]): Set<string> {
30
+ const found = new Set<string>();
31
+ const skip = new Set(["node_modules", ".protoboard", "operations", "release", ".git"]);
32
+ const walk = (dir: string) => {
33
+ let entries: Dirent[];
34
+ try {
35
+ entries = readdirSync(dir, { withFileTypes: true });
36
+ } catch {
37
+ return;
38
+ }
39
+ for (const e of entries) {
40
+ if (found.size === names.length) return;
41
+ const p = join(dir, e.name);
42
+ if (e.isDirectory()) {
43
+ if (!skip.has(e.name) && !e.name.startsWith(".")) walk(p);
44
+ } else if (/\.(c|m)?(t|j)sx?$/.test(e.name)) {
45
+ const text = readFileSync(p, "utf8");
46
+ for (const n of names) if (!found.has(n) && new RegExp(`(from|import|require)\\s*\\(?\\s*["'\`]${n.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}(/[^"'\`]*)?["'\`]`).test(text)) found.add(n);
47
+ }
48
+ }
49
+ };
50
+ walk(root);
51
+ return found;
52
+ }
53
+
@@ -0,0 +1,267 @@
1
+ /**
2
+ * `protoboard views check [--module <path or id>] [--out <dir>] [--no-render]`
3
+ * (PB-1030): which tabs each level of the project gets in the viewer, and
4
+ * whether each one draws. The tabs come from the same rules the shells use
5
+ * (@protoboardai/views/tabs); each is then opened once in the web viewer
6
+ * `protoboard dev` serves, in a headless browser, as a person would open it:
7
+ * the level by the URL, the tab by its button. A tab fails when the page
8
+ * throws or logs an error while it is shown, or when nothing is drawn
9
+ * (an empty state, or a blank view). `--out` keeps a PNG of every tab, so an
10
+ * agent can look at the board without the editor (F13).
11
+ *
12
+ * The levels are the root and every module with modules inside it (the
13
+ * levels a person drills into: a board, an enclosure, an assembly);
14
+ * `--module` picks one (an instance path, or a module id: its first
15
+ * instance), parts included.
16
+ */
17
+ import { mkdirSync, writeFileSync } from "node:fs";
18
+ import { join, resolve } from "node:path";
19
+ import type { ModuleDef } from "@deltarobotics/uhd";
20
+ import { findProjectRoot, loadProject, resolveViews, uhdBundle } from "@protoboardai/core";
21
+ import { tabsFor } from "@protoboardai/views/tabs";
22
+
23
+ export type TabStatus = "ok" | "empty" | "error" | "not-rendered";
24
+
25
+ export interface TabResult {
26
+ id: string;
27
+ title: string;
28
+ status: TabStatus;
29
+ /** Why it failed, or what was seen. */
30
+ detail?: string;
31
+ /** Share of the view's pixels that differ from its background (0–1). */
32
+ ink?: number;
33
+ /** The PNG written for it (`--out`). */
34
+ shot?: string;
35
+ }
36
+
37
+ export interface LevelResult {
38
+ /** Instance path ("" is the root). */
39
+ path: string;
40
+ module: string;
41
+ name: string;
42
+ tabs: TabResult[];
43
+ /** Tabs the viewer showed that the rules did not list, or the other way round (should be none). */
44
+ mismatch?: string;
45
+ }
46
+
47
+ export interface ViewsCheckReport {
48
+ root: string;
49
+ levels: LevelResult[];
50
+ rendered: boolean;
51
+ /** Why nothing was rendered (no browser, --no-render). */
52
+ reason?: string;
53
+ failed: number;
54
+ }
55
+
56
+ export interface ViewsCheckOptions {
57
+ cwd: string;
58
+ module?: string;
59
+ /** Folder for one PNG per tab. */
60
+ out?: string;
61
+ render?: boolean;
62
+ /** Seconds to wait for a tab to draw (default 45: a board's GLB in a software renderer takes a while). */
63
+ timeout?: number;
64
+ log?: (line: string) => void;
65
+ }
66
+
67
+ const W = 1440;
68
+ const H = 900;
69
+ /** The floating header the shell draws over the top of the view. */
70
+ const HEADER = 52;
71
+ /**
72
+ * Where ink is measured: the middle of the view, clear of the floating cards
73
+ * every view has (the title card top left, the toolbar at the bottom, an
74
+ * inspector on the right), which would count as ink on a blank view.
75
+ */
76
+ const MIDDLE = { x: Math.round(W * 0.16), y: HEADER + 70, width: Math.round(W * 0.56), height: H - HEADER - 170, scale: 0.5 };
77
+ /** Below this share of differing pixels, a view counts as blank. */
78
+ const MIN_INK = 0.002;
79
+
80
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
81
+ const slug = (s: string) => s.replace(/[^A-Za-z0-9_.-]+/g, "_") || "root";
82
+
83
+ /** The levels to check: the root and every module with modules inside it, or the one `--module` names. */
84
+ function levels(p: Awaited<ReturnType<typeof loadProject>>, module?: string): { path: string; def: ModuleDef }[] {
85
+ const at = (path: string, id: string) => ({ path, def: p.lookup(id)! });
86
+ if (module !== undefined) {
87
+ const byPath = p.instances.find((i) => i.path === module);
88
+ const byId = p.instances.find((i) => i.definition === module);
89
+ const hit = byPath ?? byId;
90
+ if (!hit) throw new Error(`no instance path or module "${module}" (paths look like board or cap/board; see \`protoboard inspect\`)`);
91
+ return [at(hit.path, hit.definition)];
92
+ }
93
+ const seen = new Set<string>();
94
+ const out: { path: string; def: ModuleDef }[] = [];
95
+ for (const i of p.instances) {
96
+ const def = p.lookup(i.definition);
97
+ if (!def) continue;
98
+ if (i.path !== "" && !(def.children ?? []).length) continue;
99
+ // a module used several times is checked once, at its first instance
100
+ if (i.path !== "" && seen.has(def.id)) continue;
101
+ seen.add(def.id);
102
+ out.push({ path: i.path, def });
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** In the page: a tab button's view title (its tooltip is "<title>[ · planned][: description]"). */
108
+ const TITLE = `(b.getAttribute('data-tooltip') || '').split(/ · |: /)[0].trim()`;
109
+ const TABS = `[...document.querySelectorAll('[role=tablist] [role=tab]')].map((b) => ${TITLE})`;
110
+
111
+ /** In the page: the share of a PNG's pixels that differ from its most common colour. */
112
+ const INK = `async (b64) => {
113
+ const img = new Image();
114
+ img.src = "data:image/png;base64," + b64;
115
+ await img.decode();
116
+ const c = document.createElement("canvas");
117
+ c.width = img.width; c.height = img.height;
118
+ const g = c.getContext("2d");
119
+ g.drawImage(img, 0, 0);
120
+ const d = g.getImageData(0, 0, c.width, c.height).data;
121
+ const counts = new Map();
122
+ for (let i = 0; i < d.length; i += 4) { const k = (d[i] >> 3) << 10 | (d[i + 1] >> 3) << 5 | (d[i + 2] >> 3); counts.set(k, (counts.get(k) || 0) + 1); }
123
+ let bg = 0, most = -1;
124
+ for (const [k, n] of counts) if (n > most) { most = n; bg = k; }
125
+ const br = (bg >> 10) << 3, bgG = ((bg >> 5) & 31) << 3, bb = (bg & 31) << 3;
126
+ let ink = 0;
127
+ for (let i = 0; i < d.length; i += 4) if (Math.abs(d[i] - br) > 24 || Math.abs(d[i + 1] - bgG) > 24 || Math.abs(d[i + 2] - bb) > 24) ink++;
128
+ return ink / (d.length / 4);
129
+ }`;
130
+
131
+ /**
132
+ * In the page: what the active tab's panel holds. An empty state is the
133
+ * viewer's EmptyState (a centred column with an upper-case title) standing
134
+ * alone: no drawing (an SVG or a canvas of some size) beside it.
135
+ */
136
+ const PANEL = `(() => {
137
+ const panel = document.querySelector('[role=tabpanel]');
138
+ if (!panel) return { panel: false };
139
+ const big = (el) => { const r = el.getBoundingClientRect(); return r.width > 200 && r.height > 150; };
140
+ const drawings = panel.querySelectorAll('.react-flow__node').length + [...panel.querySelectorAll('svg')].filter((s) => big(s) && s.querySelectorAll('*').length > 20).length
141
+ + [...panel.querySelectorAll('canvas')].filter(big).length;
142
+ const rows = panel.querySelectorAll('[role=row], tr, [data-line]').length;
143
+ const empties = [...panel.querySelectorAll('div.flex.flex-col.items-center.justify-center.text-center')]
144
+ .filter((e) => e.offsetParent !== null && e.querySelector('.uppercase'))
145
+ .map((e) => e.querySelector('.uppercase').textContent.trim());
146
+ return { panel: true, drawings, rows, empties, text: panel.innerText.slice(0, 160) };
147
+ })()`;
148
+
149
+ export async function viewsCheck(options: ViewsCheckOptions): Promise<ViewsCheckReport> {
150
+ const log = options.log ?? (() => {});
151
+ const root = findProjectRoot(options.cwd);
152
+ if (!root) throw new Error(`no protoboard.config.ts in ${options.cwd} or its parents (run \`protoboard init\`)`);
153
+ const p = await loadProject(root, { checks: false });
154
+ const bundle = await uhdBundle(p);
155
+ const { views } = resolveViews(p.extensions);
156
+ const result: LevelResult[] = levels(p, options.module).map(({ path, def }) => ({
157
+ path,
158
+ module: def.id,
159
+ name: def.name ?? def.id,
160
+ tabs: tabsFor(bundle.definitions.find((d) => d.id === def.id) ?? def, bundle, path, views).map((v) => ({ id: v.id, title: v.title, status: "not-rendered" as TabStatus })),
161
+ }));
162
+ const report: ViewsCheckReport = { root, levels: result, rendered: false, failed: 0 };
163
+ if (options.render === false) return { ...report, reason: "not rendered (--no-render)" };
164
+
165
+ const { launchBrowser } = await import("@protoboardai/core/cdp");
166
+ const { startDev } = await import("./dev/server.js");
167
+ let browser: Awaited<ReturnType<typeof launchBrowser>>;
168
+ try {
169
+ browser = await launchBrowser({ purpose: "`protoboard views check` opens each tab in a headless Chromium-family browser", echoErrors: false });
170
+ } catch (e) {
171
+ return { ...report, reason: (e as Error).message.split("\n")[0], failed: result.reduce((n, l) => n + l.tabs.length, 0) };
172
+ }
173
+ const dev = await startDev({ root, port: 0, watch: false, record: false });
174
+ const out = options.out ? resolve(options.cwd, options.out) : undefined;
175
+ if (out) mkdirSync(out, { recursive: true });
176
+ const timeout = (options.timeout ?? 45) * 1000;
177
+ try {
178
+ await browser.setViewport(W, H);
179
+ for (const level of result) {
180
+ const url = `${dev.url}?theme=protoboard-dark${level.path ? `&drill=${encodeURIComponent(level.path.split("/").join(","))}` : ""}`;
181
+ log(`${level.path || "(root)"} ${level.module}: ${level.tabs.map((t) => t.title).join(", ")}`);
182
+ await browser.navigate(url);
183
+ // the shell's tabs, once it has mounted the level
184
+ // the shell's tabs, once it has mounted the level (the same as the rules', or settled for 3 s)
185
+ const expected = level.tabs.map((t) => t.title);
186
+ let shown: string[] = [];
187
+ let first = 0;
188
+ for (const t0 = Date.now(); Date.now() - t0 < timeout; await sleep(250)) {
189
+ shown = await browser.evaluate<string[]>(TABS);
190
+ if (!shown.length) continue;
191
+ first ||= Date.now();
192
+ if ((shown.length === expected.length && shown.every((t) => expected.includes(t))) || Date.now() - first > 3000) break;
193
+ }
194
+ const extra = shown.filter((t) => !expected.includes(t));
195
+ const missing = expected.filter((t) => !shown.includes(t));
196
+ if (extra.length || missing.length) level.mismatch = `${missing.length ? `not shown: ${missing.join(", ")}` : ""}${extra.length && missing.length ? "; " : ""}${extra.length ? `shown besides: ${extra.join(", ")}` : ""}`;
197
+ browser.takeErrors();
198
+ for (const tab of level.tabs) {
199
+ if (!shown.includes(tab.title)) {
200
+ tab.status = "error";
201
+ tab.detail = "the viewer does not offer this tab here";
202
+ continue;
203
+ }
204
+ await browser.evaluate(`[...document.querySelectorAll('[role=tablist] [role=tab]')].find((b) => ${TITLE} === ${JSON.stringify(tab.title)}).click()`);
205
+ // wait for it to draw: something inked and a drawing or rows in the panel, or until the time is up
206
+ // wait for it to draw: inked and unchanged across two looks (a model still loading changes), or an
207
+ // empty state that stays, or until the time is up
208
+ let panel: { panel: boolean; drawings?: number; rows?: number; empties?: string[]; text?: string } = { panel: false };
209
+ let ink = 0;
210
+ let before = -1;
211
+ for (const t0 = Date.now(); Date.now() - t0 < timeout; await sleep(600)) {
212
+ panel = await browser.evaluate(PANEL);
213
+ const png = await browser.screenshot(MIDDLE);
214
+ ink = await browser.evaluate<number>(`(${INK})(${JSON.stringify(png.toString("base64"))})`);
215
+ const settled = Math.abs(ink - before) < 0.0005;
216
+ before = ink;
217
+ if ((panel.empties ?? []).length && !(panel.drawings ?? 0) && Date.now() - t0 > 3000) break;
218
+ if (ink >= MIN_INK && settled && ((panel.drawings ?? 0) > 0 || (panel.rows ?? 0) > 0 || Date.now() - t0 > 4000)) break;
219
+ }
220
+ await sleep(600);
221
+ const errors = browser.takeErrors().filter((e) => !/favicon|ERR_ABORTED|EventSource/i.test(e));
222
+ tab.ink = Math.round(ink * 10000) / 10000;
223
+ if (errors.length) {
224
+ tab.status = "error";
225
+ tab.detail = errors[0].split("\n")[0].slice(0, 300);
226
+ } else if (!panel.panel) {
227
+ tab.status = "error";
228
+ tab.detail = "no view panel";
229
+ } else if ((panel.empties ?? []).length && !(panel.drawings ?? 0)) {
230
+ tab.status = "empty";
231
+ tab.detail = `shows "${panel.empties![0]}"`;
232
+ } else if (ink < MIN_INK) {
233
+ tab.status = "empty";
234
+ tab.detail = "blank: nothing is drawn";
235
+ } else if (!(panel.drawings ?? 0) && !(panel.rows ?? 0)) {
236
+ tab.status = "ok";
237
+ tab.detail = `no drawing or table; shows "${(panel.text ?? "").split("\n")[0]}"`;
238
+ } else tab.status = "ok";
239
+ if (out) {
240
+ const file = join(out, `${slug(level.path)}__${tab.id}.png`);
241
+ writeFileSync(file, await browser.screenshot());
242
+ tab.shot = file;
243
+ }
244
+ log(` ${tab.status.padEnd(5)} ${tab.title}${tab.detail ? `: ${tab.detail}` : ""}`);
245
+ }
246
+ }
247
+ } finally {
248
+ await browser.close();
249
+ await dev.close();
250
+ }
251
+ const failed = result.reduce((n, l) => n + l.tabs.filter((t) => t.status === "empty" || t.status === "error").length, 0);
252
+ return { ...report, rendered: true, failed };
253
+ }
254
+
255
+ /** The report for people. */
256
+ export function formatViewsCheck(r: ViewsCheckReport): string {
257
+ const lines: string[] = [];
258
+ for (const l of r.levels) {
259
+ lines.push(`${l.path || "(root)"} ${l.module}`);
260
+ for (const t of l.tabs) lines.push(` ${t.status === "ok" ? "✓" : t.status === "not-rendered" ? "·" : "✗"} ${t.title.padEnd(10)} ${t.status === "ok" || t.status === "not-rendered" ? "" : t.status}${t.detail ? `${t.status === "ok" ? "" : ": "}${t.detail}` : ""}${t.shot ? ` ${t.shot}` : ""}`.trimEnd());
261
+ if (l.mismatch) lines.push(` ! the viewer's tabs differ from the rules: ${l.mismatch}`);
262
+ }
263
+ const tabs = r.levels.reduce((n, l) => n + l.tabs.length, 0);
264
+ lines.push("");
265
+ lines.push(r.rendered ? `${tabs} tab(s) on ${r.levels.length} level(s): ${r.failed ? `${r.failed} failed` : "every one draws"}` : `${tabs} tab(s) on ${r.levels.length} level(s), not rendered: ${r.reason}`);
266
+ return lines.join("\n");
267
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.