@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
package/dist/cli.js ADDED
@@ -0,0 +1,1066 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `ggui` — the open CLI binary.
4
+ *
5
+ * Minimal command router. The argv parsing + agent-runtime
6
+ * resolution live in `./dev-command.js` so they're unit-testable
7
+ * without triggering `main()` on import.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { GguiDevError, LocalUiRegistry, runDev } from '@ggui-ai/dev-stack';
13
+ import { discoverLocalUis, discoverMcpMounts, discoverPrimitives, GguiJsonLoadError, loadTheme, } from '@ggui-ai/project-config/node';
14
+ import { resolveAgentPlan } from './serve-fallback.js';
15
+ import { buildAgentRuntime, describeTunnelSession, launchBrowser, openTunnel, parseDevFlags, resolveAgentCommand, resolveHubUrl, shouldAutoOpen, } from './dev-command.js';
16
+ import { createNullTunnelProvider, discoverTunnelProvider, } from './tunnel-provider.js';
17
+ import { ManifestBlueprintProvider, resolveStorageFromConfig, buildNoCredentialsStackItem, } from '@ggui-ai/mcp-server';
18
+ import { InMemoryBlueprintProvider } from '@ggui-ai/mcp-server-core/in-memory';
19
+ import { createByokResolver } from './byok-resolver.js';
20
+ import { isInstalledBlueprintPath } from './internal/artifact-install.js';
21
+ import { describeGenerationBinding, probeGenerationBinding, } from './generation-probe.js';
22
+ import { buildMcpServerBackend, pickFreePort } from './mcp-backend.js';
23
+ import { createThemeWriter } from './theme-writer.js';
24
+ import { createThemeFileUploader } from './theme-file-uploader.js';
25
+ import { getPersistentDir } from './paths.js';
26
+ import { createPersistentSessionStore, createPersistentVectorStore, } from './persistent-stores.js';
27
+ import { parseServeFlags, runServe, SERVE_HELP, } from './serve-command.js';
28
+ import { runLoginCommand } from './auth-login.js';
29
+ import { runLogoutCommand } from './auth-logout.js';
30
+ import { runWhoamiCommand } from './auth-whoami.js';
31
+ import { runKeysCommand } from './auth-keys.js';
32
+ import { runGadgetCommand } from './gadget-command.js';
33
+ import { runBlueprintCommand } from './blueprint-command.js';
34
+ import { runThemeCommand } from './theme-command.js';
35
+ const HELP = `ggui — open CLI for the ggui protocol
36
+
37
+ Usage:
38
+ ggui <command> [options]
39
+
40
+ Commands:
41
+ dev Start the local UI registry server, open the dev hub,
42
+ and (optionally) supervise a local agent runtime via
43
+ the adapter seam.
44
+
45
+ --port <n> Bind port (default: 6780, 0 = OS-assigned).
46
+ --host <addr> Bind host (default: 127.0.0.1).
47
+ --no-serve Load + discover and exit without binding.
48
+ --no-open Don't auto-open the browser (also
49
+ implied by CI=1 / BROWSER=none /
50
+ non-TTY stdout).
51
+ --agent <entry> Supervise a local agent runtime.
52
+ .js/.mjs/.cjs → node <entry>
53
+ .ts/.tsx/.mts → node --import=tsx <entry>
54
+ (tsx must be resolvable)
55
+ --tunnel Opt into managed mode: after the
56
+ local stack is listening, attempt
57
+ to open a managed tunnel above it
58
+ and print the remote URL. Local
59
+ dev still runs if no tunnel
60
+ provider is configured.
61
+
62
+ serve Run the open self-hosted personal-mode app (MCP server +
63
+ supervised agent, per ggui.json). Distinct from \`ggui dev\`.
64
+
65
+ --port <n> Bind port (default: 6781, 0 = OS-assigned).
66
+ --host <addr> Bind host (default: 127.0.0.1).
67
+ --mcp-only Boot only MCP; skip agent supervision.
68
+
69
+ login Sign into ggui.ai (device flow). Tokens stored in
70
+ ~/.ggui/auth.json.
71
+ logout Discard the local ggui.ai session.
72
+ whoami Print the authenticated user.
73
+ keys Manage ggui_user_* connector keys (list/create/revoke).
74
+ gadget Author gadgets for the ggui marketplace.
75
+
76
+ gadget create <scope/name> Scaffold a new gadget repo.
77
+
78
+ blueprint Author UI blueprints for the ggui marketplace.
79
+
80
+ blueprint create <scope/name> Scaffold a new blueprint repo.
81
+
82
+ theme Validate + inspect operator-authored DTCG themes.
83
+
84
+ theme validate <path> Validate a JSON theme file against
85
+ the ThemeDocumentV1 schema.
86
+
87
+ Global options:
88
+ --help, -h Show this help.
89
+ --version Show installed version.
90
+
91
+ \`ggui\` runs the protocol locally — no account required. The hosted
92
+ Guuey platform is a separate product with its own \`guuey\` binary for
93
+ hosted control-plane commands (login/deploy/logs/secrets).
94
+ `;
95
+ async function main(argv) {
96
+ const args = argv.slice(2);
97
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
98
+ process.stdout.write(HELP);
99
+ return 0;
100
+ }
101
+ if (args[0] === '--version') {
102
+ process.stdout.write(`${readVersion()}\n`);
103
+ return 0;
104
+ }
105
+ const [command, ...rest] = args;
106
+ switch (command) {
107
+ case 'dev':
108
+ return runDevCommand(rest);
109
+ case 'serve':
110
+ return runServeCommand(rest);
111
+ case 'login':
112
+ return runLoginCommand(rest);
113
+ case 'logout':
114
+ return runLogoutCommand(rest);
115
+ case 'whoami':
116
+ return runWhoamiCommand(rest);
117
+ case 'keys':
118
+ return runKeysCommand(rest);
119
+ case 'gadget':
120
+ return runGadgetCommand(rest);
121
+ case 'blueprint':
122
+ return runBlueprintCommand(rest);
123
+ case 'theme':
124
+ return runThemeCommand(rest);
125
+ default:
126
+ process.stderr.write(`ggui: unknown command "${command}"\n\n`);
127
+ process.stderr.write(HELP);
128
+ return 1;
129
+ }
130
+ }
131
+ // `buildMcpServerBackend` moved to `./mcp-backend.ts` so tests can
132
+ // exercise the full opt-in bundle ({sessionChannel, pairing,
133
+ // console.sessionCookie, shortCodeIndex}) without triggering
134
+ // `main()` at import. See module-level doc there for the bundle
135
+ // rationale.
136
+ /**
137
+ * Event sink for the supervised agent. Production writes `[agent …]`
138
+ * lines to stderr so they interleave with the JSON-line logger
139
+ * without polluting stdout.
140
+ */
141
+ function writeAgentEventToStderr(event) {
142
+ if (event.type === 'status') {
143
+ process.stderr.write(`[agent] status=${event.status}\n`);
144
+ // Crash policy made visible: the banner above already printed
145
+ // "agent → running", so the operator's mental model diverges from
146
+ // reality the moment a crash event arrives. This follow-up line
147
+ // keeps the two in sync and documents inline why MCP keeps serving
148
+ // after the supervised agent crashes.
149
+ if (event.status === 'crashed') {
150
+ process.stderr.write(`[agent] crashed — MCP still running; restart via your supervisor or Ctrl-C + rerun.\n`);
151
+ }
152
+ return;
153
+ }
154
+ if (event.type === 'log') {
155
+ process.stderr.write(`[agent ${event.stream}] ${event.line}\n`);
156
+ return;
157
+ }
158
+ if (event.type === 'error') {
159
+ process.stderr.write(`[agent error] ${event.message}\n`);
160
+ }
161
+ }
162
+ async function runServeCommand(args) {
163
+ const parsed = parseServeFlags(args);
164
+ if (parsed.error === '__help__') {
165
+ process.stdout.write(SERVE_HELP);
166
+ return 0;
167
+ }
168
+ if (parsed.error) {
169
+ process.stderr.write(`ggui serve: ${parsed.error}\n`);
170
+ return 1;
171
+ }
172
+ // Compose SIGINT + SIGTERM into a single AbortController so
173
+ // `runServe` resolves the serve loop on either signal. Listeners
174
+ // are `once` — a second Ctrl-C during shutdown propagates the
175
+ // signal unhandled so Node force-exits, which is the right
176
+ // behavior if graceful close wedges.
177
+ const controller = new AbortController();
178
+ const shutdown = () => {
179
+ if (!controller.signal.aborted)
180
+ controller.abort();
181
+ };
182
+ process.once('SIGINT', shutdown);
183
+ process.once('SIGTERM', shutdown);
184
+ let plan;
185
+ try {
186
+ plan = resolveAgentPlan({
187
+ mcpOnly: parsed.mcpOnly,
188
+ cwd: process.cwd(),
189
+ onAgentEvent: writeAgentEventToStderr,
190
+ });
191
+ }
192
+ catch (err) {
193
+ // Hard-config-error path: malformed ggui.json or unsupported
194
+ // agent.entry. Render a crisp, actionable message + exit 1.
195
+ if (err instanceof GguiJsonLoadError) {
196
+ process.stderr.write(`ggui serve: ${err.message}\n`);
197
+ if (err.cause && err.cause instanceof Error) {
198
+ process.stderr.write(` cause: ${err.cause.message}\n`);
199
+ }
200
+ return 1;
201
+ }
202
+ process.stderr.write(`ggui serve: ${err instanceof Error ? err.message : String(err)}\n`);
203
+ return 1;
204
+ }
205
+ // Print warnings BEFORE the banner — stderr lines for missing /
206
+ // incomplete config so operators can't miss them under the pretty
207
+ // section that comes after `listen()`.
208
+ for (const warning of plan.warnings) {
209
+ process.stderr.write(`ggui serve: ${warning}\n`);
210
+ }
211
+ // Resolve `ggui.json#storage` → concrete adapters BEFORE binding.
212
+ // Absent storage block → empty bundle → server keeps in-memory
213
+ // defaults. Sqlite paths resolve relative to the manifest directory
214
+ // (not CWD) so `"path": "./ggui-sessions.sqlite"` lands next to
215
+ // `ggui.json` regardless of where the operator invoked `ggui serve`
216
+ // from. Failure to instantiate (e.g. better-sqlite3 peer dep
217
+ // missing, permission denied) propagates to the exit-1 path below.
218
+ let storage;
219
+ try {
220
+ storage = await resolveStorageFromConfigSafely(plan.manifest?.storage, plan.projectRoot);
221
+ }
222
+ catch (err) {
223
+ process.stderr.write(`ggui serve: storage: ${err instanceof Error ? err.message : String(err)}\n`);
224
+ return 1;
225
+ }
226
+ // Persistent default for SessionStore + VectorStore — layered AFTER
227
+ // the manifest resolver so explicit `ggui.json#storage` declarations
228
+ // always win, and BEFORE the banner so `describeStorageStatus`
229
+ // reflects the actual wired state. Without this layer, ggui-default
230
+ // (no `storage:` block in ggui.json) has no VectorStore, which
231
+ // breaks the handshake blueprint cache (matchBlueprint exact-key
232
+ // probe relies on the vector store). Setting `GGUI_PERSISTENT_DIR`
233
+ // overrides the default location.
234
+ {
235
+ // `--ephemeral` skips the whole persistent layering. Operators who
236
+ // pass the flag get pure in-memory defaults across the board (HMAC
237
+ // secrets mint fresh per process). Without the flag, the
238
+ // sqlite-backed VectorStore + SessionStore persist across restarts
239
+ // — note this means the install-to-cache bridge can serve stale
240
+ // install-provenance rows after a boot if the source TSX behind a
241
+ // cached row has been edited or uninstalled in the meantime.
242
+ if (!parsed.ephemeral) {
243
+ const persistentDir = getPersistentDir(plan.projectRoot);
244
+ const manifestDeclaresSessions = plan.manifest?.storage?.sessions !== undefined;
245
+ const manifestDeclaresVectors = plan.manifest?.storage?.vectors !== undefined;
246
+ // The persistent stores are SQLite-backed (`better-sqlite3`, an
247
+ // optional native module). When it isn't installed — or the
248
+ // store otherwise can't be created — fall back to the in-memory
249
+ // default rather than refusing to boot: a fresh `npm install
250
+ // @ggui-ai/cli` user can `ggui serve` immediately, and adding
251
+ // `better-sqlite3` later upgrades them to persistence. An
252
+ // explicit `ggui.json#storage` declaration still hard-fails (it
253
+ // resolved above) — the operator asked for it by name.
254
+ if (!manifestDeclaresSessions && storage.sessionStore === undefined) {
255
+ try {
256
+ const sessionStore = await createPersistentSessionStore(persistentDir);
257
+ storage = { ...storage, sessionStore };
258
+ }
259
+ catch (err) {
260
+ process.stderr.write(`ggui serve: persistent session store unavailable — using in-memory `
261
+ + `(sessions reset on restart; install \`better-sqlite3\` for persistence). `
262
+ + `[${err instanceof Error ? err.message : String(err)}]\n`);
263
+ }
264
+ }
265
+ if (!manifestDeclaresVectors && storage.vectors === undefined) {
266
+ try {
267
+ const vectors = await createPersistentVectorStore(persistentDir);
268
+ storage = { ...storage, vectors };
269
+ }
270
+ catch (err) {
271
+ process.stderr.write(`ggui serve: persistent vector store unavailable — using in-memory `
272
+ + `(blueprint cache not persisted; install \`better-sqlite3\` for persistence). `
273
+ + `[${err instanceof Error ? err.message : String(err)}]\n`);
274
+ }
275
+ }
276
+ }
277
+ }
278
+ for (const line of describeStorageStatus(plan.manifest?.storage, storage)) {
279
+ process.stdout.write(`${line}\n`);
280
+ }
281
+ // Resolve `ggui.json#blueprints.include` → indexed `ggui.ui.json`
282
+ // manifests BEFORE binding. Discovery is non-throwing — malformed
283
+ // files / duplicate ids surface as issues — but `ggui serve` escalates
284
+ // ANY issue to a fatal exit before binding, preferring explicit
285
+ // failure over a silent partial boot. A partially booted server that
286
+ // silently dropped a declared UI would hand the agent a
287
+ // `ggui_list_featured_blueprints` catalog that doesn't match the
288
+ // operator's ggui.json — the worst kind of drift.
289
+ let blueprintProvider;
290
+ // Hoist the discovered-UI list so the marketplace-install bridge can
291
+ // filter to `.ggui/installed-blueprints/` entries below. Same
292
+ // discovery walk, two consumers (blueprint catalog + matcher cache
293
+ // seed).
294
+ let discoveredUis = [];
295
+ if (plan.manifest && plan.projectRoot) {
296
+ const discovery = await discoverLocalUis({
297
+ projectRoot: plan.projectRoot,
298
+ manifest: plan.manifest,
299
+ });
300
+ for (const line of describeBlueprintIssues(discovery.issues)) {
301
+ process.stderr.write(`${line}\n`);
302
+ }
303
+ if (discovery.issues.length > 0) {
304
+ process.stderr.write(`ggui serve: refusing to start — ${discovery.issues.length} blueprint manifest issue(s). Fix the issues above and re-run.\n`);
305
+ return 1;
306
+ }
307
+ for (const line of describeBlueprintStatus(discovery.uis)) {
308
+ process.stdout.write(`${line}\n`);
309
+ }
310
+ discoveredUis = discovery.uis;
311
+ if (discovery.uis.length > 0) {
312
+ blueprintProvider = new ManifestBlueprintProvider({
313
+ manifests: discovery.uis.map((u) => u.manifest),
314
+ });
315
+ }
316
+ }
317
+ // Pair the metadata provider with a UiRegistry that can resolve
318
+ // blueprint ids → compiled bundles. OSS uses dev-stack's
319
+ // `LocalUiRegistry` (esbuild-backed compile-on-demand; same file
320
+ // layout as `discoverLocalUis`). Registered only when blueprints
321
+ // were discovered — no discovered blueprints, no render tool (the
322
+ // handler registration in `createGguiServer` is gated on this seam
323
+ // being present).
324
+ let uiRegistry;
325
+ if (blueprintProvider && plan.manifest && plan.projectRoot) {
326
+ uiRegistry = new LocalUiRegistry({
327
+ projectRoot: plan.projectRoot,
328
+ manifest: plan.manifest,
329
+ });
330
+ }
331
+ // Resolve `ggui.json#primitives.{packages,local}` → catalogs BEFORE
332
+ // binding. Same "non-throwing per source, fatal at the boot boundary"
333
+ // discipline as blueprint discovery — the capability-manifest stops
334
+ // being cosmetic the moment declared sources refuse to resolve.
335
+ let primitiveCatalogs = [];
336
+ if (plan.manifest && plan.projectRoot) {
337
+ const discovery = await discoverPrimitives({
338
+ projectRoot: plan.projectRoot,
339
+ manifest: plan.manifest,
340
+ });
341
+ for (const line of describePrimitiveIssues(discovery.issues)) {
342
+ process.stderr.write(`${line}\n`);
343
+ }
344
+ if (discovery.issues.length > 0) {
345
+ process.stderr.write(`ggui serve: refusing to start — ${discovery.issues.length} primitive manifest issue(s). Fix the issues above and re-run.\n`);
346
+ return 1;
347
+ }
348
+ for (const line of describePrimitiveStatus(discovery.catalogs)) {
349
+ process.stdout.write(`${line}\n`);
350
+ }
351
+ primitiveCatalogs = discovery.catalogs;
352
+ }
353
+ // Resolve `ggui.json#theme` → LoadedTheme BEFORE binding. Absent
354
+ // declaration → shipped default (no issue, no fatal path). Present
355
+ // but unreadable / malformed / schema-invalid → fatal exit with the
356
+ // issue printed to stderr. Same "prefer explicit failure over
357
+ // silent partial boot" escalation the blueprint + primitive slices
358
+ // adopted.
359
+ let theme;
360
+ if (plan.manifest && plan.projectRoot) {
361
+ const result = loadTheme({
362
+ projectRoot: plan.projectRoot,
363
+ manifest: plan.manifest,
364
+ });
365
+ if (!result.ok) {
366
+ for (const line of describeThemeIssue(result.issue)) {
367
+ process.stderr.write(`${line}\n`);
368
+ }
369
+ process.stderr.write(`ggui serve: refusing to start — theme manifest issue. Fix the issue above and re-run.\n`);
370
+ return 1;
371
+ }
372
+ theme = result.theme;
373
+ for (const line of describeThemeStatus(theme)) {
374
+ process.stdout.write(`${line}\n`);
375
+ }
376
+ }
377
+ // Live-theme state cell. Shared between the console-theme route's
378
+ // POST handler (mountDevtoolThemeRoutes' `onConfigChange` callback)
379
+ // and the push handler's `themeProvider` getter so a "Save to
380
+ // ggui.json" in the picker reaches the next push's bootstrap
381
+ // envelope without a server restart. Without the cell the push
382
+ // handler would capture `themeId` once at boot, and any subsequent
383
+ // edit would be silently ignored until restart.
384
+ //
385
+ // Initial value derives from the boot-time LoadedTheme so the
386
+ // first push (before any save happens) embeds the manifest's
387
+ // declared theme. POST writes mutate `themeStateCell.current`;
388
+ // the closure below reads it on every push.
389
+ const themeStateCell = {
390
+ current: theme !== undefined && theme.source === 'preset'
391
+ ? { id: theme.preset, mode: theme.mode }
392
+ : null,
393
+ };
394
+ const themeProvider = () => themeStateCell.current ?? undefined;
395
+ // The legacy `ggui.json#adapters` manifest grant has been retired.
396
+ // The adapter grant model now lives entirely on
397
+ // `clientCapabilities.gadgets[*].permission` and projects to the
398
+ // iframe's Permissions-Policy header.
399
+ // Resolve `ggui.json#mcpMounts` → invoked `createGguiMcpMount`
400
+ // factories BEFORE binding. Same "non-throwing per source, fatal at
401
+ // the boot boundary" discipline as blueprint / primitive / theme
402
+ // discovery: the operator asked for a local tool surface, so a
403
+ // malformed module is a refusal-to-start, not a silent drop. Each
404
+ // mount's factory runs once here at boot — the CLI captures the
405
+ // returned `McpServerMount` bundle and threads it into
406
+ // `buildMcpServerBackend({ mcpMounts })`.
407
+ let mcpMounts = [];
408
+ if (plan.manifest && plan.projectRoot) {
409
+ const discovery = await discoverMcpMounts({
410
+ projectRoot: plan.projectRoot,
411
+ manifest: plan.manifest,
412
+ });
413
+ for (const line of describeMcpMountIssues(discovery.issues)) {
414
+ process.stderr.write(`${line}\n`);
415
+ }
416
+ if (discovery.issues.length > 0) {
417
+ process.stderr.write(`ggui serve: refusing to start — ${discovery.issues.length} mcpMount issue(s). Fix the issues above and re-run.\n`);
418
+ return 1;
419
+ }
420
+ for (const line of describeMcpMountStatus(discovery.mounts)) {
421
+ process.stdout.write(`${line}\n`);
422
+ }
423
+ mcpMounts = narrowMcpMounts(discovery.mounts);
424
+ }
425
+ // Resolve `--port 0` BEFORE composing the backend. `createGguiServer`
426
+ // captures `mcpApps.renderBaseUrl` + `wsUrl` at construction time, so
427
+ // we need a concrete port to emit URLs the agent can hand to a
428
+ // browser. A tiny race window exists between close + bind; surface
429
+ // the bind failure cleanly if it loses.
430
+ //
431
+ // Resolved BEFORE the BYOK probe (vs. previous ordering) because
432
+ // the no-credentials fallback card needs the absolute settings URL
433
+ // — `${publicBaseUrl ?? http://${host}:${port}}/settings` — bound
434
+ // into the generation closure at probe time. Per-call URL recompute
435
+ // would be marginally cheaper but adds plumbing (closure depends on
436
+ // `effectivePort` resolved later) without a real benefit.
437
+ let effectivePort = parsed.port;
438
+ if (effectivePort === 0) {
439
+ try {
440
+ effectivePort = await pickFreePort(parsed.host);
441
+ }
442
+ catch (err) {
443
+ process.stderr.write(`ggui serve: failed to allocate a free port — ${err instanceof Error ? err.message : String(err)}\n`);
444
+ return 1;
445
+ }
446
+ }
447
+ // BYOK probe — boot scan + per-request resolver. The probe ALWAYS
448
+ // returns a `GenerationBinding` now (post-H2): when no env / global
449
+ // / user-scope key resolved at boot, the binding's `resolveLlm`
450
+ // re-runs at every push with `userScope: ctx.appId` (per-user keys
451
+ // stored in `~/.ggui/credentials.json` flip in there); when the
452
+ // re-run also misses, the bound `onNoCredentials` hook produces
453
+ // a Connect-Claude card stack item pointing at THIS server's
454
+ // `/settings`. Story-path `ggui_push` therefore always lands a
455
+ // real stack item — either a generated component (key found) or
456
+ // the card (key missing).
457
+ const baseUrlForCard = parsed.publicBaseUrl ?? `http://${parsed.host}:${effectivePort}`;
458
+ const settingsUrl = `${baseUrlForCard}/settings`;
459
+ let generationBinding;
460
+ try {
461
+ // Use whatever blueprint provider the manifest resolved, or
462
+ // fall back to an empty one so the generator has a valid
463
+ // `blueprints` dep regardless of manifest state.
464
+ const blueprintsForGen = blueprintProvider ?? new InMemoryBlueprintProvider();
465
+ generationBinding = await probeGenerationBinding({
466
+ resolver: createByokResolver(),
467
+ blueprints: blueprintsForGen,
468
+ onNoCredentials: (_ctx, story) => buildNoCredentialsStackItem({
469
+ stackItemId: story.stackItemId,
470
+ intent: story.intent,
471
+ nowIso: story.nowIso,
472
+ settingsUrl,
473
+ }),
474
+ });
475
+ }
476
+ catch (err) {
477
+ // Don't fail boot on a probe error (malformed credentials file,
478
+ // transient fs issue). Surface the reason so the operator can
479
+ // investigate; we still produce a binding for the always-on path
480
+ // by re-running with no fallback hook (resolver retries at
481
+ // request time, hits the legacy error envelope on miss).
482
+ process.stderr.write(`ggui serve: generation probe failed: ${err instanceof Error ? err.message : String(err)}\n`);
483
+ const blueprintsForGen = blueprintProvider ?? new InMemoryBlueprintProvider();
484
+ generationBinding = await probeGenerationBinding({
485
+ resolver: createByokResolver(),
486
+ blueprints: blueprintsForGen,
487
+ });
488
+ }
489
+ process.stdout.write(`${describeGenerationBinding(generationBinding)}\n`);
490
+ try {
491
+ return await runServe({
492
+ flags: {
493
+ port: effectivePort,
494
+ host: parsed.host,
495
+ mcpOnly: parsed.mcpOnly,
496
+ devAllowAll: parsed.devAllowAll,
497
+ publicDemo: parsed.publicDemo,
498
+ multiTenant: parsed.multiTenant,
499
+ oauth: parsed.oauth,
500
+ ...(parsed.publicBaseUrl !== undefined
501
+ ? { publicBaseUrl: parsed.publicBaseUrl }
502
+ : {}),
503
+ ...(parsed.keysFile !== undefined ? { keysFile: parsed.keysFile } : {}),
504
+ ...(parsed.adminToken !== undefined
505
+ ? { adminToken: parsed.adminToken }
506
+ : {}),
507
+ },
508
+ backendFactory: () => buildMcpServerBackend({
509
+ storage,
510
+ cliVersion: readVersion(),
511
+ host: parsed.host,
512
+ port: effectivePort,
513
+ devAllowAll: parsed.devAllowAll,
514
+ publicDemo: parsed.publicDemo,
515
+ multiTenant: parsed.multiTenant,
516
+ oauth: parsed.oauth,
517
+ ...(parsed.publicBaseUrl !== undefined
518
+ ? { publicBaseUrl: parsed.publicBaseUrl }
519
+ : {}),
520
+ ...(parsed.mcpInstructions !== undefined
521
+ ? { mcpInstructions: parsed.mcpInstructions }
522
+ : {}),
523
+ ...(parsed.keysFile !== undefined ? { keysFile: parsed.keysFile } : {}),
524
+ ...(parsed.adminToken !== undefined
525
+ ? { adminToken: parsed.adminToken }
526
+ : {}),
527
+ ...(blueprintProvider ? { blueprintProvider } : {}),
528
+ ...(uiRegistry ? { uiRegistry } : {}),
529
+ ...(primitiveCatalogs.length > 0 ? { primitiveCatalogs } : {}),
530
+ ...(theme ? { theme } : {}),
531
+ // Console theme picker — wired only when a ggui.json was
532
+ // resolved on disk. Operators booting without a manifest
533
+ // (no projectRoot) get the picker in read-only mode (POST
534
+ // returns 501; the UI surfaces a banner). The writer
535
+ // round-trips JSON through `JSON.parse` + `JSON.stringify`
536
+ // so unknown fields the operator added survive the Save.
537
+ ...(plan.projectRoot
538
+ ? {
539
+ themeWriter: createThemeWriter(join(plan.projectRoot, 'ggui.json')),
540
+ themeFileUploader: createThemeFileUploader(join(plan.projectRoot, 'ggui.json')),
541
+ // Live-theme: closure reads `themeStateCell` on every
542
+ // push. `onThemeConfigChange` mutates the cell from
543
+ // the console-theme route's POST handler. Together:
544
+ // console save → cell update → next push's bootstrap
545
+ // carries the new themeId. No restart required.
546
+ themeProvider,
547
+ onThemeConfigChange: (next) => {
548
+ if (next === null) {
549
+ themeStateCell.current = null;
550
+ return;
551
+ }
552
+ // ThemeConfig accepts three shapes:
553
+ // - string shorthand (`'indigo'`): preset id, no mode.
554
+ // - { preset, mode?, overrides? }: explicit preset.
555
+ // - { file, mode? }: custom DTCG document.
556
+ // For preset shapes, propagate id+mode to the live
557
+ // cell so the next push's bootstrap envelope picks
558
+ // them up. For the file branch the bootstrap's
559
+ // `themeId` field has no useful value (the runtime
560
+ // bundles only registered presets), so we clear
561
+ // the id and the iframe falls back to its baked
562
+ // default. The mode still propagates for cases
563
+ // where a future path resolves the file at runtime.
564
+ if (typeof next === 'string') {
565
+ themeStateCell.current = { id: next };
566
+ }
567
+ else if ('preset' in next) {
568
+ themeStateCell.current = {
569
+ id: next.preset,
570
+ ...(next.mode !== undefined ? { mode: next.mode } : {}),
571
+ };
572
+ }
573
+ else {
574
+ themeStateCell.current = next.mode !== undefined
575
+ ? { mode: next.mode }
576
+ : null;
577
+ }
578
+ },
579
+ }
580
+ : {}),
581
+ // Public welcome page at `/` — operator identification +
582
+ // public deep-link surfaces + operator-login affordance.
583
+ // Sourced from `ggui.json#operator` + `ggui.json#app.name`
584
+ // when a manifest was found on disk; absent without one,
585
+ // and absent if `operator` was not declared. The renderer
586
+ // hides the operator block entirely when nothing is set.
587
+ ...(plan.manifest
588
+ ? {
589
+ welcomePage: {
590
+ ...(plan.manifest.operator
591
+ ? { operator: plan.manifest.operator }
592
+ : {}),
593
+ ...(plan.manifest.app?.name
594
+ ? { appName: plan.manifest.app.name }
595
+ : {}),
596
+ },
597
+ }
598
+ : {}),
599
+ // Pass through `app.gadgets` from the manifest so
600
+ // the InMemoryAppMetadataStore seed surfaces declared gadgets
601
+ // (Leaflet, Mapbox, …) on `App.gadgets`. Omitted ⇒
602
+ // `STDLIB_GADGETS` defaults (the first-party hooks).
603
+ ...(plan.manifest?.app?.gadgets &&
604
+ plan.manifest.app.gadgets.length > 0
605
+ ? { gadgets: plan.manifest.app.gadgets }
606
+ : {}),
607
+ // Pass through `app.publicEnv`. The server projects only keys
608
+ // that some registered wrapper's `requires` lists onto
609
+ // `App.publicEnv` for the push-gate validator + the iframe
610
+ // runtime's `getPublicEnv()`.
611
+ ...(plan.manifest?.app?.publicEnv &&
612
+ Object.keys(plan.manifest.app.publicEnv).length > 0
613
+ ? { publicEnv: plan.manifest.app.publicEnv }
614
+ : {}),
615
+ ...(plan.manifest?.app?.defaultMcpAppsMode !== undefined
616
+ ? { defaultMcpAppsMode: plan.manifest.app.defaultMcpAppsMode }
617
+ : {}),
618
+ generation: generationBinding.generation,
619
+ ...(mcpMounts.length > 0 ? { mcpMounts } : {}),
620
+ // Marketplace-install bridge. Pass the projectRoot +
621
+ // filtered installed-blueprint subset so `buildMcpServerBackend`
622
+ // can construct an `InstalledBlueprintsProvider` rooted on the
623
+ // same embedding + vectorStore the matcher reads. Only
624
+ // contract-bearing entries flow through — the bridge needs a
625
+ // contract to hash for the matcher's canonical key.
626
+ ...(plan.projectRoot && discoveredUis.length > 0
627
+ ? {
628
+ installedBlueprints: {
629
+ projectRoot: plan.projectRoot,
630
+ entries: discoveredUis
631
+ .filter((ui) => isInstalledBlueprintPath(ui.manifestPath))
632
+ .filter((ui) => ui.manifest.contract !== undefined)
633
+ .map((ui) => ({
634
+ id: ui.id,
635
+ manifestPath: ui.manifestPath,
636
+ manifest: ui.manifest,
637
+ })),
638
+ },
639
+ }
640
+ : {}),
641
+ }),
642
+ agent: plan.supervision,
643
+ agentStatus: plan.status,
644
+ ...(generationBinding.bootResolved ? {} : { noLlmKey: true }),
645
+ stdout: process.stdout,
646
+ shutdownSignal: controller.signal,
647
+ });
648
+ }
649
+ catch (err) {
650
+ process.stderr.write(`ggui serve: ${err instanceof Error ? err.message : String(err)}\n`);
651
+ return 1;
652
+ }
653
+ }
654
+ /**
655
+ * Wrap `resolveStorageFromConfig` so dynamic-import failures (e.g.
656
+ * `better-sqlite3` not installed when the manifest declared sqlite)
657
+ * surface with a remediation hint instead of a bare `Cannot find
658
+ * module` trace.
659
+ */
660
+ async function resolveStorageFromConfigSafely(config, projectRoot) {
661
+ try {
662
+ return await resolveStorageFromConfig(config, {
663
+ ...(projectRoot ? { baseDir: projectRoot } : {}),
664
+ });
665
+ }
666
+ catch (err) {
667
+ if (err instanceof Error &&
668
+ /MODULE_NOT_FOUND|Cannot find module/i.test(err.message) &&
669
+ err.message.includes('better-sqlite3')) {
670
+ throw new Error('sqlite driver requires `better-sqlite3` — install it as a ' +
671
+ 'dependency of your project (`pnpm add better-sqlite3`) and re-run.');
672
+ }
673
+ throw err;
674
+ }
675
+ }
676
+ /**
677
+ * Pre-banner status lines describing the resolved storage. Pure so
678
+ * tests can pin the copy; writes to stdout via the caller.
679
+ *
680
+ * - No manifest storage block → silent (nothing to say; defaults apply)
681
+ * - Explicit memory-only → one line confirming in-memory
682
+ * - Sqlite declared → one line per sqlite surface
683
+ */
684
+ function describeStorageStatus(config, resolved) {
685
+ if (!config)
686
+ return [];
687
+ const lines = [];
688
+ if (config.sessions) {
689
+ lines.push(resolved.sessionStore
690
+ ? `storage: sessions → sqlite (${config.sessions.driver === 'sqlite' ? config.sessions.path : '—'})`
691
+ : `storage: sessions → in-memory`);
692
+ }
693
+ if (config.vectors) {
694
+ lines.push(resolved.vectors
695
+ ? `storage: vectors → sqlite (${config.vectors.driver === 'sqlite' ? config.vectors.path : '—'})`
696
+ : `storage: vectors → in-memory`);
697
+ }
698
+ if (config.threads) {
699
+ // Threads always resolve to a real store when declared — memory
700
+ // mounts `InMemoryThreadStore`, sqlite mounts `SqliteThreadStore`.
701
+ // "in-memory (ephemeral)" is the honest label; data vanishes on
702
+ // restart.
703
+ if (config.threads.driver === 'sqlite') {
704
+ lines.push(`storage: threads → sqlite (${config.threads.path}) — durable`);
705
+ }
706
+ else {
707
+ lines.push(`storage: threads → in-memory — ephemeral`);
708
+ }
709
+ }
710
+ return lines;
711
+ }
712
+ /**
713
+ * Pre-banner status lines describing the indexed blueprint manifests.
714
+ * Pure so tests can pin the copy; writes to stdout via the caller.
715
+ *
716
+ * - Empty declaration → silent
717
+ * - Non-empty → `blueprints: N declared` + one line per UI
718
+ */
719
+ function describeBlueprintStatus(uis) {
720
+ if (uis.length === 0)
721
+ return [];
722
+ const lines = [`blueprints: ${uis.length} declared`];
723
+ for (const ui of uis) {
724
+ lines.push(` - ${ui.id} (${ui.manifest.name})`);
725
+ }
726
+ return lines;
727
+ }
728
+ /**
729
+ * Stderr lines describing blueprint-manifest discovery issues. Emitted
730
+ * BEFORE the refusal-to-start exit, so the operator sees the specific
731
+ * files + messages alongside the summary. Pure; caller writes to
732
+ * stderr.
733
+ */
734
+ function describeBlueprintIssues(issues) {
735
+ return issues.map((issue) => `ggui serve: blueprints: ${issue.path} — ${issue.message}`);
736
+ }
737
+ /**
738
+ * Pre-banner status lines describing the indexed primitive catalogs.
739
+ * Pure; caller writes to stdout.
740
+ *
741
+ * - No catalogs → silent (zero-config / nothing declared)
742
+ * - Non-empty → `primitives: N catalog(s)` + one line each with
743
+ * import-specifier + `(count, source)`.
744
+ *
745
+ * Format intentionally matches the blueprint status block so operators
746
+ * see one coherent pre-banner.
747
+ */
748
+ function describePrimitiveStatus(catalogs) {
749
+ if (catalogs.length === 0)
750
+ return [];
751
+ const lines = [
752
+ `primitives: ${catalogs.length} catalog(s) declared`,
753
+ ];
754
+ for (const c of catalogs) {
755
+ lines.push(` - ${c.import} (${c.manifest.primitives.length} primitives, ${c.source})`);
756
+ }
757
+ return lines;
758
+ }
759
+ /**
760
+ * Stderr lines describing primitive-manifest discovery issues. Same
761
+ * escalation discipline as {@link describeBlueprintIssues} — emitted
762
+ * BEFORE the refusal-to-start exit so each declared source + failure
763
+ * reason is visible alongside the summary.
764
+ */
765
+ function describePrimitiveIssues(issues) {
766
+ return issues.map((issue) => `ggui serve: primitives: ${issue.path} — ${issue.message}`);
767
+ }
768
+ /**
769
+ * Pre-banner status line for the loaded theme. Pure; caller writes to
770
+ * stdout. Matches the shape of the storage / blueprint / primitive
771
+ * pre-banner lines so operators see one coherent header.
772
+ *
773
+ * - `source: 'default'` → `theme: (default — @ggui-ai/design)`
774
+ * - `source: 'file'` → `theme: <relative-ish path>`
775
+ *
776
+ * The default branch is emitted even when no `theme` is declared so
777
+ * the operator always sees which theme backs the running server —
778
+ * "silently using lightTheme" is the worst kind of surprise for
779
+ * branded deployments.
780
+ */
781
+ function describeThemeStatus(theme) {
782
+ if (theme.source === 'default') {
783
+ return [`theme: (default — @ggui-ai/design lightTheme)`];
784
+ }
785
+ if (theme.source === 'preset') {
786
+ const overrides = theme.overrides && Object.keys(theme.overrides).length > 0
787
+ ? ` (+${Object.keys(theme.overrides).length} override${Object.keys(theme.overrides).length === 1 ? '' : 's'})`
788
+ : '';
789
+ return [`theme: ${theme.preset} · ${theme.mode}${overrides}`];
790
+ }
791
+ return [`theme: ${theme.path} · ${theme.mode}`];
792
+ }
793
+ /**
794
+ * Stderr line for a theme-load issue. Single-line because `loadTheme`
795
+ * returns at most one issue per call (unlike blueprint / primitive
796
+ * discovery which walk many files).
797
+ */
798
+ function describeThemeIssue(issue) {
799
+ return [`ggui serve: theme: ${issue.path} — ${issue.message}`];
800
+ }
801
+ /**
802
+ * Pre-banner status lines for resolved mcpMount entries. Pure; caller
803
+ * writes to stdout. Matches the shape of the blueprint / primitive /
804
+ * theme pre-banner lines so operators see one coherent header.
805
+ *
806
+ * - Empty list → silent (zero-config default)
807
+ * - Non-empty → `mcpMounts: N declared` + one line each with
808
+ * mount name + declared module spec.
809
+ */
810
+ function describeMcpMountStatus(mounts) {
811
+ if (mounts.length === 0)
812
+ return [];
813
+ const lines = [`mcpMounts: ${mounts.length} declared`];
814
+ for (const m of mounts) {
815
+ lines.push(` - ${m.mount.name} (${m.mount.handlers.length} tools, ${m.spec})`);
816
+ }
817
+ return lines;
818
+ }
819
+ /**
820
+ * Stderr lines describing `mcpMounts` discovery issues. Emitted BEFORE
821
+ * the refusal-to-start exit so operators see the specific failing
822
+ * spec + underlying cause. Same escalation discipline as blueprint /
823
+ * primitive discovery.
824
+ */
825
+ function describeMcpMountIssues(issues) {
826
+ return issues.map((issue) => `ggui serve: mcpMounts: ${issue.path} — ${issue.message}`);
827
+ }
828
+ /**
829
+ * Narrow `DiscoveredMcpMount[]` (whose `handlers` is `unknown[]` by
830
+ * construction — see module JSDoc on `mcp-mount-discovery.ts`) to the
831
+ * `McpServerMount[]` the server backend expects. The factory contract
832
+ * already requires handlers to be `SharedHandler`-shaped; the
833
+ * structural validation in project-config is intentionally type-thin
834
+ * so it doesn't reverse-depend on `@ggui-ai/mcp-server`. Cast here is
835
+ * the single documented adapter point where that gap closes — not a
836
+ * workaround, a dep-graph seam.
837
+ *
838
+ * Per-handler structural validation (name uniqueness, zod shape
839
+ * presence) happens inside `composeHandlersWithMounts` at
840
+ * `createGguiServer` construction time, which will throw with a
841
+ * mount-labeled error if any mount's handlers are malformed.
842
+ */
843
+ function narrowMcpMounts(discovered) {
844
+ return discovered.map((d) => ({
845
+ name: d.mount.name,
846
+ handlers: d.mount.handlers,
847
+ }));
848
+ }
849
+ async function runDevCommand(args) {
850
+ const parsed = parseDevFlags(args);
851
+ if (parsed.error) {
852
+ process.stderr.write(`ggui dev: ${parsed.error}\n`);
853
+ return 1;
854
+ }
855
+ // `ggui dev` flips the dev-mode bit so the mcp-server mounts the
856
+ // `/devtools/*` namespace and the SPA shows the link. Set it on
857
+ // process.env BEFORE the dev-stack boots — `createGguiServer` reads
858
+ // `GGUI_MODE` at construction time. Operators can still override
859
+ // explicitly (`GGUI_MODE=prod ggui dev`) since `??=` skips
860
+ // re-assignment.
861
+ process.env.GGUI_MODE ??= 'dev';
862
+ // Resolve agent adapter up front so bad `--agent` paths fail
863
+ // before we bother binding a socket. Actual subprocess spawn
864
+ // happens inside `runDev` — this pass only validates the
865
+ // command mapping.
866
+ let runtime;
867
+ if (parsed.agent) {
868
+ const resolution = resolveAgentCommand(parsed.agent, process.cwd());
869
+ if (!resolution.ok) {
870
+ process.stderr.write(`ggui dev: ${resolution.error}\n`);
871
+ return 1;
872
+ }
873
+ runtime = buildAgentRuntime(resolution);
874
+ }
875
+ // Managed-mode prerequisite — when `--tunnel` AND an `--agent` was
876
+ // supplied, allocate a free port up-front and hand it to the
877
+ // dev-stack so it can forward it (via `portHint` + `PORT` env)
878
+ // into the supervised runtime. The tunnel provider will use this
879
+ // same port to forward inbound bridge traffic to the agent.
880
+ // Without `--agent`, there's nothing to bind, so we skip.
881
+ //
882
+ // Without `--tunnel`, we skip allocation entirely to preserve the
883
+ // current "agent picks its own port" behaviour for OSS users
884
+ // running plain `ggui dev --agent …`.
885
+ let runtimePort;
886
+ if (parsed.tunnel && runtime) {
887
+ runtimePort = await pickFreePort();
888
+ }
889
+ try {
890
+ const bootstrap = await runDev({
891
+ serve: !parsed.noServe,
892
+ port: parsed.port,
893
+ host: parsed.host,
894
+ runtime,
895
+ runtimePort,
896
+ });
897
+ if (!bootstrap.server) {
898
+ // --no-serve: one-shot inspection path. Exit cleanly.
899
+ return 0;
900
+ }
901
+ // Print the hub URL explicitly — dev-stack's banner lists the
902
+ // endpoints but not the composed URL. The CLI's banner is the
903
+ // headline the developer sees: "here's where you go." Always
904
+ // emitted, whether or not we end up auto-opening.
905
+ const hubUrl = resolveHubUrl(bootstrap.server.host, bootstrap.server.port);
906
+ const decision = shouldAutoOpen({
907
+ serving: true,
908
+ noOpen: parsed.noOpen,
909
+ isTty: process.stdout.isTTY === true,
910
+ env: process.env,
911
+ });
912
+ const banner = ['', ` hub → ${hubUrl}`];
913
+ if (decision.open) {
914
+ const result = launchBrowser(hubUrl);
915
+ banner.push(result.ok
916
+ ? ` opening browser…`
917
+ : ` (couldn't launch browser: ${result.error})`);
918
+ }
919
+ else {
920
+ banner.push(` auto-open skipped: ${decision.reason}`);
921
+ }
922
+ // Managed-mode scaffold. With `--tunnel`, the CLI asks a
923
+ // `TunnelProvider` to open a managed tunnel for the single local
924
+ // host. Today the only provider is the "null" reference, which
925
+ // always returns `{ status: 'unavailable' }`; this exercises the
926
+ // seam and prints honest copy without shipping a public-tunnel
927
+ // story before remote authentication is resolved.
928
+ const tunnelShutdown = { close: null };
929
+ if (parsed.tunnel) {
930
+ // Resolve a tunnel provider via the discovery seam. The flag
931
+ // itself stays as a hook for future ggui.ai tunnel providers
932
+ // (Cloudflare tunnel, Tailscale funnel, ...) -- selectTunnelProvider
933
+ // returns a null provider with a clear "not configured" hint
934
+ // when no `GGUI_TUNNEL_PROVIDER` is set, so local dev keeps
935
+ // running unchanged regardless of result.
936
+ const provider = await selectTunnelProvider();
937
+ const tunnelController = new AbortController();
938
+ // `localUrl` is the host root (without the `/hub` path) so
939
+ // providers can wrap the whole origin rather than a single
940
+ // route. `authToken` stays `null` in this scaffold — the null
941
+ // provider ignores it, and how a token gets surfaced is left
942
+ // for whenever a real provider lands.
943
+ const session = await openTunnel(provider, {
944
+ localUrl: hubUrl.replace(/\/hub$/, ''),
945
+ authToken: null,
946
+ project: {
947
+ slug: bootstrap.manifest.app.slug,
948
+ name: bootstrap.manifest.app.name,
949
+ },
950
+ runtimePort: bootstrap.runtimePort,
951
+ signal: tunnelController.signal,
952
+ });
953
+ banner.push(...describeTunnelSession(session));
954
+ if (session.status === 'ready') {
955
+ tunnelShutdown.close = async () => {
956
+ tunnelController.abort();
957
+ await session.close();
958
+ };
959
+ }
960
+ }
961
+ banner.push('');
962
+ process.stdout.write(`${banner.join('\n')}\n`);
963
+ // Keep the process alive while the server runs. SIGINT / SIGTERM
964
+ // shut the server down and resolve the promise so the bin exits
965
+ // with code 0. The tunnel session (if any) is closed before the
966
+ // server so the managed layer drops BEFORE the host it wraps.
967
+ await waitForShutdown(bootstrap.server, tunnelShutdown);
968
+ return 0;
969
+ }
970
+ catch (error) {
971
+ if (error instanceof GguiDevError) {
972
+ process.stderr.write(`ggui dev: ${error.message}\n`);
973
+ return 1;
974
+ }
975
+ if (error instanceof GguiJsonLoadError) {
976
+ process.stderr.write(`ggui dev: ${error.message}\n`);
977
+ if (error.cause && error.cause instanceof Error) {
978
+ process.stderr.write(` cause: ${error.cause.message}\n`);
979
+ }
980
+ return 1;
981
+ }
982
+ throw error;
983
+ }
984
+ }
985
+ async function waitForShutdown(server, tunnel) {
986
+ await new Promise((resolve) => {
987
+ const shutdown = () => {
988
+ // Close the managed layer FIRST so it drops cleanly before
989
+ // the host it wraps. If no tunnel is active `tunnel.close`
990
+ // is null and this step is a no-op. Errors inside the tunnel
991
+ // teardown never block the server close — the local loop
992
+ // ending cleanly is the priority.
993
+ const tunnelCloseP = tunnel.close
994
+ ? tunnel.close().catch(() => {
995
+ /* best-effort */
996
+ })
997
+ : Promise.resolve();
998
+ tunnelCloseP.then(() => server.close().finally(resolve));
999
+ };
1000
+ process.once('SIGINT', shutdown);
1001
+ process.once('SIGTERM', shutdown);
1002
+ });
1003
+ }
1004
+ /**
1005
+ * Choose a `TunnelProvider` for managed-mode.
1006
+ *
1007
+ * Decision tree:
1008
+ *
1009
+ * 1. Attempt provider discovery via `GGUI_TUNNEL_PROVIDER` (explicit
1010
+ * override). Users / hosts / CI pipelines can swap in alternate
1011
+ * providers this way without recompiling the CLI.
1012
+ *
1013
+ * 2. If no explicit override is set, return a null provider with
1014
+ * a "not configured" hint. The CLI does not bundle a canonical
1015
+ * default; a replacement will land with the managed-tunnel
1016
+ * transport (Cloudflare tunnel, Tailscale funnel, ...).
1017
+ *
1018
+ * The open CLI never imports from a closed tunnel package directly;
1019
+ * the discovery layer is the only path through which a real provider
1020
+ * reaches `ggui dev --tunnel`.
1021
+ */
1022
+ async function selectTunnelProvider() {
1023
+ // Step 1 -- explicit override via GGUI_TUNNEL_PROVIDER.
1024
+ const envDiscovery = await discoverTunnelProvider();
1025
+ switch (envDiscovery.kind) {
1026
+ case 'found':
1027
+ return envDiscovery.provider;
1028
+ case 'error':
1029
+ return createNullTunnelProvider({
1030
+ reason: `tunnel provider discovery failed`,
1031
+ hint: envDiscovery.reason,
1032
+ });
1033
+ case 'none':
1034
+ // Fall through to "not configured".
1035
+ break;
1036
+ }
1037
+ // Step 2 -- no canonical default currently. Surface the
1038
+ // "not configured" state with an actionable hint.
1039
+ return createNullTunnelProvider({
1040
+ reason: 'no tunnel provider configured',
1041
+ hint: '--tunnel: no provider configured. ' +
1042
+ 'Set GGUI_TUNNEL_PROVIDER to a module that exports ' +
1043
+ 'createTunnelProvider(), or omit --tunnel to keep local dev.',
1044
+ });
1045
+ }
1046
+ function readVersion() {
1047
+ // `package.json` sits one level above the compiled `dist/cli.js`.
1048
+ // Read it via `import.meta.url` so the code works identically in
1049
+ // dev (tsc `dist/`) and after an `npm publish` unpack.
1050
+ try {
1051
+ const here = dirname(fileURLToPath(import.meta.url));
1052
+ const raw = readFileSync(join(here, '..', 'package.json'), 'utf-8');
1053
+ const pkg = JSON.parse(raw);
1054
+ return pkg.version ?? '0.0.0';
1055
+ }
1056
+ catch {
1057
+ return '0.0.0';
1058
+ }
1059
+ }
1060
+ main(process.argv).then((code) => {
1061
+ process.exit(code);
1062
+ }, (error) => {
1063
+ const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
1064
+ process.stderr.write(`ggui: ${message}\n`);
1065
+ process.exit(2);
1066
+ });