@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,1141 @@
1
+ /**
2
+ * Shared publish core for `ggui gadget publish` and `ggui blueprint publish`.
3
+ *
4
+ * An internal helper that both kind-discriminated router files
5
+ * (`gadget-command.ts` + `blueprint-command.ts`) delegate to. The CLI
6
+ * verb's `kind` parameter is enforced against the on-disk manifest's
7
+ * `kind` discriminator — a `ggui gadget publish` in a directory with
8
+ * `ggui.blueprint.json` (or vice versa) prints a friendly redirect
9
+ * pointing at the correct verb instead of trying to publish.
10
+ *
11
+ * Walks the operator-facing flow end-to-end:
12
+ *
13
+ * 1. Resolve registry URL — `--registry` flag, then `GGUI_REGISTRY`
14
+ * env, then the nearest `ggui.json#registry`. No fallback to a
15
+ * hardcoded default — operators MUST opt into a registry
16
+ * explicitly so a typo doesn't accidentally publish to prod.
17
+ * 2. Load + validate `ggui.gadget.json` / `ggui.blueprint.json` in CWD;
18
+ * enforce that the manifest kind matches the verb the operator typed.
19
+ * 3. (Gadgets only) bundle the entry via esbuild (ESM / es2022 /
20
+ * platform=neutral) with manifest.peerDeps + react family
21
+ * hoisted to externals.
22
+ * 4. Authenticate — either `--auth=bearer` (explicit token for self-
23
+ * hosted / local registries) OR the default hosted-auth flow via
24
+ * the configured registry. The hosted-auth implementation here
25
+ * talks to the cloud registry's hosted identity provider, but
26
+ * the public-surface naming stays vendor-neutral (see
27
+ * `./auth-strategy.ts`).
28
+ * 5. Conformance preflight — POST `/conformance/check` with the
29
+ * manifest + bundle text. Fail-fast so a malformed bundle
30
+ * doesn't hit S3.
31
+ * 6. Sign the bundle (gadget) or canonical manifest (blueprint)
32
+ * with the operator's Ed25519 private key. Generate the
33
+ * keypair on first publish + print the public-key id for
34
+ * registry-side registration.
35
+ * 7. POST `/publish` with `{manifest, bundle?, bundleSha384?, signature}`.
36
+ * 8. Print the install command operators paste into their
37
+ * `ggui.json` consumers.
38
+ *
39
+ * `--dry-run` runs steps 1-5 and prints what would be uploaded; no
40
+ * POST happens.
41
+ *
42
+ * **HTTP contract (locked).**
43
+ *
44
+ * POST <registry>/publish
45
+ * Headers: `Authorization: Bearer <jwt>`, `Content-Type: application/json`
46
+ * Body : { manifest, bundle?, bundleSha384?, signature }
47
+ * 201 : { artifactId, version, manifestUrl, bundleUrl?, signatureUrl?, installCommand }
48
+ * 400 : { code: 'version_exists' | 'unknown_key' | 'conformance_failed' | … , message }
49
+ * 401 : auth bad
50
+ * 501 : endpoint stubbed
51
+ *
52
+ * POST <registry>/conformance/check
53
+ * Headers: `Authorization: Bearer <jwt>`, `Content-Type: application/json`
54
+ * Body : { manifest, bundle? }
55
+ * 200 : { ok: true }
56
+ * 200 : { ok: false, issues: [{code, message, path?}] }
57
+ * 5xx : transient — caller retries or surfaces
58
+ */
59
+ import { build as esbuild } from 'esbuild';
60
+ // Cloud-internal hosted-auth implementation. The vendor SDK is named
61
+ // here because this is the leaf implementation file the OSS-facing
62
+ // `acquireHostedAuthJwt` wraps; the public surface (CLI flags, help
63
+ // text, README, the auth-strategy module) is vendor-neutral.
64
+ import { CognitoIdentityProviderClient, InitiateAuthCommand, } from '@aws-sdk/client-cognito-identity-provider';
65
+ import { existsSync, readFileSync } from 'node:fs';
66
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
67
+ import { URL } from 'node:url';
68
+ import { GGUI_BLUEPRINT_JSON_FILENAME, GGUI_GADGET_JSON_FILENAME, parseBlueprintManifest, parseGadgetManifest, } from '@ggui-ai/artifact-manifest';
69
+ import { canonicalJson, derivePublicKeyId, generateEd25519Keypair, publicKeyFromPrivate, signBundleEd25519, signBundleSigstore, } from '@ggui-ai/gadget-signing';
70
+ import { resolveOidcToken, OidcResolutionError, } from './oidc-token.js';
71
+ import { sha384 } from '@noble/hashes/sha512.js';
72
+ import { isTokenFresh, loadRegistryToken, saveRegistryToken, } from './auth-cache.js';
73
+ import { getPrivateKeyPath, hasKeypair, readPrivateKey, scopeOf, writePrivateKey, } from './key-store.js';
74
+ import { acquireAuthToken, parseAuthFlags, AUTH_HELP_FRAGMENT, } from './auth-strategy.js';
75
+ /**
76
+ * Build verb-specific help text. Takes the `kind` so the rendered
77
+ * lines say `ggui gadget publish` / `ggui blueprint publish` rather
78
+ * than a generic placeholder.
79
+ */
80
+ export function buildPublishHelp(kind) {
81
+ const verb = `ggui ${kind} publish`;
82
+ return `${verb} — push a ${kind} to the marketplace registry
83
+
84
+ Usage:
85
+ ${verb} [--registry <url>] [--dry-run] [--key <path>] [--identity-token <jwt>] [--auth=bearer [--token <token>]]
86
+
87
+ Options:
88
+ --registry <url> Registry to publish to. Precedence:
89
+ 1. --registry flag
90
+ 2. GGUI_REGISTRY env
91
+ 3. ggui.json#registry (nearest, walking up from CWD)
92
+ No hardcoded default — operators must opt in.
93
+
94
+ --dry-run Run validation, bundling, auth, and conformance
95
+ preflight; skip the POST. Prints what would be
96
+ uploaded.
97
+
98
+ --key <path> Path to a 32-byte Ed25519 private key. Used to sign
99
+ **private** ${kind}s. Default:
100
+ ~/.ggui/keys/<scope>/private.key
101
+
102
+ --identity-token <jwt>
103
+ OIDC identity token used to sign **public** ${kind}s
104
+ via sigstore (cosign keyless). If absent, the CLI
105
+ resolves a token from (in order):
106
+ 1. GGUI_OIDC_TOKEN env
107
+ 2. GitHub Actions ambient OIDC
108
+ 3. interactive browser flow (TTY only)
109
+
110
+ ${AUTH_HELP_FRAGMENT}
111
+ `;
112
+ }
113
+ /**
114
+ * Parse argv tail (excluding `ggui {gadget,blueprint} publish`) into
115
+ * flags. Pure — no IO. Returned `error` is a single-line
116
+ * operator-facing string. The `--auth` + `--token` flags are peeled
117
+ * out via {@link parseAuthFlags} so the publish flag loop stays
118
+ * focused on its own surface.
119
+ */
120
+ export function parseArtifactPublishFlags(args) {
121
+ // First, peel off the auth-related flags.
122
+ const authParsed = parseAuthFlags(args);
123
+ if ('error' in authParsed) {
124
+ return { dryRun: false, help: false, error: authParsed.error };
125
+ }
126
+ const authFlags = authParsed.flags;
127
+ const rest = authParsed.rest;
128
+ let registry;
129
+ let dryRun = false;
130
+ let key;
131
+ let identityToken;
132
+ let help = false;
133
+ for (let i = 0; i < rest.length; i++) {
134
+ const arg = rest[i];
135
+ if (arg === '--help' || arg === '-h') {
136
+ help = true;
137
+ continue;
138
+ }
139
+ if (arg === '--dry-run') {
140
+ dryRun = true;
141
+ continue;
142
+ }
143
+ if (arg === '--registry') {
144
+ const value = rest[i + 1];
145
+ if (typeof value !== 'string' || value.length === 0) {
146
+ return { dryRun, help, error: '--registry requires a value' };
147
+ }
148
+ registry = value;
149
+ i += 1;
150
+ continue;
151
+ }
152
+ if (arg === '--key') {
153
+ const value = rest[i + 1];
154
+ if (typeof value !== 'string' || value.length === 0) {
155
+ return { dryRun, help, error: '--key requires a value' };
156
+ }
157
+ key = value;
158
+ i += 1;
159
+ continue;
160
+ }
161
+ if (arg === '--identity-token' || (typeof arg === 'string' && arg.startsWith('--identity-token='))) {
162
+ // Accept both `--identity-token <jwt>` and `--identity-token=<jwt>`.
163
+ const eq = typeof arg === 'string' ? arg.indexOf('=') : -1;
164
+ let value;
165
+ if (eq !== -1 && typeof arg === 'string') {
166
+ value = arg.slice(eq + 1);
167
+ }
168
+ else {
169
+ value = rest[i + 1];
170
+ i += 1;
171
+ }
172
+ if (typeof value !== 'string' || value.length === 0) {
173
+ return { dryRun, help, error: '--identity-token requires a value' };
174
+ }
175
+ identityToken = value;
176
+ continue;
177
+ }
178
+ return { dryRun, help, error: `unknown flag: ${arg}` };
179
+ }
180
+ return {
181
+ ...(registry !== undefined ? { registry } : {}),
182
+ dryRun,
183
+ ...(key !== undefined ? { key } : {}),
184
+ ...(identityToken !== undefined ? { identityToken } : {}),
185
+ ...(authFlags.auth !== undefined || authFlags.token !== undefined
186
+ ? { auth: authFlags }
187
+ : {}),
188
+ help,
189
+ };
190
+ }
191
+ // ---------------------------------------------------------------------------
192
+ // orchestration
193
+ // ---------------------------------------------------------------------------
194
+ /**
195
+ * Run the publish flow. Pure entry point — all IO + network is
196
+ * threaded through injectable seams in `opts` so tests are
197
+ * deterministic.
198
+ *
199
+ * `opts.kind` is the verb the operator typed; manifest-kind mismatch
200
+ * (e.g. `ggui gadget publish` in a directory with `ggui.blueprint.json`)
201
+ * surfaces a friendly redirect at step 2 + exits non-zero.
202
+ */
203
+ export async function runArtifactPublish(opts) {
204
+ const verb = `ggui ${opts.kind} publish`;
205
+ const cwd = opts.cwd ?? process.cwd();
206
+ const stdout = opts.stdout ?? ((s) => void process.stdout.write(s));
207
+ const stderr = opts.stderr ?? ((s) => void process.stderr.write(s));
208
+ const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
209
+ const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
210
+ // ---- step 1: resolve registry URL ----
211
+ const registryRes = resolveRegistryUrl({ flag: opts.registry, env: process.env, cwd });
212
+ if (!registryRes.ok) {
213
+ stderr(`${verb}: ${registryRes.message}\n`);
214
+ return { ok: false, exitCode: 1, error: { code: 'no_registry_resolved', message: registryRes.message } };
215
+ }
216
+ const registryUrl = registryRes.url;
217
+ stdout(`registry: ${registryUrl} (${registryRes.source})\n`);
218
+ // ---- step 2: load manifest ----
219
+ const manifestRes = loadManifest(cwd);
220
+ if (!manifestRes.ok) {
221
+ stderr(`${verb}: ${manifestRes.error.message}\n`);
222
+ if (manifestRes.error.code === 'manifest_invalid' && manifestRes.error.details) {
223
+ stderr(` ${manifestRes.error.details}\n`);
224
+ }
225
+ return { ok: false, exitCode: 1, error: manifestRes.error };
226
+ }
227
+ const { manifest, manifestPath } = manifestRes;
228
+ // ---- step 2a: enforce verb / manifest kind alignment ----
229
+ if (manifest.kind !== opts.kind) {
230
+ const foundFile = manifest.kind === 'blueprint'
231
+ ? GGUI_BLUEPRINT_JSON_FILENAME
232
+ : GGUI_GADGET_JSON_FILENAME;
233
+ const correctVerb = `ggui ${manifest.kind} publish`;
234
+ const message = `this is a ${manifest.kind} repo (found ${foundFile}).\n` +
235
+ `Run \`${correctVerb}\` instead.`;
236
+ stderr(`${verb}: ${message}\n`);
237
+ return {
238
+ ok: false,
239
+ exitCode: 1,
240
+ error: { code: 'manifest_kind_mismatch', message },
241
+ };
242
+ }
243
+ stdout(`manifest: ${manifest.kind} ${`${manifest.scope}/${manifest.name}`}@${manifest.version}\n`);
244
+ // ---- step 3: bundle (gadgets only) ----
245
+ let bundleBytes;
246
+ let bundleSha384;
247
+ if (manifest.kind === 'gadget') {
248
+ const bundleRes = await bundleGadget(manifest, dirname(manifestPath));
249
+ if (!bundleRes.ok) {
250
+ stderr(`${verb}: bundle failed — ${bundleRes.error}\n`);
251
+ return { ok: false, exitCode: 1, error: { code: 'bundle_failed', message: bundleRes.error } };
252
+ }
253
+ bundleBytes = bundleRes.bytes;
254
+ // Base64-encoded sha384 — wire format the publish handler verifies
255
+ // against its own server-side hash.
256
+ bundleSha384 = Buffer.from(sha384(bundleBytes)).toString('base64');
257
+ stdout(`bundle: ${bundleBytes.length} bytes · sha384=${bundleSha384.slice(0, 12)}…\n`);
258
+ }
259
+ // ---- step 4: authenticate ----
260
+ // Two paths:
261
+ // --auth=bearer → token from flag or GGUI_REGISTRY_TOKEN
262
+ // default → hosted auth via the configured registry (cloud)
263
+ let jwt;
264
+ const authFlags = opts.auth ?? {};
265
+ try {
266
+ jwt = await acquireAuthToken({
267
+ flags: authFlags,
268
+ env: process.env,
269
+ cwd,
270
+ registryUrl,
271
+ acquireHostedAuthJwt: async (deps) => {
272
+ const hosted = await acquireHostedAuthJwt({
273
+ registryUrl: deps.registryUrl,
274
+ env: deps.env,
275
+ cwd: deps.cwd,
276
+ hostedAuthClient: opts.hostedAuthClient,
277
+ prompt: opts.prompt,
278
+ now,
279
+ });
280
+ if (!hosted.ok) {
281
+ throw new HostedAuthError(hosted.error);
282
+ }
283
+ stdout(`auth: ${hosted.fromCache ? 'cached' : 'fresh'} JWT for ${hosted.token.username}\n`);
284
+ return hosted.token.idToken;
285
+ },
286
+ });
287
+ }
288
+ catch (err) {
289
+ if (err instanceof HostedAuthError) {
290
+ stderr(`${verb}: ${err.payload.message}\n`);
291
+ return { ok: false, exitCode: 1, error: err.payload };
292
+ }
293
+ const msg = err instanceof Error ? err.message : String(err);
294
+ stderr(`${verb}: ${msg}\n`);
295
+ return { ok: false, exitCode: 1, error: { code: 'auth_failed', message: msg } };
296
+ }
297
+ if (authFlags.auth === 'bearer') {
298
+ stdout(`auth: bearer token (length=${jwt.length})\n`);
299
+ }
300
+ // ---- step 5: conformance preflight ----
301
+ const preflightRes = await runConformancePreflight({
302
+ registryUrl,
303
+ jwt,
304
+ manifest,
305
+ bundleBytes,
306
+ fetchImpl,
307
+ });
308
+ if (!preflightRes.ok) {
309
+ stderr(`${verb}: conformance preflight failed\n`);
310
+ if (preflightRes.error.code === 'conformance_failed' && preflightRes.error.issues) {
311
+ for (const issue of preflightRes.error.issues) {
312
+ stderr(` - ${issue.code}: ${issue.message}${issue.path ? ` (${issue.path})` : ''}\n`);
313
+ }
314
+ }
315
+ else {
316
+ stderr(` ${preflightRes.error.message}\n`);
317
+ }
318
+ return { ok: false, exitCode: 1, error: preflightRes.error };
319
+ }
320
+ stdout(`conformance: ok\n`);
321
+ // ---- step 6 + 7: sign — branched on manifest.visibility ----
322
+ //
323
+ // `private` → Ed25519 keypair signing (existing path: load/generate
324
+ // a 32-byte private key at ~/.ggui/keys/<scope>/private.key, sign
325
+ // the bundle hash, ship the publicKeyId for the registry's
326
+ // AuthorKeys lookup).
327
+ //
328
+ // `public` → sigstore keyless signing (Fulcio cert + Rekor inclusion
329
+ // proof). Needs a short-lived OIDC token resolved from one of four
330
+ // sources (flag → env → GH-Actions → interactive browser).
331
+ const signaturePayload = manifest.kind === 'gadget'
332
+ ? bundleBytes
333
+ : new TextEncoder().encode(canonicalJson(manifest));
334
+ let signature;
335
+ if (manifest.visibility === 'private') {
336
+ const keyRes = await loadOrGenerateKey({
337
+ artifactId: `${manifest.scope}/${manifest.name}`,
338
+ keyFlag: opts.key,
339
+ stdout,
340
+ });
341
+ if (!keyRes.ok) {
342
+ stderr(`${verb}: ${keyRes.error.message}\n`);
343
+ return { ok: false, exitCode: 1, error: keyRes.error };
344
+ }
345
+ signature = await signBundleEd25519({
346
+ bundleBytes: signaturePayload,
347
+ privateKey: keyRes.privateKey,
348
+ publicKeyId: keyRes.publicKeyId,
349
+ });
350
+ stdout(`signature: ${signature.algorithm} · ${keyRes.publicKeyId}\n`);
351
+ }
352
+ else {
353
+ // public → sigstore keyless. Resolve OIDC first.
354
+ let oidc;
355
+ try {
356
+ oidc = await resolveOidcToken({
357
+ ...(opts.identityToken !== undefined
358
+ ? { identityTokenFlag: opts.identityToken }
359
+ : {}),
360
+ env: process.env,
361
+ isTty: process.stdout.isTTY === true,
362
+ stdout,
363
+ });
364
+ }
365
+ catch (err) {
366
+ const message = err instanceof OidcResolutionError ? err.message : err instanceof Error ? err.message : String(err);
367
+ const oidcCode = err instanceof OidcResolutionError ? err.code : undefined;
368
+ stderr(`${verb}: ${message}\n`);
369
+ return {
370
+ ok: false,
371
+ exitCode: 1,
372
+ error: {
373
+ code: 'oidc_resolution_failed',
374
+ message,
375
+ ...(oidcCode !== undefined ? { oidcCode } : {}),
376
+ },
377
+ };
378
+ }
379
+ stdout(`signature: sigstore (OIDC source=${oidc.source})\n`);
380
+ signature = await signBundleSigstore({
381
+ bundleBytes: signaturePayload,
382
+ identityToken: oidc.token,
383
+ });
384
+ stdout(`signature: ${signature.algorithm} ok\n`);
385
+ }
386
+ // ---- step 8: POST (or skip for dry-run) ----
387
+ if (opts.dryRun) {
388
+ stdout(`dry-run: would POST to ${registryUrl}/publish\n`);
389
+ stdout(`dry-run: ${`${manifest.scope}/${manifest.name}`}@${manifest.version}\n`);
390
+ return {
391
+ ok: true,
392
+ exitCode: 0,
393
+ success: {
394
+ artifactId: `${manifest.scope}/${manifest.name}`,
395
+ version: manifest.version,
396
+ manifestUrl: '(dry-run — no manifestUrl)',
397
+ installCommand: `ggui ${opts.kind} install ${`${manifest.scope}/${manifest.name}`}@${manifest.version} --registry=${registryUrl}`,
398
+ registryUrl,
399
+ dryRun: true,
400
+ },
401
+ };
402
+ }
403
+ const publishRes = await postPublish({
404
+ registryUrl,
405
+ jwt,
406
+ manifest,
407
+ bundleBytes,
408
+ bundleSha384,
409
+ signature,
410
+ fetchImpl,
411
+ });
412
+ if (!publishRes.ok) {
413
+ stderr(`${verb}: ${publishRes.error.message}\n`);
414
+ return { ok: false, exitCode: 1, error: publishRes.error };
415
+ }
416
+ // ---- step 9: success ----
417
+ stdout(`\npublished: ${publishRes.body.artifactId}@${publishRes.body.version}\n`);
418
+ stdout(`manifest: ${publishRes.body.manifestUrl}\n`);
419
+ if (publishRes.body.bundleUrl)
420
+ stdout(`bundle: ${publishRes.body.bundleUrl}\n`);
421
+ if (publishRes.body.signatureUrl)
422
+ stdout(`signature: ${publishRes.body.signatureUrl}\n`);
423
+ stdout(`\nInstall:\n ${publishRes.body.installCommand}\n`);
424
+ return {
425
+ ok: true,
426
+ exitCode: 0,
427
+ success: {
428
+ artifactId: publishRes.body.artifactId,
429
+ version: publishRes.body.version,
430
+ manifestUrl: publishRes.body.manifestUrl,
431
+ ...(publishRes.body.bundleUrl !== undefined ? { bundleUrl: publishRes.body.bundleUrl } : {}),
432
+ ...(publishRes.body.signatureUrl !== undefined ? { signatureUrl: publishRes.body.signatureUrl } : {}),
433
+ installCommand: publishRes.body.installCommand,
434
+ registryUrl,
435
+ dryRun: false,
436
+ },
437
+ };
438
+ }
439
+ export function resolveRegistryUrl(opts) {
440
+ if (opts.flag && opts.flag.length > 0) {
441
+ if (!isValidUrl(opts.flag)) {
442
+ return {
443
+ ok: false,
444
+ message: `--registry value is not a valid URL: ${opts.flag}`,
445
+ };
446
+ }
447
+ return { ok: true, url: stripTrailingSlash(opts.flag), source: 'flag' };
448
+ }
449
+ const envValue = opts.env['GGUI_REGISTRY'];
450
+ if (envValue && envValue.length > 0) {
451
+ if (!isValidUrl(envValue)) {
452
+ return {
453
+ ok: false,
454
+ message: `GGUI_REGISTRY is not a valid URL: ${envValue}`,
455
+ };
456
+ }
457
+ return { ok: true, url: stripTrailingSlash(envValue), source: 'env' };
458
+ }
459
+ const ggui = findAndReadGguiJsonRegistry(opts.cwd);
460
+ if (ggui.found) {
461
+ if (!isValidUrl(ggui.registry)) {
462
+ return {
463
+ ok: false,
464
+ message: `ggui.json#registry at ${ggui.path} is not a valid URL: ${ggui.registry}`,
465
+ };
466
+ }
467
+ return { ok: true, url: stripTrailingSlash(ggui.registry), source: 'ggui.json' };
468
+ }
469
+ return {
470
+ ok: false,
471
+ message: 'no registry resolved. Set one of:\n' +
472
+ ' --registry <url>\n' +
473
+ ' GGUI_REGISTRY env var\n' +
474
+ ' "registry": "<url>" in the nearest ggui.json',
475
+ };
476
+ }
477
+ /**
478
+ * Walk up from `startDir` looking for the nearest `ggui.json` with a
479
+ * top-level `registry` string field. Returns the first match.
480
+ *
481
+ * This is intentionally hand-rolled (not via `@ggui-ai/project-config`)
482
+ * because the schema there has not grown a typed `registry` field yet;
483
+ * until it does, the field is read opportunistically without forcing a
484
+ * parallel schema migration.
485
+ */
486
+ function findAndReadGguiJsonRegistry(startDir) {
487
+ let dir = resolve(startDir);
488
+ for (let i = 0; i <= 16; i++) {
489
+ const candidate = join(dir, 'ggui.json');
490
+ if (existsSync(candidate)) {
491
+ const raw = readFileSync(candidate, 'utf8');
492
+ let parsed;
493
+ try {
494
+ parsed = JSON.parse(raw);
495
+ }
496
+ catch {
497
+ // Malformed — fall through; the operator's `ggui serve` will
498
+ // raise a friendlier error than we can synthesize here.
499
+ return { found: false };
500
+ }
501
+ const registry = extractRegistryField(parsed);
502
+ if (typeof registry === 'string' && registry.length > 0) {
503
+ return { found: true, registry, path: candidate };
504
+ }
505
+ return { found: false };
506
+ }
507
+ const parent = dirname(dir);
508
+ if (parent === dir)
509
+ break;
510
+ dir = parent;
511
+ }
512
+ return { found: false };
513
+ }
514
+ function extractRegistryField(x) {
515
+ if (typeof x !== 'object' || x === null)
516
+ return undefined;
517
+ return x.registry;
518
+ }
519
+ function isValidUrl(s) {
520
+ try {
521
+ const u = new URL(s);
522
+ return u.protocol === 'http:' || u.protocol === 'https:';
523
+ }
524
+ catch {
525
+ return false;
526
+ }
527
+ }
528
+ function stripTrailingSlash(s) {
529
+ return s.endsWith('/') ? s.slice(0, -1) : s;
530
+ }
531
+ function loadManifest(cwd) {
532
+ const gadgetPath = join(cwd, GGUI_GADGET_JSON_FILENAME);
533
+ const blueprintPath = join(cwd, GGUI_BLUEPRINT_JSON_FILENAME);
534
+ const hasGadget = existsSync(gadgetPath);
535
+ const hasBlueprint = existsSync(blueprintPath);
536
+ if (hasGadget && hasBlueprint) {
537
+ return {
538
+ ok: false,
539
+ error: {
540
+ code: 'manifest_invalid',
541
+ message: `both ${GGUI_GADGET_JSON_FILENAME} and ${GGUI_BLUEPRINT_JSON_FILENAME} present in CWD — only one allowed per repo`,
542
+ },
543
+ };
544
+ }
545
+ if (!hasGadget && !hasBlueprint) {
546
+ return {
547
+ ok: false,
548
+ error: {
549
+ code: 'manifest_missing',
550
+ message: `no ${GGUI_GADGET_JSON_FILENAME} or ${GGUI_BLUEPRINT_JSON_FILENAME} in ${cwd}`,
551
+ },
552
+ };
553
+ }
554
+ const path = hasGadget ? gadgetPath : blueprintPath;
555
+ let parsed;
556
+ try {
557
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
558
+ }
559
+ catch (err) {
560
+ return {
561
+ ok: false,
562
+ error: {
563
+ code: 'manifest_invalid',
564
+ message: `${path} is not valid JSON`,
565
+ details: err instanceof Error ? err.message : String(err),
566
+ },
567
+ };
568
+ }
569
+ try {
570
+ const manifest = hasGadget ? parseGadgetManifest(parsed) : parseBlueprintManifest(parsed);
571
+ return { ok: true, manifest, manifestPath: path };
572
+ }
573
+ catch (err) {
574
+ return {
575
+ ok: false,
576
+ error: {
577
+ code: 'manifest_invalid',
578
+ message: `${path} failed schema validation`,
579
+ details: err instanceof Error ? err.message : String(err),
580
+ },
581
+ };
582
+ }
583
+ }
584
+ async function bundleGadget(manifest, manifestDir) {
585
+ const entry = isAbsolute(manifest.bundle)
586
+ ? manifest.bundle
587
+ : join(manifestDir, manifest.bundle);
588
+ if (!existsSync(entry)) {
589
+ return { ok: false, error: `entry not found: ${entry}` };
590
+ }
591
+ // React + jsx-runtime are always external — gadget consumers
592
+ // bring their own React. peerDeps keys add to that base set.
593
+ const externals = new Set([
594
+ 'react',
595
+ 'react-dom',
596
+ 'react/jsx-runtime',
597
+ 'react/jsx-dev-runtime',
598
+ ...(manifest.peerDeps ? Object.keys(manifest.peerDeps) : []),
599
+ ]);
600
+ try {
601
+ const result = await esbuild({
602
+ entryPoints: [entry],
603
+ bundle: true,
604
+ format: 'esm',
605
+ target: 'es2022',
606
+ platform: 'neutral',
607
+ minify: false,
608
+ write: false,
609
+ external: [...externals],
610
+ logLevel: 'silent',
611
+ });
612
+ if (result.outputFiles.length !== 1) {
613
+ return {
614
+ ok: false,
615
+ error: `esbuild produced ${result.outputFiles.length} files; expected 1`,
616
+ };
617
+ }
618
+ return { ok: true, bytes: new Uint8Array(result.outputFiles[0].contents) };
619
+ }
620
+ catch (err) {
621
+ return {
622
+ ok: false,
623
+ error: err instanceof Error ? err.message : String(err),
624
+ };
625
+ }
626
+ }
627
+ /**
628
+ * Sentinel error that lets the bearer/hosted strategy router surface
629
+ * a structured `PublishError` without leaking through `throw any`.
630
+ */
631
+ class HostedAuthError extends Error {
632
+ payload;
633
+ constructor(payload) {
634
+ super(payload.message);
635
+ this.payload = payload;
636
+ }
637
+ }
638
+ export async function acquireHostedAuthJwt(opts) {
639
+ // 1. Cached?
640
+ const cached = loadRegistryToken(opts.registryUrl);
641
+ if (cached && cached.registry === opts.registryUrl && isTokenFresh(cached, opts.now())) {
642
+ return { ok: true, token: cached, fromCache: true };
643
+ }
644
+ // 2. Need auth config (Cognito pool id + client id). Personal
645
+ // credentials never go in source-controlled ggui.json: auth config
646
+ // lives in env vars or a user-level secret store. No fallback to
647
+ // project config.
648
+ const authConfig = resolveCognitoConfig(opts.env);
649
+ if (!authConfig) {
650
+ return {
651
+ ok: false,
652
+ error: {
653
+ code: 'auth_config_missing',
654
+ message: 'registry hosted-auth config missing. Either:\n' +
655
+ ' - pass --auth=bearer --token <token> (for self-hosted / local registries), or\n' +
656
+ ' - set GGUI_REGISTRY_COGNITO_POOL_ID + GGUI_REGISTRY_COGNITO_APP_CLIENT_ID in your environment.',
657
+ },
658
+ };
659
+ }
660
+ // 3. Refresh-token path (cache exists but expired)
661
+ if (cached && cached.refreshToken) {
662
+ const refreshed = await tryRefresh({
663
+ ...(opts.hostedAuthClient ? { hostedAuthClient: opts.hostedAuthClient } : {}),
664
+ authConfig,
665
+ refreshToken: cached.refreshToken,
666
+ registryUrl: opts.registryUrl,
667
+ username: cached.username,
668
+ now: opts.now,
669
+ });
670
+ if (refreshed.ok) {
671
+ saveRegistryToken(refreshed.token);
672
+ return { ok: true, token: refreshed.token, fromCache: false };
673
+ }
674
+ // refresh failed — fall through to password flow
675
+ }
676
+ // 4. Password flow
677
+ const promptFn = opts.prompt ?? defaultPrompt;
678
+ let username;
679
+ let password;
680
+ try {
681
+ username = await promptFn('Registry username: ');
682
+ password = await promptFn('Registry password: ', { hidden: true });
683
+ }
684
+ catch (err) {
685
+ return {
686
+ ok: false,
687
+ error: {
688
+ code: 'auth_failed',
689
+ message: `prompt failed: ${err instanceof Error ? err.message : String(err)}`,
690
+ },
691
+ };
692
+ }
693
+ const idp = opts.hostedAuthClient ?? new CognitoIdentityProviderClient({});
694
+ const auth = await runUserPasswordAuth({
695
+ hostedAuthClient: idp,
696
+ authConfig,
697
+ username,
698
+ password,
699
+ });
700
+ if (!auth.ok) {
701
+ return { ok: false, error: { code: 'auth_failed', message: auth.error } };
702
+ }
703
+ const token = {
704
+ version: 1,
705
+ registry: opts.registryUrl,
706
+ idToken: auth.idToken,
707
+ accessToken: auth.accessToken,
708
+ refreshToken: auth.refreshToken,
709
+ expiresAt: opts.now() + auth.expiresIn,
710
+ username,
711
+ writtenAt: new Date().toISOString(),
712
+ };
713
+ saveRegistryToken(token);
714
+ return { ok: true, token, fromCache: false };
715
+ }
716
+ /**
717
+ * Resolve Cognito auth config from environment variables only.
718
+ *
719
+ * `ggui.json` is source-controlled, so personal credentials never go
720
+ * in git. The publish CLI's auth config comes exclusively from the
721
+ * canonical env-var pair:
722
+ *
723
+ * `GGUI_REGISTRY_COGNITO_POOL_ID` + `GGUI_REGISTRY_COGNITO_APP_CLIENT_ID`
724
+ *
725
+ * The `GGUI_REGISTRY_` prefix matches other registry-scoped vars
726
+ * (`GGUI_REGISTRY_TOKEN`, `GGUI_REGISTRY`). There is deliberately no
727
+ * unprefixed `GGUI_COGNITO_*` fallback — a second accepted form would
728
+ * re-introduce env-var ambiguity.
729
+ *
730
+ * No project-config fallback. Operators who want per-project auth
731
+ * pinning use a per-project `.envrc` (direnv) or shell wrapper.
732
+ */
733
+ function resolveCognitoConfig(env) {
734
+ const poolId = env['GGUI_REGISTRY_COGNITO_POOL_ID'];
735
+ const appClientId = env['GGUI_REGISTRY_COGNITO_APP_CLIENT_ID'];
736
+ if (poolId && appClientId) {
737
+ return { poolId, appClientId };
738
+ }
739
+ return null;
740
+ }
741
+ async function runUserPasswordAuth(opts) {
742
+ const input = {
743
+ AuthFlow: 'USER_PASSWORD_AUTH',
744
+ ClientId: opts.authConfig.appClientId,
745
+ AuthParameters: {
746
+ USERNAME: opts.username,
747
+ PASSWORD: opts.password,
748
+ },
749
+ };
750
+ try {
751
+ const cmd = new InitiateAuthCommand(input);
752
+ const res = await opts.hostedAuthClient.send(cmd);
753
+ return extractAuthResult(res);
754
+ }
755
+ catch (err) {
756
+ return {
757
+ ok: false,
758
+ error: err instanceof Error ? err.message : String(err),
759
+ };
760
+ }
761
+ }
762
+ async function tryRefresh(opts) {
763
+ const idp = opts.hostedAuthClient ?? new CognitoIdentityProviderClient({});
764
+ const input = {
765
+ AuthFlow: 'REFRESH_TOKEN_AUTH',
766
+ ClientId: opts.authConfig.appClientId,
767
+ AuthParameters: {
768
+ REFRESH_TOKEN: opts.refreshToken,
769
+ },
770
+ };
771
+ try {
772
+ const res = await idp.send(new InitiateAuthCommand(input));
773
+ const extracted = extractAuthResult(res);
774
+ if (!extracted.ok)
775
+ return { ok: false, error: extracted.error };
776
+ // Cognito's REFRESH_TOKEN flow returns a new RefreshToken only
777
+ // when rotation is enabled on the pool. When absent, reuse the
778
+ // existing refresh token (still valid until its TTL).
779
+ const refreshToken = extracted.refreshToken.length > 0 ? extracted.refreshToken : opts.refreshToken;
780
+ return {
781
+ ok: true,
782
+ token: {
783
+ version: 1,
784
+ registry: opts.registryUrl,
785
+ idToken: extracted.idToken,
786
+ accessToken: extracted.accessToken,
787
+ refreshToken,
788
+ expiresAt: opts.now() + extracted.expiresIn,
789
+ username: opts.username,
790
+ writtenAt: new Date().toISOString(),
791
+ },
792
+ };
793
+ }
794
+ catch (err) {
795
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
796
+ }
797
+ }
798
+ function extractAuthResult(res) {
799
+ const result = res.AuthenticationResult;
800
+ if (!result) {
801
+ return {
802
+ ok: false,
803
+ error: `Cognito did not return AuthenticationResult (challenge=${res.ChallengeName ?? 'none'})`,
804
+ };
805
+ }
806
+ if (typeof result.IdToken !== 'string' ||
807
+ typeof result.AccessToken !== 'string' ||
808
+ typeof result.ExpiresIn !== 'number') {
809
+ return {
810
+ ok: false,
811
+ error: 'Cognito AuthenticationResult missing IdToken/AccessToken/ExpiresIn',
812
+ };
813
+ }
814
+ return {
815
+ ok: true,
816
+ idToken: result.IdToken,
817
+ accessToken: result.AccessToken,
818
+ refreshToken: typeof result.RefreshToken === 'string' ? result.RefreshToken : '',
819
+ expiresIn: result.ExpiresIn,
820
+ };
821
+ }
822
+ async function defaultPrompt(label, opts = {}) {
823
+ // Minimal stdin reader. For hidden mode we toggle the TTY's `echo`
824
+ // off. The dependency-injected `prompt` in `runGadgetPublish` is
825
+ // the test seam — this is the fallback for real CLI use.
826
+ process.stdout.write(label);
827
+ const stdin = process.stdin;
828
+ if (!stdin.isTTY) {
829
+ throw new Error('stdin is not a TTY — pass --registry / set GGUI_REGISTRY and use a non-interactive auth path');
830
+ }
831
+ stdin.setEncoding('utf8');
832
+ if (opts.hidden) {
833
+ stdin.setRawMode(true);
834
+ }
835
+ let buf = '';
836
+ for await (const chunk of stdin) {
837
+ for (const ch of chunk) {
838
+ if (ch === '\r' || ch === '\n') {
839
+ if (opts.hidden)
840
+ stdin.setRawMode(false);
841
+ process.stdout.write('\n');
842
+ return buf;
843
+ }
844
+ if (ch === '') {
845
+ // Ctrl-C — propagate via throw so the caller exits cleanly.
846
+ if (opts.hidden)
847
+ stdin.setRawMode(false);
848
+ throw new Error('prompt cancelled');
849
+ }
850
+ if (ch === '' || ch === '\b') {
851
+ buf = buf.slice(0, -1);
852
+ continue;
853
+ }
854
+ buf += ch;
855
+ if (!opts.hidden)
856
+ process.stdout.write(ch);
857
+ }
858
+ }
859
+ if (opts.hidden)
860
+ stdin.setRawMode(false);
861
+ return buf;
862
+ }
863
+ async function runConformancePreflight(opts) {
864
+ const url = `${opts.registryUrl}/conformance/check`;
865
+ const body = JSON.stringify({
866
+ manifest: opts.manifest,
867
+ ...(opts.bundleBytes !== undefined
868
+ ? { bundle: Buffer.from(opts.bundleBytes).toString('base64') }
869
+ : {}),
870
+ });
871
+ let res;
872
+ try {
873
+ res = await opts.fetchImpl(url, {
874
+ method: 'POST',
875
+ headers: {
876
+ 'Content-Type': 'application/json',
877
+ Authorization: `Bearer ${opts.jwt}`,
878
+ },
879
+ body,
880
+ });
881
+ }
882
+ catch (err) {
883
+ return {
884
+ ok: false,
885
+ error: {
886
+ code: 'conformance_failed',
887
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
888
+ },
889
+ };
890
+ }
891
+ if (!res.ok) {
892
+ let text = '';
893
+ try {
894
+ text = await res.text();
895
+ }
896
+ catch {
897
+ // ignore
898
+ }
899
+ return {
900
+ ok: false,
901
+ error: {
902
+ code: 'conformance_failed',
903
+ message: `HTTP ${res.status} from /conformance/check${text ? `: ${text.slice(0, 200)}` : ''}`,
904
+ },
905
+ };
906
+ }
907
+ let parsed;
908
+ try {
909
+ parsed = await res.json();
910
+ }
911
+ catch (err) {
912
+ return {
913
+ ok: false,
914
+ error: {
915
+ code: 'conformance_failed',
916
+ message: `response was not JSON: ${err instanceof Error ? err.message : String(err)}`,
917
+ },
918
+ };
919
+ }
920
+ const parsedShape = parseConformanceResponse(parsed);
921
+ if (!parsedShape) {
922
+ return {
923
+ ok: false,
924
+ error: {
925
+ code: 'conformance_failed',
926
+ message: 'unrecognized conformance response shape',
927
+ },
928
+ };
929
+ }
930
+ if (parsedShape.ok === true) {
931
+ return { ok: true };
932
+ }
933
+ return {
934
+ ok: false,
935
+ error: {
936
+ code: 'conformance_failed',
937
+ message: 'manifest/bundle failed conformance checks',
938
+ issues: parsedShape.issues,
939
+ },
940
+ };
941
+ }
942
+ function parseConformanceResponse(x) {
943
+ if (typeof x !== 'object' || x === null)
944
+ return null;
945
+ const r = x;
946
+ if (r.ok === true)
947
+ return { ok: true };
948
+ if (r.ok === false) {
949
+ if (!Array.isArray(r.issues))
950
+ return { ok: false, issues: [] };
951
+ const issues = [];
952
+ for (const entry of r.issues) {
953
+ if (typeof entry !== 'object' || entry === null)
954
+ continue;
955
+ const e = entry;
956
+ if (typeof e.code !== 'string' || typeof e.message !== 'string')
957
+ continue;
958
+ issues.push({
959
+ code: e.code,
960
+ message: e.message,
961
+ ...(typeof e.path === 'string' ? { path: e.path } : {}),
962
+ });
963
+ }
964
+ return { ok: false, issues };
965
+ }
966
+ return null;
967
+ }
968
+ async function loadOrGenerateKey(opts) {
969
+ const scope = scopeOf(opts.artifactId);
970
+ if (opts.keyFlag) {
971
+ if (!existsSync(opts.keyFlag)) {
972
+ return {
973
+ ok: false,
974
+ error: {
975
+ code: 'key_missing',
976
+ message: `--key path not found: ${opts.keyFlag}`,
977
+ },
978
+ };
979
+ }
980
+ const privateKey = readPrivateKey(opts.keyFlag);
981
+ if (!privateKey) {
982
+ return {
983
+ ok: false,
984
+ error: {
985
+ code: 'key_missing',
986
+ message: `--key path could not be read: ${opts.keyFlag}`,
987
+ },
988
+ };
989
+ }
990
+ return {
991
+ ok: true,
992
+ privateKey,
993
+ publicKeyId: derivePublicKeyId(await publicKeyFromPrivate(privateKey)),
994
+ };
995
+ }
996
+ const defaultPath = getPrivateKeyPath(scope);
997
+ if (!hasKeypair(scope)) {
998
+ // First-publish path — generate + persist + print public-key id
999
+ // so the operator can register it with the registry's
1000
+ // author-keys endpoint.
1001
+ const fresh = await generateEd25519Keypair();
1002
+ writePrivateKey(scope, fresh.privateKey, fresh.publicKey);
1003
+ opts.stdout(`key: generated new keypair at ${defaultPath}\n` +
1004
+ `key: publicKeyId=${fresh.publicKeyId}\n` +
1005
+ `key: first-publish — register this public key with the registry by running \`ggui keys register --scope ${scope}\` if the registry returns \`unknown_key\`.\n`);
1006
+ return {
1007
+ ok: true,
1008
+ privateKey: fresh.privateKey,
1009
+ publicKeyId: fresh.publicKeyId,
1010
+ };
1011
+ }
1012
+ const privateKey = readPrivateKey(defaultPath);
1013
+ if (!privateKey) {
1014
+ return {
1015
+ ok: false,
1016
+ error: {
1017
+ code: 'key_missing',
1018
+ message: `${defaultPath} exists but could not be read`,
1019
+ },
1020
+ };
1021
+ }
1022
+ return {
1023
+ ok: true,
1024
+ privateKey,
1025
+ publicKeyId: derivePublicKeyId(await publicKeyFromPrivate(privateKey)),
1026
+ };
1027
+ }
1028
+ async function postPublish(opts) {
1029
+ const url = `${opts.registryUrl}/publish`;
1030
+ const body = JSON.stringify({
1031
+ manifest: opts.manifest,
1032
+ ...(opts.bundleBytes !== undefined
1033
+ ? {
1034
+ bundle: Buffer.from(opts.bundleBytes).toString('base64'),
1035
+ bundleSha384: opts.bundleSha384,
1036
+ }
1037
+ : {}),
1038
+ signature: opts.signature,
1039
+ });
1040
+ let res;
1041
+ try {
1042
+ res = await opts.fetchImpl(url, {
1043
+ method: 'POST',
1044
+ headers: {
1045
+ 'Content-Type': 'application/json',
1046
+ Authorization: `Bearer ${opts.jwt}`,
1047
+ },
1048
+ body,
1049
+ });
1050
+ }
1051
+ catch (err) {
1052
+ return {
1053
+ ok: false,
1054
+ error: {
1055
+ code: 'publish_failed',
1056
+ message: `network error: ${err instanceof Error ? err.message : String(err)}`,
1057
+ },
1058
+ };
1059
+ }
1060
+ if (res.status === 501) {
1061
+ return {
1062
+ ok: false,
1063
+ error: {
1064
+ code: 'publish_stubbed',
1065
+ message: `publish endpoint not yet live for ${opts.registryUrl} (HTTP 501).`,
1066
+ },
1067
+ };
1068
+ }
1069
+ if (res.status === 201) {
1070
+ let parsed;
1071
+ try {
1072
+ parsed = await res.json();
1073
+ }
1074
+ catch (err) {
1075
+ return {
1076
+ ok: false,
1077
+ error: {
1078
+ code: 'publish_failed',
1079
+ message: `201 but response was not JSON: ${err instanceof Error ? err.message : String(err)}`,
1080
+ httpStatus: 201,
1081
+ },
1082
+ };
1083
+ }
1084
+ const body = parsePublishResponse(parsed);
1085
+ if (!body) {
1086
+ return {
1087
+ ok: false,
1088
+ error: {
1089
+ code: 'publish_failed',
1090
+ message: '201 but response body shape was not recognized',
1091
+ httpStatus: 201,
1092
+ },
1093
+ };
1094
+ }
1095
+ return { ok: true, body };
1096
+ }
1097
+ // Surface structured error codes when present.
1098
+ let serverCode;
1099
+ let message = `HTTP ${res.status}`;
1100
+ try {
1101
+ const parsed = await res.json();
1102
+ if (typeof parsed === 'object' && parsed !== null) {
1103
+ const r = parsed;
1104
+ if (typeof r.code === 'string')
1105
+ serverCode = r.code;
1106
+ if (typeof r.message === 'string')
1107
+ message = `${message}: ${r.message}`;
1108
+ }
1109
+ }
1110
+ catch {
1111
+ // ignore — fall back to status text
1112
+ }
1113
+ return {
1114
+ ok: false,
1115
+ error: {
1116
+ code: 'publish_failed',
1117
+ message,
1118
+ httpStatus: res.status,
1119
+ ...(serverCode !== undefined ? { serverCode } : {}),
1120
+ },
1121
+ };
1122
+ }
1123
+ function parsePublishResponse(x) {
1124
+ if (typeof x !== 'object' || x === null)
1125
+ return null;
1126
+ const r = x;
1127
+ if (typeof r.artifactId !== 'string' ||
1128
+ typeof r.version !== 'string' ||
1129
+ typeof r.manifestUrl !== 'string' ||
1130
+ typeof r.installCommand !== 'string') {
1131
+ return null;
1132
+ }
1133
+ return {
1134
+ artifactId: r.artifactId,
1135
+ version: r.version,
1136
+ manifestUrl: r.manifestUrl,
1137
+ installCommand: r.installCommand,
1138
+ ...(typeof r.bundleUrl === 'string' ? { bundleUrl: r.bundleUrl } : {}),
1139
+ ...(typeof r.signatureUrl === 'string' ? { signatureUrl: r.signatureUrl } : {}),
1140
+ };
1141
+ }