@ggui-ai/cli 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +96 -0
- package/dist/agent-resolution.d.ts +41 -0
- package/dist/agent-resolution.d.ts.map +1 -0
- package/dist/agent-resolution.js +80 -0
- package/dist/api-client.d.ts +59 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +145 -0
- package/dist/auth-keys.d.ts +10 -0
- package/dist/auth-keys.d.ts.map +1 -0
- package/dist/auth-keys.js +382 -0
- package/dist/auth-login.d.ts +11 -0
- package/dist/auth-login.d.ts.map +1 -0
- package/dist/auth-login.js +231 -0
- package/dist/auth-logout.d.ts +3 -0
- package/dist/auth-logout.d.ts.map +1 -0
- package/dist/auth-logout.js +32 -0
- package/dist/auth-store.d.ts +67 -0
- package/dist/auth-store.d.ts.map +1 -0
- package/dist/auth-store.js +150 -0
- package/dist/auth-whoami.d.ts +3 -0
- package/dist/auth-whoami.d.ts.map +1 -0
- package/dist/auth-whoami.js +52 -0
- package/dist/blueprint-command.d.ts +3 -0
- package/dist/blueprint-command.d.ts.map +1 -0
- package/dist/blueprint-command.js +203 -0
- package/dist/blueprint-create.d.ts +71 -0
- package/dist/blueprint-create.d.ts.map +1 -0
- package/dist/blueprint-create.js +377 -0
- package/dist/byok-resolver.d.ts +141 -0
- package/dist/byok-resolver.d.ts.map +1 -0
- package/dist/byok-resolver.js +89 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1066 -0
- package/dist/dev-command.d.ts +169 -0
- package/dist/dev-command.d.ts.map +1 -0
- package/dist/dev-command.js +222 -0
- package/dist/gadget-command.d.ts +3 -0
- package/dist/gadget-command.d.ts.map +1 -0
- package/dist/gadget-command.js +181 -0
- package/dist/gadget-create.d.ts +106 -0
- package/dist/gadget-create.d.ts.map +1 -0
- package/dist/gadget-create.js +492 -0
- package/dist/generation-probe.d.ts +135 -0
- package/dist/generation-probe.d.ts.map +1 -0
- package/dist/generation-probe.js +132 -0
- package/dist/internal/artifact-install.d.ts +150 -0
- package/dist/internal/artifact-install.d.ts.map +1 -0
- package/dist/internal/artifact-install.js +1019 -0
- package/dist/internal/artifact-publish.d.ts +204 -0
- package/dist/internal/artifact-publish.d.ts.map +1 -0
- package/dist/internal/artifact-publish.js +1141 -0
- package/dist/internal/artifact-search.d.ts +155 -0
- package/dist/internal/artifact-search.d.ts.map +1 -0
- package/dist/internal/artifact-search.js +539 -0
- package/dist/internal/artifact-uninstall.d.ts +30 -0
- package/dist/internal/artifact-uninstall.d.ts.map +1 -0
- package/dist/internal/artifact-uninstall.js +205 -0
- package/dist/internal/auth-cache.d.ts +40 -0
- package/dist/internal/auth-cache.d.ts.map +1 -0
- package/dist/internal/auth-cache.js +133 -0
- package/dist/internal/auth-strategy.d.ts +89 -0
- package/dist/internal/auth-strategy.d.ts.map +1 -0
- package/dist/internal/auth-strategy.js +132 -0
- package/dist/internal/ggui-json.d.ts +53 -0
- package/dist/internal/ggui-json.d.ts.map +1 -0
- package/dist/internal/ggui-json.js +92 -0
- package/dist/internal/key-store.d.ts +43 -0
- package/dist/internal/key-store.d.ts.map +1 -0
- package/dist/internal/key-store.js +128 -0
- package/dist/internal/oidc-token.d.ts +52 -0
- package/dist/internal/oidc-token.d.ts.map +1 -0
- package/dist/internal/oidc-token.js +351 -0
- package/dist/internal/register-author-key.d.ts +44 -0
- package/dist/internal/register-author-key.d.ts.map +1 -0
- package/dist/internal/register-author-key.js +212 -0
- package/dist/local-keys.d.ts +47 -0
- package/dist/local-keys.d.ts.map +1 -0
- package/dist/local-keys.js +115 -0
- package/dist/mcp-backend.d.ts +356 -0
- package/dist/mcp-backend.d.ts.map +1 -0
- package/dist/mcp-backend.js +668 -0
- package/dist/paths.d.ts +77 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +119 -0
- package/dist/persistent-secrets.d.ts +28 -0
- package/dist/persistent-secrets.d.ts.map +1 -0
- package/dist/persistent-secrets.js +94 -0
- package/dist/persistent-short-code-index.d.ts +14 -0
- package/dist/persistent-short-code-index.d.ts.map +1 -0
- package/dist/persistent-short-code-index.js +39 -0
- package/dist/persistent-stores.d.ts +12 -0
- package/dist/persistent-stores.d.ts.map +1 -0
- package/dist/persistent-stores.js +42 -0
- package/dist/serve-command.d.ts +500 -0
- package/dist/serve-command.d.ts.map +1 -0
- package/dist/serve-command.js +616 -0
- package/dist/serve-fallback.d.ts +64 -0
- package/dist/serve-fallback.d.ts.map +1 -0
- package/dist/serve-fallback.js +122 -0
- package/dist/spawn-cloudflared.d.ts +61 -0
- package/dist/spawn-cloudflared.d.ts.map +1 -0
- package/dist/spawn-cloudflared.js +167 -0
- package/dist/theme-command.d.ts +4 -0
- package/dist/theme-command.d.ts.map +1 -0
- package/dist/theme-command.js +154 -0
- package/dist/theme-file-uploader.d.ts +8 -0
- package/dist/theme-file-uploader.d.ts.map +1 -0
- package/dist/theme-file-uploader.js +58 -0
- package/dist/theme-writer.d.ts +8 -0
- package/dist/theme-writer.d.ts.map +1 -0
- package/dist/theme-writer.js +103 -0
- package/dist/tunnel-provider.d.ts +273 -0
- package/dist/tunnel-provider.d.ts.map +1 -0
- package/dist/tunnel-provider.js +142 -0
- package/package.json +81 -0
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
|
+
});
|