@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,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
|
+
}
|