@ggui-ai/cli 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +96 -0
  3. package/dist/agent-resolution.d.ts +41 -0
  4. package/dist/agent-resolution.d.ts.map +1 -0
  5. package/dist/agent-resolution.js +80 -0
  6. package/dist/api-client.d.ts +59 -0
  7. package/dist/api-client.d.ts.map +1 -0
  8. package/dist/api-client.js +145 -0
  9. package/dist/auth-keys.d.ts +10 -0
  10. package/dist/auth-keys.d.ts.map +1 -0
  11. package/dist/auth-keys.js +382 -0
  12. package/dist/auth-login.d.ts +11 -0
  13. package/dist/auth-login.d.ts.map +1 -0
  14. package/dist/auth-login.js +231 -0
  15. package/dist/auth-logout.d.ts +3 -0
  16. package/dist/auth-logout.d.ts.map +1 -0
  17. package/dist/auth-logout.js +32 -0
  18. package/dist/auth-store.d.ts +67 -0
  19. package/dist/auth-store.d.ts.map +1 -0
  20. package/dist/auth-store.js +150 -0
  21. package/dist/auth-whoami.d.ts +3 -0
  22. package/dist/auth-whoami.d.ts.map +1 -0
  23. package/dist/auth-whoami.js +52 -0
  24. package/dist/blueprint-command.d.ts +3 -0
  25. package/dist/blueprint-command.d.ts.map +1 -0
  26. package/dist/blueprint-command.js +203 -0
  27. package/dist/blueprint-create.d.ts +71 -0
  28. package/dist/blueprint-create.d.ts.map +1 -0
  29. package/dist/blueprint-create.js +377 -0
  30. package/dist/byok-resolver.d.ts +141 -0
  31. package/dist/byok-resolver.d.ts.map +1 -0
  32. package/dist/byok-resolver.js +89 -0
  33. package/dist/cli.d.ts +3 -0
  34. package/dist/cli.d.ts.map +1 -0
  35. package/dist/cli.js +1066 -0
  36. package/dist/dev-command.d.ts +169 -0
  37. package/dist/dev-command.d.ts.map +1 -0
  38. package/dist/dev-command.js +222 -0
  39. package/dist/gadget-command.d.ts +3 -0
  40. package/dist/gadget-command.d.ts.map +1 -0
  41. package/dist/gadget-command.js +181 -0
  42. package/dist/gadget-create.d.ts +106 -0
  43. package/dist/gadget-create.d.ts.map +1 -0
  44. package/dist/gadget-create.js +492 -0
  45. package/dist/generation-probe.d.ts +135 -0
  46. package/dist/generation-probe.d.ts.map +1 -0
  47. package/dist/generation-probe.js +132 -0
  48. package/dist/internal/artifact-install.d.ts +150 -0
  49. package/dist/internal/artifact-install.d.ts.map +1 -0
  50. package/dist/internal/artifact-install.js +1019 -0
  51. package/dist/internal/artifact-publish.d.ts +204 -0
  52. package/dist/internal/artifact-publish.d.ts.map +1 -0
  53. package/dist/internal/artifact-publish.js +1141 -0
  54. package/dist/internal/artifact-search.d.ts +155 -0
  55. package/dist/internal/artifact-search.d.ts.map +1 -0
  56. package/dist/internal/artifact-search.js +539 -0
  57. package/dist/internal/artifact-uninstall.d.ts +30 -0
  58. package/dist/internal/artifact-uninstall.d.ts.map +1 -0
  59. package/dist/internal/artifact-uninstall.js +205 -0
  60. package/dist/internal/auth-cache.d.ts +40 -0
  61. package/dist/internal/auth-cache.d.ts.map +1 -0
  62. package/dist/internal/auth-cache.js +133 -0
  63. package/dist/internal/auth-strategy.d.ts +89 -0
  64. package/dist/internal/auth-strategy.d.ts.map +1 -0
  65. package/dist/internal/auth-strategy.js +132 -0
  66. package/dist/internal/ggui-json.d.ts +53 -0
  67. package/dist/internal/ggui-json.d.ts.map +1 -0
  68. package/dist/internal/ggui-json.js +92 -0
  69. package/dist/internal/key-store.d.ts +43 -0
  70. package/dist/internal/key-store.d.ts.map +1 -0
  71. package/dist/internal/key-store.js +128 -0
  72. package/dist/internal/oidc-token.d.ts +52 -0
  73. package/dist/internal/oidc-token.d.ts.map +1 -0
  74. package/dist/internal/oidc-token.js +351 -0
  75. package/dist/internal/register-author-key.d.ts +44 -0
  76. package/dist/internal/register-author-key.d.ts.map +1 -0
  77. package/dist/internal/register-author-key.js +212 -0
  78. package/dist/local-keys.d.ts +47 -0
  79. package/dist/local-keys.d.ts.map +1 -0
  80. package/dist/local-keys.js +115 -0
  81. package/dist/mcp-backend.d.ts +356 -0
  82. package/dist/mcp-backend.d.ts.map +1 -0
  83. package/dist/mcp-backend.js +668 -0
  84. package/dist/paths.d.ts +77 -0
  85. package/dist/paths.d.ts.map +1 -0
  86. package/dist/paths.js +119 -0
  87. package/dist/persistent-secrets.d.ts +28 -0
  88. package/dist/persistent-secrets.d.ts.map +1 -0
  89. package/dist/persistent-secrets.js +94 -0
  90. package/dist/persistent-short-code-index.d.ts +14 -0
  91. package/dist/persistent-short-code-index.d.ts.map +1 -0
  92. package/dist/persistent-short-code-index.js +39 -0
  93. package/dist/persistent-stores.d.ts +12 -0
  94. package/dist/persistent-stores.d.ts.map +1 -0
  95. package/dist/persistent-stores.js +42 -0
  96. package/dist/serve-command.d.ts +500 -0
  97. package/dist/serve-command.d.ts.map +1 -0
  98. package/dist/serve-command.js +616 -0
  99. package/dist/serve-fallback.d.ts +64 -0
  100. package/dist/serve-fallback.d.ts.map +1 -0
  101. package/dist/serve-fallback.js +122 -0
  102. package/dist/spawn-cloudflared.d.ts +61 -0
  103. package/dist/spawn-cloudflared.d.ts.map +1 -0
  104. package/dist/spawn-cloudflared.js +167 -0
  105. package/dist/theme-command.d.ts +4 -0
  106. package/dist/theme-command.d.ts.map +1 -0
  107. package/dist/theme-command.js +154 -0
  108. package/dist/theme-file-uploader.d.ts +8 -0
  109. package/dist/theme-file-uploader.d.ts.map +1 -0
  110. package/dist/theme-file-uploader.js +58 -0
  111. package/dist/theme-writer.d.ts +8 -0
  112. package/dist/theme-writer.d.ts.map +1 -0
  113. package/dist/theme-writer.js +103 -0
  114. package/dist/tunnel-provider.d.ts +273 -0
  115. package/dist/tunnel-provider.d.ts.map +1 -0
  116. package/dist/tunnel-provider.js +142 -0
  117. package/package.json +81 -0
