@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.
- package/LICENSE +201 -0
- package/README.md +96 -0
- package/dist/agent-resolution.d.ts +41 -0
- package/dist/agent-resolution.d.ts.map +1 -0
- package/dist/agent-resolution.js +80 -0
- package/dist/api-client.d.ts +59 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +145 -0
- package/dist/auth-keys.d.ts +10 -0
- package/dist/auth-keys.d.ts.map +1 -0
- package/dist/auth-keys.js +382 -0
- package/dist/auth-login.d.ts +11 -0
- package/dist/auth-login.d.ts.map +1 -0
- package/dist/auth-login.js +231 -0
- package/dist/auth-logout.d.ts +3 -0
- package/dist/auth-logout.d.ts.map +1 -0
- package/dist/auth-logout.js +32 -0
- package/dist/auth-store.d.ts +67 -0
- package/dist/auth-store.d.ts.map +1 -0
- package/dist/auth-store.js +150 -0
- package/dist/auth-whoami.d.ts +3 -0
- package/dist/auth-whoami.d.ts.map +1 -0
- package/dist/auth-whoami.js +52 -0
- package/dist/blueprint-command.d.ts +3 -0
- package/dist/blueprint-command.d.ts.map +1 -0
- package/dist/blueprint-command.js +203 -0
- package/dist/blueprint-create.d.ts +71 -0
- package/dist/blueprint-create.d.ts.map +1 -0
- package/dist/blueprint-create.js +377 -0
- package/dist/byok-resolver.d.ts +141 -0
- package/dist/byok-resolver.d.ts.map +1 -0
- package/dist/byok-resolver.js +89 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1066 -0
- package/dist/dev-command.d.ts +169 -0
- package/dist/dev-command.d.ts.map +1 -0
- package/dist/dev-command.js +222 -0
- package/dist/gadget-command.d.ts +3 -0
- package/dist/gadget-command.d.ts.map +1 -0
- package/dist/gadget-command.js +181 -0
- package/dist/gadget-create.d.ts +106 -0
- package/dist/gadget-create.d.ts.map +1 -0
- package/dist/gadget-create.js +492 -0
- package/dist/generation-probe.d.ts +135 -0
- package/dist/generation-probe.d.ts.map +1 -0
- package/dist/generation-probe.js +132 -0
- package/dist/internal/artifact-install.d.ts +150 -0
- package/dist/internal/artifact-install.d.ts.map +1 -0
- package/dist/internal/artifact-install.js +1019 -0
- package/dist/internal/artifact-publish.d.ts +204 -0
- package/dist/internal/artifact-publish.d.ts.map +1 -0
- package/dist/internal/artifact-publish.js +1141 -0
- package/dist/internal/artifact-search.d.ts +155 -0
- package/dist/internal/artifact-search.d.ts.map +1 -0
- package/dist/internal/artifact-search.js +539 -0
- package/dist/internal/artifact-uninstall.d.ts +30 -0
- package/dist/internal/artifact-uninstall.d.ts.map +1 -0
- package/dist/internal/artifact-uninstall.js +205 -0
- package/dist/internal/auth-cache.d.ts +40 -0
- package/dist/internal/auth-cache.d.ts.map +1 -0
- package/dist/internal/auth-cache.js +133 -0
- package/dist/internal/auth-strategy.d.ts +89 -0
- package/dist/internal/auth-strategy.d.ts.map +1 -0
- package/dist/internal/auth-strategy.js +132 -0
- package/dist/internal/ggui-json.d.ts +53 -0
- package/dist/internal/ggui-json.d.ts.map +1 -0
- package/dist/internal/ggui-json.js +92 -0
- package/dist/internal/key-store.d.ts +43 -0
- package/dist/internal/key-store.d.ts.map +1 -0
- package/dist/internal/key-store.js +128 -0
- package/dist/internal/oidc-token.d.ts +52 -0
- package/dist/internal/oidc-token.d.ts.map +1 -0
- package/dist/internal/oidc-token.js +351 -0
- package/dist/internal/register-author-key.d.ts +44 -0
- package/dist/internal/register-author-key.d.ts.map +1 -0
- package/dist/internal/register-author-key.js +212 -0
- package/dist/local-keys.d.ts +47 -0
- package/dist/local-keys.d.ts.map +1 -0
- package/dist/local-keys.js +115 -0
- package/dist/mcp-backend.d.ts +356 -0
- package/dist/mcp-backend.d.ts.map +1 -0
- package/dist/mcp-backend.js +668 -0
- package/dist/paths.d.ts +77 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +119 -0
- package/dist/persistent-secrets.d.ts +28 -0
- package/dist/persistent-secrets.d.ts.map +1 -0
- package/dist/persistent-secrets.js +94 -0
- package/dist/persistent-short-code-index.d.ts +14 -0
- package/dist/persistent-short-code-index.d.ts.map +1 -0
- package/dist/persistent-short-code-index.js +39 -0
- package/dist/persistent-stores.d.ts +12 -0
- package/dist/persistent-stores.d.ts.map +1 -0
- package/dist/persistent-stores.js +42 -0
- package/dist/serve-command.d.ts +500 -0
- package/dist/serve-command.d.ts.map +1 -0
- package/dist/serve-command.js +616 -0
- package/dist/serve-fallback.d.ts +64 -0
- package/dist/serve-fallback.d.ts.map +1 -0
- package/dist/serve-fallback.js +122 -0
- package/dist/spawn-cloudflared.d.ts +61 -0
- package/dist/spawn-cloudflared.d.ts.map +1 -0
- package/dist/spawn-cloudflared.js +167 -0
- package/dist/theme-command.d.ts +4 -0
- package/dist/theme-command.d.ts.map +1 -0
- package/dist/theme-command.js +154 -0
- package/dist/theme-file-uploader.d.ts +8 -0
- package/dist/theme-file-uploader.d.ts.map +1 -0
- package/dist/theme-file-uploader.js +58 -0
- package/dist/theme-writer.d.ts +8 -0
- package/dist/theme-writer.d.ts.map +1 -0
- package/dist/theme-writer.js +103 -0
- package/dist/tunnel-provider.d.ts +273 -0
- package/dist/tunnel-provider.d.ts.map +1 -0
- package/dist/tunnel-provider.js +142 -0
- 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
|
+
}
|