@ggui-ai/dev-stack 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 (37) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +63 -0
  3. package/dist/dev-server/auth.d.ts +44 -0
  4. package/dist/dev-server/auth.d.ts.map +1 -0
  5. package/dist/dev-server/auth.js +176 -0
  6. package/dist/dev-server/events.d.ts +43 -0
  7. package/dist/dev-server/events.d.ts.map +1 -0
  8. package/dist/dev-server/events.js +61 -0
  9. package/dist/dev-server/http.d.ts +79 -0
  10. package/dist/dev-server/http.d.ts.map +1 -0
  11. package/dist/dev-server/http.js +277 -0
  12. package/dist/dev-server/hub-preview.d.ts +56 -0
  13. package/dist/dev-server/hub-preview.d.ts.map +1 -0
  14. package/dist/dev-server/hub-preview.js +237 -0
  15. package/dist/dev-server/hub.d.ts +59 -0
  16. package/dist/dev-server/hub.d.ts.map +1 -0
  17. package/dist/dev-server/hub.js +723 -0
  18. package/dist/hub-preview/client.js +52346 -0
  19. package/dist/index.d.ts +34 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +49 -0
  22. package/dist/local-registry/compile-ui.d.ts +67 -0
  23. package/dist/local-registry/compile-ui.d.ts.map +1 -0
  24. package/dist/local-registry/compile-ui.js +148 -0
  25. package/dist/local-registry/local-registry.d.ts +93 -0
  26. package/dist/local-registry/local-registry.d.ts.map +1 -0
  27. package/dist/local-registry/local-registry.js +272 -0
  28. package/dist/local-registry/watcher.d.ts +42 -0
  29. package/dist/local-registry/watcher.d.ts.map +1 -0
  30. package/dist/local-registry/watcher.js +208 -0
  31. package/dist/run-dev.d.ts +147 -0
  32. package/dist/run-dev.d.ts.map +1 -0
  33. package/dist/run-dev.js +216 -0
  34. package/dist/runtime-supervisor.d.ts +140 -0
  35. package/dist/runtime-supervisor.d.ts.map +1 -0
  36. package/dist/runtime-supervisor.js +91 -0
  37. package/package.json +72 -0
