@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/index.ts
ADDED
|
@@ -0,0 +1,2024 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protoboard: headless CLI over @protoboardai/core (docs/specs/core-cli-v0.md).
|
|
3
|
+
* `--format json` gives machine output on every command; text is for people.
|
|
4
|
+
*/
|
|
5
|
+
import { notProvidedLines } from "./stack-messages.js";
|
|
6
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join, relative, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import meow from "meow";
|
|
10
|
+
import {
|
|
11
|
+
CAPABILITIES,
|
|
12
|
+
FRAMEWORK_VERSION,
|
|
13
|
+
buildProvenance,
|
|
14
|
+
fmtProvenance,
|
|
15
|
+
listCapabilities,
|
|
16
|
+
runCapabilityOperation,
|
|
17
|
+
addParts,
|
|
18
|
+
migrateParts,
|
|
19
|
+
applyUpgrade,
|
|
20
|
+
planUpgrade,
|
|
21
|
+
checkPartUpdates,
|
|
22
|
+
projectParts,
|
|
23
|
+
withUpdates,
|
|
24
|
+
type ProjectPart,
|
|
25
|
+
type ProjectPartsReport,
|
|
26
|
+
type UpdateReport,
|
|
27
|
+
connectCloud,
|
|
28
|
+
connectLibraries,
|
|
29
|
+
declaredLibraries,
|
|
30
|
+
personalLibraries,
|
|
31
|
+
USER_LIBRARIES_ENV,
|
|
32
|
+
discoverLibrary,
|
|
33
|
+
listLibraries,
|
|
34
|
+
readsNeed,
|
|
35
|
+
loadConfig,
|
|
36
|
+
parsePartRef,
|
|
37
|
+
publishPart,
|
|
38
|
+
revisionLabel,
|
|
39
|
+
searchLibraries,
|
|
40
|
+
LIBRARY_SORTS,
|
|
41
|
+
isLibrarySort,
|
|
42
|
+
libraryUsage,
|
|
43
|
+
partPrices,
|
|
44
|
+
libraryTaxonomy,
|
|
45
|
+
originsPath,
|
|
46
|
+
updatePart,
|
|
47
|
+
withLibraryAdded,
|
|
48
|
+
withLibraryMoved,
|
|
49
|
+
withLibraryRemoved,
|
|
50
|
+
writeLibraries,
|
|
51
|
+
vendorOffers,
|
|
52
|
+
type AddResult,
|
|
53
|
+
exportSkills,
|
|
54
|
+
addExtension,
|
|
55
|
+
agentsSkillPaths,
|
|
56
|
+
extensionPackage,
|
|
57
|
+
partsToMigrate,
|
|
58
|
+
refreshGuidance,
|
|
59
|
+
withCurrentGuidance,
|
|
60
|
+
readSkill,
|
|
61
|
+
findOperation,
|
|
62
|
+
listOperationRecords,
|
|
63
|
+
installRuntimes,
|
|
64
|
+
probeTools,
|
|
65
|
+
resolveSkills,
|
|
66
|
+
runtimeStatus,
|
|
67
|
+
type RuntimeStatus,
|
|
68
|
+
runOperation,
|
|
69
|
+
type SkillHost,
|
|
70
|
+
errorCount,
|
|
71
|
+
findProjectRoot,
|
|
72
|
+
initProject,
|
|
73
|
+
listTemplates,
|
|
74
|
+
vendorTemplateParts,
|
|
75
|
+
inspectInstance,
|
|
76
|
+
inspectProject,
|
|
77
|
+
listArtifacts,
|
|
78
|
+
loadProject,
|
|
79
|
+
writeBuild,
|
|
80
|
+
writeBundle,
|
|
81
|
+
type Project,
|
|
82
|
+
type ProjectDiagnostic,
|
|
83
|
+
claudeCodeHost,
|
|
84
|
+
claudeCodeJudge,
|
|
85
|
+
loadSuites,
|
|
86
|
+
runEvals,
|
|
87
|
+
scriptedHost,
|
|
88
|
+
type AgentHost,
|
|
89
|
+
AccountError,
|
|
90
|
+
CredentialStore,
|
|
91
|
+
accountStatus,
|
|
92
|
+
accountTokensFor,
|
|
93
|
+
accountService,
|
|
94
|
+
unreachableService,
|
|
95
|
+
CONFIG_FILE,
|
|
96
|
+
cloudUrl,
|
|
97
|
+
DEFAULT_CLOUD_URL,
|
|
98
|
+
savedServices,
|
|
99
|
+
unloadedProjectService,
|
|
100
|
+
type AccountServiceSource,
|
|
101
|
+
HOSTED_CLOUD_URL,
|
|
102
|
+
configuredLibraries,
|
|
103
|
+
loginWithBrowser,
|
|
104
|
+
loginWithDevice,
|
|
105
|
+
logout,
|
|
106
|
+
serviceKey,
|
|
107
|
+
ACCESS_EXIT_CODE,
|
|
108
|
+
AccessError,
|
|
109
|
+
OFFLINE_GRACE_DAYS,
|
|
110
|
+
checkAccess,
|
|
111
|
+
forgetAccess,
|
|
112
|
+
type Access,
|
|
113
|
+
carriedStack,
|
|
114
|
+
installStack,
|
|
115
|
+
globalStack,
|
|
116
|
+
chooseStack,
|
|
117
|
+
stackVersionMismatch,
|
|
118
|
+
unfitReason,
|
|
119
|
+
installedExtensions,
|
|
120
|
+
unpackStackFrom,
|
|
121
|
+
EDITOR_EXTENSION_DIRS,
|
|
122
|
+
type StackChoice,
|
|
123
|
+
type Stack,
|
|
124
|
+
isStackPackage,
|
|
125
|
+
linkProject,
|
|
126
|
+
unlinkProject,
|
|
127
|
+
linkSummary,
|
|
128
|
+
listStacks,
|
|
129
|
+
needsLink,
|
|
130
|
+
packageState,
|
|
131
|
+
projectDependencies,
|
|
132
|
+
projectStackRange,
|
|
133
|
+
readLinkRecord,
|
|
134
|
+
stackHome,
|
|
135
|
+
isAuthEnvName,
|
|
136
|
+
tightenUserFolders,
|
|
137
|
+
type LinkResult,
|
|
138
|
+
} from "@protoboardai/core";
|
|
139
|
+
import { openUrl } from "./openUrl.js";
|
|
140
|
+
import { commandGate, type GateEntry } from "./gate.js";
|
|
141
|
+
import { chooseOffer, findLine, formatBom, formatOffers, projectBom } from "./bom.js";
|
|
142
|
+
import { bomCsv } from "@protoboardai/core/bom";
|
|
143
|
+
import { writeBomSettings } from "@protoboardai/ext-sourcing/lib/cost.ts";
|
|
144
|
+
|
|
145
|
+
const cli = meow(
|
|
146
|
+
`
|
|
147
|
+
Usage
|
|
148
|
+
$ protoboard <command> [options]
|
|
149
|
+
|
|
150
|
+
Sign-in
|
|
151
|
+
Local work needs no account: init, check, build, dev, layout, views, run, release and the rest.
|
|
152
|
+
The Protoboard library does, browsing included: parts search, parts taxonomy, libraries,
|
|
153
|
+
parts add, parts update, parts list --check, parts offers, parts prices and parts publish.
|
|
154
|
+
Signed out, those stop with exit code 4 and one line saying to sign in with "protoboard login".
|
|
155
|
+
CI: set PROTOBOARD_CLOUD_TOKEN (the service token), or "protoboard login --device".
|
|
156
|
+
|
|
157
|
+
Commands
|
|
158
|
+
init <dir> Scaffold a portable Protoboard project; --template drone|wearable|iot starts from a
|
|
159
|
+
template derived from a reference project, and vendors its catalog parts from the
|
|
160
|
+
parts service (--no-parts skips that). The project's cloud.url is the hosted Protoboard
|
|
161
|
+
library (--url or PROTOBOARD_CLOUD_URL for another; --local for http://localhost:8787).
|
|
162
|
+
--list shows the templates; --name names the project (default: the folder name);
|
|
163
|
+
--registry <url> writes an .npmrc that takes @protoboardai/* and @deltarobotics/* from
|
|
164
|
+
that registry (default: the registry npm ran this CLI from, when it is not npmjs)
|
|
165
|
+
The project uses the Protoboard installed for your user (--stack global, the
|
|
166
|
+
default): its packages are links to the copy in ~/.protoboard/stack, so check, build,
|
|
167
|
+
dev and the viewer work with no npm install in the project. The copy is the one the
|
|
168
|
+
VS Code extension carries, or, for a CLI that npm installed, one this command installs
|
|
169
|
+
there once per version. --stack project (and --registry) installs the packages into
|
|
170
|
+
the project with npm instead: its npm install then exports its extensions' skills
|
|
171
|
+
to .claude/skills/ and lists them in AGENTS.md (a postinstall script; --no-skills
|
|
172
|
+
leaves it out). The last lines say how to open the project in a new VS Code window
|
|
173
|
+
init --refresh-guidance [dir]
|
|
174
|
+
In an existing project: bring AGENTS.md's Protoboard guidance (the shared sections,
|
|
175
|
+
between its markers) to this version's and keep the project's own sections, write a
|
|
176
|
+
CLAUDE.md that imports AGENTS.md when there is none, and export the skills list;
|
|
177
|
+
lines elsewhere that contradict the guidance are reported, not rewritten
|
|
178
|
+
upgrade [dir] Move a project made by an earlier Protoboard (0.1.x) to this version: package.json's
|
|
179
|
+
@protoboardai/* ranges become ^${FRAMEWORK_VERSION} and its UHD and TypeScript pins this
|
|
180
|
+
stack's, its packages are linked again to a stack copy that fits, AGENTS.md and
|
|
181
|
+
CLAUDE.md get this version's guidance (init --refresh-guidance) and vendored parts
|
|
182
|
+
move to the current layout (parts migrate). Offline; --dry-run lists the changes and
|
|
183
|
+
writes nothing. Commit the project first, then run \`protoboard check\`
|
|
184
|
+
inspect [instance] System summary, or one instance's definition, origin, links, artifacts, diagnostics
|
|
185
|
+
artifacts [instance] Artifacts of the definitions in use (or of one instance), with the instances
|
|
186
|
+
using them and the file each resolves to
|
|
187
|
+
check Resolve the project and run every available validator (exit 1 on errors)
|
|
188
|
+
build Write .protoboard/build/resolved.json (derived data only)
|
|
189
|
+
layout [level] The diagram's layout measured, level by level, as the viewer draws it: line crossings,
|
|
190
|
+
overlapping cards, the longest row or column of cards (a strip), the aspect ratio and
|
|
191
|
+
lines under cards, with what to fix. A level is an instance path ("" is the top;
|
|
192
|
+
default: every level). --apply lays the level(s) out afresh, --tidy keeps the saved
|
|
193
|
+
arrangement and fixes it; either writes .protoboard/display.json where the result
|
|
194
|
+
reads better (--force: anyway). --pin <card> keeps a card where it is (repeatable;
|
|
195
|
+
remembered in the file). --check: exit 1 when a level still has something to fix.
|
|
196
|
+
check reports the levels of modules that need it as PB_DIAGRAM_LAYOUT (info)
|
|
197
|
+
views check The viewer's tabs for the root and every level with modules inside it (--module
|
|
198
|
+
<instance path or module id> for one), each opened once in a headless browser:
|
|
199
|
+
fails (exit 1) on a tab that throws, shows an empty state or draws nothing.
|
|
200
|
+
--out-dir <folder> keeps a PNG of every tab; --no-render only lists the tabs
|
|
201
|
+
dev Serve the UHD viewer on the project: rebuilds on every source change, saves
|
|
202
|
+
diagram layout edits to .protoboard/display.json (--port, --host, --open, --no-watch)
|
|
203
|
+
bom The bill of materials, by top-level module, in US dollars: what one unit needs, the
|
|
204
|
+
offer used for each line, what must be bought (whole packs, minimum orders), the
|
|
205
|
+
cost per unit and the cost of the order. --units <n> builds n (default: the
|
|
206
|
+
project's setting, else 1); --release <version> lists that release's ordering
|
|
207
|
+
list instead of the design; --format json carries everything, --format csv is the
|
|
208
|
+
export. Offline: prices are the offers the project holds
|
|
209
|
+
bom units <n> Set the units to build (parts/bom.json)
|
|
210
|
+
bom offers <line> The offers held for a line (its part id or ordering code), best first
|
|
211
|
+
bom choose <line> <offer|supplier|auto>
|
|
212
|
+
Use that offer for the line (parts/bom.json); auto goes back to the best offer
|
|
213
|
+
parts list The project's parts: library parts (pinned revision; diverged when their vendored
|
|
214
|
+
files were edited), researched (parts/local) and designed (src/). Offline;
|
|
215
|
+
--check asks the libraries which have an update
|
|
216
|
+
parts search [words] Search every part library, results merged with the library each comes from
|
|
217
|
+
(--taxonomy, --domain, --protocol, --tag, --limit, --library; --facets adds counts;
|
|
218
|
+
--cursor <c> gives the next page, from the previous page's nextCursor).
|
|
219
|
+
--sort relevance|name|manufacturer|updated orders them, in a library that sorts
|
|
220
|
+
(relevance, the library's own ranking, is its default; --format json carries each
|
|
221
|
+
part's relevance value)
|
|
222
|
+
parts taxonomy The libraries' category taxonomy merged, with part counts over --domain,
|
|
223
|
+
--protocol, --tag and the words given
|
|
224
|
+
parts add <id[@rev]...>
|
|
225
|
+
Vendor library revisions (default: the recommended one) with their closure and
|
|
226
|
+
files into parts/catalog, pinned in parts/origins.json. The part comes from the
|
|
227
|
+
first library that has it, unless --library names one or origins pins one. Its
|
|
228
|
+
.uhd.ts source comes too (linted, and checked to evaluate to the pinned
|
|
229
|
+
definition); --compiled vendors the definition JSON only
|
|
230
|
+
parts migrate Move vendored parts from the folder layout of 0.1.6 and earlier (which repeats the part
|
|
231
|
+
id in every path, too long for Windows in a nested project) to the short one; offline,
|
|
232
|
+
the pins stay. check reports PB_CATALOG_LAYOUT_EARLIER (info) while there are some
|
|
233
|
+
parts update [id...] Show the recommended revision of vendored parts and what it changes; writes nothing
|
|
234
|
+
parts update <id> --to <rev|recommended>
|
|
235
|
+
Deliberately re-pin a vendored part (the replaced pin is kept as previous);
|
|
236
|
+
--library moves it to another library; --compiled as for add
|
|
237
|
+
parts offers [id...] Vendor the service's supplier offers for catalog parts into parts/offers.json
|
|
238
|
+
(what a release quotes offline; replaces those parts' entries)
|
|
239
|
+
parts prices <id> A library part's prices by source: each supplier's price breaks, stock, pack and
|
|
240
|
+
minimum order, the link and when it was observed (--mpn for one ordering code of a
|
|
241
|
+
series part). Needs a signed-in account; writes nothing
|
|
242
|
+
parts publish <path> Publish a project-researched part (parts/local) as its next catalog revision
|
|
243
|
+
(needs a publisher: signed in as one, or PROTOBOARD_CLOUD_TOKEN; --dry-run shows
|
|
244
|
+
the revision without publishing)
|
|
245
|
+
login Sign in to Protoboard Cloud (the parts service at --url, else the project's
|
|
246
|
+
cloud.url, else, outside a project, the hosted Protoboard library): a browser sign-in, or
|
|
247
|
+
--device for agents and machines without a browser (a code to approve on the
|
|
248
|
+
sign-in site). The sign-in goes to Linux's keyring, Windows' DPAPI, else a file
|
|
249
|
+
only you can read; never to the project. Everything in the library needs it: search,
|
|
250
|
+
adding parts, prices and downloads
|
|
251
|
+
logout Forget the sign-in for the service at --url
|
|
252
|
+
whoami Who is signed in to the service at --url (--check asks the service)
|
|
253
|
+
libraries The project's part libraries in order (--check asks each for its discovery document)
|
|
254
|
+
libraries add <id> <url> [--auth-env PROTOBOARD_<NAME>_TOKEN] [--position n]
|
|
255
|
+
Add a uhd-library/v1 library (last unless --position); with only <url>, its
|
|
256
|
+
discovery document suggests the id. --auth-env names the environment variable
|
|
257
|
+
holding its read token; tokens are never written to the project
|
|
258
|
+
libraries remove <id> Remove a library (parts vendored from it stay vendored)
|
|
259
|
+
libraries move <id> <position|first|last|up|down>
|
|
260
|
+
Reorder: the first library that has a part id provides it.
|
|
261
|
+
Personal libraries (not in the project) come from PROTOBOARD_USER_LIBRARIES, a JSON
|
|
262
|
+
array of { id, url, authEnv? }; they are searched after the project's
|
|
263
|
+
doctor Extensions, compatibility, tool availability, runtimes, skills, the sign-in and where
|
|
264
|
+
the stack's packages come from. Its Build line says whether this is a release or a
|
|
265
|
+
development build, and the commit it was built from when known
|
|
266
|
+
stack Where this CLI and the project's packages come from: installed by npm, or links to a
|
|
267
|
+
stack copy in ~/.protoboard/stack (the copy the VS Code extension carries)
|
|
268
|
+
stack link Link the project's Protoboard packages to the Protoboard installed for your user: a
|
|
269
|
+
stack copy that fits the project (its range, its packages), this CLI's own version
|
|
270
|
+
first, and it says when it takes another (what Initialize does in VS Code, with no
|
|
271
|
+
npm and no registry); --force re-points links that lead to another copy; --carried
|
|
272
|
+
links the copy this CLI runs from whatever package.json asks. npm-installed packages
|
|
273
|
+
are never replaced. Every command warns when the project links to another version
|
|
274
|
+
stack unpack [<extension folder | .vsix>]
|
|
275
|
+
Unpack the stack a Protoboard VS Code extension carries into ~/.protoboard/stack, as
|
|
276
|
+
the extension does when it starts (its archives checked against their digests), then
|
|
277
|
+
link the project to it when it fits. With no path: the installed extension of this
|
|
278
|
+
CLI's version, else the newest installed (\`protoboard stack\` lists them). A copy of
|
|
279
|
+
the same version from another build is replaced only with --replace
|
|
280
|
+
setup Set up the rest of this machine, with no sign-in: install the Protoboard extension
|
|
281
|
+
(deltarobotics.vscode-protoboard) into VS Code, Cursor, Windsurf and VSCodium (those
|
|
282
|
+
whose command is found; it asks about each, --yes does not), from the Marketplace or
|
|
283
|
+
Open VSX through each editor's own command, or from --vsix <file> offline; then offer
|
|
284
|
+
to put the protoboard command on your PATH and to connect your agents (agents setup).
|
|
285
|
+
Never runs by itself
|
|
286
|
+
stack install Install the stack version package.json pins into ~/.protoboard/stack/<version> from a
|
|
287
|
+
registry (needs npm; --registry <url>), then link the project to it;
|
|
288
|
+
--from-vsix <extension folder | .vsix> is \`stack unpack\`
|
|
289
|
+
tools Tool runtimes the extensions declare, and whether they are installed here
|
|
290
|
+
tools install <cad|circuit|all>
|
|
291
|
+
Create the project's runtime in .protoboard/tools/ (a Python venv or Node
|
|
292
|
+
packages, exact pins from the extensions) and record it; probes, checks and
|
|
293
|
+
operations then use it without environment setup (--python <interpreter>
|
|
294
|
+
makes the venv; --clean starts over)
|
|
295
|
+
skills List installed skills
|
|
296
|
+
skills read <name> [file]
|
|
297
|
+
Print a skill's SKILL.md (or one of its files, such as references/<file>), by name:
|
|
298
|
+
how AGENTS.md tells an agent to read one, on any machine
|
|
299
|
+
skills export --host <claude-code|agents-md|all>
|
|
300
|
+
Put the skills where an agent host discovers them: .claude/skills/ (claude-code),
|
|
301
|
+
a list in AGENTS.md (agents-md), or both (all). A project made by init runs it
|
|
302
|
+
at the end of npm install (--on-install: both, one line, never fails)
|
|
303
|
+
extensions add <name...>
|
|
304
|
+
Add an extension to the project (ext-tscircuit, tscircuit and
|
|
305
|
+
@protoboardai/ext-tscircuit are one): package.json and protoboard.config.ts list it,
|
|
306
|
+
it is linked from the Protoboard installed for your user (no npm), and its skills
|
|
307
|
+
are exported. A project that installs its own packages (--stack project) fetches it
|
|
308
|
+
with its next npm install
|
|
309
|
+
run <operation> Run an extension operation (--input key=value; --authorize for external/physical)
|
|
310
|
+
run <capability>:<op> --module <instance path or module id>
|
|
311
|
+
Run a capability operation (generate|export|extract|check, or one the
|
|
312
|
+
provider adds, such as circuit:lock) through the provider named by the
|
|
313
|
+
module's <capability>.config.ts; --force for one that takes it
|
|
314
|
+
capabilities Capabilities, their providers, and which modules use which provider
|
|
315
|
+
operations List operation records
|
|
316
|
+
release [--version <v>]
|
|
317
|
+
Write the Manufacturing Package to release/<version>/ (@protoboardai/ext-release).
|
|
318
|
+
Refused while check reports errors, an output is stale or the project has
|
|
319
|
+
uncommitted changes (--allow-dirty records them; --check only reports)
|
|
320
|
+
evals Run the eval cases that installed extensions ship in evals/: each trial works on
|
|
321
|
+
a scratch copy of the case's fixture, then is graded; scorecard and summary go to
|
|
322
|
+
.protoboard/evals/<run>/ (--extension, --case, --trials, --jobs, --host, --judge,
|
|
323
|
+
--model, --no-skills, --keep, --work-dir, --out-dir, --list, --ci)
|
|
324
|
+
agents The agents found on this machine (Claude Code, Claude Desktop, Cursor, VS Code,
|
|
325
|
+
Windsurf, Codex, Gemini CLI, Cline) and whether each has Protoboard's MCP server
|
|
326
|
+
configured. Reads only
|
|
327
|
+
agents setup Add the MCP server to the agents' configuration, so an agent knows Protoboard from
|
|
328
|
+
the start. Asks about each agent; without a terminal, --agent <id> (repeatable) or
|
|
329
|
+
--all, with --yes. Changes only Protoboard's entry, keeps a copy of the file before
|
|
330
|
+
its first write, and says what it wrote where. With the first yes it also writes
|
|
331
|
+
Protoboard's launcher into its own folder. --scope project writes the project's
|
|
332
|
+
own file (.mcp.json, .cursor/mcp.json, .vscode/mcp.json, …)
|
|
333
|
+
agents remove Take the MCP server out again (the same options)
|
|
334
|
+
agents config The command, JSON and steps for any other MCP client. Prints only
|
|
335
|
+
agents refresh Write or update Protoboard's launcher (in its own folder; no agent's file), for a
|
|
336
|
+
client set up by hand from agents config
|
|
337
|
+
mcp The Protoboard MCP server on stdio: what an agent starts (it takes no arguments)
|
|
338
|
+
|
|
339
|
+
Options
|
|
340
|
+
--format text|json Output format (default text); --json is the same as --format json.
|
|
341
|
+
\`protoboard --version --json\` adds the build: commit, dirty, branch, source, release
|
|
342
|
+
--cwd <dir> Project directory (default: current)
|
|
343
|
+
--url <service> parts: the Protoboard library's URL (default PROTOBOARD_CLOUD_URL, then cloud.url in
|
|
344
|
+
protoboard.config.ts, then http://localhost:8787). Only parts commands, init
|
|
345
|
+
--template (its catalog parts) and login, logout and whoami use it; those three,
|
|
346
|
+
run outside a project, default to the hosted Protoboard library instead of
|
|
347
|
+
localhost.
|
|
348
|
+
--library <id> parts: the library to use (search: only that one)
|
|
349
|
+
--compiled parts add, parts update --to: vendor the definition JSON only, not its .uhd.ts
|
|
350
|
+
source
|
|
351
|
+
--device login: sign in with a code approved on another device (the default when this
|
|
352
|
+
terminal has no browser: no TTY, CI, SSH or no display)
|
|
353
|
+
--severity <level> check: lowest severity to print (error|warning|info, default warning)
|
|
354
|
+
--module <ref> run <capability>:<op>: instance path (board) or module id (watch-mainboard)
|
|
355
|
+
--port <n> dev: first port to try (default 5180; taken ports are skipped unless given)
|
|
356
|
+
--host <addr> dev: interface to bind (default 127.0.0.1)
|
|
357
|
+
--open dev: open the viewer in the default browser
|
|
358
|
+
--no-watch dev: serve the current build without watching
|
|
359
|
+
--host-origin <origin> dev: origin of a window framing the viewer that may drive it and hear its
|
|
360
|
+
events (repeatable; scheme://* and * allowed; default: the page's own origin
|
|
361
|
+
and VS Code webviews)
|
|
362
|
+
--allow-host <name> dev: a Host name the server answers besides the loopback names, for a proxy that
|
|
363
|
+
forwards the external one (repeatable; * answers any: only behind a proxy that
|
|
364
|
+
authenticates, as a browser-hosted VS Code's)
|
|
365
|
+
--python <path> tools install: interpreter that creates a Python venv (default: the first
|
|
366
|
+
Python found that works; the ones refused are listed with the reason)
|
|
367
|
+
--clean tools install: delete the runtime's folder first
|
|
368
|
+
--version <v> release: the release version (default: the root module's version)
|
|
369
|
+
--allow-dirty release: release from uncommitted changes (recorded in the manifest)
|
|
370
|
+
--check release: run the preconditions only; write nothing. libraries: ask each
|
|
371
|
+
library for its discovery document. layout: exit 1 when a level has a warning
|
|
372
|
+
--apply layout: lay the level(s) out afresh and write the display file where it reads better
|
|
373
|
+
--tidy layout: start from the saved arrangement, fix what reads badly, and write it
|
|
374
|
+
--pin <card> layout: a card (node id on that level) that keeps its place (repeatable)
|
|
375
|
+
--extension <id> evals: one extension (@protoboardai/ext-parts, ext-parts or parts)
|
|
376
|
+
--case <id> evals: one case, a pattern with * (author-*), or several, comma-separated
|
|
377
|
+
--trials <n> evals: trials per case (default 1)
|
|
378
|
+
--jobs <n> evals: fixtures and trials to run at once, across cases, into one scorecard (default 1)
|
|
379
|
+
--no-skills init: no postinstall that exports the skills (npm run skills:export does it by
|
|
380
|
+
hand). evals: the ablation arm: no skills exported, their documents left out of
|
|
381
|
+
the module mirror, no Skill tool
|
|
382
|
+
--on-install skills export: what a project's postinstall runs: both hosts, one line of output,
|
|
383
|
+
nothing when no skill is installed, and never a failure
|
|
384
|
+
--host <agent> evals: scripted (default; replays each case's known-good steps) or claude-code
|
|
385
|
+
(Claude Code headless: claude -p, no MCP servers, no user settings, the case's
|
|
386
|
+
tools only, inside the scratch copy)
|
|
387
|
+
--judge <judge> evals: claude-code scores rubric graders (default: off, rubric graders skipped)
|
|
388
|
+
--model <model> evals: the model for the claude-code host and judge
|
|
389
|
+
--keep evals: keep the fixture and trial directories
|
|
390
|
+
--work-dir <dir> evals: scratch folder (default: the system temp folder)
|
|
391
|
+
--out-dir <dir> evals: where scorecards go (default: .protoboard/evals in the project)
|
|
392
|
+
--list evals: list the cases without running them
|
|
393
|
+
--agent <id> agents setup, remove: one agent (repeatable); the ids are in agents --format json
|
|
394
|
+
--all agents setup, remove: every detected agent
|
|
395
|
+
--yes agents setup, remove: do not ask (needs --agent or --all). setup: install the
|
|
396
|
+
extension into every editor found, and add the command to PATH, without asking
|
|
397
|
+
--stack global|project init: where the project's packages come from (default global: links to the Protoboard
|
|
398
|
+
installed for your user; project: installed into the project with npm)
|
|
399
|
+
--vsix <file> setup: install this extension package instead, offline (default: the extension by its
|
|
400
|
+
id, from the Marketplace or Open VSX)
|
|
401
|
+
--editor <id> setup: only this editor (code, cursor, windsurf or codium; repeatable)
|
|
402
|
+
--no-agents setup: leave the agents' configuration alone
|
|
403
|
+
--scope user|project agents: your own configuration (default) or the project's shared file. A project's
|
|
404
|
+
entry is "npx --no-install protoboard mcp" when the project depends on the CLI
|
|
405
|
+
(--portable forces that, --no-portable writes this machine's launcher)
|
|
406
|
+
--ci evals: the CI hook: scripted host only, exit 1 on any failed trial or broken case
|
|
407
|
+
`,
|
|
408
|
+
{
|
|
409
|
+
importMeta: import.meta,
|
|
410
|
+
// --version is the release version; `protoboard --version` alone prints the CLI's
|
|
411
|
+
autoVersion: false,
|
|
412
|
+
flags: {
|
|
413
|
+
version: { type: "string" },
|
|
414
|
+
release: { type: "string" },
|
|
415
|
+
units: { type: "number" },
|
|
416
|
+
allowDirty: { type: "boolean", default: false },
|
|
417
|
+
check: { type: "boolean", default: false },
|
|
418
|
+
apply: { type: "boolean", default: false },
|
|
419
|
+
tidy: { type: "boolean", default: false },
|
|
420
|
+
pin: { type: "string", isMultiple: true },
|
|
421
|
+
format: { type: "string", default: "text" },
|
|
422
|
+
json: { type: "boolean", default: false },
|
|
423
|
+
cwd: { type: "string" },
|
|
424
|
+
url: { type: "string" },
|
|
425
|
+
library: { type: "string" },
|
|
426
|
+
taxonomy: { type: "string" },
|
|
427
|
+
authEnv: { type: "string" },
|
|
428
|
+
position: { type: "number" },
|
|
429
|
+
to: { type: "string" },
|
|
430
|
+
domain: { type: "string" },
|
|
431
|
+
protocol: { type: "string" },
|
|
432
|
+
tag: { type: "string" },
|
|
433
|
+
limit: { type: "number" },
|
|
434
|
+
cursor: { type: "string" },
|
|
435
|
+
sort: { type: "string" },
|
|
436
|
+
mpn: { type: "string" },
|
|
437
|
+
days: { type: "number" },
|
|
438
|
+
from: { type: "string" },
|
|
439
|
+
facets: { type: "boolean", default: false },
|
|
440
|
+
dryRun: { type: "boolean", default: false },
|
|
441
|
+
severity: { type: "string", default: "warning" },
|
|
442
|
+
host: { type: "string" },
|
|
443
|
+
hostOrigin: { type: "string", isMultiple: true },
|
|
444
|
+
allowHost: { type: "string", isMultiple: true },
|
|
445
|
+
input: { type: "string", isMultiple: true },
|
|
446
|
+
module: { type: "string" },
|
|
447
|
+
authorize: { type: "boolean", default: false },
|
|
448
|
+
python: { type: "string" },
|
|
449
|
+
port: { type: "number" },
|
|
450
|
+
open: { type: "boolean", default: false },
|
|
451
|
+
watch: { type: "boolean", default: true },
|
|
452
|
+
clean: { type: "boolean", default: false },
|
|
453
|
+
extension: { type: "string" },
|
|
454
|
+
case: { type: "string" },
|
|
455
|
+
trials: { type: "number", default: 1 },
|
|
456
|
+
jobs: { type: "number", default: 1 },
|
|
457
|
+
skills: { type: "boolean", default: true },
|
|
458
|
+
onInstall: { type: "boolean", default: false },
|
|
459
|
+
judge: { type: "string" },
|
|
460
|
+
model: { type: "string" },
|
|
461
|
+
keep: { type: "boolean", default: false },
|
|
462
|
+
workDir: { type: "string" },
|
|
463
|
+
outDir: { type: "string" },
|
|
464
|
+
list: { type: "boolean", default: false },
|
|
465
|
+
template: { type: "string" },
|
|
466
|
+
registry: { type: "string" },
|
|
467
|
+
stack: { type: "string" },
|
|
468
|
+
vsix: { type: "string" },
|
|
469
|
+
carried: { type: "boolean", default: false },
|
|
470
|
+
fromVsix: { type: "string" },
|
|
471
|
+
replace: { type: "boolean", default: false },
|
|
472
|
+
editor: { type: "string", isMultiple: true },
|
|
473
|
+
agents: { type: "boolean", default: true },
|
|
474
|
+
name: { type: "string" },
|
|
475
|
+
parts: { type: "boolean", default: true },
|
|
476
|
+
local: { type: "boolean", default: false },
|
|
477
|
+
compiled: { type: "boolean", default: false },
|
|
478
|
+
ci: { type: "boolean", default: false },
|
|
479
|
+
device: { type: "boolean", default: false },
|
|
480
|
+
force: { type: "boolean", default: false },
|
|
481
|
+
agent: { type: "string", isMultiple: true },
|
|
482
|
+
all: { type: "boolean", default: false },
|
|
483
|
+
yes: { type: "boolean", default: false },
|
|
484
|
+
scope: { type: "string" },
|
|
485
|
+
portable: { type: "boolean" },
|
|
486
|
+
refreshGuidance: { type: "boolean", default: false },
|
|
487
|
+
render: { type: "boolean", default: true },
|
|
488
|
+
timeout: { type: "number" },
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
const [command, ...args] = cli.input;
|
|
494
|
+
// `--json` is the same as `--format json`
|
|
495
|
+
const json = cli.flags.format === "json" || cli.flags.json === true;
|
|
496
|
+
|
|
497
|
+
/** Where this CLI's build came from (build-info.ts): what dogfooding reports name, and doctor shows. */
|
|
498
|
+
const provenance = () => buildProvenance({ cliDir: fileURLToPath(new URL("..", import.meta.url)), version: String(cli.pkg.version), carried: carriedStack() });
|
|
499
|
+
const cwd = resolve(cli.flags.cwd ?? process.cwd());
|
|
500
|
+
const out = (data: unknown, text: () => string) => process.stdout.write(json ? JSON.stringify(data, null, 2) + "\n" : text() + "\n");
|
|
501
|
+
|
|
502
|
+
/** One runtime's state for doctor and `tools`. */
|
|
503
|
+
const fmtRuntime = (r: RuntimeStatus) =>
|
|
504
|
+
[
|
|
505
|
+
` ${r.installed && !r.stale && !r.problems.length ? "✓" : "✗"} ${r.id.padEnd(10)} ${r.kind.padEnd(7)} ${
|
|
506
|
+
r.problems.length ? "conflicting declarations" : r.installed ? (r.stale ? `installed with other pins; run \`protoboard tools install ${r.id}\`` : `installed in ${r.record!.dir}`) : `not installed; run \`protoboard tools install ${r.id}\``
|
|
507
|
+
}`,
|
|
508
|
+
` ${r.packages.join(" ") || "(no packages)"} for ${r.provides.join(", ")}`,
|
|
509
|
+
...r.problems.map((x) => ` ✗ ${x}`),
|
|
510
|
+
].join("\n");
|
|
511
|
+
|
|
512
|
+
const SEV_RANK = { error: 0, warning: 1, info: 2 } as const;
|
|
513
|
+
const fmtDiag = (d: ProjectDiagnostic) =>
|
|
514
|
+
`${d.severity.toUpperCase().padEnd(7)} ${d.code} ${d.message}${d.source ? `\n at ${d.source.file}${d.source.line ? `:${d.source.line}` : ""}` : ""} [${d.validator}]`;
|
|
515
|
+
|
|
516
|
+
function summaryLine(p: Project) {
|
|
517
|
+
const c = (s: string) => p.diagnostics.filter((d) => d.severity === s).length;
|
|
518
|
+
return `${c("error")} error(s), ${c("warning")} warning(s), ${c("info")} info`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The services a cloud command would use (gate.ts): for a library command the
|
|
523
|
+
* project's libraries (or the one `--library` names), else the project's own
|
|
524
|
+
* Protoboard service. A library whose token comes from the environment
|
|
525
|
+
* (`authEnv`, set) is not asked: its token stands for the account.
|
|
526
|
+
*/
|
|
527
|
+
async function cloudServices(entry: GateEntry): Promise<string[]> {
|
|
528
|
+
const own = await projectService();
|
|
529
|
+
if (entry.uses !== "libraries" || !own.root || own.unread) return [own.url];
|
|
530
|
+
try {
|
|
531
|
+
const libs = configuredLibraries((await loadConfig(own.root)) ?? undefined, { url: cli.flags.url });
|
|
532
|
+
const named = cli.flags.library ? libs.filter((l) => l.id === cli.flags.library) : libs;
|
|
533
|
+
if (named.some((l) => isAuthEnvName(l.authEnv) && process.env[l.authEnv])) return [];
|
|
534
|
+
return named.length ? [...new Set(named.map((l) => l.url))] : [own.url];
|
|
535
|
+
} catch {
|
|
536
|
+
return [own.url];
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Before a cloud command (gate.ts): stop it, with one line and exit code 4,
|
|
542
|
+
* when none of the services it uses accepts this machine's sign-in. A service
|
|
543
|
+
* that cannot be reached, or signs no one in, lets the command go on. Local
|
|
544
|
+
* commands never come here.
|
|
545
|
+
*/
|
|
546
|
+
async function requireCloud(entry: GateEntry): Promise<void> {
|
|
547
|
+
// a command on the project's libraries outside a project stops on that ("no protoboard.config.ts"), which is the
|
|
548
|
+
// more useful line; it reaches no library either way
|
|
549
|
+
if (entry.uses === "libraries" && !findProjectRoot(cwd)) return;
|
|
550
|
+
const services = await cloudServices(entry);
|
|
551
|
+
if (!services.length) return;
|
|
552
|
+
const answers = await Promise.all(services.map((s) => checkAccess(s)));
|
|
553
|
+
if (answers.some((a) => a.allowed || a.reason === "unreachable")) return;
|
|
554
|
+
throw new AccessError(answers[0]!, entry.what);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The service a new project's library is: --url, PROTOBOARD_CLOUD_URL, --local (this machine's), else the hosted one (PB-915). */
|
|
558
|
+
const initService = () => cloudUrl(undefined, cli.flags.url || process.env.PROTOBOARD_CLOUD_URL || (cli.flags.local ? DEFAULT_CLOUD_URL : HOSTED_CLOUD_URL));
|
|
559
|
+
|
|
560
|
+
/** The service whose sign-in an account command is about (`login`, `whoami`, `doctor`): the project's Protoboard library, or for `init` the new project's. */
|
|
561
|
+
async function gateService(): Promise<string> {
|
|
562
|
+
if (command === "init") return initService();
|
|
563
|
+
return (await projectService()).url;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The service of the project here, for the sign-in: the one its configuration
|
|
568
|
+
* names. A project whose configuration cannot be loaded (its packages are not
|
|
569
|
+
* set up yet) does not send the sign-in to localhost (first builds review,
|
|
570
|
+
* item 23): the `cloud.url` written in the file, else the service this
|
|
571
|
+
* machine is signed in to, else the hosted library. `unread` says why the
|
|
572
|
+
* configuration did not load.
|
|
573
|
+
*/
|
|
574
|
+
async function projectService(): Promise<{ url: string; source: AccountServiceSource; root?: string; unread?: string }> {
|
|
575
|
+
const root = findProjectRoot(cwd);
|
|
576
|
+
if (!root) return accountService(undefined, { url: cli.flags.url, inProject: false });
|
|
577
|
+
try {
|
|
578
|
+
return { ...accountService((await loadConfig(root)) ?? undefined, { url: cli.flags.url, inProject: true }), root };
|
|
579
|
+
} catch (e) {
|
|
580
|
+
let text: string | undefined;
|
|
581
|
+
try {
|
|
582
|
+
text = readFileSync(join(root, CONFIG_FILE), "utf8");
|
|
583
|
+
} catch {
|
|
584
|
+
text = undefined;
|
|
585
|
+
}
|
|
586
|
+
return { ...unloadedProjectService(text, { url: cli.flags.url, saved: savedServices({ also: [HOSTED_CLOUD_URL] }) }), root, unread: (e as Error).message.split("\n")[0]! };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** This CLI's own entry, and whether npm installed it (it sits in a `node_modules`), as opposed to running from the stack's repository. */
|
|
591
|
+
const SELF = realpathOr(fileURLToPath(new URL("../bin/protoboard.mjs", import.meta.url)));
|
|
592
|
+
const INSTALLED_BY_NPM = /[\\/]node_modules[\\/]@protoboardai[\\/]cli[\\/]bin[\\/]/.test(SELF);
|
|
593
|
+
function realpathOr(p: string): string {
|
|
594
|
+
try {
|
|
595
|
+
return realpathSync(p);
|
|
596
|
+
} catch {
|
|
597
|
+
return p;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The user's global stack for a project (first builds review, D3): the copy
|
|
603
|
+
* this CLI runs from (the VS Code extension's), else, for a CLI npm
|
|
604
|
+
* installed, a copy in ~/.protoboard/stack that the project's range accepts
|
|
605
|
+
* and that has all its packages. From the stack's own repository there is
|
|
606
|
+
* none unless asked (`force`): its tests and examples install with npm.
|
|
607
|
+
*/
|
|
608
|
+
function globalStackFor(root: string, force = false): Stack | undefined {
|
|
609
|
+
return stackChoiceFor(root, force).stack;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Which copy a project would link to and why (PB-1024, PB-1037): one that
|
|
614
|
+
* fits the project, this CLI's version first (the copy it runs from, when it
|
|
615
|
+
* runs from one), saying when it falls back to another.
|
|
616
|
+
*/
|
|
617
|
+
function stackChoiceFor(root: string, force = false): StackChoice {
|
|
618
|
+
if (!carriedStack() && !INSTALLED_BY_NPM && !force) return { rejected: [] };
|
|
619
|
+
return chooseStack(root, { version: String(cli.pkg.version) });
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Why no copy fits a project, for the error that says so. */
|
|
623
|
+
const noStackFits = (choice: StackChoice) =>
|
|
624
|
+
`no Protoboard stack on this machine fits this project${choice.rejected.length ? ` (${choice.rejected.map((r) => `${r.version}: ${r.reason}`).join("; ")})` : ""}. \`protoboard stack unpack\` unpacks the stack an installed VS Code extension (or a .vsix) carries; \`protoboard stack install\` fetches the version package.json pins from a registry (it needs npm); the Protoboard VS Code extension carries one and needs neither (Initialize in its views). \`protoboard stack\` lists the copies in ${join(stackHome(), "stack")}`;
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* A project that has no packages of its own gets them from the global stack
|
|
628
|
+
* as links, said in one line (PB-929; D3). Nothing is fetched here: a project
|
|
629
|
+
* whose stack is not on this machine is left as it is, and its commands say
|
|
630
|
+
* what is missing.
|
|
631
|
+
*/
|
|
632
|
+
function linkIfCarried(): LinkResult | undefined {
|
|
633
|
+
const root = findProjectRoot(cwd);
|
|
634
|
+
if (!root || !needsLink(root)) return undefined;
|
|
635
|
+
const stack = globalStackFor(root);
|
|
636
|
+
return stack ? linkProject(root, stack) : undefined;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** What `npm install` does in a linked project (npm 10 and 11, tried): said wherever links are made. */
|
|
640
|
+
const NPM_AND_LINKS = "A later `npm install` keeps each link whose version package.json accepts and adds what is missing; a package whose range asks for another version is fetched from a registry instead.";
|
|
641
|
+
|
|
642
|
+
/** The extension packages a stack copy carries. */
|
|
643
|
+
function listExtensionsIn(dir: string): string[] {
|
|
644
|
+
try {
|
|
645
|
+
return readdirSync(join(dir, "node_modules", "@protoboardai")).filter((n) => n.startsWith("ext-")).map((n) => `@protoboardai/${n}`).sort();
|
|
646
|
+
} catch {
|
|
647
|
+
return [];
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** What a project's `npm install` would have run last: the skills of its extensions, exported. Never fails the command. */
|
|
652
|
+
async function exportSkillsAfterLink(root: string) {
|
|
653
|
+
try {
|
|
654
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { scripts?: Record<string, string> };
|
|
655
|
+
if (!/skills export/.test(pkg.scripts?.postinstall ?? "")) return;
|
|
656
|
+
const p = await loadProject(root, { checks: false });
|
|
657
|
+
const skills = resolveSkills(p, p.extensions).filter((s) => s.file);
|
|
658
|
+
if (!skills.length) return;
|
|
659
|
+
exportSkills(p, skills, "claude-code");
|
|
660
|
+
exportSkills(p, skills, "agents-md");
|
|
661
|
+
process.stderr.write(`protoboard: ${skills.length} skills of the project's extensions are in .claude/skills/ and listed in AGENTS.md\n`);
|
|
662
|
+
} catch (e) {
|
|
663
|
+
process.stderr.write(`protoboard: the extensions' skills were not exported (${(e as Error).message.split("\n")[0]}); run \`protoboard skills export --host all\` in the project\n`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* What doctor says about the project's own files (PB-1018): an AGENTS.md
|
|
669
|
+
* whose skills list points at one machine's paths (written before 0.1.7), an
|
|
670
|
+
* AGENTS.md that Claude Code does not read (no CLAUDE.md importing it), and
|
|
671
|
+
* vendored parts in the folder layout of 0.1.6 and earlier.
|
|
672
|
+
*/
|
|
673
|
+
function projectGuidance(root: string, catalog?: string): { id: string; ok: boolean; message: string }[] {
|
|
674
|
+
const out: { id: string; ok: boolean; message: string }[] = [];
|
|
675
|
+
const agents = join(root, "AGENTS.md");
|
|
676
|
+
if (existsSync(agents)) {
|
|
677
|
+
const paths = agentsSkillPaths(readFileSync(agents, "utf8"));
|
|
678
|
+
if (paths.length) out.push({ id: "agents-skill-paths", ok: false, message: `AGENTS.md lists its skills by paths on one machine (${paths.length}, such as ${paths[0]}), as Protoboard 0.1.6 and earlier wrote it; \`protoboard skills export --host agents-md\` rewrites the list with the skills' names` });
|
|
679
|
+
const claude = join(root, "CLAUDE.md");
|
|
680
|
+
if (!existsSync(claude)) out.push({ id: "claude-md", ok: false, message: "no CLAUDE.md beside AGENTS.md: Claude Code reads CLAUDE.md, not AGENTS.md; `protoboard init --refresh-guidance` writes one that imports AGENTS.md" });
|
|
681
|
+
else if (!/^@AGENTS\.md\s*$/m.test(readFileSync(claude, "utf8"))) out.push({ id: "claude-md", ok: false, message: "CLAUDE.md does not import AGENTS.md: add the line `@AGENTS.md` to it, or Claude Code does not read this project's guidance" });
|
|
682
|
+
const text = readFileSync(agents, "utf8");
|
|
683
|
+
if (withCurrentGuidance(text).how !== "unchanged") out.push({ id: "agents-guidance", ok: false, message: "AGENTS.md does not carry this stack's Protoboard guidance (it was written by an earlier Protoboard, or edited inside it); `protoboard init --refresh-guidance` brings it up to date and keeps the project's own sections" });
|
|
684
|
+
}
|
|
685
|
+
const earlier = partsToMigrate(root, catalog);
|
|
686
|
+
if (earlier.length) out.push({ id: "catalog-layout", ok: false, message: `${earlier.length} vendored part(s) in the folder layout of Protoboard 0.1.6 and earlier, whose paths can be too long for Windows; \`protoboard parts migrate\` moves them (offline)` });
|
|
687
|
+
return out;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function main() {
|
|
691
|
+
// the user's Protoboard folders (sign-ins, the stack) are readable by the user only, also when an earlier version made them (PB-1048)
|
|
692
|
+
try {
|
|
693
|
+
tightenUserFolders();
|
|
694
|
+
} catch {}
|
|
695
|
+
// meow prints help only for a bare `--help`; `protoboard <command> --help` must not run the command
|
|
696
|
+
// (what follows `--` is not an option: `parts search -- --help` searches for the word)
|
|
697
|
+
const given = process.argv.slice(2);
|
|
698
|
+
if ((given.includes("--") ? given.slice(0, given.indexOf("--")) : given).includes("--help")) cli.showHelp(0);
|
|
699
|
+
if (!command && cli.flags.version !== undefined) {
|
|
700
|
+
// the plain line stays the bare version (scripts read it); JSON adds where the build came from
|
|
701
|
+
if (json) out(provenance(), () => "");
|
|
702
|
+
else out({ version: cli.pkg.version }, () => String(cli.pkg.version));
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
// PB-929: a project without packages gets them from the stack copy this CLI runs from, before anything loads it:
|
|
706
|
+
// its configuration, which names the service the sign-in is for, loads only with them
|
|
707
|
+
// The account's own commands and `setup` do not: a sign-in is the user's, and its service is read without loading
|
|
708
|
+
// the project (projectService), so asking who is signed in never changes a project (first builds review, item 23)
|
|
709
|
+
// `upgrade` changes the ranges first and links after, so it does not link to a copy the old ranges choose
|
|
710
|
+
const linked = command && !["init", "stack", "login", "logout", "whoami", "setup", "upgrade"].includes(command) ? linkIfCarried() : undefined;
|
|
711
|
+
// PB-1024: the CLI and the stack the project links to are different versions (`stack` says it in its own report)
|
|
712
|
+
if (command && !["stack", "setup", "login", "logout", "whoami", "upgrade"].includes(command)) {
|
|
713
|
+
const root = findProjectRoot(cwd);
|
|
714
|
+
const mismatch = root ? stackVersionMismatch(root, String(cli.pkg.version)) : undefined;
|
|
715
|
+
if (mismatch) process.stderr.write(`protoboard: warning: ${mismatch}\n`);
|
|
716
|
+
}
|
|
717
|
+
// PB-928: only what uses the cloud needs a sign-in (gate.ts); local work never asks
|
|
718
|
+
const gate = commandGate(command, args[0], { check: cli.flags.check });
|
|
719
|
+
if (gate.gate === "cloud") {
|
|
720
|
+
try {
|
|
721
|
+
await requireCloud(gate);
|
|
722
|
+
} catch (e) {
|
|
723
|
+
// a refused command leaves the project as it was: the links made to read its configuration are taken back
|
|
724
|
+
if (linked) unlinkProject(linked);
|
|
725
|
+
throw e;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (linked) process.stderr.write(`protoboard: this project had no installed packages: ${linkSummary(linked)}. ${NPM_AND_LINKS}\n`);
|
|
729
|
+
if (linked?.linked.length) await exportSkillsAfterLink(linked.root);
|
|
730
|
+
switch (command) {
|
|
731
|
+
case "init": {
|
|
732
|
+
if (cli.flags.list) {
|
|
733
|
+
const ts = listTemplates();
|
|
734
|
+
out({ templates: ts }, () => ts.map((t) => `${t.id.padEnd(10)} ${t.title}\n${" ".repeat(11)}${t.description}\n${" ".repeat(11)}from ${t.derivedFrom}; extensions: ${t.extensions.join(", ")}`).join("\n") || "no templates installed");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (cli.flags.refreshGuidance) {
|
|
738
|
+
// an existing project's AGENTS.md and CLAUDE.md brought to this stack's guidance (F03), then its skills list
|
|
739
|
+
const root = findProjectRoot(resolve(cwd, args[0] ?? "."));
|
|
740
|
+
if (!root) throw new Error(`no protoboard.config.ts in ${resolve(cwd, args[0] ?? ".")} or its parents: --refresh-guidance works on an existing project`);
|
|
741
|
+
const r = await refreshProjectGuidance(root);
|
|
742
|
+
out(r, () => guidanceLines(r).join("\n"));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (!args[0]) throw new Error("usage: protoboard init <dir> [--template <drone|wearable|iot>] [--name <name>] [--stack global|project] [--url <parts service> | --local] [--no-parts] [--no-skills]");
|
|
746
|
+
if (cli.flags.stack !== undefined && cli.flags.stack !== "global" && cli.flags.stack !== "project") throw new Error(`--stack takes global (the default: the project uses the Protoboard installed for your user, with nothing installed into it) or project (its packages are installed into it with npm), not "${cli.flags.stack}"`);
|
|
747
|
+
const service = initService();
|
|
748
|
+
const r = initProject(args[0], { ...(cli.flags.template ? { template: cli.flags.template } : {}), ...(cli.flags.name ? { name: cli.flags.name } : {}), cloudUrl: service, skills: cli.flags.skills });
|
|
749
|
+
// the stack's packages are private: a project made from a registry's CLI (npm exec, which sets
|
|
750
|
+
// npm_config_registry) or given --registry installs them from the same registry
|
|
751
|
+
const registry = cli.flags.registry ?? (process.env.npm_config_registry && !/registry\.npmjs\.org/.test(process.env.npm_config_registry) ? process.env.npm_config_registry : undefined);
|
|
752
|
+
if (registry) {
|
|
753
|
+
const url = new URL(registry).href;
|
|
754
|
+
const npmrc = join(r.root, ".npmrc");
|
|
755
|
+
const lines = `# The Protoboard Stack's packages come from this registry (docs/guides/local-registry.md in the stack repository).\n@protoboardai:registry=${url}\n@deltarobotics:registry=${url}\n`;
|
|
756
|
+
// after the template's own settings, when it has some
|
|
757
|
+
if (existsSync(npmrc)) appendFileSync(npmrc, lines);
|
|
758
|
+
else {
|
|
759
|
+
writeFileSync(npmrc, lines);
|
|
760
|
+
r.files.push(".npmrc");
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// a template's starting system uses catalog parts: vendor them from the parts service now
|
|
764
|
+
let vendored: Awaited<ReturnType<typeof vendorTemplateParts>> | undefined;
|
|
765
|
+
let vendorError: string | undefined;
|
|
766
|
+
// the one cloud step of init (gate.ts): signed out, the project is still made and its parts wait for a sign-in
|
|
767
|
+
let partsSkipped: string | undefined;
|
|
768
|
+
if (r.catalogParts.length && cli.flags.parts) {
|
|
769
|
+
const access = await checkAccess(service);
|
|
770
|
+
if (!access.allowed && access.reason !== "unreachable") partsSkipped = new AccessError(access, `The starting system's ${r.catalogParts.length} library part(s)`).message;
|
|
771
|
+
}
|
|
772
|
+
if (r.catalogParts.length && cli.flags.parts && !partsSkipped) {
|
|
773
|
+
try {
|
|
774
|
+
vendored = await vendorTemplateParts(r.root, r.catalogParts, { url: service, accountTokens: await signedIn([service]) });
|
|
775
|
+
if (vendored.results.some((x) => x.status === "conflict")) vendorError = vendored.results.filter((x) => x.status === "conflict").map((x) => `${x.id}: ${x.message}`).join("; ");
|
|
776
|
+
} catch (e) {
|
|
777
|
+
vendorError = (e as Error).message;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const addCommand = `protoboard parts add ${r.catalogParts.join(" ")}`;
|
|
781
|
+
// how to run it in the project: the command on PATH when the project is linked to the user's stack, else the
|
|
782
|
+
// scoped npm name; never a bare `npx protoboard`, which fetches an unrelated public package of that name
|
|
783
|
+
const runIn = (command: string) => (link?.linked.length ? command : command.replace(/^protoboard /, "npx @protoboardai/cli "));
|
|
784
|
+
if (vendorError) process.exitCode = 1;
|
|
785
|
+
// Global by default (first builds review, D3): the project uses the Protoboard installed for the user, through
|
|
786
|
+
// links, with no npm install into it. `--stack project` (or `--registry`, the registry route as it was) leaves
|
|
787
|
+
// the packages to the project's own npm install.
|
|
788
|
+
const stackMode: "global" | "project" = cli.flags.stack === "project" || (cli.flags.stack === undefined && (cli.flags.registry !== undefined || (!carriedStack() && !INSTALLED_BY_NPM))) ? "project" : "global";
|
|
789
|
+
let link: LinkResult | undefined;
|
|
790
|
+
let linkError: string | undefined;
|
|
791
|
+
if (stackMode === "global") {
|
|
792
|
+
try {
|
|
793
|
+
// the copy this CLI runs from (the VS Code extension's), one already in ~/.protoboard/stack, or, for a CLI
|
|
794
|
+
// npm installed, one installed there now from the registry npm uses: once per version, for every project
|
|
795
|
+
let stack = globalStackFor(r.root, true);
|
|
796
|
+
if (!stack && !carriedStack()) {
|
|
797
|
+
process.stderr.write(`protoboard: setting up the Protoboard stack for your user in ${join(stackHome(), "stack")} (once per version, with npm; projects link to it)\n`);
|
|
798
|
+
stack = installStack(r.root, { ...(registry ? { registry } : {}), log: (l) => process.stderr.write(`${l}\n`) });
|
|
799
|
+
}
|
|
800
|
+
if (!stack) throw new Error("this CLI's stack copy does not have every package the project names");
|
|
801
|
+
link = linkProject(r.root, stack);
|
|
802
|
+
if (cli.flags.skills && link.linked.length) await exportSkillsAfterLink(r.root);
|
|
803
|
+
} catch (e) {
|
|
804
|
+
linkError = (e as Error).message;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
// first builds review, item 35: how to open it, and what VS Code asks the first time
|
|
808
|
+
const openIn = { command: "code", args: ["--new-window", r.root], note: "VS Code asks whether you trust the authors of the folder the first time it opens: say yes, or the Protoboard extension stays off in that window." };
|
|
809
|
+
// the skills are read from the installed extension packages, so they are exported by the project's npm install
|
|
810
|
+
const skillsLine = link?.linked.length
|
|
811
|
+
? cli.flags.skills
|
|
812
|
+
? "Skills: exported to .claude/skills/ and listed in AGENTS.md (`protoboard skills export --host all` does it again)."
|
|
813
|
+
: "Skills: not exported (--no-skills); `protoboard skills export --host all` puts them in .claude/skills/ and lists them in AGENTS.md."
|
|
814
|
+
: !cli.flags.skills
|
|
815
|
+
? "Skills: not exported at install (--no-skills); `npm run skills:export` after `npm install` puts them in .claude/skills/ and lists them in AGENTS.md."
|
|
816
|
+
: r.template
|
|
817
|
+
? `Skills: \`npm install\` exports the skills of the project's ${r.template.extensions.length} extensions to .claude/skills/ and lists them in AGENTS.md (its postinstall; \`npm run skills:export\` does it again).`
|
|
818
|
+
: "Skills: `npm install` exports the skills of the project's extension (@protoboardai/ext-hardware) to .claude/skills/ and lists them in AGENTS.md (`npm run skills:export` does it again).";
|
|
819
|
+
out({ ...r, skills: link?.linked.length && cli.flags.skills ? "exported" : cli.flags.skills ? "postinstall" : "manual", stackMode, open: openIn, ...(link ? { stack: link } : {}), ...(linkError ? { stackError: linkError } : {}), ...(vendored ? { service: vendored.service, parts: vendored.results } : {}), ...(vendorError ? { partsError: vendorError, partsCommand: addCommand } : {}), ...(partsSkipped ? { partsSkipped, partsCommand: addCommand } : {}) }, () =>
|
|
820
|
+
[
|
|
821
|
+
`Created ${r.root}${r.template ? ` from the ${r.template.id} template (${r.template.derivedFrom})` : ""}`,
|
|
822
|
+
...r.files.map((f) => ` ${f}`),
|
|
823
|
+
...(r.kept ?? []).map((f) => ` ${f} (kept as it was)`),
|
|
824
|
+
...(r.notes ?? []).map((n) => `Note: ${n}`),
|
|
825
|
+
...(vendored ? ["", `Catalog parts from ${vendored.service}:`, fmtAdd(vendored.results, false)] : []),
|
|
826
|
+
...(vendorError
|
|
827
|
+
? ["", `The catalog parts of the starting system were not vendored: ${vendorError}`, `With the parts service reachable (signed in: \`protoboard login\`), run in the project:`, ` ${runIn(addCommand)}`]
|
|
828
|
+
: partsSkipped
|
|
829
|
+
? ["", partsSkipped, "The project is made and works without them for now; after signing in, run in the project:", ` ${runIn(addCommand)}`]
|
|
830
|
+
: r.catalogParts.length && !cli.flags.parts
|
|
831
|
+
? ["", "Catalog parts not vendored (--no-parts); run in the project:", ` ${runIn(addCommand)}`]
|
|
832
|
+
: []),
|
|
833
|
+
"",
|
|
834
|
+
skillsLine,
|
|
835
|
+
...(link?.linked.length
|
|
836
|
+
? [
|
|
837
|
+
`Packages: ${linkSummary(link)}. No npm install is needed. ${NPM_AND_LINKS}`,
|
|
838
|
+
...(link.notProvided.length ? [` Not provided by the stack copy: ${link.notProvided.join(", ")} (only \`npm run typecheck\` needs them; \`npm install\` fetches them from the public npm registry and keeps the links).`] : []),
|
|
839
|
+
`Next: cd ${relative(process.cwd(), r.root) || "."}, then \`protoboard check\` and \`protoboard build\`.`,
|
|
840
|
+
]
|
|
841
|
+
: [
|
|
842
|
+
...(linkError
|
|
843
|
+
? [
|
|
844
|
+
`The project was not set up with the Protoboard installed for your user: ${linkError}`,
|
|
845
|
+
"It can still be set up without npm: open it in VS Code with the Protoboard extension and choose Initialize, or run `protoboard stack install` here once the registry answers.",
|
|
846
|
+
]
|
|
847
|
+
: []),
|
|
848
|
+
...(stackMode === "project" && !carriedStack() && !INSTALLED_BY_NPM && cli.flags.stack === undefined && cli.flags.registry === undefined ? ["Packages: this CLI runs from the stack's repository, so the project installs its own (an installed Protoboard sets a new project up with no npm install; --stack global asks for that here)."] : []),
|
|
849
|
+
`Next: cd ${relative(process.cwd(), r.root) || "."} && npm install, then \`npx @protoboardai/cli check\` and \`npx @protoboardai/cli build\`.`,
|
|
850
|
+
]),
|
|
851
|
+
"",
|
|
852
|
+
`Open it in VS Code, in a new window: ${openIn.command} ${openIn.args.map((a) => (/^[\w@%+=:,./-]+$/.test(a) ? a : JSON.stringify(a))).join(" ")}`,
|
|
853
|
+
` (or File > New Window, then Open Folder…). ${openIn.note}`,
|
|
854
|
+
].join("\n"),
|
|
855
|
+
);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
case "inspect": {
|
|
859
|
+
const p = await loadProject(cwd);
|
|
860
|
+
if (args[0] !== undefined) {
|
|
861
|
+
const r = inspectInstance(p, args[0]);
|
|
862
|
+
if (!r) throw new Error(`no instance "${args[0]}" (paths look like arm_fl/motor; see \`protoboard inspect\`)`);
|
|
863
|
+
out(r, () =>
|
|
864
|
+
[
|
|
865
|
+
`${r.path} ${r.name} (${r.definition}, ${r.kind}${r.quantity > 1 ? ` ×${r.quantity}` : ""})`,
|
|
866
|
+
`origin ${r.origin.tier} ${r.origin.source.file}${r.origin.source.line ? `:${r.origin.source.line}` : ""}${r.origin.revision !== undefined ? ` @ ${revisionLabel(r.origin.revision)}` : ""}${r.origin.service ? ` (${r.origin.service})` : ""}`,
|
|
867
|
+
r.origin.definitionSource ? `source ${r.origin.definitionSource.file}` : "",
|
|
868
|
+
r.manufacturer ? `part ${r.manufacturer} ${r.partNumber ?? ""}` : "",
|
|
869
|
+
`interfaces ${r.interfaces.map((i) => i.id + (i.geometry?.frame ? "◆" : "")).join(", ")}`,
|
|
870
|
+
r.children.length ? `children ${r.children.join(", ")}` : "",
|
|
871
|
+
`links ${r.links.map((l) => l.id).join(", ") || "—"}`,
|
|
872
|
+
`artifacts ${r.artifacts.map((a) => `${a.id}${a.present === true ? "" : a.present === "remote" ? " (url)" : " (missing)"}`).join(", ") || "—"}`,
|
|
873
|
+
...r.diagnostics.map(fmtDiag),
|
|
874
|
+
]
|
|
875
|
+
.filter(Boolean)
|
|
876
|
+
.join("\n"),
|
|
877
|
+
);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
const s = inspectProject(p);
|
|
881
|
+
out(s, () =>
|
|
882
|
+
[
|
|
883
|
+
`${s.project} (root ${s.root})`,
|
|
884
|
+
`${s.instances} instances · ${s.definitions.used} definitions used (${s.definitions.tiers.catalog} catalog, ${s.definitions.tiers["project-researched"]} project-researched, ${s.definitions.tiers["project-specific"]} project-specific) · ${s.links} links`,
|
|
885
|
+
`artifacts ${s.artifacts.present} present, ${s.artifacts.remote} remote (URL), ${s.artifacts.provenanceOnly} provenance-only, ${s.artifacts.missing} missing`,
|
|
886
|
+
"",
|
|
887
|
+
...s.children.map((c) => ` ${c.path.padEnd(18)} ${c.definition}${c.quantity > 1 ? ` ×${c.quantity}` : ""} [${c.kind}]`),
|
|
888
|
+
"",
|
|
889
|
+
...s.checks.map((c) => ` ${c.status === "ran" ? "✓" : "–"} ${c.validator.padEnd(24)} ${c.status === "ran" ? `${c.counts.error}E ${c.counts.warning}W ${c.counts.info}I` : `skipped: ${c.reason}`}${c.status === "ran" && c.reason ? ` (${c.reason})` : ""}`),
|
|
890
|
+
"",
|
|
891
|
+
summaryLine(p),
|
|
892
|
+
].join("\n"),
|
|
893
|
+
);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
case "artifacts": {
|
|
897
|
+
const p = await loadProject(cwd, { checks: false });
|
|
898
|
+
const r = listArtifacts(p, args[0]);
|
|
899
|
+
if (!r) throw new Error(`no instance "${args[0]}" (paths look like arm_fl/motor; see \`protoboard inspect\`)`);
|
|
900
|
+
const where = (a: (typeof r)[number]) => (a.present === true ? a.resolved! : a.present === "remote" ? a.url! : a.present === "provenance" ? "(reference only)" : `${a.filePath} (missing)`);
|
|
901
|
+
out({ artifacts: r }, () => r.map((a) => `${`${a.definition}#${a.id}`.padEnd(44)} ${(a.format ?? "").padEnd(6)} ${where(a)}\n${" ".repeat(45)}used by ${a.instances.join(", ") || "—"}`).join("\n") || "no artifacts");
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
case "check": {
|
|
905
|
+
const p = await loadProject(cwd);
|
|
906
|
+
// the diagram's levels of modules, as an agent should leave them (PB-1007): info, never a failure
|
|
907
|
+
const { layoutDiagnostics } = await import("./layout.js");
|
|
908
|
+
const layout = await layoutDiagnostics(p);
|
|
909
|
+
p.diagnostics.push(...layout);
|
|
910
|
+
if (p.rootDef) p.checks.push({ validator: "protoboard/layout", status: "ran", counts: { error: 0, warning: 0, info: layout.length } });
|
|
911
|
+
const min = SEV_RANK[cli.flags.severity as keyof typeof SEV_RANK] ?? 1;
|
|
912
|
+
const shown = p.diagnostics.filter((d) => SEV_RANK[d.severity] <= min).sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity]);
|
|
913
|
+
out({ checks: p.checks, diagnostics: p.diagnostics }, () =>
|
|
914
|
+
[
|
|
915
|
+
...shown.map(fmtDiag),
|
|
916
|
+
"",
|
|
917
|
+
...p.checks.map((c) => `${c.status === "ran" ? "✓" : "–"} ${c.validator}${c.subject ? ` [${c.subject}]` : ""}${c.status === "skipped" ? ` (skipped: ${c.reason})` : c.reason ? ` (${c.reason})` : ""}`),
|
|
918
|
+
summaryLine(p),
|
|
919
|
+
].join("\n"),
|
|
920
|
+
);
|
|
921
|
+
process.exitCode = errorCount(p) ? 1 : 0;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
case "layout": {
|
|
925
|
+
if (cli.flags.apply && cli.flags.tidy) throw new Error("give --apply or --tidy, not both");
|
|
926
|
+
const p = await loadProject(cwd, { checks: false });
|
|
927
|
+
const { runLayout, formatLayout } = await import("./layout.js");
|
|
928
|
+
const r = await runLayout(p, {
|
|
929
|
+
...(args[0] !== undefined ? { level: args[0] } : {}),
|
|
930
|
+
...(cli.flags.apply ? { apply: "default" as const } : cli.flags.tidy ? { apply: "tidy" as const } : {}),
|
|
931
|
+
...(cli.flags.pin?.length ? { pin: cli.flags.pin.flatMap((x) => x.split(",").map((y) => y.trim()).filter(Boolean)) } : {}),
|
|
932
|
+
force: cli.flags.force,
|
|
933
|
+
});
|
|
934
|
+
out(r, () => formatLayout(r));
|
|
935
|
+
process.exitCode = cli.flags.check && r.warnings ? 1 : 0;
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
case "build": {
|
|
939
|
+
const p = await loadProject(cwd);
|
|
940
|
+
const r = writeBuild(p);
|
|
941
|
+
const b = await writeBundle(p);
|
|
942
|
+
out({ ...r, bundle: b }, () => `wrote ${r.file} (${r.bytes} bytes, ${r.digest.slice(0, 23)}…)\nwrote ${b.file} (UHD bundle for viewers, ${b.bytes} bytes)\n${summaryLine(p)}`);
|
|
943
|
+
process.exitCode = errorCount(p) ? 1 : 0;
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
case "dev": {
|
|
947
|
+
const root = findProjectRoot(cwd);
|
|
948
|
+
if (!root) throw new Error(`no protoboard.config.ts in ${cwd} or its parents (run \`protoboard init\`)`);
|
|
949
|
+
const { startDev } = await import("./dev/server.js");
|
|
950
|
+
const clock = () => new Date().toTimeString().slice(0, 8);
|
|
951
|
+
const dev = await startDev({
|
|
952
|
+
root,
|
|
953
|
+
port: cli.flags.port,
|
|
954
|
+
strictPort: cli.flags.port !== undefined,
|
|
955
|
+
host: cli.flags.host,
|
|
956
|
+
watch: cli.flags.watch,
|
|
957
|
+
hostOrigins: cli.flags.hostOrigin?.flatMap((o) => o.split(",").map((x) => x.trim()).filter(Boolean)),
|
|
958
|
+
allowHosts: cli.flags.allowHost?.flatMap((o) => o.split(",").map((x) => x.trim()).filter(Boolean)),
|
|
959
|
+
log: (e) =>
|
|
960
|
+
json
|
|
961
|
+
? process.stdout.write(JSON.stringify({ time: new Date().toISOString(), ...e }) + "\n")
|
|
962
|
+
: (e.level === "info" ? process.stdout : process.stderr).write(`${clock()} ${e.level === "info" ? "" : `${e.level.toUpperCase()} `}${e.message}\n`),
|
|
963
|
+
});
|
|
964
|
+
if (cli.flags.open) openUrl(dev.url);
|
|
965
|
+
if (!json) process.stdout.write(`Press Ctrl+C to stop.\n`);
|
|
966
|
+
let stopping = false;
|
|
967
|
+
const stop = () => {
|
|
968
|
+
if (stopping) return;
|
|
969
|
+
stopping = true;
|
|
970
|
+
void dev.close().then(() => process.exit(0));
|
|
971
|
+
};
|
|
972
|
+
process.on("SIGINT", stop);
|
|
973
|
+
process.on("SIGTERM", stop);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
case "bom": {
|
|
977
|
+
const p = await loadProject(cwd, { checks: false });
|
|
978
|
+
const opts = { ...(cli.flags.release ? { release: cli.flags.release } : {}), ...(cli.flags.units !== undefined ? { units: cli.flags.units } : {}) };
|
|
979
|
+
if (args[0] === "units") {
|
|
980
|
+
const n = Number(args[1]);
|
|
981
|
+
if (!Number.isInteger(n) || n < 1) throw new Error("usage: protoboard bom units <n> (a whole number, 1 or more)");
|
|
982
|
+
const saved = writeBomSettings(p.root, { units: n });
|
|
983
|
+
out(saved, () => `Units to build: ${saved.units} (parts/bom.json)`);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (args[0] === "offers") {
|
|
987
|
+
if (!args[1]) throw new Error("usage: protoboard bom offers <line>");
|
|
988
|
+
const t = projectBom(p, opts);
|
|
989
|
+
const line = findLine(t, args[1]);
|
|
990
|
+
out({ line: line.key, units: t.units, needed: line.needed, chosen: line.chosen ?? null, choice: line.choice, offers: line.offers, quotes: line.quotes }, () => formatOffers(t, line));
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (args[0] === "choose") {
|
|
994
|
+
if (!args[1] || !args[2]) throw new Error("usage: protoboard bom choose <line> <offer id | supplier | auto>");
|
|
995
|
+
const r = chooseOffer(p, args[1], args.slice(2).join(" "));
|
|
996
|
+
out(r, () => (r.offer ? `${r.line}: ${r.supplier} is used (${r.offer}; parts/bom.json)` : `${r.line}: the best offer by the rules is used again (parts/bom.json)`));
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (args[0]) throw new Error(`unknown bom command "${args[0]}"; use: bom, bom units <n>, bom offers <line>, bom choose <line> <offer|supplier|auto>`);
|
|
1000
|
+
const t = projectBom(p, opts);
|
|
1001
|
+
if (cli.flags.format === "csv") process.stdout.write(bomCsv(t));
|
|
1002
|
+
else out(t, () => formatBom(t));
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
case "parts": {
|
|
1006
|
+
await parts(args[0], args.slice(1));
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
case "login":
|
|
1010
|
+
case "logout":
|
|
1011
|
+
case "whoami": {
|
|
1012
|
+
await account(command);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
case "libraries": {
|
|
1016
|
+
await libraries(args[0], args.slice(1));
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
case "doctor": {
|
|
1020
|
+
if (!findProjectRoot(cwd)) {
|
|
1021
|
+
// outside a project there is still a setup to explain: the sign-in, and which CLI and Node.js run
|
|
1022
|
+
const access = await checkAccess(await gateService());
|
|
1023
|
+
const packages = stackStatus();
|
|
1024
|
+
const note = `No project here (no protoboard.config.ts in ${cwd} or its parents): extensions, tools, runtimes and skills are reported inside a project (\`protoboard init\` makes one).`;
|
|
1025
|
+
const build = provenance();
|
|
1026
|
+
out({ framework: FRAMEWORK_VERSION, build, account: access, packages, project: null, note }, () => [`Protoboard ${FRAMEWORK_VERSION}`, `Build ${fmtProvenance(build)}`, "", "Account (the Protoboard library)", ` ${accessMark(access)} ${fmtAccess(access)}`, "", "Packages", ...fmtStack(packages).map((l) => ` ${l}`), "", note].join("\n"));
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const p = await loadProject(cwd, { checks: false });
|
|
1030
|
+
// a runtime's first probe in this project is slow (a cold import): said on stderr before it blocks, once per
|
|
1031
|
+
// line (an operation of several extensions names the same tool)
|
|
1032
|
+
const saidOnce = new Set<string>();
|
|
1033
|
+
const tools = probeTools(p.extensions, {
|
|
1034
|
+
root: p.root,
|
|
1035
|
+
log: (line) => {
|
|
1036
|
+
if (saidOnce.has(line)) return;
|
|
1037
|
+
saidOnce.add(line);
|
|
1038
|
+
process.stderr.write(`${line}\n`);
|
|
1039
|
+
},
|
|
1040
|
+
});
|
|
1041
|
+
const runtimes = runtimeStatus(p.root, p.extensions);
|
|
1042
|
+
const skills = resolveSkills(p, p.extensions);
|
|
1043
|
+
const extDiags = p.diagnostics.filter((d) => d.validator === "protoboard/extensions");
|
|
1044
|
+
// the sign-in (PB-928) and where the packages come from (PB-929): what a broken setup most often is
|
|
1045
|
+
const access = await checkAccess(await gateService());
|
|
1046
|
+
const packages = stackStatus();
|
|
1047
|
+
const guidance = projectGuidance(p.root, p.config.catalog);
|
|
1048
|
+
const build = provenance();
|
|
1049
|
+
const data = {
|
|
1050
|
+
framework: FRAMEWORK_VERSION,
|
|
1051
|
+
build,
|
|
1052
|
+
account: access,
|
|
1053
|
+
packages,
|
|
1054
|
+
guidance,
|
|
1055
|
+
extensions: p.extensions.map((e) => ({
|
|
1056
|
+
id: e.manifest.id,
|
|
1057
|
+
version: e.manifest.version,
|
|
1058
|
+
compatibility: e.manifest.compatibility,
|
|
1059
|
+
operations: (e.manifest.operations ?? []).map((o) => ({ id: o.id, effect: o.effect })),
|
|
1060
|
+
checks: (e.manifest.checks ?? []).map((c) => c.id),
|
|
1061
|
+
...(e.manifest.capabilities?.length ? { capabilities: e.manifest.capabilities.map((c) => c.id) } : {}),
|
|
1062
|
+
})),
|
|
1063
|
+
tools,
|
|
1064
|
+
runtimes: runtimes.map(({ record, ...r }) => ({ ...r, ...(record ? { dir: record.dir, installedAt: record.installedAt } : {}) })),
|
|
1065
|
+
skills: skills.map((s) => ({ id: s.id, extension: s.extension, found: !!s.file })),
|
|
1066
|
+
diagnostics: extDiags,
|
|
1067
|
+
};
|
|
1068
|
+
out(data, () =>
|
|
1069
|
+
[
|
|
1070
|
+
`Protoboard ${FRAMEWORK_VERSION} · ${p.name}`,
|
|
1071
|
+
`Build ${fmtProvenance(build)}`,
|
|
1072
|
+
"",
|
|
1073
|
+
"Account (the Protoboard library)",
|
|
1074
|
+
` ${accessMark(access)} ${fmtAccess(access)}`,
|
|
1075
|
+
"",
|
|
1076
|
+
"Packages",
|
|
1077
|
+
...fmtStack(packages).map((l) => ` ${l}`),
|
|
1078
|
+
"",
|
|
1079
|
+
"Extensions",
|
|
1080
|
+
...(data.extensions.length
|
|
1081
|
+
? data.extensions.map((e) => ` ✓ ${e.id}@${e.version} ops: ${e.operations.map((o) => `${o.id} (${o.effect})`).join(", ") || "—"} checks: ${e.checks.join(", ") || "—"}${e.capabilities ? ` provides: ${e.capabilities.join(", ")}` : ""}`)
|
|
1082
|
+
: [" (none configured)"]),
|
|
1083
|
+
...extDiags.map((d) => ` ✗ ${d.message}`),
|
|
1084
|
+
"",
|
|
1085
|
+
"Tools",
|
|
1086
|
+
...(tools.length
|
|
1087
|
+
? tools.map(
|
|
1088
|
+
(t) =>
|
|
1089
|
+
` ${t.available ? "✓" : "✗"} ${t.id.padEnd(20)} ${t.purpose}${t.source === "runtime" ? ` (project runtime ${t.runtime})` : t.source === "environment" ? " (from the environment)" : ""}${t.note ? ` (${t.note})` : ""}${t.available ? "" : `\n missing: ${t.detail}${t.install ? `\n install: ${t.install}` : ""}`}`,
|
|
1090
|
+
)
|
|
1091
|
+
: [" (no external tools required)"]),
|
|
1092
|
+
...(runtimes.length ? ["", "Runtimes (.protoboard/tools)", ...runtimes.map(fmtRuntime)] : []),
|
|
1093
|
+
"",
|
|
1094
|
+
`Skills (${skills.length})`,
|
|
1095
|
+
...skills.map((s) => ` ${s.file ? "✓" : "✗"} ${s.id.padEnd(24)} ${s.extension}`),
|
|
1096
|
+
...(guidance.length ? ["", "Project files", ...guidance.map((g) => ` ${g.ok ? "✓" : "✗"} ${g.message}`)] : []),
|
|
1097
|
+
].join("\n"),
|
|
1098
|
+
);
|
|
1099
|
+
process.exitCode = extDiags.some((d) => d.severity === "error") ? 1 : 0;
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
case "tools": {
|
|
1103
|
+
const p = await loadProject(cwd, { checks: false });
|
|
1104
|
+
if (args[0] === "install") {
|
|
1105
|
+
const ids = args.slice(1);
|
|
1106
|
+
if (!ids.length) throw new Error("usage: protoboard tools install <cad|circuit|all> [--python <interpreter>] [--clean]");
|
|
1107
|
+
const r = await installRuntimes(p.root, p.extensions, ids, {
|
|
1108
|
+
python: cli.flags.python,
|
|
1109
|
+
clean: cli.flags.clean,
|
|
1110
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
1111
|
+
});
|
|
1112
|
+
out(r, () =>
|
|
1113
|
+
r
|
|
1114
|
+
.map((x) =>
|
|
1115
|
+
[
|
|
1116
|
+
`${x.status === "installed" ? "✓" : "✗"} ${x.id} (${x.kind}) ${x.status === "installed" ? `installed in ${x.dir}` : "failed"}${x.message ? `: ${x.message}` : ""}`,
|
|
1117
|
+
...x.verified.map((v) => ` ${v.available ? "✓" : "✗"} ${v.tool} ${v.detail ?? ""}`),
|
|
1118
|
+
...(x.shadowed ?? []).map((v) => ` note: ${v.name} is set (${v.value}); doctor and operations use it instead of this runtime until you unset it`),
|
|
1119
|
+
].join("\n"),
|
|
1120
|
+
)
|
|
1121
|
+
.join("\n"),
|
|
1122
|
+
);
|
|
1123
|
+
process.exitCode = r.every((x) => x.status === "installed") ? 0 : 1;
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (args[0] !== undefined) throw new Error("usage: protoboard tools [install <cad|circuit|all>]");
|
|
1127
|
+
const r = runtimeStatus(p.root, p.extensions);
|
|
1128
|
+
out(r, () => (r.length ? ["Runtimes (.protoboard/tools)", ...r.map(fmtRuntime)].join("\n") : "no extension in this project declares a managed runtime"));
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
case "extensions": {
|
|
1132
|
+
if (args[0] !== "add" || args.length < 2) throw new Error("usage: protoboard extensions add <name...> (e.g. ext-tscircuit)");
|
|
1133
|
+
const root = findProjectRoot(cwd);
|
|
1134
|
+
if (!root) throw new Error(`no protoboard.config.ts in ${cwd} or its parents (run \`protoboard init\`)`);
|
|
1135
|
+
const wanted = args.slice(1).map(extensionPackage);
|
|
1136
|
+
// a project whose packages npm installed (a project-local stack) gets the extension from its own npm install;
|
|
1137
|
+
// every other project links it from the Protoboard installed for the user, which must carry it
|
|
1138
|
+
const local = packageState(root, "@protoboardai/core").state === "installed";
|
|
1139
|
+
const stack = local ? undefined : globalStackFor(root, true);
|
|
1140
|
+
if (!local) {
|
|
1141
|
+
if (!stack) throw new Error("the Protoboard installed for your user was not found for this project (`protoboard stack` says where it looks); nothing was changed");
|
|
1142
|
+
const missing = wanted.filter((w) => !existsSync(join(stack.dir, "node_modules", ...w.split("/"), "package.json")));
|
|
1143
|
+
if (missing.length) throw new Error(`${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not in the Protoboard installed for your user (${stack.marker.version}, ${stack.dir}); nothing was changed. The extensions it has: ${listExtensionsIn(stack.dir).join(", ") || "none"}`);
|
|
1144
|
+
}
|
|
1145
|
+
const added: Awaited<ReturnType<typeof addExtension>>[] = [];
|
|
1146
|
+
for (const w of wanted) added.push(await addExtension(root, w, { fallbackRange: `^${FRAMEWORK_VERSION}` }));
|
|
1147
|
+
const link = stack ? linkProject(root, stack) : undefined;
|
|
1148
|
+
let skills: string | undefined;
|
|
1149
|
+
try {
|
|
1150
|
+
const p = await loadProject(root, { checks: false });
|
|
1151
|
+
const found = resolveSkills(p, p.extensions).filter((x) => x.file);
|
|
1152
|
+
exportSkills(p, found, "claude-code");
|
|
1153
|
+
exportSkills(p, found, "agents-md");
|
|
1154
|
+
skills = `${found.length} skills in .claude/skills/ and listed in AGENTS.md`;
|
|
1155
|
+
} catch (e) {
|
|
1156
|
+
skills = `not exported (${(e as Error).message.split("\n")[0]}); \`protoboard skills export --host all\` does it`;
|
|
1157
|
+
}
|
|
1158
|
+
out({ added, ...(link ? { stack: link } : {}), local, skills }, () =>
|
|
1159
|
+
[
|
|
1160
|
+
...added.map((a) => `${a.packageJson === "present" && a.config === "present" ? "·" : "✓"} ${a.package}: ${a.packageJson === "added" ? `package.json ${a.range}` : "already in package.json"}; ${a.config === "added" ? "listed in protoboard.config.ts" : "already listed in protoboard.config.ts"}`),
|
|
1161
|
+
link ? `Packages: ${linkSummary(link)}.` : "This project installs its own packages (a project-local stack): its next `npm install` fetches the extension. Ask the person before running it.",
|
|
1162
|
+
`Skills: ${skills}.`,
|
|
1163
|
+
].join("\n"),
|
|
1164
|
+
);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
case "skills": {
|
|
1168
|
+
if (args[0] === "export" && cli.flags.onInstall) {
|
|
1169
|
+
// a project's postinstall: both hosts, one line, and never a failed install
|
|
1170
|
+
try {
|
|
1171
|
+
const p = await loadProject(cwd, { checks: false });
|
|
1172
|
+
const skills = resolveSkills(p, p.extensions).filter((s) => s.file);
|
|
1173
|
+
if (!skills.length) return;
|
|
1174
|
+
exportSkills(p, skills, "claude-code");
|
|
1175
|
+
exportSkills(p, skills, "agents-md");
|
|
1176
|
+
process.stdout.write(`protoboard: ${skills.length} skills of the installed extensions are in .claude/skills/ and listed in AGENTS.md (npm run skills:export does it again)\n`);
|
|
1177
|
+
} catch (e) {
|
|
1178
|
+
process.stdout.write(`protoboard: the extensions' skills were not exported (${(e as Error).message.split("\n")[0]}); run \`npm run skills:export\` in the project\n`);
|
|
1179
|
+
}
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
const p = await loadProject(cwd, { checks: false });
|
|
1183
|
+
const skills = resolveSkills(p, p.extensions);
|
|
1184
|
+
if (args[0] === "read") {
|
|
1185
|
+
// a skill by name, wherever this machine keeps it: what AGENTS.md tells an agent to run (PB-1018)
|
|
1186
|
+
if (!args[1] || args.length > 3) throw new Error("usage: protoboard skills read <name> [references/<file>]");
|
|
1187
|
+
const r = readSkill(skills, args[1], args[2]);
|
|
1188
|
+
out({ id: r.skill.id, extension: r.skill.extension, file: args[2] ?? "SKILL.md", files: r.files, text: r.text }, () => r.text.trimEnd() + (args[2] || !r.files.length ? "" : `\n\n---\nFiles of this skill (protoboard skills read ${r.skill.id} <file>): ${r.files.join(", ")}`));
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (args[0] === "export") {
|
|
1192
|
+
const host = cli.flags.host ?? "";
|
|
1193
|
+
if (!["claude-code", "agents-md", "all"].includes(host)) throw new Error("usage: protoboard skills export --host claude-code|agents-md|all");
|
|
1194
|
+
const hosts: SkillHost[] = host === "all" ? ["claude-code", "agents-md"] : [host as SkillHost];
|
|
1195
|
+
const each = hosts.map((h) => exportSkills(p, skills, h));
|
|
1196
|
+
const r = { written: each.flatMap((x) => x.written), skipped: [...new Set(each.flatMap((x) => x.skipped))] };
|
|
1197
|
+
out(r, () => `${r.written.length} written${r.skipped.length ? `, ${r.skipped.length} skipped (not found: ${r.skipped.join(", ")})` : ""}\n${r.written.map((w) => ` ${w}`).join("\n")}`);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
out(
|
|
1201
|
+
skills.map((s) => ({ ...s, file: s.file ? s.file : null })),
|
|
1202
|
+
() => skills.map((s) => `${s.id.padEnd(24)} ${s.summary}\n${" ".repeat(25)}${s.file ?? "(not found)"}${s.verifiedBy ? ` · verify: ${s.verifiedBy}` : ""}`).join("\n"),
|
|
1203
|
+
);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
case "run": {
|
|
1207
|
+
if (!args[0]) throw new Error("usage: protoboard run <operation> [--input key=value] [--authorize]");
|
|
1208
|
+
const p = await loadProject(cwd);
|
|
1209
|
+
const inputs = Object.fromEntries((cli.flags.input ?? []).map((kv) => [kv.split("=")[0], kv.split("=").slice(1).join("=")]));
|
|
1210
|
+
const [capability, capOp] = args[0].split(":");
|
|
1211
|
+
if (capOp !== undefined && (CAPABILITIES as readonly string[]).includes(capability)) {
|
|
1212
|
+
const bound = p.capabilities.filter((b) => b.capability === capability).map((b) => b.module ?? b.config);
|
|
1213
|
+
if (!cli.flags.module) throw new Error(`usage: protoboard run ${args[0]} --module <instance path or module id>; modules with a ${capability} config: ${bound.join(", ") || "none"}`);
|
|
1214
|
+
// --force: for an operation that declares a force input (circuit:generate on a locked board)
|
|
1215
|
+
if (cli.flags.force && inputs.force === undefined) inputs.force = "true";
|
|
1216
|
+
const rec = await runCapabilityOperation(p, capability, capOp, cli.flags.module, inputs, { authorize: cli.flags.authorize });
|
|
1217
|
+
out(rec, () =>
|
|
1218
|
+
[
|
|
1219
|
+
`${rec.status.toUpperCase()} ${rec.operation} ${rec.capability!.module} via ${rec.extension.id} ${rec.summary}`,
|
|
1220
|
+
...rec.outputs.map((o) => ` → ${o.path} (${o.bytes} bytes)`),
|
|
1221
|
+
...(rec.diagnostics ?? []).map(fmtDiag),
|
|
1222
|
+
].join("\n"),
|
|
1223
|
+
);
|
|
1224
|
+
process.exitCode = rec.status === "succeeded" ? 0 : 1;
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const found = findOperation(p.extensions, args[0]);
|
|
1228
|
+
if (!found) throw new Error(`no operation "${args[0]}" in the configured extensions (see \`protoboard doctor\`)`);
|
|
1229
|
+
const rec = await runOperation(p, found.ext, found.op, inputs, { authorize: cli.flags.authorize });
|
|
1230
|
+
out(rec, () => `${rec.status.toUpperCase()} ${found.ext.manifest.id}:${rec.operation} ${rec.summary}\n${rec.outputs.map((o) => ` → ${o.path} (${o.bytes} bytes)`).join("\n")}`);
|
|
1231
|
+
process.exitCode = rec.status === "succeeded" ? 0 : 1;
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
case "capabilities": {
|
|
1235
|
+
const p = await loadProject(cwd, { checks: false });
|
|
1236
|
+
const r = listCapabilities(p);
|
|
1237
|
+
const problems = p.diagnostics.filter((d) => d.validator === "protoboard/capabilities");
|
|
1238
|
+
out({ ...r, diagnostics: problems }, () =>
|
|
1239
|
+
[
|
|
1240
|
+
...r.capabilities.flatMap((c) => [
|
|
1241
|
+
`${c.id} (${c.operations.join(", ")}; outputs ${c.outputs.join(", ")})`,
|
|
1242
|
+
...(c.providers.length ? c.providers.map((x) => ` provider ${x.extension}@${x.version} native: ${x.native}/`) : [" (no provider installed)"]),
|
|
1243
|
+
...r.modules.filter((m) => m.capability === c.id).map((m) => ` ${m.status === "ready" ? "✓" : "✗"} ${(m.module ?? "?").padEnd(24)} ${m.provider} ${m.config}${m.instances.length ? ` [${m.instances.join(", ")}]` : ""}${m.problem ? `\n ${m.problem}` : ""}`),
|
|
1244
|
+
]),
|
|
1245
|
+
...problems.filter((d) => d.severity !== "error" || !r.modules.some((m) => m.problem === d.message)).map(fmtDiag),
|
|
1246
|
+
].join("\n"),
|
|
1247
|
+
);
|
|
1248
|
+
process.exitCode = problems.some((d) => d.severity === "error") ? 1 : 0;
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
case "release": {
|
|
1252
|
+
const p = await loadProject(cwd);
|
|
1253
|
+
const found = findOperation(p.extensions, "release");
|
|
1254
|
+
if (!found) throw new Error("protoboard release needs @protoboardai/ext-release: npm install it and add it to extensions in protoboard.config.ts");
|
|
1255
|
+
const inputs: Record<string, string> = { "allow-dirty": String(cli.flags.allowDirty), check: String(cli.flags.check) };
|
|
1256
|
+
if (cli.flags.version) inputs.version = cli.flags.version;
|
|
1257
|
+
const rec = await runOperation(p, found.ext, found.op, inputs, { record: !cli.flags.check });
|
|
1258
|
+
const data = (rec.data ?? {}) as { diagnostics?: { severity: string; code: string; message: string }[]; documents?: { path: string; pages: number }[] };
|
|
1259
|
+
out(rec, () =>
|
|
1260
|
+
[
|
|
1261
|
+
`${rec.status.toUpperCase()} release ${rec.summary}`,
|
|
1262
|
+
...(data.documents ?? []).map((d) => ` → ${d.path} (${d.pages} page${d.pages === 1 ? "" : "s"})`),
|
|
1263
|
+
...(data.diagnostics ?? []).filter((d) => d.severity !== "info").map((d) => `${d.severity.toUpperCase().padEnd(7)} ${d.code} ${d.message}`),
|
|
1264
|
+
].join("\n"),
|
|
1265
|
+
);
|
|
1266
|
+
process.exitCode = rec.status === "succeeded" ? 0 : 1;
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
case "operations": {
|
|
1270
|
+
const p = await loadProject(cwd, { checks: false, extensions: false });
|
|
1271
|
+
const recs = listOperationRecords(p);
|
|
1272
|
+
out(recs, () => recs.map((r) => `${r.record.startedAt} ${r.record.status.padEnd(9)} ${r.record.extension.id}:${r.record.operation} ${r.record.summary}`).join("\n") || "no operation records");
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
case "evals": {
|
|
1276
|
+
await evals();
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
case "views": {
|
|
1280
|
+
if (args[0] !== "check") throw new Error("usage: protoboard views check [--module <instance path or module id>] [--out-dir <folder for PNGs>] [--no-render] [--timeout <s>]");
|
|
1281
|
+
const { viewsCheck, formatViewsCheck } = await import("./views-check.js");
|
|
1282
|
+
const r = await viewsCheck({ cwd, module: cli.flags.module, out: cli.flags.outDir, render: cli.flags.render, timeout: cli.flags.timeout, log: json ? undefined : (line) => process.stderr.write(line + "\n") });
|
|
1283
|
+
out(r, () => formatViewsCheck(r));
|
|
1284
|
+
process.exitCode = r.failed || (!r.rendered && cli.flags.render) ? 1 : 0;
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
case "upgrade": {
|
|
1288
|
+
await upgradeCommand(args[0]);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
case "stack": {
|
|
1292
|
+
if (args[0] === "unpack") await stackUnpack(args[1]);
|
|
1293
|
+
else await stackCommand(args[0]);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
case "agents": {
|
|
1297
|
+
const { agentsCommand, terminalConfirm } = await import("./agents.js");
|
|
1298
|
+
await agentsCommand(args[0], args.slice(1), { agent: cli.flags.agent, all: cli.flags.all, yes: cli.flags.yes, scope: cli.flags.scope, portable: process.argv.includes("--portable") ? true : process.argv.includes("--no-portable") ? false : undefined }, { json, cwd, version: String(cli.pkg.version), out, confirm: terminalConfirm() });
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
case "setup": {
|
|
1302
|
+
if (args.length) throw new Error("usage: protoboard setup [--vsix <file>] [--editor code|cursor|windsurf|codium]… [--yes] [--no-agents]");
|
|
1303
|
+
// no sign-in and no service: the editors install the extension from their own galleries (PB-1047)
|
|
1304
|
+
const { setupCommand, setupExitCode } = await import("./setup.js");
|
|
1305
|
+
const { agentsCommand, terminalConfirm } = await import("./agents.js");
|
|
1306
|
+
const confirm = terminalConfirm();
|
|
1307
|
+
const r = await setupCommand(
|
|
1308
|
+
{ ...(cli.flags.vsix ? { vsix: cli.flags.vsix } : {}), yes: cli.flags.yes, ...(cli.flags.editor?.length ? { editor: cli.flags.editor } : {}), agents: cli.flags.agents },
|
|
1309
|
+
{
|
|
1310
|
+
json,
|
|
1311
|
+
cwd,
|
|
1312
|
+
out,
|
|
1313
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
1314
|
+
...(confirm ? { confirm } : {}),
|
|
1315
|
+
// the agents' set-up asks about each agent itself: never with this command's --yes
|
|
1316
|
+
agents: () => agentsCommand("setup", [], {}, { json, cwd, version: String(cli.pkg.version), out, confirm }),
|
|
1317
|
+
},
|
|
1318
|
+
);
|
|
1319
|
+
process.exitCode = process.exitCode || setupExitCode(r);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
case "mcp": {
|
|
1323
|
+
// the server an agent starts: stdout is the protocol's
|
|
1324
|
+
const { serveStdio } = await import("./mcp/server.js");
|
|
1325
|
+
await serveStdio(cli.flags.cwd ? { cwd } : {});
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
default:
|
|
1329
|
+
cli.showHelp(command ? 2 : 0);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** The mark beside the account line: signed out is not a problem of the setup. */
|
|
1334
|
+
const accessMark = (a: Access) => (a.allowed ? "✓" : a.reason === "signed-out" ? "–" : "✗");
|
|
1335
|
+
|
|
1336
|
+
/** The account line of `doctor` and `whoami`: the cloud's access, never a fault of the local setup. */
|
|
1337
|
+
function fmtAccess(a: Access): string {
|
|
1338
|
+
if (!a.allowed && a.reason === "signed-out") return `not signed in to ${a.service}: local work needs no account; the library (searching it, its parts, prices and publishing) does (sign in with \`protoboard login\`)`;
|
|
1339
|
+
if (!a.allowed) return a.message ?? "not allowed";
|
|
1340
|
+
switch (a.via) {
|
|
1341
|
+
case "open-service":
|
|
1342
|
+
return `${a.service} signs no one in: no account is needed`;
|
|
1343
|
+
case "service-token":
|
|
1344
|
+
return "PROTOBOARD_CLOUD_TOKEN is set (the service token)";
|
|
1345
|
+
case "offline":
|
|
1346
|
+
return `signed in${a.email ? ` as ${a.email}` : ""}; ${a.service} cannot be reached, and its last confirmation (${a.verifiedAt?.slice(0, 10)}) is accepted until ${a.offlineUntil?.slice(0, 10)} (${OFFLINE_GRACE_DAYS} days)`;
|
|
1347
|
+
default:
|
|
1348
|
+
return `signed in${a.email ? ` as ${a.email}` : ""}; ${a.service} accepts the account${a.via === "recent" ? ` (confirmed ${a.verifiedAt?.slice(0, 16).replace("T", " ")} UTC)` : ""}`;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** Where this CLI and the project's packages come from (`protoboard stack`, `doctor`). */
|
|
1353
|
+
function stackStatus() {
|
|
1354
|
+
const carried = carriedStack();
|
|
1355
|
+
const home = stackHome();
|
|
1356
|
+
const root = findProjectRoot(cwd);
|
|
1357
|
+
const deps = root ? projectDependencies(root) : {};
|
|
1358
|
+
return {
|
|
1359
|
+
cli: { version: String(cli.pkg.version), entry: fileURLToPath(new URL("../bin/protoboard.mjs", import.meta.url)), from: carried ? ("stack" as const) : ("npm" as const), ...(carried ? { stack: carried.dir, source: carried.marker.source } : {}) },
|
|
1360
|
+
node: { version: process.versions.node, path: process.execPath, ...(process.versions.electron ? { vscodeRuntime: true } : {}) },
|
|
1361
|
+
home,
|
|
1362
|
+
stacks: listStacks(home).map((s) => ({ version: s.marker.version, source: s.marker.source, dir: s.dir })),
|
|
1363
|
+
// PB-1024: the editors' Protoboard extensions, and whether the stack each carries is unpacked here
|
|
1364
|
+
extensions: installedExtensions(home).map((e) => ({ dir: e.dir, version: e.version, carries: e.carried?.version ?? null, unpacked: e.unpacked })),
|
|
1365
|
+
...(root
|
|
1366
|
+
? {
|
|
1367
|
+
project: {
|
|
1368
|
+
root,
|
|
1369
|
+
...(projectStackRange(root) ? { range: projectStackRange(root) } : {}),
|
|
1370
|
+
...(readLinkRecord(root) ? { link: readLinkRecord(root) } : {}),
|
|
1371
|
+
...(stackVersionMismatch(root, String(cli.pkg.version)) ? { mismatch: stackVersionMismatch(root, String(cli.pkg.version)) } : {}),
|
|
1372
|
+
packages: Object.keys(deps)
|
|
1373
|
+
.filter(isStackPackage)
|
|
1374
|
+
.sort()
|
|
1375
|
+
.map((name) => ({ name, ...packageState(root, name) })),
|
|
1376
|
+
},
|
|
1377
|
+
}
|
|
1378
|
+
: {}),
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function fmtStack(s: ReturnType<typeof stackStatus>): string[] {
|
|
1383
|
+
const pk = s.project?.packages ?? [];
|
|
1384
|
+
const count = (state: string) => pk.filter((x) => x.state === state).length;
|
|
1385
|
+
return [
|
|
1386
|
+
`CLI ${s.cli.version}: ${s.cli.from === "stack" ? `the stack copy ${s.cli.stack} (${s.cli.source === "vsix" ? "carried by the VS Code extension" : "installed from a registry"})` : `installed by npm (${s.cli.entry})`}`,
|
|
1387
|
+
`Node.js ${s.node.version}${s.node.vscodeRuntime ? " (VS Code's own runtime)" : ""}: ${s.node.path}`,
|
|
1388
|
+
...(s.project
|
|
1389
|
+
? [
|
|
1390
|
+
`Project packages${s.project.range ? ` (package.json asks for ${s.project.range})` : ""}: ${[count("installed") && `${count("installed")} installed by npm`, count("linked") && `${count("linked")} linked to ${s.project.link?.stack ?? "a stack copy"}`, count("broken-link") && `${count("broken-link")} links to a copy that is gone (\`protoboard stack link\`)`, count("missing") && `${count("missing")} missing (\`npm install\`, or \`protoboard stack link\`)`].filter(Boolean).join(", ") || "none named"}`,
|
|
1391
|
+
]
|
|
1392
|
+
: []),
|
|
1393
|
+
...(s.project?.mismatch ? [`Warning: ${s.project.mismatch}`] : []),
|
|
1394
|
+
`Stack copies in ${join(s.home, "stack")}: ${s.stacks.map((x) => `${x.version} (${x.source === "vsix" ? "VS Code extension" : "registry"})`).join(", ") || "none"}`,
|
|
1395
|
+
...s.extensions.map((e) =>
|
|
1396
|
+
e.carries === null
|
|
1397
|
+
? `VS Code extension ${e.version} (${e.dir}): carries no stack`
|
|
1398
|
+
: e.unpacked
|
|
1399
|
+
? `VS Code extension ${e.version} (${e.dir}): its stack ${e.carries} is unpacked`
|
|
1400
|
+
: `VS Code extension ${e.version} (${e.dir}): its stack ${e.carries} is not unpacked yet. \`protoboard stack unpack ${e.dir}\` unpacks it (the extension also does when VS Code starts it)`,
|
|
1401
|
+
),
|
|
1402
|
+
];
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** `init --refresh-guidance` (F03): AGENTS.md and CLAUDE.md brought to this stack's guidance, then the skills list. */
|
|
1406
|
+
async function refreshProjectGuidance(root: string) {
|
|
1407
|
+
const r = refreshGuidance(root);
|
|
1408
|
+
let skills: string | undefined;
|
|
1409
|
+
try {
|
|
1410
|
+
const p = await loadProject(root, { checks: false });
|
|
1411
|
+
const found = resolveSkills(p, p.extensions).filter((x) => x.file);
|
|
1412
|
+
if (found.length) {
|
|
1413
|
+
exportSkills(p, found, "claude-code");
|
|
1414
|
+
exportSkills(p, found, "agents-md");
|
|
1415
|
+
skills = `${found.length} skills of the project's extensions listed in AGENTS.md and copied to .claude/skills/`;
|
|
1416
|
+
}
|
|
1417
|
+
} catch (e) {
|
|
1418
|
+
skills = `the skills list was not refreshed (${(e as Error).message.split("\n")[0]}); run \`protoboard skills export --host all\``;
|
|
1419
|
+
}
|
|
1420
|
+
return { ...r, ...(skills ? { skills } : {}) };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function guidanceLines(r: Awaited<ReturnType<typeof refreshProjectGuidance>>): string[] {
|
|
1424
|
+
const said = { replaced: "its Protoboard guidance replaced with this stack's", inserted: "this stack's Protoboard guidance added", unchanged: "its Protoboard guidance already this stack's", written: "written, with this stack's Protoboard guidance" }[r.agents];
|
|
1425
|
+
return [`AGENTS.md: ${said}`, `CLAUDE.md: ${r.claude === "written" ? "written (it imports AGENTS.md)" : "kept as it was"}`, ...(r.skills ? [r.skills] : []), ...r.notes.map((n) => `note: ${n}`)];
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* `protoboard upgrade [dir]` (PB-1051): a project made by an earlier stack line moved to this one, offline.
|
|
1430
|
+
* 1. package.json: the @protoboardai/* ranges and the toolchain pins (core's planUpgrade);
|
|
1431
|
+
* 2. the packages: linked again to a copy that fits the new ranges (a project whose packages npm installed is
|
|
1432
|
+
* told to run npm install instead; nothing npm put there is replaced);
|
|
1433
|
+
* 3. AGENTS.md and CLAUDE.md: `init --refresh-guidance`;
|
|
1434
|
+
* 4. vendored parts: `parts migrate`.
|
|
1435
|
+
*/
|
|
1436
|
+
async function upgradeCommand(dir: string | undefined) {
|
|
1437
|
+
const root = findProjectRoot(resolve(cwd, dir ?? "."));
|
|
1438
|
+
if (!root) throw new Error(`no protoboard.config.ts in ${resolve(cwd, dir ?? ".")} or its parents: upgrade works on an existing project`);
|
|
1439
|
+
const plan = planUpgrade(root);
|
|
1440
|
+
const changeLines = plan.changes.map((c) => ` ${c.name}: ${c.from} → ${c.to} (${c.field})`);
|
|
1441
|
+
const keptLines = plan.kept.map((k) => ` ${k.name}: ${k.spec} kept (not a version range)`);
|
|
1442
|
+
if (cli.flags.dryRun) {
|
|
1443
|
+
out({ dryRun: true, ...plan }, () =>
|
|
1444
|
+
[plan.changes.length ? `package.json would change (${plan.from ?? "no stack range"} → ${plan.to}):` : `package.json already asks for ${plan.to}`, ...changeLines, ...keptLines, "then: the packages linked again, AGENTS.md and CLAUDE.md refreshed, vendored parts migrated. Nothing was written."].join("\n"),
|
|
1445
|
+
);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
applyUpgrade(plan);
|
|
1449
|
+
// the packages: npm-installed ones are the project's; links (or none) are re-pointed to a copy the new ranges accept
|
|
1450
|
+
const names = Object.keys(projectDependencies(root)).filter(isStackPackage);
|
|
1451
|
+
const installed = names.filter((n) => packageState(root, n).state === "installed");
|
|
1452
|
+
let link: LinkResult | undefined;
|
|
1453
|
+
let linkNote: string | undefined;
|
|
1454
|
+
if (installed.length) linkNote = `${installed.length} package(s) were installed by npm: run \`npm install\` in the project to fetch ${plan.to}`;
|
|
1455
|
+
else {
|
|
1456
|
+
const choice = stackChoiceFor(root, true);
|
|
1457
|
+
if (choice.stack) link = linkProject(root, choice.stack, { force: true });
|
|
1458
|
+
else linkNote = noStackFits(choice);
|
|
1459
|
+
}
|
|
1460
|
+
// guidance and parts need the project's configuration to load: with packages that do not fit yet, say what to run
|
|
1461
|
+
const loads = !installed.length && Boolean(link);
|
|
1462
|
+
const guidance = await refreshProjectGuidance(root);
|
|
1463
|
+
let migrated: Awaited<ReturnType<typeof migrateParts>> | undefined;
|
|
1464
|
+
let migrateNote: string | undefined;
|
|
1465
|
+
if (loads) {
|
|
1466
|
+
try {
|
|
1467
|
+
migrated = migrateParts(await loadProject(root, { checks: false }));
|
|
1468
|
+
} catch (e) {
|
|
1469
|
+
migrateNote = `vendored parts were not migrated (${(e as Error).message.split("\n")[0]}); run \`protoboard parts migrate\``;
|
|
1470
|
+
}
|
|
1471
|
+
} else migrateNote = "vendored parts: run `protoboard parts migrate` once the packages are installed";
|
|
1472
|
+
const moved = migrated?.filter((x) => x.status === "moved") ?? [];
|
|
1473
|
+
const next = loads ? "protoboard check" : installed.length ? "npm install, then protoboard parts migrate and protoboard check" : "protoboard stack unpack (or stack install), then protoboard upgrade again";
|
|
1474
|
+
out({ ...plan, ...(link ? { link } : {}), ...(linkNote ? { linkNote } : {}), guidance, ...(migrated ? { migrated: moved.map((m) => m.id) } : {}), ...(migrateNote ? { migrateNote } : {}), next }, () =>
|
|
1475
|
+
[
|
|
1476
|
+
plan.changes.length ? `package.json: ${plan.from ?? "no stack range"} → ${plan.to}` : `package.json: already ${plan.to}`,
|
|
1477
|
+
...changeLines,
|
|
1478
|
+
...keptLines,
|
|
1479
|
+
link ? linkSummary(link) : `packages: ${linkNote}`,
|
|
1480
|
+
...guidanceLines(guidance),
|
|
1481
|
+
migrated ? (moved.length ? `parts: ${moved.length} vendored part(s) moved to the current folder layout; parts/origins.json records the new paths` : "parts: every vendored part is in the current folder layout") : `parts: ${migrateNote}`,
|
|
1482
|
+
...(migrated && migrateNote ? [`parts: ${migrateNote}`] : []),
|
|
1483
|
+
`Next: ${next}. Review the changes and commit them with the project.`,
|
|
1484
|
+
].join("\n"),
|
|
1485
|
+
);
|
|
1486
|
+
if (link?.linked.length) await exportSkillsAfterLink(root);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/** `protoboard stack [link|install]` (PB-929). */
|
|
1490
|
+
async function stackCommand(sub: string | undefined) {
|
|
1491
|
+
if (sub === undefined) {
|
|
1492
|
+
const s = stackStatus();
|
|
1493
|
+
out(s, () => fmtStack(s).join("\n"));
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const root = findProjectRoot(cwd);
|
|
1497
|
+
if (!root) throw new Error(`no protoboard.config.ts in ${cwd} or its parents (run \`protoboard init\`)`);
|
|
1498
|
+
if (sub === "link") {
|
|
1499
|
+
// a copy that fits the project, this CLI's version first (D3: the user's global stack; PB-1024). --carried: the copy
|
|
1500
|
+
// this CLI runs from whatever package.json asks (the extension's "Update Project to the Stack This Extension Carries")
|
|
1501
|
+
let choice: StackChoice;
|
|
1502
|
+
if (cli.flags.carried) {
|
|
1503
|
+
const carried = carriedStack();
|
|
1504
|
+
if (!carried) throw new Error("--carried: this CLI does not run from a stack copy (it was installed by npm, or runs from the stack's repository)");
|
|
1505
|
+
choice = { stack: carried, preferred: carried.marker.version, rejected: [] };
|
|
1506
|
+
} else choice = stackChoiceFor(root, true);
|
|
1507
|
+
const stack = choice.stack;
|
|
1508
|
+
if (!stack) throw new Error(noStackFits(choice));
|
|
1509
|
+
const r = linkProject(root, stack, { force: cli.flags.force });
|
|
1510
|
+
const fallback = choice.fallback ? `${choice.fallback}; linked ${stack.marker.version} instead` : undefined;
|
|
1511
|
+
const mismatch = stackVersionMismatch(root, String(cli.pkg.version));
|
|
1512
|
+
if (fallback) process.stderr.write(`protoboard: ${fallback}\n`);
|
|
1513
|
+
else if (mismatch) process.stderr.write(`protoboard: warning: ${mismatch}\n`);
|
|
1514
|
+
out({ ...r, ...(choice.preferred ? { preferred: choice.preferred } : {}), ...(fallback ? { fallback } : {}), ...(mismatch ? { mismatch } : {}) }, () =>
|
|
1515
|
+
[
|
|
1516
|
+
linkSummary(r),
|
|
1517
|
+
...notProvidedLines(root, r.notProvided),
|
|
1518
|
+
...(r.range && r.satisfied === false ? [`package.json asks for ${r.range} and this copy is ${r.version}: \`protoboard stack install\` fetches a matching one from a registry, or change the ranges in package.json`] : []),
|
|
1519
|
+
...(r.linked.length && r.made.length === 0 && r.version !== String(cli.pkg.version) && !cli.flags.force ? [`the links were there already; \`protoboard stack link --force\` re-points them to the copy chosen now`] : []),
|
|
1520
|
+
].join("\n"),
|
|
1521
|
+
);
|
|
1522
|
+
if (r.linked.length) await exportSkillsAfterLink(root);
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
if (sub === "install" && cli.flags.fromVsix !== undefined) return stackUnpack(cli.flags.fromVsix);
|
|
1526
|
+
if (sub === "install") {
|
|
1527
|
+
const stack = installStack(root, { ...(cli.flags.registry ? { registry: cli.flags.registry } : {}), log: (l) => process.stderr.write(`${l}\n`) });
|
|
1528
|
+
const r = linkProject(root, stack, { force: true });
|
|
1529
|
+
out({ stack: stack.dir, version: stack.marker.version, link: r }, () => `installed stack ${stack.marker.version} in ${stack.dir}\n${linkSummary(r)}`);
|
|
1530
|
+
if (r.linked.length) await exportSkillsAfterLink(root);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
throw new Error("usage: protoboard stack [link [--force] [--carried] | unpack [<extension folder | .vsix>] | install [--registry <url> | --from-vsix <extension folder | .vsix>]]");
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* `protoboard stack unpack [<extension folder | .vsix>]` (PB-1024): the stack
|
|
1538
|
+
* a VS Code extension carries, unpacked into ~/.protoboard/stack with the
|
|
1539
|
+
* extension's own checked steps; then, in a project it fits, linked.
|
|
1540
|
+
*/
|
|
1541
|
+
async function stackUnpack(given: string | undefined) {
|
|
1542
|
+
const home = stackHome();
|
|
1543
|
+
let from = given ? resolve(cwd, given) : undefined;
|
|
1544
|
+
if (!from) {
|
|
1545
|
+
const found = installedExtensions(home).filter((e) => e.carried);
|
|
1546
|
+
const pick = found.find((e) => e.version === String(cli.pkg.version)) ?? found[0];
|
|
1547
|
+
if (!pick) throw new Error(`no Protoboard VS Code extension that carries a stack is installed in ${EDITOR_EXTENSION_DIRS.map((d) => `~/${d}`).join(", ")}: give its folder or a .vsix file (protoboard stack unpack <path>)`);
|
|
1548
|
+
from = pick.dir;
|
|
1549
|
+
}
|
|
1550
|
+
let last = -1;
|
|
1551
|
+
const r = await unpackStackFrom(from, home, {
|
|
1552
|
+
// a copy of the same version from another build is replaced only when asked (PB-1048)
|
|
1553
|
+
replace: cli.flags.replace,
|
|
1554
|
+
onProgress: (f, what) => {
|
|
1555
|
+
const pct = Math.floor(f * 10) * 10;
|
|
1556
|
+
if (pct !== last) process.stderr.write(`protoboard: ${what} (${pct}%)\n`);
|
|
1557
|
+
last = pct;
|
|
1558
|
+
},
|
|
1559
|
+
});
|
|
1560
|
+
const root = findProjectRoot(cwd);
|
|
1561
|
+
const unfit = root ? unfitReason(root, r.stack) : undefined;
|
|
1562
|
+
const link = root && !unfit ? linkProject(root, r.stack, { force: cli.flags.force }) : undefined;
|
|
1563
|
+
out({ version: r.version, stack: r.stack.dir, unpacked: r.unpacked, from, ...(link ? { link } : {}), ...(root && unfit ? { notLinked: unfit } : {}) }, () =>
|
|
1564
|
+
[
|
|
1565
|
+
r.unpacked ? `unpacked the Protoboard stack ${r.version} into ${r.stack.dir} (from ${from})` : `the Protoboard stack ${r.version} from ${from} is in ${r.stack.dir} already`,
|
|
1566
|
+
...(link ? [linkSummary(link)] : []),
|
|
1567
|
+
...(root && unfit ? [`this project was not linked to it: ${unfit}`] : []),
|
|
1568
|
+
].join("\n"),
|
|
1569
|
+
);
|
|
1570
|
+
if (link?.linked.length) await exportSkillsAfterLink(link.root);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/** `protoboard evals`: discover the suites installed here, run them through one host, write the scorecard. */
|
|
1574
|
+
async function evals() {
|
|
1575
|
+
const root = findProjectRoot(cwd) ?? cwd;
|
|
1576
|
+
if (cli.flags.list) {
|
|
1577
|
+
const suites = await loadSuites(cwd, { extension: cli.flags.extension, case: cli.flags.case });
|
|
1578
|
+
const rows = suites.flatMap((s) => s.cases.map((c) => ({ key: c.key, title: c.title, requires: c.requires ?? [], budget: c.budget, graders: c.graders.map((g) => g.kind) })));
|
|
1579
|
+
const problems = suites.flatMap((s) => s.problems.map((p) => ({ extension: s.extension, ...p })));
|
|
1580
|
+
out({ cases: rows, problems }, () =>
|
|
1581
|
+
[
|
|
1582
|
+
...rows.map((r) => `${r.key}\n ${r.title} (${r.budget.turns} turns, ${r.budget.seconds} s; graders: ${r.graders.join(", ")}${r.requires.length ? `; needs ${r.requires.join(", ")}` : ""})`),
|
|
1583
|
+
...problems.map((p) => `✗ ${p.extension} ${p.dir}: ${p.message}`),
|
|
1584
|
+
`${rows.length} case(s)`,
|
|
1585
|
+
].join("\n"),
|
|
1586
|
+
);
|
|
1587
|
+
process.exitCode = problems.length ? 1 : 0;
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
const hostId = cli.flags.host ?? "scripted";
|
|
1591
|
+
if (!(cli.flags.jobs >= 1)) throw new Error("--jobs is a whole number, 1 or more");
|
|
1592
|
+
if (cli.flags.ci && (hostId !== "scripted" || cli.flags.judge)) throw new Error("--ci runs the scripted host only, without a judge (agent runs cost money and are not deterministic)");
|
|
1593
|
+
const hosts: Record<string, () => AgentHost> = { scripted: () => scriptedHost, "claude-code": () => claudeCodeHost({ model: cli.flags.model }) };
|
|
1594
|
+
if (!hosts[hostId]) throw new Error(`unknown host ${hostId}; use scripted or claude-code`);
|
|
1595
|
+
if (cli.flags.judge !== undefined && cli.flags.judge !== "claude-code") throw new Error(`unknown judge ${cli.flags.judge}; use claude-code`);
|
|
1596
|
+
const r = await runEvals({
|
|
1597
|
+
from: cwd,
|
|
1598
|
+
extension: cli.flags.extension,
|
|
1599
|
+
case: cli.flags.case,
|
|
1600
|
+
trials: cli.flags.trials,
|
|
1601
|
+
jobs: cli.flags.jobs,
|
|
1602
|
+
skills: cli.flags.skills,
|
|
1603
|
+
host: hosts[hostId](),
|
|
1604
|
+
judge: cli.flags.judge ? claudeCodeJudge({ model: cli.flags.model }) : undefined,
|
|
1605
|
+
cli: [process.execPath, fileURLToPath(new URL("../bin/protoboard.mjs", import.meta.url))],
|
|
1606
|
+
outDir: cli.flags.outDir ? resolve(cli.flags.outDir) : join(root, ".protoboard", "evals"),
|
|
1607
|
+
workDir: cli.flags.workDir ? resolve(cli.flags.workDir) : undefined,
|
|
1608
|
+
keep: cli.flags.keep,
|
|
1609
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
1610
|
+
});
|
|
1611
|
+
out(r.scorecard, () => `${r.summary}\nScorecard: ${r.file}`);
|
|
1612
|
+
const t = r.scorecard.totals;
|
|
1613
|
+
process.exitCode = t.errors || r.scorecard.problems.length || t.passed < t.trials ? 1 : 0;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const fmtAdd = (r: AddResult[], showLibrary = false) =>
|
|
1617
|
+
r
|
|
1618
|
+
.map((x) => `${x.status.padEnd(10)} ${x.id}${x.revision ? `@${x.revision}` : ""}${showLibrary && x.library ? ` from ${x.library}` : ""}${x.requiredBy ? ` (for ${x.requiredBy})` : ""}${x.files ? ` ${x.files} file(s)` : ""}${x.message ? `\n ${x.message}` : ""}`)
|
|
1619
|
+
.join("\n");
|
|
1620
|
+
|
|
1621
|
+
/** `parts search|add|update|offers|prices|publish` (and `usage`, for the library's publishers): the only commands that talk to part libraries. */
|
|
1622
|
+
async function parts(sub: string | undefined, rest: string[]) {
|
|
1623
|
+
const usage = "usage: protoboard parts list [--check] | search [words] [--sort <order>] | taxonomy [words] | add <id[@rev]...> | update [id...] [--to <rev>] | offers [id...] | prices <id> [--mpn <code>] | publish <path> [--dry-run] | migrate";
|
|
1624
|
+
// `usage` is the library owners' command (publishers only): it works, and is left out of the help users read
|
|
1625
|
+
if (!["list", "search", "taxonomy", "add", "update", "offers", "prices", "usage", "publish", "migrate"].includes(sub ?? "")) throw new Error(usage);
|
|
1626
|
+
const p = await loadProject(cwd, { checks: sub === "publish" });
|
|
1627
|
+
if (sub === "list" && !cli.flags.check) return listParts(projectParts(p));
|
|
1628
|
+
if (sub === "migrate") {
|
|
1629
|
+
// offline: the vendored files move to the short layout, their bytes and pins stay (PB-1018)
|
|
1630
|
+
const r = migrateParts(p);
|
|
1631
|
+
const moved = r.filter((x) => x.status === "moved");
|
|
1632
|
+
out({ parts: r }, () =>
|
|
1633
|
+
moved.length
|
|
1634
|
+
? [
|
|
1635
|
+
`${moved.length} vendored part(s) moved to the short folder layout; parts/origins.json records the new paths`,
|
|
1636
|
+
...moved.map((x) => ` ${x.id}: ${x.fromFiles ?? x.from}/ → ${x.to}/ (${x.files} file(s))${x.missing?.length ? `; not there to move: ${x.missing.length} (\`protoboard parts add ${x.id}\` restores them)` : ""}${x.leftOver?.length ? `; left in ${x.from}/: ${x.leftOver.join(", ")}` : ""}`),
|
|
1637
|
+
"Commit the moves with parts/origins.json.",
|
|
1638
|
+
].join("\n")
|
|
1639
|
+
: `every vendored part is already in the short folder layout (${r.length} checked)`,
|
|
1640
|
+
);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
// the user's sign-ins (protoboard login) for these libraries, refreshed; never read from or written to the project
|
|
1644
|
+
const accountTokens = await signedIn(configuredLibraries(p.config, { url: cli.flags.url }).map((l) => l.url));
|
|
1645
|
+
const libs = connectLibraries(p.config, { url: cli.flags.url, accountTokens });
|
|
1646
|
+
const named = cli.flags.library;
|
|
1647
|
+
if (named && !libs.some((l) => l.id === named)) throw new Error(`no library "${named}" in protoboard.config.ts (configured: ${libs.map((l) => l.id).join(", ")}; \`protoboard libraries\`)`);
|
|
1648
|
+
const service = libs[0].url;
|
|
1649
|
+
switch (sub) {
|
|
1650
|
+
case "list": {
|
|
1651
|
+
// the libraries asked once each; a library that cannot answer leaves its parts as pinned
|
|
1652
|
+
const report = projectParts(p);
|
|
1653
|
+
const problems: string[] = [];
|
|
1654
|
+
const updates: UpdateReport[] = [];
|
|
1655
|
+
for (const lib of libs) {
|
|
1656
|
+
const ids = report.parts.filter((x) => x.tier === "catalog" && (x.library ?? DEFAULT_LIBRARY) === lib.id).map((x) => x.id);
|
|
1657
|
+
if (!ids.length) continue;
|
|
1658
|
+
try {
|
|
1659
|
+
updates.push(...(await checkPartUpdates(p, ids, lib)));
|
|
1660
|
+
} catch (e) {
|
|
1661
|
+
problems.push(`${lib.id}: ${(e as Error).message}`);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return listParts(withUpdates(report, updates, new Date().toISOString(), problems));
|
|
1665
|
+
}
|
|
1666
|
+
case "search": {
|
|
1667
|
+
const asked = named ? libs.filter((l) => l.id === named) : libs;
|
|
1668
|
+
const sort = cli.flags.sort;
|
|
1669
|
+
if (sort !== undefined && !isLibrarySort(sort)) throw new Error(`--sort takes ${LIBRARY_SORTS.join(", ")}; not "${sort}"`);
|
|
1670
|
+
const r = await searchLibraries(asked, {
|
|
1671
|
+
...(sort ? { sort } : {}),
|
|
1672
|
+
q: rest.join(" "),
|
|
1673
|
+
taxonomy: cli.flags.taxonomy,
|
|
1674
|
+
domain: cli.flags.domain,
|
|
1675
|
+
protocol: cli.flags.protocol,
|
|
1676
|
+
tag: cli.flags.tag,
|
|
1677
|
+
limit: cli.flags.limit,
|
|
1678
|
+
...(cli.flags.cursor ? { cursor: cli.flags.cursor } : {}),
|
|
1679
|
+
...(cli.flags.facets ? { facets: true } : {}),
|
|
1680
|
+
});
|
|
1681
|
+
const width = Math.max(...r.parts.map((x) => x.library.length), 0);
|
|
1682
|
+
out({ service: asked[0].url, ...r }, () =>
|
|
1683
|
+
[
|
|
1684
|
+
asked.length === 1 && !r.libraries[0].error
|
|
1685
|
+
? `${r.total} part(s) at ${asked[0].url}${r.parts.length < r.total ? ` (showing ${r.parts.length})` : ""}`
|
|
1686
|
+
: `${r.total} part(s) in ${asked.length} libraries${r.parts.length < r.total ? ` (showing ${r.parts.length})` : ""}: ${r.libraries.map((l) => (l.error ? `${l.id} failed` : `${l.id} ${l.total}`)).join(", ")}`,
|
|
1687
|
+
...r.parts.map(
|
|
1688
|
+
(x) =>
|
|
1689
|
+
` ${x.library.padEnd(width)} ${`${x.partId}@${x.recommendedRevision ?? x.latestRevision}`.padEnd(44)} ${[x.manufacturer, x.partNumber].filter(Boolean).join(" ").slice(0, 34).padEnd(34)} ${x.protocols.join(",") || x.domains.join(",")}${x.shadowedBy ? ` (${x.shadowedBy} provides this id first)` : ""}`,
|
|
1690
|
+
),
|
|
1691
|
+
...r.libraries.filter((l) => l.error).map((l) => ` ${l.id}: ${l.error}`),
|
|
1692
|
+
...(sort && r.libraries.some((l) => !l.error && !l.sorted) ? [` not sorted by ${sort} (the library does not offer it; its own order): ${r.libraries.filter((l) => !l.error && !l.sorted).map((l) => l.id).join(", ")}`] : []),
|
|
1693
|
+
...(r.nextCursor ? [` more: --cursor ${r.nextCursor}${sort ? ` --sort ${sort}` : ""}`] : []),
|
|
1694
|
+
].join("\n"),
|
|
1695
|
+
);
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
case "prices": {
|
|
1699
|
+
if (rest.length !== 1) throw new Error("usage: protoboard parts prices <id> [--mpn <ordering code>]");
|
|
1700
|
+
// offers are the Protoboard Cloud service's, not part of the library protocol
|
|
1701
|
+
const cloud = connectCloud(p.config, { url: cli.flags.url, accountTokens });
|
|
1702
|
+
const r = await partPrices(cloud, parsePartRef(rest[0]).partId, { ...(cli.flags.mpn ? { mpn: cli.flags.mpn } : {}) });
|
|
1703
|
+
const money = (n: number, currency?: string) => `${currency ?? ""} ${n}`.trim();
|
|
1704
|
+
out({ service: cloud.url, ...r }, () =>
|
|
1705
|
+
r.unavailable
|
|
1706
|
+
? `no prices for ${r.partId}: ${r.unavailable.message}`
|
|
1707
|
+
: [
|
|
1708
|
+
`${r.partId}: ${r.sources.length} source(s), ${r.offers} offer(s) at ${cloud.url}`,
|
|
1709
|
+
...r.sources.flatMap((s) => [
|
|
1710
|
+
` ${s.supplier}${s.authorized ? " (authorized)" : ""}${s.from ? ` from ${money(s.from.unitPrice, s.from.currency)}` : ""}`,
|
|
1711
|
+
...s.offers.map(
|
|
1712
|
+
(o) =>
|
|
1713
|
+
` ${[o.mpn, o.sku && `sku ${o.sku}`, (o.priceBreaks ?? (o.unitPrice !== undefined ? [{ quantity: o.minQuantity ?? 1, unitPrice: o.unitPrice }] : [])).map((b) => `${b.quantity}+ ${money(b.unitPrice, o.currency)}`).join(", "), o.stock !== undefined && `${o.stock} in stock`, o.packaging, o.minQuantity !== undefined && `min ${o.minQuantity}`, `observed ${o.observedAt.slice(0, 10)}${o.stale ? " (stale)" : ""}`]
|
|
1714
|
+
.filter(Boolean)
|
|
1715
|
+
.join(" ")}${o.url ? `\n ${o.url}` : ""}`,
|
|
1716
|
+
),
|
|
1717
|
+
]),
|
|
1718
|
+
...(r.sources.length ? [] : [" the library holds no offers for it"]),
|
|
1719
|
+
].join("\n"),
|
|
1720
|
+
);
|
|
1721
|
+
process.exitCode = r.unavailable ? 1 : 0;
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
case "usage": {
|
|
1725
|
+
const cloud = connectCloud(p.config, { url: cli.flags.url, accountTokens });
|
|
1726
|
+
const r = await libraryUsage(cloud, { ...(cli.flags.days ? { days: cli.flags.days } : {}), ...(cli.flags.from ? { from: cli.flags.from } : {}), ...(cli.flags.to ? { to: cli.flags.to } : {}), ...(cli.flags.limit ? { limit: cli.flags.limit } : {}) });
|
|
1727
|
+
const width = Math.max(...r.parts.map((x) => x.partId.length), 8);
|
|
1728
|
+
out({ service: cloud.url, ...r }, () =>
|
|
1729
|
+
[
|
|
1730
|
+
`${cloud.url} ${r.from} to ${r.to} (${r.days} day(s)): ${r.totals.adds} add(s) of ${r.totals.parts} part(s), ${r.totals.downloads} download(s)`,
|
|
1731
|
+
` ${"part".padEnd(width)} adds downloads as dependency seed relevance`,
|
|
1732
|
+
...r.parts.map((x) => ` ${x.partId.padEnd(width)} ${String(x.adds).padStart(4)} ${String(x.downloads).padStart(9)} ${String(x.asDependency).padStart(13)} ${String(x.seed).padStart(4)} ${String(x.relevance).padStart(9)}`),
|
|
1733
|
+
...(r.parts.length ? [] : [" no part was downloaded in this period"]),
|
|
1734
|
+
" adds: a part added or updated in a project, once per account, revision and day; downloads: every request for the revision, looks included. Counts only: the library does not keep who.",
|
|
1735
|
+
].join("\n"),
|
|
1736
|
+
);
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
case "taxonomy": {
|
|
1740
|
+
const asked = named ? libs.filter((l) => l.id === named) : libs;
|
|
1741
|
+
const r = await libraryTaxonomy(asked, { q: rest.join(" ") || undefined, domain: cli.flags.domain, protocol: cli.flags.protocol, tag: cli.flags.tag });
|
|
1742
|
+
const lines: string[] = [];
|
|
1743
|
+
const walk = (nodes: typeof r.nodes, depth: number) => {
|
|
1744
|
+
for (const n of nodes) {
|
|
1745
|
+
if (!n.count && depth > 0) continue;
|
|
1746
|
+
lines.push(`${" ".repeat(depth + 1)}${n.path.padEnd(44 - depth * 2)} ${String(n.count).padStart(4)} ${n.name}`);
|
|
1747
|
+
walk(n.children, depth + 1);
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
walk(r.nodes, 0);
|
|
1751
|
+
out(r, () =>
|
|
1752
|
+
[
|
|
1753
|
+
`${r.total} part(s), ${r.uncategorised} without a category: ${r.libraries.map((l) => (l.error ? `${l.id} failed` : `${l.id} (${l.taxonomy!.id} ${l.taxonomy!.version})`)).join(", ")}`,
|
|
1754
|
+
...lines,
|
|
1755
|
+
...r.unknown.map((u) => ` not in the taxonomy: ${u.path} ${u.count}`),
|
|
1756
|
+
...r.libraries.filter((l) => l.error).map((l) => ` ${l.id}: ${l.error}`),
|
|
1757
|
+
].join("\n"),
|
|
1758
|
+
);
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
case "add": {
|
|
1762
|
+
if (!rest.length) throw new Error("usage: protoboard parts add <id[@rev]...> [--library <id>] [--compiled]");
|
|
1763
|
+
rest.forEach(parsePartRef);
|
|
1764
|
+
const r = await addParts(p, rest, libs, { ...(named ? { library: named } : {}), compiled: cli.flags.compiled });
|
|
1765
|
+
out({ service, results: r }, () => fmtAdd(r, libs.length > 1));
|
|
1766
|
+
process.exitCode = r.some((x) => x.status === "conflict") ? 1 : 0;
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
case "update": {
|
|
1770
|
+
if (cli.flags.to !== undefined) {
|
|
1771
|
+
if (rest.length !== 1) throw new Error("usage: protoboard parts update <id> --to <revision|recommended> [--library <id>]");
|
|
1772
|
+
const to = cli.flags.to === "recommended" ? "recommended" : Number(cli.flags.to);
|
|
1773
|
+
if (to !== "recommended" && !(Number.isInteger(to) && to >= 1)) throw new Error(`--to takes a revision number or "recommended", not "${cli.flags.to}"`);
|
|
1774
|
+
const r = await updatePart(p, rest[0], to, libs, { ...(named ? { library: named } : {}), compiled: cli.flags.compiled });
|
|
1775
|
+
out({ service, results: r }, () => fmtAdd(r, libs.length > 1));
|
|
1776
|
+
process.exitCode = r.some((x) => x.status === "conflict") ? 1 : 0;
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
const r = await checkPartUpdates(p, rest, libs);
|
|
1780
|
+
out({ service, parts: r }, () =>
|
|
1781
|
+
r.map((x) => `${x.status.padEnd(16)} ${x.id}${x.pinned !== undefined ? ` pinned ${revisionLabel(x.pinned)}` : ""}${x.recommended !== undefined ? `, recommended ${x.recommended}` : ""}${libs.length > 1 && x.library ? ` [${x.library}]` : ""}\n ${x.message}`).join("\n") || "no vendored parts",
|
|
1782
|
+
);
|
|
1783
|
+
process.exitCode = r.some((x) => x.status === "not-vendored") ? 1 : 0;
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
case "offers": {
|
|
1787
|
+
// offers are the Protoboard Cloud service's, not part of the library protocol
|
|
1788
|
+
const cloud = connectCloud(p.config, { url: cli.flags.url, accountTokens });
|
|
1789
|
+
const r = await vendorOffers(p, rest, cloud);
|
|
1790
|
+
out(r, () => [...r.map((x) => `${x.status.padEnd(12)} ${x.id}${x.status === "vendored" ? ` ${x.offers} offer(s)` : " (not a catalog part; record its offers with ext-sourcing)"}`), `wrote parts/offers.json from ${cloud.url}`].join("\n"));
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
case "publish": {
|
|
1794
|
+
if (rest.length !== 1) throw new Error("usage: protoboard parts publish <parts/local/<file>.uhd.ts | part id> [--dry-run]");
|
|
1795
|
+
const cloud = connectCloud(p.config, { url: cli.flags.url, accountTokens });
|
|
1796
|
+
const r = await publishPart(p, rest[0], cloud, { cwd, dryRun: cli.flags.dryRun });
|
|
1797
|
+
out(r, () =>
|
|
1798
|
+
[
|
|
1799
|
+
r.message,
|
|
1800
|
+
` ${r.artifacts.length} file(s), ${r.evidence.length} evidence file(s)${r.dependencies.length ? `, contains ${r.dependencies.map((d) => `${d.partId}@${d.revision}`).join(", ")}` : ""}`,
|
|
1801
|
+
...r.missingArtifacts.map((m) => ` not carried: ${m.path} (${m.reason})`),
|
|
1802
|
+
...(r.status === "published" ? [` To use the catalog copy here: remove the parts/local source, then \`protoboard parts add ${r.id}@${r.revision}\`.`] : []),
|
|
1803
|
+
].join("\n"),
|
|
1804
|
+
);
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
const DEFAULT_LIBRARY = "protoboard";
|
|
1811
|
+
|
|
1812
|
+
/** `parts list`: the project's parts by group, the way the Project parts view shows them. */
|
|
1813
|
+
function listParts(r: ProjectPartsReport) {
|
|
1814
|
+
const groups: [string, ProjectPart[]][] = [
|
|
1815
|
+
["Updates available", r.parts.filter((x) => x.status === "update-available")],
|
|
1816
|
+
...[...new Set(r.parts.filter((x) => x.tier === "catalog" && x.status !== "update-available").map((x) => x.library ?? "library"))].map(
|
|
1817
|
+
(lib): [string, ProjectPart[]] => [`Library · ${lib}`, r.parts.filter((x) => x.tier === "catalog" && x.status !== "update-available" && (x.library ?? "library") === lib)],
|
|
1818
|
+
),
|
|
1819
|
+
["Project · researched", r.parts.filter((x) => x.tier === "project-researched")],
|
|
1820
|
+
["Project · designed", r.parts.filter((x) => x.tier === "project-specific")],
|
|
1821
|
+
];
|
|
1822
|
+
const mark: Record<ProjectPart["status"], string> = { pinned: " ", "up-to-date": "✓", "update-available": "U", diverged: "M", local: " ", designed: " " };
|
|
1823
|
+
out(r, () =>
|
|
1824
|
+
[
|
|
1825
|
+
`${r.counts.total} part(s): ${r.counts.library} from libraries, ${r.counts.researched} researched, ${r.counts.designed} designed${r.checked ? ` · ${r.counts.updates} update(s)` : " (not checked for updates: --check)"}${r.counts.diverged ? ` · ${r.counts.diverged} diverged` : ""}`,
|
|
1826
|
+
...groups
|
|
1827
|
+
.filter(([, list]) => list.length)
|
|
1828
|
+
.flatMap(([title, list]) => [
|
|
1829
|
+
"",
|
|
1830
|
+
`${title} (${list.length})`,
|
|
1831
|
+
...list.map(
|
|
1832
|
+
(x) =>
|
|
1833
|
+
` ${mark[x.status]} ${x.name.padEnd(36)} ${[
|
|
1834
|
+
x.revision !== undefined ? `@${x.revision}${x.recommended !== undefined ? ` → @${x.recommended}` : ""}` : "",
|
|
1835
|
+
x.capability ? `${x.capability.capability} · ${x.capability.provider.replace(/^@[^/]+\/(ext-)?/, "")}` : "",
|
|
1836
|
+
x.status === "diverged" ? `edited: ${x.edited!.join(", ")}` : "",
|
|
1837
|
+
x.published ? `published @${x.published.revision}` : "",
|
|
1838
|
+
x.instances > 1 ? `×${x.instances}` : "",
|
|
1839
|
+
]
|
|
1840
|
+
.filter(Boolean)
|
|
1841
|
+
.join(" ")}`,
|
|
1842
|
+
),
|
|
1843
|
+
]),
|
|
1844
|
+
...(r.checked?.problems ?? []).map((m) => ` not checked: ${m}`),
|
|
1845
|
+
].join("\n"),
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/** ID tokens for the services among `urls` the user is signed in to; a sign-in that stopped working is reported, not fatal (a library that signs no one in needs none). */
|
|
1850
|
+
async function signedIn(urls: string[]): Promise<Map<string, string>> {
|
|
1851
|
+
const { tokens, problems } = await accountTokensFor(urls);
|
|
1852
|
+
for (const p of problems) process.stderr.write(`protoboard: not using the sign-in for ${p}\n`);
|
|
1853
|
+
return tokens;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/** Whether this terminal can show a browser sign-in: a person at a TTY with a display (not CI, not over SSH without one). */
|
|
1857
|
+
function hasBrowser(): boolean {
|
|
1858
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY || process.env.CI) return false;
|
|
1859
|
+
if (process.env.SSH_CONNECTION && !process.env.DISPLAY) return false;
|
|
1860
|
+
return process.platform !== "linux" || !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/** `login`, `logout`, `whoami`: the user's Protoboard Cloud sign-in for the parts service at --url, else the project's, else (no project) the hosted library. */
|
|
1864
|
+
async function account(sub: "login" | "logout" | "whoami") {
|
|
1865
|
+
const chosen = await projectService();
|
|
1866
|
+
const root = chosen.root;
|
|
1867
|
+
const service = serviceKey(chosen.url);
|
|
1868
|
+
// a config that does not load is said so, with the service used instead: the one its text names, else the user's own
|
|
1869
|
+
if (chosen.unread && chosen.source !== "flag" && chosen.source !== "environment") {
|
|
1870
|
+
const why = chosen.source === "project-file" ? "its cloud.url was read from the file as text" : chosen.source === "saved" ? "its cloud.url is not known: using the service this machine is signed in to" : "its cloud.url is not known: using the hosted Protoboard library";
|
|
1871
|
+
process.stderr.write(`protoboard: ${join(root!, CONFIG_FILE)} could not be loaded (${chosen.unread}), so ${why}, ${service}\n`);
|
|
1872
|
+
}
|
|
1873
|
+
const unreachable = (what: string) => unreachableService(service, what, chosen.source, root ? join(root, CONFIG_FILE) : undefined);
|
|
1874
|
+
const store = new CredentialStore();
|
|
1875
|
+
if (sub === "logout") {
|
|
1876
|
+
const had = logout(service, { store });
|
|
1877
|
+
forgetAccess(service);
|
|
1878
|
+
out({ service, signedOut: had }, () => (had ? `Signed out of ${service}; the sign-in was removed from ${store.describe()}.` : `Not signed in to ${service}.`));
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
if (sub === "whoami") {
|
|
1882
|
+
const s = await accountStatus(service, { store, check: cli.flags.check });
|
|
1883
|
+
// PB-928: whether the library's account features (the cloud) may be used here; --check asks the service again
|
|
1884
|
+
const access = await checkAccess(service, { store, fresh: cli.flags.check });
|
|
1885
|
+
const accessLine = access.allowed || access.reason === "signed-out" ? [] : [`The Protoboard library: ✗ ${fmtAccess(access)}`];
|
|
1886
|
+
if (s.unreachable) {
|
|
1887
|
+
// the local sign-in is still reported; what the service thinks of it is unknown
|
|
1888
|
+
out({ ...s, access, source: chosen.source, message: unreachable(s.unreachable) }, () =>
|
|
1889
|
+
[unreachable(s.unreachable!), s.signedIn ? `The sign-in kept here for it is ${s.email ?? s.uid}${s.via === "device" ? " (device sign-in)" : ""}, in ${s.store}; whether the service accepts it is not known.` : "No sign-in is kept here for it.", ...accessLine].join("\n"),
|
|
1890
|
+
);
|
|
1891
|
+
process.exitCode = 1;
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
out({ ...s, access }, () =>
|
|
1895
|
+
[
|
|
1896
|
+
s.signedIn
|
|
1897
|
+
? [`Signed in to ${service} as ${s.email ?? s.uid}${s.via === "device" ? " (device sign-in)" : ""}`, ` kept in ${s.store}`, ...(s.valid === undefined ? [] : [s.valid ? ` the service accepts it${s.publisher ? "; you may publish" : ""}` : ` ✗ ${s.problem}`])].join("\n")
|
|
1898
|
+
: s.needsAccount === false || access.via === "open-service"
|
|
1899
|
+
? `${service} signs no one in: everything there is open, with no account (a parts service without sign-in).`
|
|
1900
|
+
: `Not signed in to ${service}. Local work (check, build, the viewer) needs no account; the Protoboard library (searching it, adding parts, prices and publishing) does: sign in with \`protoboard login\`.`,
|
|
1901
|
+
...(access.via === "open-service" ? [] : accessLine),
|
|
1902
|
+
].join("\n"),
|
|
1903
|
+
);
|
|
1904
|
+
process.exitCode = (s.signedIn && s.valid !== false) || (!s.signedIn && (s.needsAccount === false || access.via === "open-service")) ? 0 : 1;
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
const device = cli.flags.device || !hasBrowser();
|
|
1908
|
+
const log = (line: string) => process.stderr.write(`${line}\n`);
|
|
1909
|
+
try {
|
|
1910
|
+
const session = device
|
|
1911
|
+
? await loginWithDevice(
|
|
1912
|
+
service,
|
|
1913
|
+
(prompt) => {
|
|
1914
|
+
if (json) process.stdout.write(JSON.stringify({ prompt: "approve this code", ...prompt }) + "\n");
|
|
1915
|
+
else
|
|
1916
|
+
log(
|
|
1917
|
+
[
|
|
1918
|
+
"To sign in, open this page on any device and approve the code there:",
|
|
1919
|
+
` ${prompt.verificationUriComplete ?? prompt.verificationUri ?? "(the service named no page)"}`,
|
|
1920
|
+
` code: ${prompt.userCode} (expires in ${Math.round(prompt.expiresIn / 60)} min)`,
|
|
1921
|
+
"Waiting for approval…",
|
|
1922
|
+
].join("\n"),
|
|
1923
|
+
);
|
|
1924
|
+
},
|
|
1925
|
+
// the platform only: the machine's name is the user's, and the approval page needs none of it (PB-1047)
|
|
1926
|
+
{ store, client: `protoboard CLI (${process.platform})` },
|
|
1927
|
+
)
|
|
1928
|
+
: await loginWithBrowser(service, openUrl, { store, log });
|
|
1929
|
+
out({ service, signedIn: true, email: session.email, uid: session.uid, via: session.via, store: store.describe() }, () => `Signed in to ${service} as ${session.email ?? session.uid}. The sign-in is kept in ${store.describe()}, never in a project.`);
|
|
1930
|
+
} catch (e) {
|
|
1931
|
+
if (e instanceof AccountError && e.code === "NO_SIGN_IN") {
|
|
1932
|
+
out({ service, signedIn: false, message: e.message }, () => e.message);
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
if (e instanceof AccountError && e.code === "UNREACHABLE") throw new Error(unreachable(e.message));
|
|
1936
|
+
throw e;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/** `libraries [list] | add | remove | move`: the project's part libraries, in protoboard.config.ts. */
|
|
1941
|
+
async function libraries(sub: string | undefined, rest: string[]) {
|
|
1942
|
+
const usage = "usage: protoboard libraries [list [--check]] | add <id> <url> [--auth-env NAME] [--position n] | add <url> | remove <id> | move <id> <position|first|last|up|down>";
|
|
1943
|
+
const root = findProjectRoot(cwd);
|
|
1944
|
+
if (!root) throw new Error(`no protoboard.config.ts in ${cwd} or its parents (run \`protoboard init\`)`);
|
|
1945
|
+
const config = await loadConfig(root);
|
|
1946
|
+
const show = async (check: boolean, done?: string) => {
|
|
1947
|
+
const list = await listLibraries(root, config2 ?? config, { check, url: cli.flags.url });
|
|
1948
|
+
const origins = relative(root, originsPath(root, (config2 ?? config).catalog)).split("\\").join("/");
|
|
1949
|
+
out({ ...(done ? { done } : {}), libraries: list, origins }, () =>
|
|
1950
|
+
[
|
|
1951
|
+
...(done ? [done] : []),
|
|
1952
|
+
...list.map((l) =>
|
|
1953
|
+
[
|
|
1954
|
+
`${String(l.position).padStart(2)}. ${l.id.padEnd(14)} ${l.url}${l.declared ? "" : l.personal ? " (personal: PROTOBOARD_USER_LIBRARIES)" : " (default: not listed in protoboard.config.ts)"}`,
|
|
1955
|
+
...(l.authEnv ? [` token from $${l.authEnv} (${process.env[l.authEnv] ? "set" : "not set"})`] : []),
|
|
1956
|
+
...(l.pinned.length ? [` ${l.pinned.length} vendored part(s) pinned to it`] : []),
|
|
1957
|
+
...(l.discovery ? ["error" in l.discovery ? [` ✗ ${l.discovery.error}`] : [` ✓ ${l.discovery.name} ${l.discovery.version} (${l.discovery.protocol}, API ${l.discovery.api}, ${readsNeed(l.discovery)})`]] : []).flat(),
|
|
1958
|
+
].join("\n"),
|
|
1959
|
+
),
|
|
1960
|
+
].join("\n"),
|
|
1961
|
+
);
|
|
1962
|
+
};
|
|
1963
|
+
let config2: typeof config | undefined;
|
|
1964
|
+
const save = async (list: ReturnType<typeof declaredLibraries>, done: string) => {
|
|
1965
|
+
await writeLibraries(root, list);
|
|
1966
|
+
config2 = { ...config, libraries: list };
|
|
1967
|
+
await show(false, done);
|
|
1968
|
+
};
|
|
1969
|
+
const current = declaredLibraries(config);
|
|
1970
|
+
// a personal library is not in protoboard.config.ts: say where it does come from
|
|
1971
|
+
const personalOnly = (id: string | undefined, verb: string) => {
|
|
1972
|
+
if (!id || current.some((l) => l.id === id)) return;
|
|
1973
|
+
if (!personalLibraries(process.env, current.map((l) => l.id)).libraries.some((l) => l.id === id)) return;
|
|
1974
|
+
throw new Error(
|
|
1975
|
+
`"${id}" is one of your personal libraries: it comes from ${USER_LIBRARIES_ENV} (a JSON array in your environment; VS Code sets it from the protoboard.libraries setting), not from protoboard.config.ts, so \`libraries ${verb}\` cannot change it. Edit ${USER_LIBRARIES_ENV} (or the setting) instead.`,
|
|
1976
|
+
);
|
|
1977
|
+
};
|
|
1978
|
+
switch (sub ?? "list") {
|
|
1979
|
+
case "list":
|
|
1980
|
+
return show(cli.flags.check);
|
|
1981
|
+
case "add": {
|
|
1982
|
+
let [id, url] = rest;
|
|
1983
|
+
if (rest.length === 1 && /^https?:\/\//.test(rest[0])) {
|
|
1984
|
+
// only a URL: the library's discovery document suggests its id
|
|
1985
|
+
url = rest[0];
|
|
1986
|
+
const found = await discoverLibrary(url);
|
|
1987
|
+
if (!found.document?.id) throw new Error(`${url} has no uhd-library/v1 discovery document with an id; name one: protoboard libraries add <id> ${url}`);
|
|
1988
|
+
id = found.document.id;
|
|
1989
|
+
}
|
|
1990
|
+
if (!id || !url || rest.length > 2) throw new Error(usage);
|
|
1991
|
+
const lib = { id, url: url.replace(/\/+$/, ""), ...(cli.flags.authEnv ? { authEnv: cli.flags.authEnv } : {}) };
|
|
1992
|
+
return save(withLibraryAdded(current, lib, cli.flags.position), `added library ${id}`);
|
|
1993
|
+
}
|
|
1994
|
+
case "remove": {
|
|
1995
|
+
if (rest.length !== 1) throw new Error(usage);
|
|
1996
|
+
personalOnly(rest[0], "remove");
|
|
1997
|
+
const pinned = (await listLibraries(root, config, { url: cli.flags.url })).find((l) => l.id === rest[0])?.pinned ?? [];
|
|
1998
|
+
return save(
|
|
1999
|
+
withLibraryRemoved(current, rest[0]),
|
|
2000
|
+
`removed library ${rest[0]}${pinned.length ? `; ${pinned.length} part(s) vendored from it stay vendored (${pinned.join(", ")}), but \`parts update\` cannot ask it until it is added back` : ""}`,
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
case "move": {
|
|
2004
|
+
if (rest.length !== 2) throw new Error(usage);
|
|
2005
|
+
personalOnly(rest[0], "move");
|
|
2006
|
+
return save(withLibraryMoved(current, rest[0], rest[1]), `moved library ${rest[0]}`);
|
|
2007
|
+
}
|
|
2008
|
+
default:
|
|
2009
|
+
throw new Error(usage);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
main().catch((e: Error) => {
|
|
2014
|
+
if (e instanceof AccessError) {
|
|
2015
|
+
// PB-928: one line, and its own exit code so a script or the extension can tell it from a failed command
|
|
2016
|
+
if (json) process.stdout.write(JSON.stringify({ error: e.message, code: e.code, access: e.access }) + "\n");
|
|
2017
|
+
else process.stderr.write(`protoboard: ${e.message}\n`);
|
|
2018
|
+
process.exitCode = ACCESS_EXIT_CODE;
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
if (json) process.stdout.write(JSON.stringify({ error: e.message }) + "\n");
|
|
2022
|
+
else process.stderr.write(`protoboard: ${e.message}\n`);
|
|
2023
|
+
process.exitCode = 2;
|
|
2024
|
+
});
|