@@ -0,0 +1,1019 @@
1
+ /**
2
+ * `ggui gadget install <scope/name>@<version>` — fetch a published
3
+ * gadget/blueprint from the marketplace registry, verify its signature
4
+ * + SRI, and append it to the consuming project's `ggui.json`.
5
+ *
6
+ * Consumer-side counterpart to `gadget publish`. The end-to-end model
7
+ * is:
8
+ *
9
+ * 1. Author writes a gadget, runs `ggui gadget publish` → uploads
10
+ * `{manifest, bundle, signature}` to the registry (`POST /publish`).
11
+ * 2. Consumer runs `ggui gadget install @scope/name@1.0.0` → this
12
+ * module fetches `GET /pkg/<scope>/<name>/<version>`, downloads
13
+ * `bundleUrl` + `signatureUrl`, recomputes SRI + Ed25519 signature,
14
+ * and writes a {@link GadgetDescriptor}-shaped row into the
15
+ * app's `ggui.json#app.gadgets`.
16
+ *
17
+ * **HTTP contract (mirrors the registry read handler).**
18
+ *
19
+ * GET <registry>/pkg/<scope>/<name>/<version>
20
+ * 200: { manifest, bundleUrl?, bundleSri?, signatureUrl?, compiledBytes?, publishedAt, publishedBy }
21
+ * 403: { error: 'forbidden', message } — private artifact (JWT not yet threaded)
22
+ * 404: { error: 'not_found', message } — artifactId/version unknown
23
+ * 410: { manifest, …, publishedAt, publishedBy } — yanked (body shape = ReadPkgResponse)
24
+ * other 4xx/5xx: { error, message }
25
+ *
26
+ * GET <bundleUrl> → raw esbuild output (presigned S3)
27
+ * GET <signatureUrl> → JSON-encoded {@link Ed25519Signature}
28
+ *
29
+ * **Verification chain.** Two independent integrity checks MUST both
30
+ * pass before any disk write:
31
+ *
32
+ * - **SRI** — the iframe-runtime enforces `<script integrity=…>` on
33
+ * load, so we must agree with it. Recompute `sha384(bundleBytes)`
34
+ * and compare against the manifest's published `bundleSri`. SRI
35
+ * hash must match `sha384-<base64>` form verbatim.
36
+ *
37
+ * - **Ed25519** — recompute the bundle hash, confirm it matches the
38
+ * signature's `bundleSha384`, then run the signature against the
39
+ * author's public key. When the registry read handler does not
40
+ * return the author's `publicKey`, signature verification cannot
41
+ * run: a warning is printed and the check is skipped unless
42
+ * `--strict` is set, in which case install exits 1.
43
+ *
44
+ * **Blueprint branch.** Blueprints carry their TSX in `manifest.source`
45
+ * (the registry stores the canonical compiled JS in a separate
46
+ * two-layer blob keyed by `compiledDigest`; the CLI install writes the
47
+ * raw TSX for local-dev editability — the cloud install path consumes
48
+ * the compiled bytes). No `bundleUrl`. The signature is over the
49
+ * canonical-JSON form of the manifest (sorted keys), matching the
50
+ * publish-side `canonicalJson()` helper. Installed blueprints are
51
+ * materialized to `.ggui/installed-blueprints/<id>/` and registered as
52
+ * a filesystem glob in `ggui.json#blueprints.include`. A blueprint
53
+ * with no `contract` field is verified but not materialized, since
54
+ * `UiManifest.contract` is required.
55
+ *
56
+ * Kept side-effect-free at module load. All IO threads through the
57
+ * injectable seams on {@link RunArtifactInstallDeps} so tests stub
58
+ * `fetch` + `cwd` + writers without touching globals.
59
+ */
60
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
61
+ import { dirname, join } from 'node:path';
62
+ import { sha384 } from '@noble/hashes/sha512.js';
63
+ import { manifestToRegistryEntry, parseArtifactManifest, } from '@ggui-ai/artifact-manifest';
64
+ import { canonicalJson, derivePublicKeyId, isGadgetSignature, verifyBundleEd25519, verifyBundleSigstore, } from '@ggui-ai/gadget-signing';
65
+ import { READ_ERROR_CODES } from '@ggui-ai/registry-core';
66
+ import { findGguiJson, readGguiJson, writeGguiJson, } from './ggui-json.js';
67
+ /* -------------------------------------------------------------------------- */
68
+ /* Defaults */
69
+ /* -------------------------------------------------------------------------- */
70
+ /**
71
+ * Fallback registry URL when neither `--registry`, `GGUI_REGISTRY`,
72
+ * nor a `ggui.json#registry` field is set.
73
+ *
74
+ * Production consumers should resolve their registry explicitly. The
75
+ * `--registry` flag and the `GGUI_REGISTRY` env var always take
76
+ * precedence, so this is a safety net, not a recommendation.
77
+ */
78
+ const DEFAULT_REGISTRY_URL = 'https://dev.registry.sandbox.ggui.ai';
79
+ /**
80
+ * Build verb-specific help text. Takes the `kind` so the rendered
81
+ * lines say `ggui gadget install` / `ggui blueprint install` rather
82
+ * than a generic placeholder.
83
+ */
84
+ export function buildInstallHelp(kind) {
85
+ const verb = `ggui ${kind} install`;
86
+ const extra = kind === 'gadget'
87
+ ? ' Gadgets are appended to ggui.json#app.gadgets[].\n'
88
+ : ' Blueprints are materialized to .ggui/installed-blueprints/<id>/.\n';
89
+ return `${verb} — fetch + verify + register a ${kind} from the marketplace
90
+
91
+ Usage:
92
+ ${verb} <scope/name>@<version> [options]
93
+
94
+ Arguments:
95
+ <scope/name>@<version> Full install identifier. Scope MUST start
96
+ with \`@\`. Version MUST follow the publish-
97
+ side SemVer rule (no \`latest\`, no ranges).
98
+
99
+ Options:
100
+ --registry <url> Override the registry URL. Three-layer
101
+ resolution: flag > ggui.json#registry >
102
+ GGUI_REGISTRY env > default
103
+ (\`${DEFAULT_REGISTRY_URL}\`).
104
+ --no-prompt Skip interactive prompts for any
105
+ \`publicEnv\` keys the gadget's manifest
106
+ declares as required. Missing keys surface
107
+ as warnings + exit 0; the operator wires
108
+ them in by hand afterwards.
109
+ --strict Fail (exit 1) if signature verification is
110
+ skipped for any reason. Default behavior
111
+ warns + continues — the registry's
112
+ \`GET /pkg\` response doesn't yet expose
113
+ the author's public key, so signatures
114
+ verify only when an out-of-band key is
115
+ wired in.
116
+ --verify-identity <patt> Sigstore-signed gadgets only. Demand the
117
+ bundle's Fulcio leaf-cert OIDC subject
118
+ matches \`<patt>\`. Pattern is a literal
119
+ subject string today; a future verify
120
+ impl may extend to regex. No effect on
121
+ Ed25519-signed (private) gadgets.
122
+ --help, -h Show this help.
123
+
124
+ Effect on disk:
125
+ ${extra}
126
+ Examples:
127
+ ${verb} @my-org/weather-card@0.1.0
128
+ ${verb} @my-org/leaflet@2.3.1 --registry=https://r.example.com
129
+ ${verb} @my-org/leaflet@2.3.1 --no-prompt --strict
130
+ `;
131
+ }
132
+ /**
133
+ * Parse `args` (the argv tail after `ggui {gadget,blueprint} install`)
134
+ * into {@link InstallFlags}. The `kind` parameter is the verb the
135
+ * operator typed — it's stamped onto the returned flags so the
136
+ * downstream {@link runArtifactInstall} can enforce manifest-kind
137
+ * alignment without re-parsing argv.
138
+ *
139
+ * Returns an `{ error }` object on usage failure so the CLI driver
140
+ * branches without try/catch. `'__help__'` is the sentinel for
141
+ * `--help`; every other `error` is stderr-bound copy.
142
+ */
143
+ export function parseArtifactInstallFlags(kind, args) {
144
+ let positional;
145
+ let registry;
146
+ let noPrompt = false;
147
+ let strict = false;
148
+ let verifyIdentity;
149
+ /** `--name value` and `--name=value` both supported. */
150
+ function readValue(arg, nextIdx) {
151
+ const eq = arg.indexOf('=');
152
+ if (eq !== -1) {
153
+ const v = arg.slice(eq + 1);
154
+ if (v.length === 0)
155
+ return null;
156
+ return { value: v, advance: 0 };
157
+ }
158
+ const next = args[nextIdx];
159
+ if (typeof next !== 'string' || next.length === 0)
160
+ return null;
161
+ return { value: next, advance: 1 };
162
+ }
163
+ for (let i = 0; i < args.length; i++) {
164
+ const arg = args[i];
165
+ if (arg === '--help' || arg === '-h') {
166
+ return { error: '__help__' };
167
+ }
168
+ if (arg === '--no-prompt') {
169
+ noPrompt = true;
170
+ continue;
171
+ }
172
+ if (arg === '--strict') {
173
+ strict = true;
174
+ continue;
175
+ }
176
+ if (arg === '--registry' || arg.startsWith('--registry=')) {
177
+ const read = readValue(arg, i + 1);
178
+ if (read === null) {
179
+ return { error: '--registry requires a value' };
180
+ }
181
+ registry = read.value;
182
+ i += read.advance;
183
+ continue;
184
+ }
185
+ if (arg === '--verify-identity' || arg.startsWith('--verify-identity=')) {
186
+ const read = readValue(arg, i + 1);
187
+ if (read === null) {
188
+ return { error: '--verify-identity requires a value' };
189
+ }
190
+ verifyIdentity = read.value;
191
+ i += read.advance;
192
+ continue;
193
+ }
194
+ if (arg.startsWith('--')) {
195
+ return { error: `unknown flag: ${arg}` };
196
+ }
197
+ if (positional === undefined) {
198
+ positional = arg;
199
+ continue;
200
+ }
201
+ return { error: `unexpected positional argument: ${arg}` };
202
+ }
203
+ if (positional === undefined) {
204
+ return {
205
+ error: 'missing positional argument: <scope/name>@<version> (e.g. @my-org/leaflet@1.0.0)',
206
+ };
207
+ }
208
+ // Split `<scope/name>@<version>` on the LAST `@` — the scope's
209
+ // leading `@` matches a literal at position 0 and must not be
210
+ // mistaken for the version separator.
211
+ const lastAt = positional.lastIndexOf('@');
212
+ if (lastAt <= 0) {
213
+ return {
214
+ error: `invalid install identifier: ${positional} — expected <scope/name>@<version>`,
215
+ };
216
+ }
217
+ const artifactId = positional.slice(0, lastAt);
218
+ const version = positional.slice(lastAt + 1);
219
+ if (!artifactId.startsWith('@') || artifactId.indexOf('/') === -1) {
220
+ return {
221
+ error: `invalid artifactId: ${artifactId} — expected \`@scope/name\``,
222
+ };
223
+ }
224
+ if (version.length === 0) {
225
+ return {
226
+ error: `invalid version: empty (expected SemVer after \`@\`)`,
227
+ };
228
+ }
229
+ return {
230
+ kind,
231
+ artifactId,
232
+ version,
233
+ ...(registry !== undefined ? { registry } : {}),
234
+ noPrompt,
235
+ strict,
236
+ ...(verifyIdentity !== undefined ? { verifyIdentity } : {}),
237
+ };
238
+ }
239
+ function isReadErrorBody(v) {
240
+ if (v === null || typeof v !== 'object')
241
+ return false;
242
+ const o = v;
243
+ if (typeof o.error !== 'string' || typeof o.message !== 'string')
244
+ return false;
245
+ return READ_ERROR_CODES.includes(o.error);
246
+ }
247
+ function isReadPkgResponse(v) {
248
+ if (v === null || typeof v !== 'object')
249
+ return false;
250
+ const o = v;
251
+ if (o.manifest === null || typeof o.manifest !== 'object')
252
+ return false;
253
+ if (typeof o.publishedAt !== 'string')
254
+ return false;
255
+ if (typeof o.publishedBy !== 'string')
256
+ return false;
257
+ return true;
258
+ }
259
+ /* -------------------------------------------------------------------------- */
260
+ /* Helpers */
261
+ /* -------------------------------------------------------------------------- */
262
+ /** Encode bytes → base64 without a `node:buffer` dependency (matches
263
+ * the gadget-signing helper's posture so the package stays
264
+ * browser-portable). */
265
+ function bytesToBase64(bytes) {
266
+ let bin = '';
267
+ for (let i = 0; i < bytes.length; i++)
268
+ bin += String.fromCharCode(bytes[i]);
269
+ return btoa(bin);
270
+ }
271
+ /** Decode base64 → bytes, returning `undefined` on malformed input
272
+ * (instead of throwing) so callers can surface a typed error message.
273
+ * Matches the publish handler's `safeBase64Decode` posture. */
274
+ function decodeBase64(s) {
275
+ try {
276
+ const bin = atob(s);
277
+ const out = new Uint8Array(bin.length);
278
+ for (let i = 0; i < bin.length; i++)
279
+ out[i] = bin.charCodeAt(i);
280
+ return out;
281
+ }
282
+ catch {
283
+ return undefined;
284
+ }
285
+ }
286
+ /**
287
+ * Resolve the registry URL the install should target.
288
+ *
289
+ * Precedence: `--registry` flag > `ggui.json#registry` > env var >
290
+ * built-in {@link DEFAULT_REGISTRY_URL}. Mirrors publish + search's
291
+ * three-layer chain, with the addition of a hardcoded fallback so an
292
+ * install with no manifest still works against the sandbox registry
293
+ * (consumers without a project yet shouldn't have to type
294
+ * `--registry=…` every call).
295
+ *
296
+ * Returns the URL with any trailing `/` stripped so route concat below
297
+ * (e.g. `${url}/pkg/…`) doesn't double-slash.
298
+ */
299
+ function resolveRegistry(args) {
300
+ const candidates = [];
301
+ if (args.flag !== undefined && args.flag.length > 0) {
302
+ candidates.push({ raw: args.flag, source: '--registry' });
303
+ }
304
+ else {
305
+ const registryField = args.gguiJson?.['registry'];
306
+ if (typeof registryField === 'string' && registryField.length > 0) {
307
+ candidates.push({ raw: registryField, source: 'ggui.json#registry' });
308
+ }
309
+ else {
310
+ const envValue = args.env?.['GGUI_REGISTRY'];
311
+ if (typeof envValue === 'string' && envValue.length > 0) {
312
+ candidates.push({ raw: envValue, source: 'GGUI_REGISTRY' });
313
+ }
314
+ else {
315
+ candidates.push({ raw: DEFAULT_REGISTRY_URL, source: '(default)' });
316
+ }
317
+ }
318
+ }
319
+ const picked = candidates[0];
320
+ let parsed;
321
+ try {
322
+ parsed = new URL(picked.raw);
323
+ }
324
+ catch {
325
+ return {
326
+ error: `registry URL is malformed (${picked.source}): ${picked.raw}`,
327
+ };
328
+ }
329
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
330
+ return {
331
+ error: `registry URL must be http(s) (${picked.source}): ${picked.raw}`,
332
+ };
333
+ }
334
+ let serialized = parsed.toString();
335
+ while (serialized.endsWith('/'))
336
+ serialized = serialized.slice(0, -1);
337
+ return { url: serialized };
338
+ }
339
+ /**
340
+ * Decompose `@scope/name` into its `scope` + `name` parts so the
341
+ * read-handler URL `/pkg/{scope}/{name}/{version}` can be composed.
342
+ *
343
+ * The handler accepts the scope without the leading `@` (API GW path
344
+ * param semantics — see read handler's `parsePathParams`). We strip
345
+ * the `@` before composing the URL; the registry's normalizer
346
+ * re-prepends it server-side.
347
+ */
348
+ function splitPluginId(artifactId) {
349
+ const slash = artifactId.indexOf('/');
350
+ // parseGadgetInstallFlags already validated this — keep an assert.
351
+ const scope = artifactId.slice(1, slash); // drop leading `@`
352
+ const name = artifactId.slice(slash + 1);
353
+ return { scope, name };
354
+ }
355
+ /**
356
+ * Translate the `--verify-identity <pattern>` CLI argument into the
357
+ * `expectedIdentity` shape `verifyBundleSigstore` accepts.
358
+ *
359
+ * Pattern form:
360
+ * - `/regex/[flags]` (slash-delimited) → RegExp source
361
+ * - everything else → literal subject string
362
+ *
363
+ * Returns `undefined` when the flag is unset (verify accepts ANY valid
364
+ * OIDC identity).
365
+ */
366
+ function parseVerifyIdentity(raw) {
367
+ if (raw === undefined || raw.length === 0)
368
+ return undefined;
369
+ // Slash-delimited regex form: `/pattern/` or `/pattern/flags`.
370
+ if (raw.length >= 2 && raw.startsWith('/')) {
371
+ const lastSlash = raw.lastIndexOf('/');
372
+ if (lastSlash > 0) {
373
+ const pattern = raw.slice(1, lastSlash);
374
+ const flags = raw.slice(lastSlash + 1);
375
+ try {
376
+ return { subject: new RegExp(pattern, flags) };
377
+ }
378
+ catch {
379
+ // Invalid regex source — fall through to literal.
380
+ }
381
+ }
382
+ }
383
+ return { subject: raw };
384
+ }
385
+ /**
386
+ * Fetch raw bytes from a presigned-S3-style URL. Used for the bundle
387
+ * download. Distinct from the JSON-parsing flows because the bundle
388
+ * is an opaque blob (esbuild ESM output) the verifier hashes verbatim.
389
+ */
390
+ async function fetchBytes(fetchImpl, url) {
391
+ let res;
392
+ try {
393
+ res = await fetchImpl(url, { method: 'GET' });
394
+ }
395
+ catch (err) {
396
+ return {
397
+ error: `network error fetching ${url}: ${err instanceof Error ? err.message : String(err)}`,
398
+ };
399
+ }
400
+ if (!res.ok) {
401
+ return { error: `GET ${url} returned ${res.status}` };
402
+ }
403
+ const buf = await res.arrayBuffer();
404
+ return { bytes: new Uint8Array(buf) };
405
+ }
406
+ /**
407
+ * Verify the `bundleSri` field (form: `sha384-<base64>`) against the
408
+ * downloaded bundle bytes. Independent of the Ed25519 signature
409
+ * pathway — SRI is what the iframe-runtime enforces on `<script>`
410
+ * loading, so it MUST agree with the published value. A mismatch
411
+ * here is bundle tampering (or a registry that re-served the wrong
412
+ * object).
413
+ */
414
+ function verifyBundleSri(bundleBytes, expectedSri) {
415
+ // The publish-side computes `bundleSri = sha384-<base64(sha384(bytes))>`;
416
+ // gating on the prefix catches accidental sha256/sha512 SRI strings.
417
+ if (!expectedSri.startsWith('sha384-')) {
418
+ return {
419
+ ok: false,
420
+ reason: `unsupported SRI algorithm: ${expectedSri} (expected sha384-…)`,
421
+ };
422
+ }
423
+ const recomputed = `sha384-${bytesToBase64(sha384(bundleBytes))}`;
424
+ if (recomputed !== expectedSri) {
425
+ return {
426
+ ok: false,
427
+ reason: `SRI mismatch: published=${expectedSri}, recomputed=${recomputed}`,
428
+ };
429
+ }
430
+ return { ok: true };
431
+ }
432
+ // The manifest → registry-entry translation lives in
433
+ // `@ggui-ai/artifact-manifest#manifestToRegistryEntry`, next to the
434
+ // manifest schema. Call sites import that canonical helper directly.
435
+ /* -------------------------------------------------------------------------- */
436
+ /* runArtifactInstall */
437
+ /* -------------------------------------------------------------------------- */
438
+ /**
439
+ * Run the install flow end-to-end. Returns a numeric exit code so the
440
+ * router writes it directly to `process.exit`.
441
+ *
442
+ * Exit codes:
443
+ * - 0 — success (gadget installed, ggui.json updated) OR blueprint
444
+ * fetched + verified (no write target yet, see step 10).
445
+ * - 1 — operational failure (registry 404 / 410, manifest invalid,
446
+ * signature mismatch, SRI mismatch, etc.).
447
+ * - 2 — usage error (bad flags, no ggui.json found).
448
+ */
449
+ export async function runArtifactInstall(flags, deps = {}) {
450
+ const verb = `ggui ${flags.kind} install`;
451
+ const cwd = deps.cwd ?? process.cwd();
452
+ const env = deps.env ?? process.env;
453
+ const fetchImpl = deps.fetch ?? globalThis.fetch.bind(globalThis);
454
+ const stdout = deps.stdout ?? ((s) => void process.stdout.write(s));
455
+ const stderr = deps.stderr ?? ((s) => void process.stderr.write(s));
456
+ // ---- step 2: find ggui.json ----
457
+ const gguiPath = findGguiJson(cwd);
458
+ if (gguiPath === null) {
459
+ stderr(`${verb}: no ggui.json found in ${cwd} or any ancestor (up to 8 levels).\n`);
460
+ return 2;
461
+ }
462
+ const loaded = readGguiJson(gguiPath);
463
+ if ('error' in loaded) {
464
+ stderr(`${verb}: ${loaded.error}\n`);
465
+ return 2;
466
+ }
467
+ const gguiJson = loaded.value;
468
+ // ---- step 3: resolve registry URL ----
469
+ const registryRes = resolveRegistry({
470
+ ...(flags.registry !== undefined ? { flag: flags.registry } : {}),
471
+ gguiJson,
472
+ env,
473
+ });
474
+ if ('error' in registryRes) {
475
+ stderr(`${verb}: ${registryRes.error}\n`);
476
+ return 1;
477
+ }
478
+ const registryUrl = registryRes.url;
479
+ // ---- step 4: GET /pkg/{scope}/{name}/{version} ----
480
+ const { scope, name } = splitPluginId(flags.artifactId);
481
+ const pkgUrl = `${registryUrl}/pkg/${encodeURIComponent(scope)}/${encodeURIComponent(name)}/${encodeURIComponent(flags.version)}`;
482
+ stdout(`fetching: ${pkgUrl}\n`);
483
+ let pkgRes;
484
+ try {
485
+ pkgRes = await fetchImpl(pkgUrl, {
486
+ method: 'GET',
487
+ headers: { accept: 'application/json' },
488
+ });
489
+ }
490
+ catch (err) {
491
+ stderr(`${verb}: network error fetching registry: ${err instanceof Error ? err.message : String(err)}\n`);
492
+ return 1;
493
+ }
494
+ let pkgBody;
495
+ try {
496
+ pkgBody = await pkgRes.json();
497
+ }
498
+ catch (err) {
499
+ stderr(`${verb}: registry returned ${pkgRes.status} with non-JSON body: ${err instanceof Error ? err.message : String(err)}\n`);
500
+ return 1;
501
+ }
502
+ if (pkgRes.status === 404) {
503
+ stderr(`${verb}: package ${flags.artifactId}@${flags.version} not found\n`);
504
+ return 1;
505
+ }
506
+ if (pkgRes.status === 410) {
507
+ stderr(`${verb}: ${flags.artifactId}@${flags.version} was yanked; choose a different version\n`);
508
+ return 1;
509
+ }
510
+ if (pkgRes.status === 403) {
511
+ stderr(`${verb}: ${flags.artifactId}@${flags.version} is private — log in with \`ggui auth login\` (JWT auth for install is not yet supported)\n`);
512
+ return 1;
513
+ }
514
+ if (!pkgRes.ok) {
515
+ const detail = isReadErrorBody(pkgBody)
516
+ ? ` (${pkgBody.error}: ${pkgBody.message})`
517
+ : '';
518
+ stderr(`${verb}: registry returned ${pkgRes.status}${detail}\n`);
519
+ return 1;
520
+ }
521
+ if (!isReadPkgResponse(pkgBody)) {
522
+ stderr(`${verb}: registry response did not match ReadPkgResponse shape\n`);
523
+ return 1;
524
+ }
525
+ const readPkg = pkgBody;
526
+ // ---- step 5: parse manifest ----
527
+ let manifest;
528
+ try {
529
+ manifest = parseArtifactManifest(readPkg.manifest);
530
+ }
531
+ catch (err) {
532
+ stderr(`${verb}: manifest from registry failed schema validation: ${err instanceof Error ? err.message : String(err)}\n`);
533
+ return 1;
534
+ }
535
+ // ---- step 5a: enforce verb / manifest kind alignment ----
536
+ if (manifest.kind !== flags.kind) {
537
+ const correctVerb = `ggui ${manifest.kind} install`;
538
+ stderr(`${verb}: ${flags.artifactId}@${flags.version} is a ${manifest.kind} on the registry.\n` +
539
+ `Run \`${correctVerb} ${flags.artifactId}@${flags.version}\` instead.\n`);
540
+ return 1;
541
+ }
542
+ stdout(`manifest: ${manifest.kind} ${manifest.scope}/${manifest.name}@${manifest.version}\n`);
543
+ // ---- step 6+7: download bundle (gadgets) or use manifest.source (blueprints),
544
+ // then fetch signature ----
545
+ let signaturePayload;
546
+ let signature;
547
+ if (manifest.kind === 'gadget') {
548
+ if (readPkg.bundleUrl === undefined) {
549
+ stderr(`${verb}: registry response is missing \`bundleUrl\` for a gadget — refusing to install\n`);
550
+ return 1;
551
+ }
552
+ const bundleRes = await fetchBytes(fetchImpl, readPkg.bundleUrl);
553
+ if ('error' in bundleRes) {
554
+ stderr(`${verb}: ${bundleRes.error}\n`);
555
+ return 1;
556
+ }
557
+ signaturePayload = bundleRes.bytes;
558
+ stdout(`bundle: ${bundleRes.bytes.length} bytes\n`);
559
+ // SRI gate — independent of Ed25519. Mismatch = bundle tampering.
560
+ if (readPkg.bundleSri !== undefined) {
561
+ const sriRes = verifyBundleSri(bundleRes.bytes, readPkg.bundleSri);
562
+ if (!sriRes.ok) {
563
+ stderr(`${verb}: ${sriRes.reason}\n`);
564
+ return 1;
565
+ }
566
+ stdout(`SRI: ok (${readPkg.bundleSri.slice(0, 20)}…)\n`);
567
+ }
568
+ else {
569
+ stderr(`${verb}: registry response is missing \`bundleSri\` — SRI check skipped\n`);
570
+ }
571
+ }
572
+ else {
573
+ // Blueprint — signature is over canonical-JSON of the manifest. The
574
+ // raw TSX lives on `manifest.source` (guaranteed non-empty by the
575
+ // BlueprintManifest schema, which we've already parsed). The
576
+ // registry's `compiledBytes` field is observability-only at this
577
+ // layer; the CLI writes the raw TSX for local-dev editability.
578
+ signaturePayload = new TextEncoder().encode(canonicalJson(readPkg.manifest));
579
+ if (manifest.kind === 'blueprint') {
580
+ stdout(`blueprint source: ${manifest.source.length} chars\n`);
581
+ if (typeof readPkg.compiledBytes === 'string' && readPkg.compiledBytes.length > 0) {
582
+ stdout(`compiled bytes: ${readPkg.compiledBytes.length} base64 chars\n`);
583
+ }
584
+ }
585
+ }
586
+ // ---- step 7: fetch signature ----
587
+ if (readPkg.signatureUrl !== undefined) {
588
+ let sigRes;
589
+ try {
590
+ sigRes = await fetchImpl(readPkg.signatureUrl, {
591
+ method: 'GET',
592
+ headers: { accept: 'application/json' },
593
+ });
594
+ }
595
+ catch (err) {
596
+ stderr(`${verb}: network error fetching signature: ${err instanceof Error ? err.message : String(err)}\n`);
597
+ return 1;
598
+ }
599
+ if (!sigRes.ok) {
600
+ stderr(`${verb}: GET ${readPkg.signatureUrl} returned ${sigRes.status}\n`);
601
+ return 1;
602
+ }
603
+ let sigBody;
604
+ try {
605
+ sigBody = await sigRes.json();
606
+ }
607
+ catch (err) {
608
+ stderr(`${verb}: signature body is not valid JSON: ${err instanceof Error ? err.message : String(err)}\n`);
609
+ return 1;
610
+ }
611
+ if (!isGadgetSignature(sigBody)) {
612
+ stderr(`${verb}: signature body did not match Ed25519Signature or SigstoreSignature shape\n`);
613
+ return 1;
614
+ }
615
+ signature = sigBody;
616
+ }
617
+ // ---- verify signature ----
618
+ // Two-leg verification: (1) recompute `sha384(payload)` and match
619
+ // against `signature.bundleSha384` for fast tamper detection, then
620
+ // (2) verify the Ed25519 signature against the publish-pinned
621
+ // `authorPublicKey`. Both legs are required for a green install;
622
+ // the pubkey leg is bypassable with a warning ONLY when the
623
+ // registry didn't pin a public key (older historical rows).
624
+ if (signature !== undefined) {
625
+ const recomputedSha384 = bytesToBase64(sha384(signaturePayload));
626
+ if (recomputedSha384 !== signature.bundleSha384) {
627
+ stderr(`${verb}: bundleSha384 mismatch — signature.bundleSha384=${signature.bundleSha384.slice(0, 16)}…, recomputed=${recomputedSha384.slice(0, 16)}…\n`);
628
+ return 1;
629
+ }
630
+ stdout(`signature: bundleSha384 ok\n`);
631
+ if (signature.algorithm === 'ed25519') {
632
+ if (readPkg.authorPublicKey !== undefined) {
633
+ const publicKeyBytes = decodeBase64(readPkg.authorPublicKey);
634
+ if (publicKeyBytes === undefined) {
635
+ stderr(`${verb}: registry-returned \`authorPublicKey\` is not valid base64\n`);
636
+ return 1;
637
+ }
638
+ // Bind: the signature's `publicKeyId` MUST derive from the
639
+ // returned public key. Mismatch = the registry served a row
640
+ // pinned to a different key than the one that produced the
641
+ // signature — either a server-side bug or an attempted swap.
642
+ const derivedKeyId = await derivePublicKeyId(publicKeyBytes);
643
+ if (derivedKeyId !== signature.publicKeyId) {
644
+ stderr(`${verb}: publicKeyId mismatch — signature.publicKeyId=${signature.publicKeyId}, derived=${derivedKeyId}\n`);
645
+ return 1;
646
+ }
647
+ const verifyRes = await verifyBundleEd25519({
648
+ bundleBytes: signaturePayload,
649
+ signature,
650
+ publicKey: publicKeyBytes,
651
+ });
652
+ if (!verifyRes.valid) {
653
+ stderr(`${verb}: Ed25519 verify failed: ${verifyRes.reason}\n`);
654
+ return 1;
655
+ }
656
+ stdout(`signature: Ed25519 ok (keyId=${signature.publicKeyId})\n`);
657
+ }
658
+ else {
659
+ const skipReason = 'registry response is missing `authorPublicKey` — full Ed25519 verification was skipped (legacy row)';
660
+ if (flags.strict) {
661
+ stderr(`${verb}: ${skipReason} (--strict)\n`);
662
+ return 1;
663
+ }
664
+ stderr(`${verb}: warning — ${skipReason}\n`);
665
+ }
666
+ }
667
+ else {
668
+ // Sigstore (Fulcio + Rekor) verify.
669
+ // Identity-claim assertion: `--verify-identity <pattern>` tightens
670
+ // verify to demand a subject match against the bundle's Fulcio
671
+ // leaf cert. When unset, verify accepts ANY valid OIDC identity
672
+ // (the trust decision is then "the bundle was signed by a
673
+ // Sigstore-recognized identity" — install operators wanting
674
+ // tighter policy must pass `--verify-identity`).
675
+ //
676
+ // Pattern form: `/regex/` (slash-delimited) → RegExp; anything
677
+ // else → literal subject. Matches the upstream verify shape
678
+ // (`expectedIdentity.subject: string | RegExp`).
679
+ const expectedIdentity = parseVerifyIdentity(flags.verifyIdentity);
680
+ const verifyRes = await verifyBundleSigstore({
681
+ bundleBytes: signaturePayload,
682
+ signature,
683
+ ...(expectedIdentity !== undefined ? { expectedIdentity } : {}),
684
+ });
685
+ if (!verifyRes.valid) {
686
+ stderr(`${verb}: sigstore verify failed: ${verifyRes.reason}\n`);
687
+ return 1;
688
+ }
689
+ stdout(`signature: sigstore ok\n`);
690
+ }
691
+ }
692
+ else {
693
+ stderr(`${verb}: warning — no signatureUrl in registry response; integrity check limited to SRI\n`);
694
+ }
695
+ // ---- step 10: write to ggui.json ----
696
+ if (manifest.kind === 'gadget') {
697
+ const entry = manifestToRegistryEntry(manifest, {
698
+ version: manifest.version,
699
+ ...(readPkg.bundleUrl !== undefined ? { bundleUrl: readPkg.bundleUrl } : {}),
700
+ ...(readPkg.bundleSri !== undefined ? { bundleSri: readPkg.bundleSri } : {}),
701
+ });
702
+ const writeRes = appendGadget(gguiJson, entry);
703
+ if ('error' in writeRes) {
704
+ stderr(`${verb}: ${writeRes.error}\n`);
705
+ return 1;
706
+ }
707
+ // Required public-env keys. Gadget's `requires[]` lists the
708
+ // `GGUI_PUBLIC_APP_*` keys the wrapper reads via getPublicEnv().
709
+ // Prompt for each (or skip silently under --no-prompt).
710
+ const required = manifest.requires;
711
+ if (required && required.length > 0) {
712
+ await maybePromptPublicEnv({
713
+ keys: required,
714
+ gguiJson,
715
+ flags,
716
+ deps: { stdout, stderr, ...(deps.promptForEnv ? { promptForEnv: deps.promptForEnv } : {}) },
717
+ });
718
+ }
719
+ writeGguiJson(gguiPath, gguiJson);
720
+ stdout(`installed: ${manifest.scope}/${manifest.name}@${manifest.version} → ${gguiPath}\n`);
721
+ // Touch dirname() so the helper is in scope for future expansion
722
+ // (e.g. style sheet caching adjacent to ggui.json).
723
+ void dirname;
724
+ return 0;
725
+ }
726
+ // Blueprint branch — materialize the manifest's TSX source +
727
+ // generated `ggui.ui.json` into `.ggui/installed-blueprints/` and
728
+ // append the glob to `ggui.json#blueprints.include` so the existing
729
+ // OSS-server discovery picks them up at boot. The discovery loader
730
+ // (`packages/project-config/src/discovery.ts`) is the integration
731
+ // boundary — no new boot-time loader needed.
732
+ if (manifest.kind === 'blueprint') {
733
+ const projectRoot = dirname(gguiPath);
734
+ const materializeRes = materializeBlueprint({ projectRoot, manifest });
735
+ if ('error' in materializeRes) {
736
+ stderr(`${verb}: ${materializeRes.error}\n`);
737
+ return 1;
738
+ }
739
+ if (materializeRes.skipped) {
740
+ stderr(`${verb}: warning — blueprint ${manifest.scope}/${manifest.name}@${manifest.version} has no \`contract\` field; verified + skipped materialization (UiManifest.contract is required). The author should re-publish with a contract before this blueprint becomes installable.\n`);
741
+ return 0;
742
+ }
743
+ const globAdded = ensureBlueprintGlobIncluded(gguiJson);
744
+ writeGguiJson(gguiPath, gguiJson);
745
+ stdout(`installed blueprint: ${manifest.scope}/${manifest.name}@${manifest.version}\n` +
746
+ ` - source: ${materializeRes.files.source}\n` +
747
+ ` - manifest: ${materializeRes.files.uiManifest}\n` +
748
+ (globAdded
749
+ ? ` - added glob to ggui.json#blueprints.include\n`
750
+ : ''));
751
+ return 0;
752
+ }
753
+ // Unreachable — manifest.kind is exhaustively narrowed above. The
754
+ // explicit branch keeps the discriminated-union check honest if a
755
+ // third kind ever lands on `ArtifactManifest`.
756
+ const _exhaustive = manifest;
757
+ void _exhaustive;
758
+ return 0;
759
+ }
760
+ /* -------------------------------------------------------------------------- */
761
+ /* ggui.json mutation */
762
+ /* -------------------------------------------------------------------------- */
763
+ /**
764
+ * Append a {@link GadgetDescriptor} to `app.gadgets`,
765
+ * creating the `app` block + array if either is missing. The schema's
766
+ * default for absent `gadgets` is the protocol's
767
+ * `STDLIB_GADGETS`; once an install adds a 3rd-party entry,
768
+ * the operator opts into explicit-declaration mode and the stdlib
769
+ * still gets merged at server boot (the merge lives on the cloud +
770
+ * dev-stack sides, not here).
771
+ *
772
+ * Idempotent on `(scope, name, version)` — re-running install on the
773
+ * same identifier replaces the existing row rather than adding a
774
+ * duplicate.
775
+ */
776
+ function appendGadget(gguiJson, entry) {
777
+ const app = gguiJson['app'];
778
+ if (app === undefined) {
779
+ // No app block — bail; the schema requires one and we don't want
780
+ // to synthesize identity (`slug` + `name`) on the operator's
781
+ // behalf. Operator should run `ggui init` (or scaffolding their
782
+ // server set up) first.
783
+ return {
784
+ error: 'ggui.json#app is missing — run your project scaffolder first (every project needs `app.slug` + `app.name`)',
785
+ };
786
+ }
787
+ if (typeof app !== 'object' || app === null || Array.isArray(app)) {
788
+ return { error: 'ggui.json#app must be an object' };
789
+ }
790
+ const appObj = app;
791
+ let libs = appObj['gadgets'];
792
+ if (libs === undefined) {
793
+ libs = [];
794
+ appObj['gadgets'] = libs;
795
+ }
796
+ if (!Array.isArray(libs)) {
797
+ return { error: 'ggui.json#app.gadgets must be an array' };
798
+ }
799
+ // Idempotent replace by `(package, version)` — a gadget descriptor
800
+ // is a PACKAGE, and `(package, version)` is its frozen registry
801
+ // identity tuple. Re-installing the same package version replaces
802
+ // the existing row rather than adding a duplicate.
803
+ const existingIdx = libs.findIndex((e) => {
804
+ if (e === null || typeof e !== 'object')
805
+ return false;
806
+ const cand = e;
807
+ return cand.package === entry.package && cand.version === entry.version;
808
+ });
809
+ if (existingIdx >= 0) {
810
+ libs[existingIdx] = entry;
811
+ }
812
+ else {
813
+ libs.push(entry);
814
+ }
815
+ return { ok: true };
816
+ }
817
+ /* -------------------------------------------------------------------------- */
818
+ /* publicEnv prompts */
819
+ /* -------------------------------------------------------------------------- */
820
+ /**
821
+ * For each `GGUI_PUBLIC_APP_*` key the gadget requires, prompt the
822
+ * operator (unless `--no-prompt`). Already-present keys are left
823
+ * untouched. Missing keys under `--no-prompt` surface as a warning
824
+ * + the install still succeeds (operator wires them in later).
825
+ */
826
+ async function maybePromptPublicEnv(args) {
827
+ const verb = `ggui ${args.flags.kind} install`;
828
+ const app = args.gguiJson['app'];
829
+ if (typeof app !== 'object' || app === null || Array.isArray(app))
830
+ return;
831
+ const appObj = app;
832
+ let publicEnv = appObj['publicEnv'];
833
+ if (publicEnv === undefined) {
834
+ publicEnv = {};
835
+ appObj['publicEnv'] = publicEnv;
836
+ }
837
+ if (typeof publicEnv !== 'object' || publicEnv === null || Array.isArray(publicEnv)) {
838
+ args.deps.stderr(`${verb}: warning — ggui.json#app.publicEnv is not an object; skipping prompt\n`);
839
+ return;
840
+ }
841
+ const envObj = publicEnv;
842
+ for (const key of args.keys) {
843
+ if (envObj[key] !== undefined)
844
+ continue; // already wired
845
+ if (args.flags.noPrompt) {
846
+ args.deps.stderr(`${verb}: warning — required public-env key ${key} is not set; add it to ggui.json#app.publicEnv before booting\n`);
847
+ continue;
848
+ }
849
+ if (args.deps.promptForEnv === undefined) {
850
+ args.deps.stderr(`${verb}: warning — no prompt sink configured; required key ${key} skipped (re-run with --no-prompt to silence)\n`);
851
+ continue;
852
+ }
853
+ const value = await args.deps.promptForEnv(key);
854
+ if (value === null || value.length === 0) {
855
+ args.deps.stderr(`${verb}: warning — declined value for ${key}; you can wire it in later\n`);
856
+ continue;
857
+ }
858
+ envObj[key] = value;
859
+ args.deps.stdout(`set: ggui.json#app.publicEnv.${key}\n`);
860
+ }
861
+ }
862
+ /* -------------------------------------------------------------------------- */
863
+ /* Blueprint materialization */
864
+ /* -------------------------------------------------------------------------- */
865
+ /**
866
+ * Destination subdirectory under the project root for marketplace-
867
+ * installed blueprints. Chosen so the existing `blueprints.include`
868
+ * glob loader picks them up without any code change — `discoverLocalUis`
869
+ * walks every glob, parses every matched `ggui.ui.json`, and emits
870
+ * `DiscoveredUi` entries. The "registry-installed" subtree is just
871
+ * one more glob root.
872
+ */
873
+ export const INSTALLED_BLUEPRINTS_SUBDIR = '.ggui/installed-blueprints';
874
+ /**
875
+ * Glob pattern auto-appended to `ggui.json#blueprints.include` so the
876
+ * OSS server discovers materialized blueprints alongside hand-authored
877
+ * ones. Idempotent — install only adds the entry once.
878
+ */
879
+ export const INSTALLED_BLUEPRINTS_GLOB = `${INSTALLED_BLUEPRINTS_SUBDIR}/**/ggui.ui.json`;
880
+ /**
881
+ * Predicate: "is this discovered-UI manifest path inside
882
+ * `.ggui/installed-blueprints/`?".
883
+ *
884
+ * Cross-platform — matches both Unix (`/.ggui/installed-blueprints/`)
885
+ * and Windows (`\.ggui\installed-blueprints\`) separators. A
886
+ * Unix-only `includes('/.ggui/installed-blueprints/')` check would
887
+ * silently match zero entries on Windows, where `tinyglobby` returns
888
+ * `\`-separated absolute paths.
889
+ *
890
+ * Defined once here, next to the constant it is derived from. Both
891
+ * `cli.ts` (filter discovered UIs) and `mcp-backend.ts` (project to
892
+ * bridge entries) consume it, so the two filter sites cannot drift.
893
+ */
894
+ export function isInstalledBlueprintPath(manifestPath) {
895
+ // Build separator-specific needles from the constant rather than
896
+ // hand-coding both forms (defensive against future renames).
897
+ const segments = INSTALLED_BLUEPRINTS_SUBDIR.split('/');
898
+ const unixNeedle = `/${segments.join('/')}/`;
899
+ const winNeedle = `\\${segments.join('\\')}\\`;
900
+ return manifestPath.includes(unixNeedle) || manifestPath.includes(winNeedle);
901
+ }
902
+ /**
903
+ * Compute the directory leaf for a given blueprint manifest. Safe for
904
+ * filesystem paths — `<scope-without-@>__<name>__<version>`, no slashes
905
+ * or colons (the version's `.` is fine on every supported FS). The
906
+ * `__` separator avoids confusion with scope names that contain `-`.
907
+ */
908
+ export function blueprintInstallSubdir(args) {
909
+ const scopeBare = args.scope.startsWith('@') ? args.scope.slice(1) : args.scope;
910
+ return `${scopeBare}__${args.name}__${args.version}`;
911
+ }
912
+ /**
913
+ * UI manifest `id` for a marketplace-installed blueprint. Must satisfy
914
+ * `UiManifestV1#id` (`^[A-Za-z0-9][A-Za-z0-9._:-]*$`). We use
915
+ * `<scope-bare>:<name>:<version>` — colons + dots + hyphens + alphanum
916
+ * are all admitted.
917
+ */
918
+ export function blueprintUiManifestId(args) {
919
+ const scopeBare = args.scope.startsWith('@') ? args.scope.slice(1) : args.scope;
920
+ return `${scopeBare}:${args.name}:${args.version}`;
921
+ }
922
+ /**
923
+ * Materialize a verified blueprint manifest onto disk:
924
+ * - `<projectRoot>/.ggui/installed-blueprints/<id-dir>/index.tsx`
925
+ * - `<projectRoot>/.ggui/installed-blueprints/<id-dir>/ggui.ui.json`
926
+ *
927
+ * Returns a discriminated result. `skipped: true` is the explicit
928
+ * "blueprint has no contract" path — the verification succeeded but
929
+ * `UiManifest.contract` is required, so we don't write a half-formed
930
+ * row to disk. Caller surfaces the warning.
931
+ *
932
+ * Refuses to overwrite an existing directory — the same `(scope, name,
933
+ * version)` triple is immutable under the registry's append-only
934
+ * posture, so a re-install on the same version is operator error
935
+ * (uninstall the blueprint first if you really mean to refresh it).
936
+ */
937
+ function materializeBlueprint(args) {
938
+ const { projectRoot, manifest } = args;
939
+ if (manifest.contract === undefined) {
940
+ return { ok: true, skipped: true };
941
+ }
942
+ const subdir = blueprintInstallSubdir(manifest);
943
+ const targetDir = join(projectRoot, INSTALLED_BLUEPRINTS_SUBDIR, subdir);
944
+ if (existsSync(targetDir)) {
945
+ return {
946
+ error: `${manifest.scope}/${manifest.name}@${manifest.version} is already installed at ${targetDir} — registry versions are immutable; uninstall first if you really mean to refresh`,
947
+ };
948
+ }
949
+ try {
950
+ mkdirSync(targetDir, { recursive: true });
951
+ }
952
+ catch (err) {
953
+ return {
954
+ error: `failed to create install dir ${targetDir}: ${err instanceof Error ? err.message : String(err)}`,
955
+ };
956
+ }
957
+ const sourcePath = join(targetDir, 'index.tsx');
958
+ const manifestPath = join(targetDir, 'ggui.ui.json');
959
+ // Deliberately omit `entryPoint`. The install-bridge compile
960
+ // callback (and dev-stack's `compileUiOnDemand`) resolves
961
+ // `manifest.entryPoint` relative to `projectRoot` — an
962
+ // `entryPoint: 'index.tsx'` here would resolve to
963
+ // `<projectRoot>/index.tsx`, NOT the install dir. When `entryPoint`
964
+ // is omitted, `resolveEntryFile` falls back to `ENTRY_CANDIDATES`
965
+ // (ggui.ui.tsx / index.tsx / component.tsx) resolved relative to the
966
+ // manifest dir — exactly what installed blueprints need.
967
+ const uiManifest = {
968
+ id: blueprintUiManifestId(manifest),
969
+ name: `${manifest.scope}/${manifest.name}`,
970
+ ...(manifest.description !== undefined
971
+ ? { description: manifest.description }
972
+ : {}),
973
+ contract: manifest.contract,
974
+ };
975
+ try {
976
+ writeFileSync(sourcePath, manifest.source, 'utf-8');
977
+ writeFileSync(manifestPath, `${JSON.stringify(uiManifest, null, 2)}\n`, 'utf-8');
978
+ }
979
+ catch (err) {
980
+ return {
981
+ error: `failed to write blueprint files under ${targetDir}: ${err instanceof Error ? err.message : String(err)}`,
982
+ };
983
+ }
984
+ return {
985
+ ok: true,
986
+ files: { source: sourcePath, uiManifest: manifestPath },
987
+ };
988
+ }
989
+ /**
990
+ * Ensure `ggui.json#blueprints.include` contains the installed-
991
+ * blueprints glob. Returns `true` if a write was needed; `false` if
992
+ * the glob was already present (idempotent). Creates the `blueprints`
993
+ * block if missing.
994
+ */
995
+ function ensureBlueprintGlobIncluded(gguiJson) {
996
+ let blueprints = gguiJson['blueprints'];
997
+ if (blueprints === undefined) {
998
+ blueprints = { include: [INSTALLED_BLUEPRINTS_GLOB] };
999
+ gguiJson['blueprints'] = blueprints;
1000
+ return true;
1001
+ }
1002
+ if (typeof blueprints !== 'object' || blueprints === null || Array.isArray(blueprints)) {
1003
+ // Schema-side parse would reject this anyway; treat as "no change"
1004
+ // since we don't want to clobber an operator's already-broken field.
1005
+ return false;
1006
+ }
1007
+ const blueprintsObj = blueprints;
1008
+ const include = blueprintsObj['include'];
1009
+ if (include === undefined) {
1010
+ blueprintsObj['include'] = [INSTALLED_BLUEPRINTS_GLOB];
1011
+ return true;
1012
+ }
1013
+ if (!Array.isArray(include))
1014
+ return false;
1015
+ if (include.includes(INSTALLED_BLUEPRINTS_GLOB))
1016
+ return false;
1017
+ include.push(INSTALLED_BLUEPRINTS_GLOB);
1018
+ return true;
1019
+ }