@@ -0,0 +1,42 @@
1
+ import type { UiRegistryEvent } from '@ggui-ai/ui-registry';
2
+ /**
3
+ * Listener for raw events the watcher emits. Delivery is
4
+ * at-least-once — consumers dedupe where needed (e.g. the SSE
5
+ * handler just forwards; Studio's subscription logic bumps a
6
+ * counter and doesn't care about dupes).
7
+ */
8
+ export type WatchListener = (event: UiRegistryEvent) => void;
9
+ export interface LocalWatcherOptions {
10
+ /** Absolute project root — chokidar's root path. */
11
+ projectRoot: string;
12
+ /**
13
+ * Mapping from absolute manifest directory → current UI id.
14
+ * The watcher looks changed files up by walking up directories
15
+ * until it hits one of these paths.
16
+ */
17
+ getManifestDirIndex: () => Map<string, string>;
18
+ /**
19
+ * Called when a `ggui.ui.json` is added or removed so the
20
+ * registry can re-run discovery. Any id/dir index
21
+ * changes it produces propagate on the next fs event.
22
+ */
23
+ onManifestsChanged: () => Promise<void>;
24
+ }
25
+ export interface LocalWatcher {
26
+ /** Register a listener. Returns an unsubscribe function. */
27
+ subscribe(listener: WatchListener): () => void;
28
+ /** Start watching if not already. Safe to call repeatedly. */
29
+ start(): Promise<void>;
30
+ /** Stop the watcher and drop listeners. */
31
+ close(): Promise<void>;
32
+ }
33
+ export declare function createLocalWatcher(options: LocalWatcherOptions): LocalWatcher;
34
+ /**
35
+ * Utility used by tests and the registry — `fs.stat` with a
36
+ * `null` on missing-file so callers can compare mtimes without
37
+ * branching on ENOENT.
38
+ */
39
+ export declare function statOrNull(path: string): Promise<{
40
+ mtimeMs: number;
41
+ } | null>;
42
+ //# sourceMappingURL=watcher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watcher.d.ts","sourceRoot":"","sources":["../../src/local-registry/watcher.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAE5D;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;AAE7D,MAAM,WAAW,mBAAmB;IAClC,oDAAoD;IACpD,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,mBAAmB,EAAE,MAAM,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/C;;;;OAIG;IACH,kBAAkB,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,YAAY;IAC3B,4DAA4D;IAC5D,SAAS,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,IAAI,CAAC;IAC/C,8DAA8D;IAC9D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,2CAA2C;IAC3C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAUD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,YAAY,CA0K7E;AAED;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAOrC"}
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Filesystem watcher for the local `ggui dev` registry.
3
+ *
4
+ * Maps raw chokidar events to the contract's `UiRegistryEvent`
5
+ * union (`added` / `changed` / `removed`). The watcher is scoped
6
+ * to what actually affects what Studio can see:
7
+ *
8
+ * - Any `ggui.ui.json` under the project tree → affects the
9
+ * manifest itself. A new one means `added`, a deletion
10
+ * means `removed`, an edit means `changed`.
11
+ * - Any `.tsx` / `.ts` / `.jsx` / `.js` file in the same
12
+ * directory as a known manifest (or a descendant of it)
13
+ * → affects the compiled bundle for that manifest's id.
14
+ * Emitted as `changed { id }`.
15
+ * - Any colocated `ggui.ui.js` / `ggui.ui.mjs` change →
16
+ * also `changed { id }` (the user's own build step ran).
17
+ *
18
+ * Scope deliberately narrow: we don't try to resolve cross-dir
19
+ * imports. A shared utility in `packages/shared/` won't trigger
20
+ * a refresh. That's a known limitation — re-importing the module
21
+ * graph from esbuild metafiles is a bigger slice. For the dev
22
+ * loop, edit the UI file, get the update. Shared-utility edits
23
+ * still work on the next manual reload.
24
+ */
25
+ import { stat } from 'node:fs/promises';
26
+ import { dirname, relative, resolve, sep } from 'node:path';
27
+ import { watch as chokidarWatch } from 'chokidar';
28
+ /** Paths that should never trigger discovery / recompile work. */
29
+ const IGNORED_SEGMENTS = ['node_modules', '.git', 'dist', '.turbo', '.next', '.ggui-cache'];
30
+ /** Filename suffixes we care about for compile invalidation. */
31
+ const SOURCE_SUFFIXES = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.css'];
32
+ const MANIFEST_FILENAME = 'ggui.ui.json';
33
+ export function createLocalWatcher(options) {
34
+ const listeners = new Set();
35
+ let watcher = null;
36
+ let started = false;
37
+ function emit(event) {
38
+ for (const listener of listeners) {
39
+ try {
40
+ listener(event);
41
+ }
42
+ catch {
43
+ // A bad listener mustn't starve other listeners / the
44
+ // watcher itself. Errors surface in caller's own scope.
45
+ }
46
+ }
47
+ }
48
+ /**
49
+ * Resolve the UI id a changed filesystem path belongs to by
50
+ * walking up directories until a known manifest dir matches.
51
+ * Returns `null` when the path lies outside all watched UIs.
52
+ */
53
+ function findOwningId(absolutePath) {
54
+ const index = options.getManifestDirIndex();
55
+ let dir = dirname(absolutePath);
56
+ const root = options.projectRoot;
57
+ // Prevent walking past project root — files above it can
58
+ // never belong to a project UI.
59
+ const normalizedRoot = resolve(root);
60
+ while (dir.length >= normalizedRoot.length) {
61
+ const id = index.get(dir);
62
+ if (id !== undefined)
63
+ return id;
64
+ const parent = dirname(dir);
65
+ if (parent === dir)
66
+ break;
67
+ dir = parent;
68
+ }
69
+ return null;
70
+ }
71
+ function isInteresting(absolutePath) {
72
+ const rel = relative(options.projectRoot, absolutePath);
73
+ if (rel.startsWith('..'))
74
+ return false;
75
+ if (rel.split(sep).some((seg) => IGNORED_SEGMENTS.includes(seg)))
76
+ return false;
77
+ if (absolutePath.endsWith(sep + MANIFEST_FILENAME) || absolutePath.endsWith('/' + MANIFEST_FILENAME)) {
78
+ return true;
79
+ }
80
+ return SOURCE_SUFFIXES.some((suf) => absolutePath.endsWith(suf));
81
+ }
82
+ async function handleFsEvent(kind, absolutePath) {
83
+ if (!isInteresting(absolutePath))
84
+ return;
85
+ const isManifest = absolutePath.endsWith(sep + MANIFEST_FILENAME) ||
86
+ absolutePath.endsWith('/' + MANIFEST_FILENAME);
87
+ if (isManifest) {
88
+ // Let the registry re-scan, then decide what to emit from
89
+ // the updated index.
90
+ const before = new Set(options.getManifestDirIndex().values());
91
+ await options.onManifestsChanged();
92
+ const after = options.getManifestDirIndex();
93
+ const manifestDir = dirname(absolutePath);
94
+ const idForDir = after.get(manifestDir);
95
+ if (kind === 'unlink') {
96
+ // The manifest went away — find any id that was present
97
+ // before but isn't now and emit removed for each.
98
+ for (const id of before) {
99
+ if (!Array.from(after.values()).includes(id)) {
100
+ emit({ type: 'removed', id });
101
+ }
102
+ }
103
+ return;
104
+ }
105
+ if (idForDir === undefined) {
106
+ // Manifest add/change that didn't produce a valid id
107
+ // (e.g., parse failure, duplicate-id conflict). Issues
108
+ // are collected by the registry; no event to emit.
109
+ return;
110
+ }
111
+ if (!before.has(idForDir)) {
112
+ emit({ type: 'added', id: idForDir });
113
+ }
114
+ else {
115
+ emit({ type: 'changed', id: idForDir, contentHash: '' });
116
+ }
117
+ return;
118
+ }
119
+ // Non-manifest source file. Attribute it to the nearest
120
+ // manifest directory above.
121
+ const owningId = findOwningId(absolutePath);
122
+ if (owningId === null)
123
+ return;
124
+ emit({ type: 'changed', id: owningId, contentHash: '' });
125
+ }
126
+ async function start() {
127
+ if (started)
128
+ return;
129
+ started = true;
130
+ // chokidar v4 uses a path-match function for ignored. We
131
+ // filter in `handleFsEvent` too, but keeping the ignored
132
+ // list on the watcher avoids waking up for every stat on
133
+ // massive vendored trees.
134
+ watcher = chokidarWatch(options.projectRoot, {
135
+ ignored: (path) => {
136
+ const rel = relative(options.projectRoot, path);
137
+ if (rel === '' || rel === '.')
138
+ return false;
139
+ return rel.split(sep).some((seg) => IGNORED_SEGMENTS.includes(seg));
140
+ },
141
+ ignoreInitial: true,
142
+ persistent: true,
143
+ awaitWriteFinish: {
144
+ stabilityThreshold: 100,
145
+ pollInterval: 50,
146
+ },
147
+ });
148
+ watcher.on('add', (p) => {
149
+ void handleFsEvent('add', p);
150
+ });
151
+ watcher.on('change', (p) => {
152
+ void handleFsEvent('change', p);
153
+ });
154
+ watcher.on('unlink', (p) => {
155
+ void handleFsEvent('unlink', p);
156
+ });
157
+ // Directory-level deletes bubble up through `unlink` for
158
+ // contained files; no special `unlinkDir` handling needed.
159
+ // Give chokidar a microtask to initialise before we return
160
+ // — many platforms emit a `ready` event, but awaiting that
161
+ // is implementation-specific. The watcher works synchronously
162
+ // enough that callers who depend on first-event ordering
163
+ // should use `awaitReady()` below.
164
+ await new Promise((ready) => {
165
+ const w = watcher;
166
+ if (!w)
167
+ return ready();
168
+ w.once('ready', () => ready());
169
+ // Safety timeout — extremely-slow fs init shouldn't block
170
+ // a CLI invocation forever.
171
+ setTimeout(ready, 500).unref?.();
172
+ });
173
+ }
174
+ return {
175
+ subscribe(listener) {
176
+ listeners.add(listener);
177
+ // Lazy-start on first subscribe keeps cost zero when
178
+ // nobody's watching.
179
+ void start();
180
+ return () => {
181
+ listeners.delete(listener);
182
+ };
183
+ },
184
+ start,
185
+ async close() {
186
+ listeners.clear();
187
+ if (watcher) {
188
+ await watcher.close();
189
+ watcher = null;
190
+ }
191
+ started = false;
192
+ },
193
+ };
194
+ }
195
+ /**
196
+ * Utility used by tests and the registry — `fs.stat` with a
197
+ * `null` on missing-file so callers can compare mtimes without
198
+ * branching on ENOENT.
199
+ */
200
+ export async function statOrNull(path) {
201
+ try {
202
+ const s = await stat(path);
203
+ return { mtimeMs: s.mtimeMs };
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
@@ -0,0 +1,147 @@
1
+ import { type DiscoveryResult } from '@ggui-ai/project-config/node';
2
+ import type { GguiJsonV1 } from '@ggui-ai/project-config';
3
+ import type { AgentRuntimeAdapter, AgentRuntimeHandle } from '@ggui-ai/agent-runtime';
4
+ import { LocalUiRegistry } from './local-registry/local-registry.js';
5
+ import { type DevServerHandle } from './dev-server/http.js';
6
+ import { RuntimeSupervisor } from './runtime-supervisor.js';
7
+ /** Default port the read-only dev registry binds to. */
8
+ export declare const DEFAULT_DEV_PORT = 6780;
9
+ /** Default bind address. Loopback-only so LAN peers can't reach the dev server by accident. */
10
+ export declare const DEFAULT_DEV_HOST = "127.0.0.1";
11
+ /**
12
+ * Options the command accepts. All optional — the zero-config call
13
+ * (`runDev({})`) uses `process.cwd()` and stdout.
14
+ */
15
+ export interface DevOptions {
16
+ /**
17
+ * Where to start looking for `ggui.json`. Defaults to
18
+ * `process.cwd()`. Useful for tests and programmatic embeds.
19
+ */
20
+ cwd?: string;
21
+ /**
22
+ * Writer for informational output. Defaults to `console.log`. The
23
+ * command never writes to stderr from success paths — errors
24
+ * throw so the caller decides how to render them.
25
+ */
26
+ log?: (line: string) => void;
27
+ /**
28
+ * Don't start the HTTP dev server; just load + discover and
29
+ * return. Tests use this to keep the runner fast; the CLI leaves
30
+ * it off so `ggui dev` actually serves.
31
+ */
32
+ serve?: boolean;
33
+ /** Bind port. Defaults to {@link DEFAULT_DEV_PORT}. `0` = OS-assigned. */
34
+ port?: number;
35
+ /** Bind host. Defaults to {@link DEFAULT_DEV_HOST} (loopback). */
36
+ host?: string;
37
+ /**
38
+ * Optional port the supervised agent runtime should bind. When
39
+ * provided AND `options.runtime` is supplied, the dev stack:
40
+ *
41
+ * 1. Sets `PORT=<runtimePort>` in the runtime's start env. This
42
+ * matches the convention every `ggui dev` agent template
43
+ * already respects (and Node's default `process.env.PORT`
44
+ * idiom).
45
+ * 2. Surfaces the same value as
46
+ * {@link AgentRuntimeStartInput.portHint} so adapters that
47
+ * don't read `PORT` directly can still honour it.
48
+ * 3. Echoes the chosen port on {@link DevBootstrap.runtimePort}
49
+ * so the CLI orchestration layer can forward it to a
50
+ * {@link TunnelProvider}.
51
+ *
52
+ * `undefined` preserves the current behaviour — the agent picks
53
+ * whatever port it wants, and the dev-stack doesn't care.
54
+ *
55
+ * This is narrow scope on purpose: the dev-stack only *forwards*
56
+ * the port; the CLI owns allocation + tunnel-side use.
57
+ */
58
+ runtimePort?: number;
59
+ /**
60
+ * Optional agent runtime adapter. When present AND `serve !== false`,
61
+ * the dev stack calls `runtime.start(...)` after the HTTP registry
62
+ * is listening and tears it down on `server.close()`. The returned
63
+ * {@link DevBootstrap.runtime} exposes the live handle so callers
64
+ * (CLI banner, the hub, custom supervisors) can subscribe to
65
+ * status / log events.
66
+ *
67
+ * Leaving this out runs the UI loop only — the pre-supervision
68
+ * shape `ggui dev` has shipped. Framework-specific adapters
69
+ * (Claude Agent SDK, OpenAI Agents, etc.) live in their own
70
+ * packages; this seam is deliberately framework-neutral.
71
+ */
72
+ runtime?: AgentRuntimeAdapter;
73
+ }
74
+ /**
75
+ * Result of a successful `ggui dev` bootstrap — the loaded manifest
76
+ * + absolute path to its source. Returned so tests (and future
77
+ * embedders) can drive the command without parsing stdout.
78
+ */
79
+ export interface DevBootstrap {
80
+ manifestPath: string;
81
+ manifest: GguiJsonV1;
82
+ /**
83
+ * UIs discovered under `blueprints.include`. Malformed manifests
84
+ * show up in `discovery.issues`; the command still boots so the
85
+ * valid UIs are reachable.
86
+ */
87
+ discovery: DiscoveryResult;
88
+ /**
89
+ * Local registry wrapping the discovered UIs. Callers can query
90
+ * `list()` / `get(id)` / `getBundle(id)` directly without going
91
+ * through HTTP — useful for tests and embedders.
92
+ */
93
+ registry: LocalUiRegistry;
94
+ /**
95
+ * Running HTTP handle. Present iff `options.serve !== false`. Caller
96
+ * is responsible for shutting it down via `server.close()` —
97
+ * which also stops the supervised runtime when one is wired in.
98
+ */
99
+ server: DevServerHandle | null;
100
+ /**
101
+ * Live agent runtime handle when `options.runtime` was supplied.
102
+ * `null` otherwise. Consumers MAY subscribe for status / log /
103
+ * error events. Ownership stays with `runDev`: calling
104
+ * `server.close()` stops the runtime before the HTTP socket
105
+ * closes so there are no half-state races.
106
+ */
107
+ runtime: AgentRuntimeHandle | null;
108
+ /**
109
+ * Ring-buffered supervisor attached to {@link runtime}. Hosts
110
+ * (CLI banner refresh, the hub snapshot endpoint, HTTP status
111
+ * routes) call `runtimeSupervisor.snapshot()` for a consistent
112
+ * point-in-time view without having to subscribe themselves.
113
+ * `null` when no runtime was supplied OR the adapter's
114
+ * `start()` threw.
115
+ */
116
+ runtimeSupervisor: RuntimeSupervisor | null;
117
+ /**
118
+ * The port the supervised agent runtime was asked to bind via
119
+ * {@link DevOptions.runtimePort}. Echoed here so CLI-layer
120
+ * orchestration (tunnel providers, banner renderers) can forward
121
+ * it to downstream consumers without re-reading DevOptions.
122
+ *
123
+ * `null` when {@link DevOptions.runtimePort} was not set, OR when
124
+ * `options.runtime` was absent (no agent to bind).
125
+ *
126
+ * Note: this is the *requested* port. The seam has no built-in
127
+ * way for an adapter to report back the port it actually bound
128
+ * on — adapters MUST honour `portHint` / `PORT` to keep this
129
+ * value honest. Framework-specific adapters that can't comply
130
+ * document that separately.
131
+ */
132
+ runtimePort: number | null;
133
+ }
134
+ export declare class GguiDevError extends Error {
135
+ constructor(message: string);
136
+ }
137
+ /**
138
+ * Boot the local dev stack up to the "manifest loaded" milestone.
139
+ *
140
+ * Throws {@link GguiDevError} when no `ggui.json` is found in the
141
+ * walk. Re-throws the underlying `GguiJsonLoadError` from
142
+ * `@ggui-ai/project-config/node` when the file exists but fails to
143
+ * load — callers can inspect `.cause` (a `ZodError` for schema
144
+ * failures) for issue details.
145
+ */
146
+ export declare function runDev(options?: DevOptions): Promise<DevBootstrap>;
147
+ //# sourceMappingURL=run-dev.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run-dev.d.ts","sourceRoot":"","sources":["../src/run-dev.ts"],"names":[],"mappings":"AA6BA,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,KAAK,EACV,mBAAmB,EACnB,kBAAkB,EACnB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAC;AACrE,OAAO,EAAkB,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAE5E,OAAO,EACL,iBAAiB,EAElB,MAAM,yBAAyB,CAAC;AAEjC,wDAAwD;AACxD,eAAO,MAAM,gBAAgB,OAAO,CAAC;AACrC,+FAA+F;AAC/F,eAAO,MAAM,gBAAgB,cAAc,CAAC;AAE5C;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb;;;;OAIG;IACH,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAE7B;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAEhB,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd,kEAAkE;IAClE,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,EAAE,mBAAmB,CAAC;CAC/B;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,UAAU,CAAC;IACrB;;;;OAIG;IACH,SAAS,EAAE,eAAe,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,EAAE,eAAe,CAAC;IAC1B;;;;OAIG;IACH,MAAM,EAAE,eAAe,GAAG,IAAI,CAAC;IAC/B;;;;;;OAMG;IACH,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAAC;IACnC;;;;;;;OAOG;IACH,iBAAiB,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAE5C;;;;;;;;;;;;;;OAcG;IACH,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,qBAAa,YAAa,SAAQ,KAAK;gBACzB,OAAO,EAAE,MAAM;CAI5B;AAED;;;;;;;;GAQG;AACH,wBAAsB,MAAM,CAAC,OAAO,GAAE,UAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAqK5E"}
@@ -0,0 +1,216 @@
1
+ /**
2
+ * `runDev` — orchestration entry point for the shared open dev
3
+ * engine. The `@ggui-ai/cli` bin and any other host that wants to
4
+ * bring up the full local dev loop (registry + HTTP + SSE + auth)
5
+ * calls this function and keeps the returned handle alive.
6
+ *
7
+ * Responsibilities:
8
+ *
9
+ * 1. Find the project's `ggui.json` by walking up from `cwd`.
10
+ * 2. Load + validate it via `@ggui-ai/project-config/node`.
11
+ * 3. Discover the UIs under `blueprints.include`.
12
+ * 4. Build a `LocalUiRegistry` and (optionally) start the HTTP
13
+ * server with a bearer-gated policy.
14
+ * 5. Print a concrete summary so the developer can confirm what
15
+ * the engine actually sees.
16
+ *
17
+ * Optional: when the caller passes an
18
+ * {@link AgentRuntimeAdapter} via `options.runtime`, the dev stack
19
+ * calls `runtime.start(...)` after the HTTP server is listening
20
+ * and ties the runtime's lifetime to the server's close. No agent
21
+ * framework is hardcoded — the seam is framework-neutral.
22
+ *
23
+ * Kept deliberately narrow: the host (CLI, future `ggui dev
24
+ * --tunnel`, the local hub) owns arg parsing, log formatting beyond
25
+ * the baseline summary, and the decision of which runtime adapter
26
+ * to supply. `runDev` is the single seam every local-dev host
27
+ * composes against.
28
+ */
29
+ import { dirname, resolve } from 'node:path';
30
+ import { discoverFromGguiJsonPath, findGguiJson, loadGguiJson, } from '@ggui-ai/project-config/node';
31
+ import { LocalUiRegistry } from './local-registry/local-registry.js';
32
+ import { startDevServer } from './dev-server/http.js';
33
+ import { createSecurityPolicy } from './dev-server/auth.js';
34
+ import { RuntimeSupervisor, formatRuntimeEventLine, } from './runtime-supervisor.js';
35
+ /** Default port the read-only dev registry binds to. */
36
+ export const DEFAULT_DEV_PORT = 6780;
37
+ /** Default bind address. Loopback-only so LAN peers can't reach the dev server by accident. */
38
+ export const DEFAULT_DEV_HOST = '127.0.0.1';
39
+ export class GguiDevError extends Error {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = 'GguiDevError';
43
+ }
44
+ }
45
+ /**
46
+ * Boot the local dev stack up to the "manifest loaded" milestone.
47
+ *
48
+ * Throws {@link GguiDevError} when no `ggui.json` is found in the
49
+ * walk. Re-throws the underlying `GguiJsonLoadError` from
50
+ * `@ggui-ai/project-config/node` when the file exists but fails to
51
+ * load — callers can inspect `.cause` (a `ZodError` for schema
52
+ * failures) for issue details.
53
+ */
54
+ export async function runDev(options = {}) {
55
+ const cwd = options.cwd ?? process.cwd();
56
+ const log = options.log ?? ((line) => process.stdout.write(`${line}\n`));
57
+ const manifestPath = findGguiJson(cwd);
58
+ if (!manifestPath) {
59
+ throw new GguiDevError(`No ggui.json found walking up from ${cwd}. ` +
60
+ `Run this command from inside a ggui project, or create a ggui.json at the project root.`);
61
+ }
62
+ const manifest = loadGguiJson(manifestPath);
63
+ log(`ggui dev — ${manifest.app.name} (${manifest.app.slug})`);
64
+ log(` manifest: ${manifestPath}`);
65
+ log(` protocol: ${manifest.protocol}`);
66
+ log(` blueprints.include: ${describeList(manifest.blueprints.include)}`);
67
+ log(` primitives.packages: ${describeList(manifest.primitives.packages)}`);
68
+ log(` primitives.local: ${describeList(manifest.primitives.local)}`);
69
+ log(` theme: ${manifest.theme ?? '(default tokens)'}`);
70
+ const discovery = await discoverFromGguiJsonPath(manifestPath, manifest);
71
+ if (discovery.uis.length === 0 && discovery.issues.length === 0) {
72
+ log(` uis: (none discovered)`);
73
+ }
74
+ else {
75
+ log(` uis: ${discovery.uis.length} discovered`);
76
+ for (const ui of discovery.uis) {
77
+ log(` - ${ui.id} (${ui.manifest.name})`);
78
+ }
79
+ }
80
+ if (discovery.issues.length > 0) {
81
+ log(` issues: ${discovery.issues.length}`);
82
+ for (const issue of discovery.issues) {
83
+ log(` ! ${issue.path}: ${issue.message}`);
84
+ }
85
+ }
86
+ // LocalUiRegistry re-runs discovery inside `refresh()`; reuse the
87
+ // current result by handing it a ready state rather than doing the
88
+ // same walk twice. Simplest: construct + refresh. Duplication cost
89
+ // is one extra walk on startup, which is cheap and keeps the
90
+ // registry's invariants (single source of truth) intact.
91
+ const projectRoot = dirname(resolve(manifestPath));
92
+ const registry = new LocalUiRegistry({ projectRoot, manifest });
93
+ await registry.refresh();
94
+ let server = null;
95
+ let runtime = null;
96
+ let runtimeSupervisor = null;
97
+ if (options.serve !== false) {
98
+ const security = createSecurityPolicy();
99
+ server = await startDevServer({
100
+ registry,
101
+ manifest,
102
+ port: options.port ?? DEFAULT_DEV_PORT,
103
+ host: options.host ?? DEFAULT_DEV_HOST,
104
+ security,
105
+ // Late-binding accessor so `/runtime/*` sees a supervisor
106
+ // that attaches AFTER the HTTP server is listening (current
107
+ // ordering, by design — server up first so the developer
108
+ // can reach `/health` even if the agent fails to boot).
109
+ getRuntimeSupervisor: () => runtimeSupervisor,
110
+ });
111
+ log(` registry: http://${server.host}:${server.port}`);
112
+ log(` GET /health (no auth)`);
113
+ log(` GET /hub (no auth — local dev dashboard)`);
114
+ log(` GET /hub/preview?ui=<id> (no auth — preview iframe)`);
115
+ log(` GET /uis (Authorization: Bearer ...)`);
116
+ log(` GET /uis/:id (Authorization: Bearer ...)`);
117
+ log(` GET /uis/:id/bundle (Authorization: Bearer ...)`);
118
+ log(` GET /events (SSE, Authorization: Bearer ...)`);
119
+ if (security.tokenGenerated) {
120
+ log(` token (generated): ${security.token}`);
121
+ log(` export GGUI_DEV_TOKEN=${security.token}`);
122
+ }
123
+ else {
124
+ log(` token: (from GGUI_DEV_TOKEN — not echoed)`);
125
+ }
126
+ if (options.runtime) {
127
+ // Supervision is additive — if `start()` throws we surface the
128
+ // error but leave the HTTP server up so the developer can still
129
+ // read the logged cause. Callers that want strict start-or-fail
130
+ // wrap `runDev` and close the server on rejection.
131
+ try {
132
+ runtime = await options.runtime.start({
133
+ projectRoot,
134
+ project: {
135
+ slug: manifest.app.slug,
136
+ name: manifest.app.name,
137
+ protocol: manifest.protocol,
138
+ },
139
+ // Forward the caller-requested runtime port two ways:
140
+ // - `portHint` — honoured by `@ggui-ai/agent-runtime`'s
141
+ // process adapter and any adapter that reads the seam's
142
+ // own port field.
143
+ // - `env.PORT` — honoured by the broad Node / framework
144
+ // convention. Matches what `ggui dev` and its closed-CLI
145
+ // predecessor have always passed to the supervised agent.
146
+ //
147
+ // An adapter that respects EITHER path will bind on the
148
+ // right port. An adapter that respects NEITHER is a
149
+ // framework-specific bug, not a dev-stack concern.
150
+ ...(options.runtimePort !== undefined
151
+ ? {
152
+ portHint: options.runtimePort,
153
+ env: { PORT: String(options.runtimePort) },
154
+ }
155
+ : {}),
156
+ });
157
+ log(` runtime: ${options.runtime.name} (${runtime.runId})`);
158
+ log(` GET /runtime/status (Authorization: Bearer ...)`);
159
+ log(` GET /runtime/events (Authorization: Bearer ...)`);
160
+ // Attach the supervisor AFTER we've logged the banner line
161
+ // so event-line forwarding doesn't race the header. The
162
+ // supervisor keeps a bounded ring of events for any host
163
+ // that wants a snapshot (CLI status refresh, the dev hub,
164
+ // the HTTP `/runtime/...` surface).
165
+ runtimeSupervisor = new RuntimeSupervisor({
166
+ adapter: options.runtime,
167
+ handle: runtime,
168
+ onEvent: (event) => log(` ${formatRuntimeEventLine(event)}`),
169
+ });
170
+ // Tie the runtime's lifetime to the server's close. The
171
+ // server's existing `close()` already sequences registry
172
+ // shutdown + socket teardown; we pre-sequence runtime stop
173
+ // in front of both so a runtime holding open sockets / child
174
+ // processes has a chance to exit cleanly before the dev
175
+ // server terminates its own connections.
176
+ const innerClose = server.close.bind(server);
177
+ server.close = async () => {
178
+ if (runtime) {
179
+ try {
180
+ await runtime.stop();
181
+ }
182
+ catch {
183
+ // A runtime that fails to stop must not keep the HTTP
184
+ // server alive; swallow and continue. Callers that
185
+ // care can subscribe to the runtime's error events.
186
+ }
187
+ }
188
+ runtimeSupervisor?.close();
189
+ await innerClose();
190
+ };
191
+ }
192
+ catch (error) {
193
+ const message = error instanceof Error ? error.message : String(error);
194
+ log(` runtime: failed to start — ${message}`);
195
+ }
196
+ }
197
+ }
198
+ return {
199
+ manifestPath,
200
+ manifest,
201
+ discovery,
202
+ registry,
203
+ server,
204
+ runtime,
205
+ runtimeSupervisor,
206
+ // Surface only when both a runtime was supplied AND the caller
207
+ // asked for a specific port. An absent runtime means nothing to
208
+ // bind; an unset `runtimePort` means the caller didn't care.
209
+ runtimePort: options.runtime && options.runtimePort !== undefined ? options.runtimePort : null,
210
+ };
211
+ }
212
+ function describeList(items) {
213
+ if (items.length === 0)
214
+ return '(none)';
215
+ return items.join(', ');
216
+ }