@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,668 @@
1
+ /**
2
+ * `ggui serve` → `@ggui-ai/mcp-server` composition.
3
+ *
4
+ * Split out from `cli.ts` so tests can import the factory without
5
+ * triggering the CLI `main()` side-effect at module load. The banner /
6
+ * signal / lifecycle wiring stays in `cli.ts` / `serve-command.ts`;
7
+ * this module owns exactly one thing: the option bundle that turns
8
+ * `createGguiServer()` into the canonical OSS first-run server.
9
+ *
10
+ * What gets wired by default — see OSS-split §2 (the five server
11
+ * responsibilities):
12
+ *
13
+ * - `sessionChannel: true` — live-channel `/ws` endpoint. Required for
14
+ * MCP Apps iframes AND the console session viewer.
15
+ * - `pairing: true` — `POST /pair` + `POST /admin/pair/init` so
16
+ * remote clients (Portal, third-party) can pair with this server.
17
+ * The default `InMemoryAuthAdapter` registers pairing tokens
18
+ * through `onTokenIssued` automatically.
19
+ * - `console: { sessionCookie: true }` — landing page at `/` +
20
+ * `/s/<shortCode>` viewer + same-origin HTTP-only cookie flow.
21
+ * - `shortCodeIndex: new InMemoryShortCodeIndex()` — required by
22
+ * the `sessionCookie` flow so `POST /ggui/console/session-cookie`
23
+ * can resolve a posted shortCode to `{sessionId, appId}`.
24
+ * In-memory is correct for the OSS first-run: session state lives
25
+ * in memory unless the operator opts into sqlite, so a matching
26
+ * index lifetime is what operators expect.
27
+ * - `mcpApps: { renderBaseUrl, wsUrl }` — registers the `ggui_push`
28
+ * tool with MCP Apps `_meta.ui.*` declaration, serves
29
+ * `ui://ggui/session`, and advertises the `io.modelcontextprotocol/ui`
30
+ * capability. Without this the console `/s/<shortCode>` viewer
31
+ * is orphaned — there's no tool minting shortCodes. Resolved host +
32
+ * port are mandatory so the agent-returned `url` resolves to this
33
+ * same server's `/s/` endpoint. See §2.4.1 "sole entry-point tool"
34
+ * lock in the OSS-split plan.
35
+ *
36
+ * Embedding hosts that want a different shape (no landing page, no
37
+ * pairing, programmatic control) compose `createGguiServer()` directly
38
+ * rather than going through `ggui serve`.
39
+ */
40
+ import { createServer as createNetServer } from 'node:net';
41
+ import { randomUUID } from 'node:crypto';
42
+ import { existsSync } from 'node:fs';
43
+ import { composeWiredActionRouterFromMounts, createGguiServer, DEFAULT_BUILDER_APP_ID, FileSystemCodeStore, FixedWindowRateLimiter, InMemoryAuthAdapter, InMemoryQuotaStore, InMemoryShortCodeIndex, selectEmailSenderFromEnv, } from '@ggui-ai/mcp-server';
44
+ import { createDeterministicPreviewEmitter } from '@ggui-ai/preview-a2ui/emitters';
45
+ import { RUNTIME_BUNDLE_URL_PATH } from '@ggui-ai/iframe-runtime/server';
46
+ import { createLocalEmbeddingProvider } from '@ggui-ai/embedding-local';
47
+ import { PlaintextFileProviderKeyStore } from '@ggui-ai/mcp-server-core/plaintext';
48
+ import { InMemoryAppMetadataStore, InMemoryVectorStore } from '@ggui-ai/mcp-server-core/in-memory';
49
+ import { listThemes } from '@ggui-ai/design/themes';
50
+ import { createInstalledBlueprintsProvider, } from '@ggui-ai/mcp-server-handlers/session-mutations';
51
+ import { compileUiOnDemand } from '@ggui-ai/dev-stack';
52
+ import { getCodeCacheDir, getCredentialsFile, getEmbeddingCacheDir, } from './paths.js';
53
+ import { readOrMintHexSecret } from './persistent-secrets.js';
54
+ import { join } from 'node:path';
55
+ /**
56
+ * Pick a free localhost TCP port by letting the OS assign one on a
57
+ * throwaway listener. Close the listener immediately and return the
58
+ * chosen number. Exported so the CLI (`cli.ts::runServeCommand`) and
59
+ * the integration tests can resolve `--port 0` to a concrete port
60
+ * BEFORE constructing the backend — {@link buildMcpServerBackend}
61
+ * captures the port in the composed `mcpApps.renderBaseUrl` / `wsUrl`
62
+ * at construction time, so the caller must know it up-front.
63
+ *
64
+ * Tiny race window: another process could claim the port between our
65
+ * `close()` and the caller's `listen()`. The CLI surfaces bind errors
66
+ * as a fatal exit; the OSS first-run path recovers by re-running.
67
+ */
68
+ export function pickFreePort(host = '127.0.0.1') {
69
+ return new Promise((resolve, reject) => {
70
+ const probe = createNetServer();
71
+ probe.once('error', (err) => {
72
+ probe.close();
73
+ reject(err);
74
+ });
75
+ probe.listen(0, host, () => {
76
+ const addr = probe.address();
77
+ if (addr === null || typeof addr === 'string') {
78
+ probe.close();
79
+ reject(new Error(`pickFreePort: could not resolve a free port (got ${String(addr)})`));
80
+ return;
81
+ }
82
+ const chosen = addr.port;
83
+ probe.close(() => resolve(chosen));
84
+ });
85
+ });
86
+ }
87
+ /**
88
+ * Build the `ServeBackend` that `runServe` drives. Returns a thin
89
+ * adapter around `createGguiServer` — the factory is invoked here so
90
+ * preconditions that throw (e.g. missing auth bridge support for
91
+ * `pairing: true`) surface synchronously, before `runServe` bothers
92
+ * binding a port.
93
+ */
94
+ export function buildMcpServerBackend(opts) {
95
+ const storage = opts.storage ?? {};
96
+ if (!Number.isInteger(opts.port) || opts.port <= 0 || opts.port > 65535) {
97
+ throw new Error(`buildMcpServerBackend: \`port\` must be a concrete TCP port (1..65535), got ${opts.port}. Resolve \`--port 0\` via pickFreePort() before composing.`);
98
+ }
99
+ // `baseUrl` feeds `mcpApps.renderBaseUrl` + `runtime.url`; `wsBaseUrl`
100
+ // feeds `mcpApps.wsUrl`. With `--public-base-url`, both derive from
101
+ // the operator-provided tunnel URL so the agent emits links that
102
+ // resolve from the remote host's perspective. Without it, both fall
103
+ // back to the local bind. The shape was validated at flag-parse
104
+ // time (`http://`/`https://`, no trailing slash); we just substitute
105
+ // the scheme for ws/wss to derive the live-channel URL.
106
+ const baseUrl = opts.publicBaseUrl ?? `http://${opts.host}:${opts.port}`;
107
+ const wsBaseUrl = baseUrl.replace(/^http(s?):\/\//, 'ws$1://');
108
+ // Router over the mount handler bundle. Computed once; absent
109
+ // (`null`) when no mounts are declared so the server falls through
110
+ // to agent-routed behavior.
111
+ const wiredActionRouter = composeWiredActionRouterFromMounts(opts.mcpMounts, () => ({
112
+ appId: DEFAULT_BUILDER_APP_ID,
113
+ requestId: randomUUID(),
114
+ }));
115
+ // Strict-auth OSS default (`devAllowAll: false`). `createGguiServer`
116
+ // would otherwise fall back to `InMemoryAuthAdapter({ devAllowAll:
117
+ // true })`, which authenticates any non-empty bearer as builder — a
118
+ // permissive seam that makes the `/mcp` ingress indistinguishable
119
+ // from "no auth at all" for operators running `ggui serve`. Pairing
120
+ // is on by default (see `pairing: true` below), so the honest
121
+ // first-run story is: mint an initial pair code → complete `/pair`
122
+ // → use the resulting token. The CLI surfaces that initial code as
123
+ // the `PAIR_CODE` boot beacon + banner line; see `runServe` in
124
+ // `./serve-command.ts`.
125
+ //
126
+ // `--dev-allow-all` flips this to `true` for local-dev / tunnel
127
+ // smoke against MCP custom connectors that probe `/mcp` without
128
+ // bearer (claude.ai, ChatGPT). The banner prints a loud warning
129
+ // and pair-code emission is suppressed (meaningless under allow-all)
130
+ // so operators don't accidentally take the dev posture to production.
131
+ // `--public-demo` shares the auth shape with `--dev-allow-all`
132
+ // (every bearer = builder). The CLI guarantees these are mutually
133
+ // exclusive at parse time; only one is ever true here.
134
+ const auth = new InMemoryAuthAdapter({
135
+ devAllowAll: (opts.devAllowAll ?? false) || (opts.publicDemo ?? false),
136
+ });
137
+ // Per-IP rate limiter — only bound under `--public-demo` so an
138
+ // anonymous visitor can't burn the operator's BYOK budget by pumping
139
+ // ggui_push. Defaults: 30 generations / 10 minutes per IP. Operators
140
+ // who need different ceilings construct their own backend
141
+ // programmatically (calling `createGguiServer({ rateLimiter })`
142
+ // directly). Skipped under `--dev-allow-all` because that's the
143
+ // single-operator escape hatch where rate limits add noise without
144
+ // protection.
145
+ const rateLimiter = opts.publicDemo
146
+ ? new FixedWindowRateLimiter({
147
+ store: new InMemoryQuotaStore(),
148
+ limit: 30,
149
+ windowMs: 10 * 60 * 1000,
150
+ })
151
+ : undefined;
152
+ // Local-transformer embedder default. Replaces the server's
153
+ // `MockEmbeddingProvider` last-resort fallback for the user-facing
154
+ // OSS path. `bge-small-en-v1.5` (~33MB ONNX) downloads lazily into
155
+ // `~/.ggui/models/` on first embed; `cacheDir` resolves to that
156
+ // path via the CLI's own paths helper. `warmup: true` (the default
157
+ // inside the provider) embeds a dummy string after the pipeline
158
+ // first loads so the first real embedding doesn't pay the ~200-
159
+ // 400ms transformers cold-start tax.
160
+ //
161
+ // Programmatic embedders calling `createGguiServer` directly still
162
+ // get `MockEmbeddingProvider` if they don't bind an embedder — that
163
+ // keeps the 60MB `@huggingface/transformers` install surface off
164
+ // the unit-test path.
165
+ const embedding = createLocalEmbeddingProvider({
166
+ cacheDir: getEmbeddingCacheDir(),
167
+ });
168
+ // BYOK provider-key store — same `~/.ggui/credentials.json` that
169
+ // `byok-resolver.ts` reads on every `resolve()`. Threading the store
170
+ // here makes the operator-facing `/ggui/console/llm-keys` admin API
171
+ // write to the same file generation later reads, with no in-process
172
+ // sync needed (`PlaintextFileProviderKeyStore` re-reads on every
173
+ // `get()`). Operators who set `GGUI_CONFIG_DIR` for a clean-room get
174
+ // the override automatically via `getCredentialsFile()`.
175
+ const providerKeys = new PlaintextFileProviderKeyStore({
176
+ filename: getCredentialsFile(),
177
+ });
178
+ // Email-login sender selection. Reads `GGUI_EMAIL_SENDER`
179
+ // (default: `console`) and provider-specific env vars
180
+ // (`RESEND_API_KEY`, `SMTP_URL` / `SMTP_HOST` + auth, etc.). Misconfig
181
+ // (e.g. `=resend` without `RESEND_API_KEY`) logs a warning and falls
182
+ // back to console rather than crashing — the rest of the server still
183
+ // boots and operators can fix the env without losing other surfaces.
184
+ // Programmatic embedders that want a different shape compose
185
+ // `createGguiServer({emailLogin: {sender: ...}})` directly.
186
+ const emailSenderSelection = selectEmailSenderFromEnv();
187
+ let emailSender;
188
+ let emailFromAddress;
189
+ const defaultFromAddress = 'ggui-serve <noreply@localhost>';
190
+ if (emailSenderSelection.kind === 'ok') {
191
+ emailSender = emailSenderSelection.sender;
192
+ emailFromAddress = emailSenderSelection.fromAddress ?? defaultFromAddress;
193
+ }
194
+ else {
195
+ // eslint-disable-next-line no-console -- one-shot boot warning, mirrors banner output
196
+ console.warn(`[ggui-cli] email-login: ${emailSenderSelection.reason}; falling back to ConsoleEmailSender (magic links print to terminal).`);
197
+ const fallback = selectEmailSenderFromEnv({
198
+ env: { GGUI_EMAIL_SENDER: 'console' },
199
+ });
200
+ if (fallback.kind !== 'ok') {
201
+ throw new Error(`[ggui-cli] email-login: console fallback selection failed: ${fallback.reason}`);
202
+ }
203
+ emailSender = fallback.sender;
204
+ emailFromAddress = process.env.GGUI_EMAIL_FROM?.trim() || defaultFromAddress;
205
+ }
206
+ // Resolve cross-restart HMAC secrets. When the caller declared a
207
+ // persistent dir, read-or-mint `bootstrap-secret.hex` +
208
+ // `render-signer-secret.hex` (32 bytes / 64 hex chars, 0600). Both
209
+ // get threaded into `createGguiServer` below so the server stops
210
+ // minting fresh process-local secrets every boot — the precondition
211
+ // for any cached `_meta.ggui.bootstrap.token` or `/r/<code>?sig=&exp=`
212
+ // URL surviving a restart. Absent dir = legacy ephemeral behavior.
213
+ let persistedBootstrapSecret;
214
+ let persistedRenderSignerSecret;
215
+ if (opts.persistentDir !== undefined) {
216
+ persistedBootstrapSecret = readOrMintHexSecret(join(opts.persistentDir, 'bootstrap-secret.hex'));
217
+ persistedRenderSignerSecret = readOrMintHexSecret(join(opts.persistentDir, 'render-signer-secret.hex'));
218
+ }
219
+ // Resolve mcpInstructions: CLI flag wins over env var. Validated
220
+ // env-var values pass through to createGguiServer; invalid values
221
+ // fall through to the no-preset default with a one-line warning.
222
+ const envInstructions = process.env.GGUI_MCP_INSTRUCTIONS?.trim();
223
+ let resolvedMcpInstructions = opts.mcpInstructions;
224
+ if (resolvedMcpInstructions === undefined && envInstructions) {
225
+ if (envInstructions === 'default' ||
226
+ envInstructions === 'aggressive' ||
227
+ envInstructions === 'always' ||
228
+ envInstructions === 'minimal' ||
229
+ envInstructions === 'off') {
230
+ resolvedMcpInstructions = envInstructions;
231
+ }
232
+ else {
233
+ // eslint-disable-next-line no-console -- one-shot boot warning
234
+ console.warn(`[ggui-cli] GGUI_MCP_INSTRUCTIONS='${envInstructions}' not recognized; falling back to default preset.`);
235
+ }
236
+ }
237
+ // Per-app metadata store seeded from `ggui.json#theme.preset` (when
238
+ // the manifest declares a preset variant). Single-tenant OSS: any
239
+ // appId the handlers see picks up this default theme via
240
+ // `InMemoryAppMetadataStore.get`'s defaults fall-through. File-mode
241
+ // themes don't have a registry id to surface here — agents pick from
242
+ // `listThemes()` either way; the file-mode override only affects the
243
+ // server's bound `theme` (rendered CSS variables), not the
244
+ // session/handler-side `themeId` chain.
245
+ const manifestThemePreset = opts.theme?.source === 'preset' ? opts.theme.preset : undefined;
246
+ const appMetadataStore = new InMemoryAppMetadataStore({
247
+ ...(manifestThemePreset !== undefined
248
+ ? { defaultThemeId: manifestThemePreset }
249
+ : {}),
250
+ // `ggui.json#app.gadgets` declarations (Leaflet, Mapbox, …). When
251
+ // omitted, the InMemoryAppMetadataStore falls back to
252
+ // STDLIB_GADGETS from @ggui-ai/protocol so the first-party hooks
253
+ // remain available without explicit declaration. The constructor
254
+ // expects this on `defaultGadgets`, not `gadgets`.
255
+ ...(opts.gadgets !== undefined && opts.gadgets.length > 0
256
+ ? { defaultGadgets: opts.gadgets }
257
+ : {}),
258
+ // `ggui.json#app.publicEnv` operator-stamped values for wrapper
259
+ // hooks to read via `getPublicEnv()`. The push gate
260
+ // (`assertPublicEnvSatisfied`) verifies every declared wrapper's
261
+ // `requires` keys are present in this map BEFORE the iframe boots.
262
+ ...(opts.publicEnv !== undefined && Object.keys(opts.publicEnv).length > 0
263
+ ? { defaultPublicEnv: opts.publicEnv }
264
+ : {}),
265
+ // `ggui.json#app.defaultMcpAppsMode`. `'canvas'` makes
266
+ // `ggui_new_session` mint a session-scoped iframe + route
267
+ // subsequent pushes through the session channel; absent ⇒ inline.
268
+ ...(opts.defaultMcpAppsMode !== undefined
269
+ ? { defaultMcpAppsMode: opts.defaultMcpAppsMode }
270
+ : {}),
271
+ });
272
+ // Materialize the vectorStore here so the marketplace-install
273
+ // bridge writes into the SAME instance the matcher reads. Without
274
+ // this, calling `createGguiServer({ vectors })`
275
+ // when no `storage.vectors` is declared leaves vectorStore
276
+ // construction inside the server, hidden from us, so the provider
277
+ // would have no handle to register into. Mint an `InMemoryVectorStore`
278
+ // here on the absent-storage path; threaded back as `vectors` to
279
+ // `createGguiServer` so server + provider share state.
280
+ const vectorStore = storage.vectors ?? new InMemoryVectorStore();
281
+ // Construct the marketplace-install bridge BEFORE `createGguiServer`
282
+ // so we can fold it into `opts.generation.installedBlueprints` below.
283
+ // Per design lock (project_slices_5_6_7_plan.md), the provider
284
+ // lazily compiles every installed-blueprint entry on the first
285
+ // `matchBlueprint` call per scope. Idempotent + best-effort per
286
+ // entry — a broken installed-blueprint never sinks the match flow.
287
+ let installedBlueprintsProvider;
288
+ if (opts.installedBlueprints && opts.installedBlueprints.entries.length > 0) {
289
+ const { projectRoot, entries } = opts.installedBlueprints;
290
+ const byId = new Map(entries.map((e) => [e.id, e]));
291
+ const providerOptions = {
292
+ installedBlueprints: () => entries.flatMap((entry) => {
293
+ if (entry.manifest.contract === undefined)
294
+ return [];
295
+ const intent = entry.manifest.description ??
296
+ entry.manifest.name ??
297
+ entry.id;
298
+ return [
299
+ {
300
+ id: entry.id,
301
+ manifestPath: entry.manifestPath,
302
+ contract: entry.manifest.contract,
303
+ intent,
304
+ },
305
+ ];
306
+ }),
307
+ compile: async (entry) => {
308
+ const ui = byId.get(entry.id);
309
+ if (!ui) {
310
+ return { kind: 'missing-entry', tried: [] };
311
+ }
312
+ const result = await compileUiOnDemand({
313
+ projectRoot,
314
+ manifestPath: ui.manifestPath,
315
+ manifest: ui.manifest,
316
+ });
317
+ if (result.kind === 'ok') {
318
+ return { kind: 'ok', code: result.code };
319
+ }
320
+ if (result.kind === 'missing-entry') {
321
+ return { kind: 'missing-entry', tried: result.tried };
322
+ }
323
+ return {
324
+ kind: 'failure',
325
+ errors: result.errors.map((m) => m.text),
326
+ };
327
+ },
328
+ deps: { embedding, vectorStore },
329
+ onIssue: (issue) => {
330
+ process.stderr.write(`[ggui serve] installed-blueprint ${issue.id}: ${issue.kind}: ${issue.message}\n`);
331
+ },
332
+ };
333
+ installedBlueprintsProvider =
334
+ createInstalledBlueprintsProvider(providerOptions);
335
+ }
336
+ // Fold the bridge into the generation deps so the push handler +
337
+ // handshake negotiator both consume it via `MatchBlueprintDeps.
338
+ // installedBlueprints`. Two-step pattern keeps the createGguiServer
339
+ // call below unchanged when no provider is wired.
340
+ const generationWithInstalled = opts.generation
341
+ ? installedBlueprintsProvider
342
+ ? {
343
+ ...opts.generation,
344
+ installedBlueprints: installedBlueprintsProvider,
345
+ }
346
+ : opts.generation
347
+ : undefined;
348
+ const server = createGguiServer({
349
+ auth,
350
+ embedding,
351
+ providerKeys,
352
+ // Seeded per-app metadata store. Threaded so the in-process
353
+ // singleton is shared across `ggui_list_gadgets`,
354
+ // `ggui_list_themes`, `ggui_new_session` (theme default
355
+ // resolution), and the handshake (gadgets lookup).
356
+ appMetadataStore,
357
+ // Theme catalog resolver. Reads from `@ggui-ai/design`'s registry
358
+ // each call so runtime additions surface without a restart.
359
+ // Co-binding with `appMetadataStore` mounts `ggui_list_themes` on
360
+ // the agent route.
361
+ themes: () => listThemes(),
362
+ // Cross-restart HMAC secrets. Present iff the caller declared a
363
+ // persistent dir. Both threaded so the next restart can verify
364
+ // tokens minted by the previous run instead of regenerating per
365
+ // process. Render-signer.secret rides on the `renderSigning`
366
+ // discriminated union (the `false` shape disables the layer
367
+ // entirely; we always want it on here, so build the object form).
368
+ ...(persistedBootstrapSecret !== undefined
369
+ ? { bootstrapSecret: persistedBootstrapSecret }
370
+ : {}),
371
+ ...(persistedRenderSignerSecret !== undefined
372
+ ? { renderSigning: { secret: persistedRenderSignerSecret } }
373
+ : {}),
374
+ ...(resolvedMcpInstructions !== undefined
375
+ ? { mcpInstructions: resolvedMcpInstructions }
376
+ : {}),
377
+ // `--multi-tenant` flips the `/ggui/console/llm-keys` gate from
378
+ // admin-token to auth-adapter. Default scope derivation in the
379
+ // server picks up `userId` / `appId` from the resolved identity;
380
+ // operators with composite scopes pass their own `providerKeyScope`
381
+ // by composing `createGguiServer` directly.
382
+ ...(opts.multiTenant
383
+ ? { providerKeysGate: 'auth-adapter' }
384
+ : {}),
385
+ // Gate OAuth login routes on publicBaseUrl. Without it the
386
+ // redirect_uri can't be composed; the admin transport still mounts
387
+ // so operators can paste credentials in advance.
388
+ ...(opts.publicBaseUrl ? { publicBaseUrl: opts.publicBaseUrl } : {}),
389
+ ...(rateLimiter ? { rateLimiter } : {}),
390
+ ...(storage.sessionStore ? { sessionStore: storage.sessionStore } : {}),
391
+ // Always pass the materialized vectorStore so the install bridge
392
+ // and the matcher share state. Passing only `storage.vectors`
393
+ // (set when the manifest declares sqlite) would, on an absent
394
+ // declaration, leave the server to mint its own internal
395
+ // InMemoryVectorStore that the bridge couldn't see.
396
+ vectors: vectorStore,
397
+ // `threads:` is an opt-in on createGguiServer (no zero-config
398
+ // fallback — threads without an explicit store wouldn't mount
399
+ // routes at all). We only pass it when the manifest declares a
400
+ // store; absent declaration → no thread routes.
401
+ //
402
+ // Durability claim is whatever the resolver surfaced: `'durable'`
403
+ // for sqlite, `'ephemeral'` for memory. The server echoes this
404
+ // through `/ggui/health`, so Portal's caveat logic stays honest.
405
+ ...(storage.threadStore
406
+ ? {
407
+ threads: {
408
+ store: storage.threadStore,
409
+ ...(storage.threadDurability
410
+ ? { durability: storage.threadDurability }
411
+ : {}),
412
+ },
413
+ }
414
+ : {}),
415
+ sessionChannel: true,
416
+ pairing: opts.keysFile
417
+ ? { persistencePath: opts.keysFile }
418
+ : true,
419
+ // Email magic-link login. OSS first-run default mirrors
420
+ // `provisionalPreview` — wire a sender so magic links can be
421
+ // delivered. `selectEmailSenderFromEnv` reads
422
+ // `GGUI_EMAIL_SENDER` (default: `console`, terminal-only) and
423
+ // optionally `RESEND_API_KEY` / `SMTP_*` to construct a real
424
+ // sender. Misconfig is non-fatal: we log a warning + fall back
425
+ // to console so the boot path stays alive.
426
+ //
427
+ // Routes only mount when `publicBaseUrl` is set (the verify URL
428
+ // can't resolve otherwise) — the server logs
429
+ // `email_login_skipped` in that case and `/login` falls back to
430
+ // OAuth + pair-code. Hosted + programmatic embedders pass their
431
+ // own sender by composing `createGguiServer` directly.
432
+ emailLogin: {
433
+ sender: emailSender,
434
+ fromAddress: emailFromAddress,
435
+ },
436
+ // OAuth 2.1 + PKCE + DCR. Opt-in via `--oauth` because the routes
437
+ // add an HTML paste-key page + 4 well-known endpoints — not free
438
+ // attack surface for operators who only consume from pure-bearer
439
+ // clients (Claude Desktop, the console). Required for MCP custom-
440
+ // connector hosts (claude.ai, ChatGPT) whose form has no bearer
441
+ // field; flag flips on `oauth: true` defaults (in-memory storage,
442
+ // built-in paste-key consent page).
443
+ ...(opts.oauth ? { oauth: true } : {}),
444
+ console: {
445
+ sessionCookie: true,
446
+ ...(opts.adminToken !== undefined ? { adminToken: opts.adminToken } : {}),
447
+ // First-run onboarding redirect. When neither the credentials
448
+ // file (`~/.ggui/credentials.json`) nor any provider env var
449
+ // (ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY /
450
+ // OPENROUTER_API_KEY) is present, send `GET /` to the
451
+ // **admin** onboarding flow — the operator running `ggui serve`
452
+ // IS the admin on first-run, so they need the operator-key
453
+ // plane (`/admin/llm-keys`) and the assistant-connection card
454
+ // surfaced there. Cookie-gated end-user `/settings` is for a
455
+ // *separate* user paired with the server later, not the
456
+ // first-run operator. Once any key is configured, the next
457
+ // visit serves the SPA normally. Scoped to the root path;
458
+ // deep links bypass the redirect.
459
+ landingRedirect: () => {
460
+ try {
461
+ const credsFileExists = existsSync(getCredentialsFile());
462
+ const anyEnvKey = !!process.env.ANTHROPIC_API_KEY ||
463
+ !!process.env.OPENAI_API_KEY ||
464
+ !!process.env.GOOGLE_API_KEY ||
465
+ !!process.env.OPENROUTER_API_KEY;
466
+ if (!credsFileExists && !anyEnvKey) {
467
+ return '/admin-login?next=%2Fadmin%2Fllm-keys';
468
+ }
469
+ return null;
470
+ }
471
+ catch {
472
+ return null;
473
+ }
474
+ },
475
+ },
476
+ // Public welcome page at `/` — operator identification + public
477
+ // deep-link surfaces + operator-login affordance. Resolved from
478
+ // `ggui.json#operator` + `ggui.json#app.name` by serve-command.ts;
479
+ // absent = no welcome page (SPA index handler still owns `/`).
480
+ ...(opts.welcomePage ? { welcomePage: opts.welcomePage } : {}),
481
+ // Default to in-memory; caller substitutes a persistent impl
482
+ // (SqliteShortCodeIndex under `persistentDir`) so cached
483
+ // `/api/bootstrap/<code>` lookups survive a restart. Without
484
+ // that, /r/<code> 404s the moment the agent's host (claude.ai)
485
+ // replays its cached `_meta.ggui.bootstrap` envelope.
486
+ shortCodeIndex: opts.shortCodeIndex ?? new InMemoryShortCodeIndex(),
487
+ // Content-addressable componentCode delivery. The OSS dev default
488
+ // is filesystem-backed at `~/.ggui/code-cache/` (override via
489
+ // `GGUI_CODE_CACHE_DIR`). Survives `ggui serve` restart so
490
+ // claude.ai's iframe cache still resolves URLs minted before the
491
+ // restart. Operators can `rm -rf` the cache at any time —
492
+ // immutable URLs guarantee a fresh push repopulates as needed.
493
+ codeStore: new FileSystemCodeStore({ root: getCodeCacheDir() }),
494
+ // Manifest-declared blueprints. Only present when `ggui serve`
495
+ // found UIs in `blueprints.include`; absent = the server's
496
+ // `ggui_list_featured_blueprints` returns an empty catalog (the
497
+ // zero-config default).
498
+ ...(opts.blueprintProvider
499
+ ? { blueprintProvider: opts.blueprintProvider }
500
+ : {}),
501
+ // Manifest-backed UI registry — resolves blueprint ids to
502
+ // compiled bundles for `ggui_render_blueprint`. Paired with
503
+ // `blueprintProvider` above (metadata vs. bundle resolution on
504
+ // the same source). Absent = render tool not registered.
505
+ ...(opts.uiRegistry ? { uiRegistry: opts.uiRegistry } : {}),
506
+ // Manifest-declared primitive catalogs. Present when `ggui serve`
507
+ // resolved `primitives.{packages,local}`; absent = in-memory-only
508
+ // server with an empty `server.primitiveCatalogs`.
509
+ ...(opts.primitiveCatalogs
510
+ ? { primitiveCatalogs: opts.primitiveCatalogs }
511
+ : {}),
512
+ // Manifest-declared theme. Present when `ggui serve` resolved
513
+ // `ggui.json#theme`; absent = `createGguiServer` falls back to its
514
+ // `lightTheme`-backed default internally.
515
+ ...(opts.theme ? { theme: opts.theme } : {}),
516
+ // Console theme picker writer — paired with the admin-gated
517
+ // `/ggui/console/theme` POST. Absent = picker UI surfaces a
518
+ // read-only banner + POST returns 501. CLI sets this when
519
+ // `plan.projectRoot` resolved to a manifest path on disk.
520
+ ...(opts.themeWriter ? { themeWriter: opts.themeWriter } : {}),
521
+ // Live-theme wiring — `themeProvider` reads from a shared cell
522
+ // that `onThemeConfigChange` writes to on every console save,
523
+ // so the picker's "Save to ggui.json" reaches the next push's
524
+ // bootstrap envelope without a restart. Both threaded through
525
+ // when present; absent = boot-baked behaviour (legacy).
526
+ ...(opts.themeProvider ? { themeProvider: opts.themeProvider } : {}),
527
+ ...(opts.onThemeConfigChange
528
+ ? { onThemeConfigChange: opts.onThemeConfigChange }
529
+ : {}),
530
+ // Register `ggui_push` + serve `ui://ggui/session`. URLs point at
531
+ // this server's own console viewer + live channel — same-origin,
532
+ // same process. Without this, `ggui_push` is absent and the `/s/`
533
+ // viewer can never resolve (nothing mints shortCodes).
534
+ mcpApps: {
535
+ // The `/r/` route serves the public self-contained shell that
536
+ // any MCP Apps host (Claude Desktop, Claude Code, claude.ai)
537
+ // can render directly — `structuredContent.url` lands on it
538
+ // as a full HTML page. First-party hosts (Studio, Portal,
539
+ // console) consume `_meta.ui.resourceUri` and never hit the
540
+ // URL directly, so the same prefix works for everyone.
541
+ renderBaseUrl: `${baseUrl}/r/`,
542
+ wsUrl: `${wsBaseUrl}/ws`,
543
+ },
544
+ // Task #382 — `runtimeUrl` MUST be absolute. The thin-shell HTML
545
+ // served from `ui://ggui/session` is mounted via `srcdoc` in most
546
+ // consumers (`<McpAppIframe>` default path for inline text), which
547
+ // gives the iframe `about:srcdoc` as its URL — a relative path
548
+ // like `/_ggui/iframe-runtime.js` resolves against `about:` and the
549
+ // `<script src>` fetch silently fails. Publishing the absolute
550
+ // URL built from the CLI's own known `baseUrl` makes srcdoc mount
551
+ // work without operator action. The server still serves the bundle
552
+ // at `/_ggui/iframe-runtime.js` under `runtimePath`; this overrides
553
+ // only the URL published on `_meta.ggui.bootstrap.runtimeUrl`.
554
+ runtime: { url: `${baseUrl}${RUNTIME_BUNDLE_URL_PATH}` },
555
+ // OSS first-run default: provisional A2UI preview is on. The
556
+ // deterministic emitter from `@ggui-ai/preview-a2ui/emitters`
557
+ // adapts each push's `story.intent` into a small A2UI surface
558
+ // (heading → shell → teardown) and streams it on
559
+ // `_ggui:preview`. Embedded-ui's `/s/<shortCode>` viewer mounts
560
+ // `<ProvisionalRenderer>` to paint the frames as they arrive,
561
+ // so operators see a live UI build-up the first time they
562
+ // invoke `ggui_push` — before any LLM generation lands on OSS.
563
+ //
564
+ // Hosted + programmatic hosts opt-in by composing
565
+ // `createGguiServer()` directly and supplying their own
566
+ // emitter (e.g. Haiku-backed); the CLI keeps the OSS story
567
+ // honest with a zero-config default that fires.
568
+ provisionalPreview: {
569
+ enabled: true,
570
+ emitter: createDeterministicPreviewEmitter(),
571
+ },
572
+ // Generation wiring. Threaded through to
573
+ // `defaultHandlers.push.generation` → `createGguiPushHandler`.
574
+ // Absent = placeholder mode: push creates sessions + shortCodes
575
+ // + preview but does NOT produce componentCode. The CLI's
576
+ // `runServeCommand` probes BYOK (env → credentials file) at
577
+ // boot and only supplies this opt on a hit. See
578
+ // `./generation-probe.ts`.
579
+ ...(generationWithInstalled ? { generation: generationWithInstalled } : {}),
580
+ // Mount aggregation. External SharedHandler bundles appended onto
581
+ // `/mcp` alongside ggui-native tools. No CLI config loader yet —
582
+ // programmatic hosts + integration tests compose mounts through
583
+ // this opt directly.
584
+ ...(opts.mcpMounts && opts.mcpMounts.length > 0
585
+ ? { mcpMounts: opts.mcpMounts }
586
+ : {}),
587
+ // wiredActionRouter composed from the same mount handlers. With
588
+ // mounts present, every mounted tool becomes wire-dispatchable
589
+ // from a generated UI's `actionSpec[name].dispatch.tool` (when
590
+ // `dispatch.kind === 'tool'`) — the "zero agent code" path.
591
+ // Without mounts, no router is wired and inbound actions stay on
592
+ // the agent-routed path.
593
+ ...(wiredActionRouter ? { wiredActionRouter } : {}),
594
+ // Schema-compat mode. Defaults to `'reject'` inside
595
+ // `createGguiServer`; the env override is the operator's escape
596
+ // hatch for scenarios that intentionally ship a blueprint with a
597
+ // schema mismatch (e.g. contract-probe fixtures that test runtime
598
+ // TOOL_THREW / SCHEMA_VIOLATION error paths — `'reject'` would
599
+ // otherwise block the stack item at push/try-live time before
600
+ // runtime gets to emit the envelope under test).
601
+ //
602
+ // Accepted values match `SchemaCompatMode`: `'reject' | 'warn' |
603
+ // 'off'`. Unrecognized values fall through to the server's
604
+ // default. Keeping the parse narrow so a typo yields default
605
+ // behavior (reject) rather than silent relaxation.
606
+ ...(process.env['GGUI_SCHEMA_COMPAT_MODE'] === 'warn' ||
607
+ process.env['GGUI_SCHEMA_COMPAT_MODE'] === 'off' ||
608
+ process.env['GGUI_SCHEMA_COMPAT_MODE'] === 'reject'
609
+ ? {
610
+ schemaCompatCheck: process.env['GGUI_SCHEMA_COMPAT_MODE'],
611
+ }
612
+ : {}),
613
+ // Wired-tool per-call timeout override (default 30 s in
614
+ // `session-channel.ts::DEFAULT_WIRED_TOOL_TIMEOUT_MS`). Mirrors the
615
+ // `GGUI_SCHEMA_COMPAT_MODE` escape-hatch shape: env-only; absent or
616
+ // unparseable falls through to the server's default. Test harnesses
617
+ // exercising TOOL_TIMEOUT envelopes lower this to keep per-test
618
+ // budgets small (the conformance fixture
619
+ // `wired-action-tool-timeout.json` deliberately wires a hanging
620
+ // tool; the default 30 s wait would dominate a Lane-1 spec budget).
621
+ //
622
+ // Parse uses `Number()` + finite + positive-integer checks; a typo
623
+ // (`"abc"`, `"-100"`, `"NaN"`) falls through silently to default.
624
+ // Operators that need a different production timeout SHOULD compose
625
+ // `createGguiServer` directly rather than rely on env coupling.
626
+ ...(() => {
627
+ const raw = process.env['GGUI_WIRED_TIMEOUT_MS'];
628
+ if (raw === undefined || raw === '')
629
+ return {};
630
+ const parsed = Number(raw);
631
+ if (!Number.isFinite(parsed) || parsed <= 0)
632
+ return {};
633
+ return { wiredActionTimeoutMs: Math.floor(parsed) };
634
+ })(),
635
+ });
636
+ return {
637
+ listen: async (port, host) => {
638
+ const httpServer = await server.listen(port, host);
639
+ const addr = httpServer.address();
640
+ if (!addr || typeof addr === 'string') {
641
+ throw new Error('server.address() returned an unexpected shape');
642
+ }
643
+ return addr.port;
644
+ },
645
+ close: () => server.close(),
646
+ toolCount: server.toolCount,
647
+ serverName: 'ggui-mcp-server',
648
+ serverVersion: opts.cliVersion,
649
+ primitiveCatalogCount: server.primitiveCatalogs.length,
650
+ themeSource: server.theme.source,
651
+ // Always non-null in the CLI bundle because `pairing: true` is
652
+ // wired above. Exposed through the narrow `ServeBackend` contract
653
+ // so `runServe` can pre-mint an initial pair code after bind
654
+ // without reaching into `GguiServer`.
655
+ pairingService: server.pairingService,
656
+ // Always non-null in the CLI bundle because `console` is wired
657
+ // above (sessionCookie: true). Surfaced so the CLI banner can
658
+ // print the operator-facing value alongside PAIR_CODE.
659
+ adminToken: server.adminToken,
660
+ // Surface the embedding model id so `describeServeBanner` can
661
+ // print a `rag` line. Read off the provider's `id` field — local
662
+ // provider returns the model id (e.g. `Xenova/bge-small-en-v1.5`);
663
+ // MockEmbeddingProvider returns `'mock-sine'`. The banner only
664
+ // shows the line for real models (this CLI bundle always wires
665
+ // the local provider, so the line always appears in `ggui serve`).
666
+ embeddingModel: embedding.id,
667
+ };
668
+ }