@protoboardai/cli 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Where the viewer's modules are written down, for its `openSource` event
3
+ * (docs/specs/vscode-extension.md §3): each build of `protoboard dev` writes
4
+ * an index of definition sources and instance declarations next to the
5
+ * bundle, and the server answers `/__protoboard/source` from it, so a host
6
+ * (the VS Code extension) can open the file at the line.
7
+ *
8
+ * - A definition: the file that declares it (a `.uhd.ts` source, or the
9
+ * vendored catalog JSON), at the line of its id.
10
+ * - An instance: the child entry in its parent's definition, at the line of
11
+ * the child's id (searched from the parent's own line, so another module
12
+ * in the same file with a child of that name does not match first; then
13
+ * from the top, for children built from data above the definition).
14
+ */
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { isAbsolute, join, relative, resolve } from "node:path";
17
+ import type { Project } from "@protoboardai/core";
18
+
19
+ export const SOURCES_SCHEMA = "protoboard.dev-sources/v0";
20
+ /** The index's file name, next to the bundle. */
21
+ export const SOURCES_FILE = "sources.json";
22
+
23
+ /** A place in a project file (project-relative). */
24
+ export interface SourcePlace {
25
+ file: string;
26
+ line?: number;
27
+ }
28
+
29
+ export interface SourceIndex {
30
+ schema: typeof SOURCES_SCHEMA;
31
+ /** Definition id → where it is declared, and its tier (catalog parts are vendored JSON). */
32
+ definitions: Record<string, SourcePlace & { tier: string }>;
33
+ /** Instance path ("arm_fl/motor") → its definition and where its parent declares it. */
34
+ instances: Record<string, SourcePlace & { definition: string }>;
35
+ }
36
+
37
+ /** A resolved place, with the absolute path a host opens. */
38
+ export interface ResolvedPlace extends SourcePlace {
39
+ path: string;
40
+ }
41
+
42
+ export interface ResolvedSource {
43
+ defId: string;
44
+ instancePath?: string;
45
+ /** Where the definition is declared. */
46
+ source?: ResolvedPlace;
47
+ /** Where the instance is declared in its parent's definition. */
48
+ instanceSource?: ResolvedPlace;
49
+ }
50
+
51
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
+
53
+ /** The 1-based line of `id: "<value>"` (TypeScript or JSON) in `text`, at or after line `from`. */
54
+ export function lineOf(text: string, value: string, from = 1, key = "id"): number | undefined {
55
+ const needle = new RegExp(`(?:\\b${key}|"${key}")\\s*:\\s*["'\`]${escape(value)}["'\`]`);
56
+ const lines = text.split("\n");
57
+ for (let i = Math.max(0, from - 1); i < lines.length; i++) if (needle.test(lines[i])) return i + 1;
58
+ return undefined;
59
+ }
60
+
61
+ /** The index for a loaded project. */
62
+ export function sourceIndex(p: Project): SourceIndex {
63
+ const texts = new Map<string, string | undefined>();
64
+ const text = (file: string) => {
65
+ if (!texts.has(file)) {
66
+ const abs = join(p.root, file);
67
+ texts.set(file, existsSync(abs) ? readFileSync(abs, "utf8") : undefined);
68
+ }
69
+ return texts.get(file);
70
+ };
71
+ const definitions: SourceIndex["definitions"] = {};
72
+ for (const [id, rec] of p.definitions) {
73
+ // a catalog part vendored with its .uhd.ts (PB-910) opens at that source, not the pinned JSON
74
+ const { file, line } = rec.origin.definitionSource ?? rec.origin.source;
75
+ // a vendored catalog file carries no line: the line that names its id
76
+ const content = line ? undefined : text(file);
77
+ const at = line ?? (content === undefined ? undefined : lineOf(content, id));
78
+ definitions[id] = { file, ...(at ? { line: at } : {}), tier: rec.origin.tier };
79
+ }
80
+ const instances: SourceIndex["instances"] = {};
81
+ const definitionAt = new Map(p.instances.map((i) => [i.path, i.definition]));
82
+ for (const inst of p.instances) {
83
+ if (!inst.path) continue;
84
+ const slash = inst.path.lastIndexOf("/");
85
+ const parentPath = slash < 0 ? "" : inst.path.slice(0, slash);
86
+ const childId = inst.path.slice(slash + 1);
87
+ const parentDef = definitionAt.get(parentPath);
88
+ const where = parentDef ? definitions[parentDef] : undefined;
89
+ if (!where) continue;
90
+ const content = text(where.file);
91
+ // after the parent's own line first; children built from data (ARMS.map) are named earlier in the file
92
+ const line = content === undefined ? undefined : (lineOf(content, childId, where.line ?? 1) ?? lineOf(content, childId));
93
+ instances[inst.path] = { definition: inst.definition, file: where.file, ...(line ? { line } : {}) };
94
+ }
95
+ return { schema: SOURCES_SCHEMA, definitions, instances };
96
+ }
97
+
98
+ /**
99
+ * Where a definition and an instance are written down. The definition named
100
+ * by `defId` wins over the instance's when both are given; an instance alone
101
+ * also names its definition. Files outside the project are not returned.
102
+ */
103
+ export function resolveSource(index: SourceIndex, root: string, request: { defId?: string; instancePath?: string }): ResolvedSource | undefined {
104
+ const instancePath = request.instancePath?.replace(/^\/+|\/+$/g, "") || undefined;
105
+ const inst = instancePath ? index.instances[instancePath] : undefined;
106
+ const defId = request.defId ?? inst?.definition;
107
+ if (!defId) return undefined;
108
+ const place = (p?: SourcePlace): ResolvedPlace | undefined => {
109
+ if (!p) return undefined;
110
+ const path = resolve(root, p.file);
111
+ const rel = relative(resolve(root), path);
112
+ if (rel.startsWith("..") || isAbsolute(rel)) return undefined;
113
+ return { file: p.file, ...(p.line ? { line: p.line } : {}), path };
114
+ };
115
+ const def = index.definitions[defId];
116
+ const source = place(def && { file: def.file, line: def.line });
117
+ const instanceSource = place(inst && { file: inst.file, line: inst.line });
118
+ if (!source && !instanceSource) return undefined;
119
+ return { defId, ...(instancePath ? { instancePath } : {}), ...(source ? { source } : {}), ...(instanceSource ? { instanceSource } : {}) };
120
+ }
package/src/gate.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Which CLI commands need the Protoboard cloud (PB-928, as changed on
3
+ * 2026-10-21): the table the CLI asks before it runs a command.
4
+ *
5
+ * - `local`: works on the project on this machine, with no account and no
6
+ * service. Creating, modelling, checking, building, the viewer, layout,
7
+ * circuits and every other operation, releases, skills, tools, the stack.
8
+ * - `cloud`: uses the library (as a user or a publisher). The library is the Protoboard cloud, and signed out there
9
+ * is no access to it at all: searching it and its
10
+ * category tree, the project's libraries, full revisions and their files,
11
+ * prices and offers, publishing. Signed out, the CLI stops with one line
12
+ * ("… needs a Protoboard account: sign in with `protoboard login`") and
13
+ * exit code 4, before anything changes. The service refuses the same
14
+ * requests on its own: this table only saves a round trip and words the
15
+ * refusal, since a fork can remove it.
16
+ *
17
+ * A library that signs no one in (a local parts service, or another
18
+ * organisation's library read with its own token, `authEnv`) lets a
19
+ * `libraries` command through: requireCloud in index.ts asks each library
20
+ * the command uses.
21
+ *
22
+ * `init` is local; a template's library parts are its one cloud step, and
23
+ * signed out it skips that step and says how to add them later.
24
+ */
25
+ export type Gate = "local" | "cloud";
26
+
27
+ export interface GateEntry {
28
+ gate: Gate;
29
+ /** For a cloud command: what needs the cloud, as the refusal says it ("Adding library parts"). */
30
+ what?: string;
31
+ /**
32
+ * For a cloud command: which services it uses. `libraries`: the project's
33
+ * libraries (a part can come from any of them; `--library` names one).
34
+ * `cloud`: the project's Protoboard service (prices, offers, publishing).
35
+ */
36
+ uses?: "libraries" | "cloud";
37
+ }
38
+
39
+ const LOCAL: GateEntry = { gate: "local" };
40
+
41
+ /** `parts <sub>`: the one command whose subcommands differ. */
42
+ const PARTS: Record<string, GateEntry> = {
43
+ list: LOCAL,
44
+ migrate: LOCAL,
45
+ // browsing is the library too (PB-928, 2026-10-21; it was open before 0.2.0, PB-911)
46
+ search: { gate: "cloud", what: "Searching the Protoboard library", uses: "libraries" },
47
+ taxonomy: { gate: "cloud", what: "Browsing the Protoboard library's categories", uses: "libraries" },
48
+ add: { gate: "cloud", what: "Adding library parts", uses: "libraries" },
49
+ // an update check compares full revisions, which the service serves to signed-in accounts
50
+ update: { gate: "cloud", what: "Updating library parts", uses: "libraries" },
51
+ offers: { gate: "cloud", what: "Fetching offers from the Protoboard library", uses: "cloud" },
52
+ prices: { gate: "cloud", what: "Prices from the Protoboard library", uses: "cloud" },
53
+ publish: { gate: "cloud", what: "Publishing a part", uses: "cloud" },
54
+ usage: { gate: "cloud", what: "The library's usage figures", uses: "cloud" },
55
+ };
56
+
57
+ /** Every command, by name; a command not listed is local. */
58
+ export const COMMAND_GATES: Readonly<Record<string, GateEntry>> = {
59
+ init: LOCAL,
60
+ upgrade: LOCAL,
61
+ inspect: LOCAL,
62
+ artifacts: LOCAL,
63
+ check: LOCAL,
64
+ layout: LOCAL,
65
+ build: LOCAL,
66
+ dev: LOCAL,
67
+ bom: LOCAL,
68
+ // by subcommand: commandGate below
69
+ parts: LOCAL,
70
+ login: LOCAL,
71
+ logout: LOCAL,
72
+ whoami: LOCAL,
73
+ // the project's libraries are where its parts come from: with no account, none of them is the Protoboard library's to use
74
+ libraries: { gate: "cloud", what: "Managing the project's libraries", uses: "libraries" },
75
+ doctor: LOCAL,
76
+ tools: LOCAL,
77
+ extensions: LOCAL,
78
+ skills: LOCAL,
79
+ run: LOCAL,
80
+ capabilities: LOCAL,
81
+ release: LOCAL,
82
+ operations: LOCAL,
83
+ evals: LOCAL,
84
+ views: LOCAL,
85
+ stack: LOCAL,
86
+ agents: LOCAL,
87
+ setup: LOCAL,
88
+ mcp: LOCAL,
89
+ };
90
+
91
+ /**
92
+ * What a command line needs. `parts list --check` asks the libraries for
93
+ * updates (full revisions), so it needs the cloud; `parts list` alone does not.
94
+ */
95
+ export function commandGate(command: string | undefined, sub?: string, flags: { check?: boolean } = {}): GateEntry {
96
+ if (!command) return LOCAL;
97
+ if (command === "parts") {
98
+ if (sub === "list" && flags.check) return { gate: "cloud", what: "Checking library parts for updates", uses: "libraries" };
99
+ return PARTS[sub ?? ""] ?? LOCAL;
100
+ }
101
+ return COMMAND_GATES[command] ?? LOCAL;
102
+ }