@ggui-ai/cli 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +96 -0
  3. package/dist/agent-resolution.d.ts +41 -0
  4. package/dist/agent-resolution.d.ts.map +1 -0
  5. package/dist/agent-resolution.js +80 -0
  6. package/dist/api-client.d.ts +59 -0
  7. package/dist/api-client.d.ts.map +1 -0
  8. package/dist/api-client.js +145 -0
  9. package/dist/auth-keys.d.ts +10 -0
  10. package/dist/auth-keys.d.ts.map +1 -0
  11. package/dist/auth-keys.js +382 -0
  12. package/dist/auth-login.d.ts +11 -0
  13. package/dist/auth-login.d.ts.map +1 -0
  14. package/dist/auth-login.js +231 -0
  15. package/dist/auth-logout.d.ts +3 -0
  16. package/dist/auth-logout.d.ts.map +1 -0
  17. package/dist/auth-logout.js +32 -0
  18. package/dist/auth-store.d.ts +67 -0
  19. package/dist/auth-store.d.ts.map +1 -0
  20. package/dist/auth-store.js +150 -0
  21. package/dist/auth-whoami.d.ts +3 -0
  22. package/dist/auth-whoami.d.ts.map +1 -0
  23. package/dist/auth-whoami.js +52 -0
  24. package/dist/blueprint-command.d.ts +3 -0
  25. package/dist/blueprint-command.d.ts.map +1 -0
  26. package/dist/blueprint-command.js +203 -0
  27. package/dist/blueprint-create.d.ts +71 -0
  28. package/dist/blueprint-create.d.ts.map +1 -0
  29. package/dist/blueprint-create.js +377 -0
  30. package/dist/byok-resolver.d.ts +141 -0
  31. package/dist/byok-resolver.d.ts.map +1 -0
  32. package/dist/byok-resolver.js +89 -0
  33. package/dist/cli.d.ts +3 -0
  34. package/dist/cli.d.ts.map +1 -0
  35. package/dist/cli.js +1066 -0
  36. package/dist/dev-command.d.ts +169 -0
  37. package/dist/dev-command.d.ts.map +1 -0
  38. package/dist/dev-command.js +222 -0
  39. package/dist/gadget-command.d.ts +3 -0
  40. package/dist/gadget-command.d.ts.map +1 -0
  41. package/dist/gadget-command.js +181 -0
  42. package/dist/gadget-create.d.ts +106 -0
  43. package/dist/gadget-create.d.ts.map +1 -0
  44. package/dist/gadget-create.js +492 -0
  45. package/dist/generation-probe.d.ts +135 -0
  46. package/dist/generation-probe.d.ts.map +1 -0
  47. package/dist/generation-probe.js +132 -0
  48. package/dist/internal/artifact-install.d.ts +150 -0
  49. package/dist/internal/artifact-install.d.ts.map +1 -0
  50. package/dist/internal/artifact-install.js +1019 -0
  51. package/dist/internal/artifact-publish.d.ts +204 -0
  52. package/dist/internal/artifact-publish.d.ts.map +1 -0
  53. package/dist/internal/artifact-publish.js +1141 -0
  54. package/dist/internal/artifact-search.d.ts +155 -0
  55. package/dist/internal/artifact-search.d.ts.map +1 -0
  56. package/dist/internal/artifact-search.js +539 -0
  57. package/dist/internal/artifact-uninstall.d.ts +30 -0
  58. package/dist/internal/artifact-uninstall.d.ts.map +1 -0
  59. package/dist/internal/artifact-uninstall.js +205 -0
  60. package/dist/internal/auth-cache.d.ts +40 -0
  61. package/dist/internal/auth-cache.d.ts.map +1 -0
  62. package/dist/internal/auth-cache.js +133 -0
  63. package/dist/internal/auth-strategy.d.ts +89 -0
  64. package/dist/internal/auth-strategy.d.ts.map +1 -0
  65. package/dist/internal/auth-strategy.js +132 -0
  66. package/dist/internal/ggui-json.d.ts +53 -0
  67. package/dist/internal/ggui-json.d.ts.map +1 -0
  68. package/dist/internal/ggui-json.js +92 -0
  69. package/dist/internal/key-store.d.ts +43 -0
  70. package/dist/internal/key-store.d.ts.map +1 -0
  71. package/dist/internal/key-store.js +128 -0
  72. package/dist/internal/oidc-token.d.ts +52 -0
  73. package/dist/internal/oidc-token.d.ts.map +1 -0
  74. package/dist/internal/oidc-token.js +351 -0
  75. package/dist/internal/register-author-key.d.ts +44 -0
  76. package/dist/internal/register-author-key.d.ts.map +1 -0
  77. package/dist/internal/register-author-key.js +212 -0
  78. package/dist/local-keys.d.ts +47 -0
  79. package/dist/local-keys.d.ts.map +1 -0
  80. package/dist/local-keys.js +115 -0
  81. package/dist/mcp-backend.d.ts +356 -0
  82. package/dist/mcp-backend.d.ts.map +1 -0
  83. package/dist/mcp-backend.js +668 -0
  84. package/dist/paths.d.ts +77 -0
  85. package/dist/paths.d.ts.map +1 -0
  86. package/dist/paths.js +119 -0
  87. package/dist/persistent-secrets.d.ts +28 -0
  88. package/dist/persistent-secrets.d.ts.map +1 -0
  89. package/dist/persistent-secrets.js +94 -0
  90. package/dist/persistent-short-code-index.d.ts +14 -0
  91. package/dist/persistent-short-code-index.d.ts.map +1 -0
  92. package/dist/persistent-short-code-index.js +39 -0
  93. package/dist/persistent-stores.d.ts +12 -0
  94. package/dist/persistent-stores.d.ts.map +1 -0
  95. package/dist/persistent-stores.js +42 -0
  96. package/dist/serve-command.d.ts +500 -0
  97. package/dist/serve-command.d.ts.map +1 -0
  98. package/dist/serve-command.js +616 -0
  99. package/dist/serve-fallback.d.ts +64 -0
  100. package/dist/serve-fallback.d.ts.map +1 -0
  101. package/dist/serve-fallback.js +122 -0
  102. package/dist/spawn-cloudflared.d.ts +61 -0
  103. package/dist/spawn-cloudflared.d.ts.map +1 -0
  104. package/dist/spawn-cloudflared.js +167 -0
  105. package/dist/theme-command.d.ts +4 -0
  106. package/dist/theme-command.d.ts.map +1 -0
  107. package/dist/theme-command.js +154 -0
  108. package/dist/theme-file-uploader.d.ts +8 -0
  109. package/dist/theme-file-uploader.d.ts.map +1 -0
  110. package/dist/theme-file-uploader.js +58 -0
  111. package/dist/theme-writer.d.ts +8 -0
  112. package/dist/theme-writer.d.ts.map +1 -0
  113. package/dist/theme-writer.js +103 -0
  114. package/dist/tunnel-provider.d.ts +273 -0
  115. package/dist/tunnel-provider.d.ts.map +1 -0
  116. package/dist/tunnel-provider.js +142 -0
  117. package/package.json +81 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `ggui serve` agent-plan resolver โ€” the ยง10.2a fallback matrix.
3
+ *
4
+ * Reads `ggui.json` via `@ggui-ai/project-config/node`, resolves the
5
+ * `agent.entry` (if any) via `./agent-resolution.ts`, and returns a
6
+ * `ResolvedAgentPlan` that the CLI runner hands straight to
7
+ * `runServe`. Pure-ish โ€” does real file I/O so tests exercise it
8
+ * with `mkdtempSync` + `writeFileSync` rather than mocking.
9
+ *
10
+ * Split out from `cli.ts` so the bin's top-level `main()` call
11
+ * doesn't fire when the test runner imports this module.
12
+ */
13
+ import { dirname } from 'node:path';
14
+ import { findGguiJson, GguiJsonLoadError, safeLoadGguiJson, } from '@ggui-ai/project-config/node';
15
+ import { buildAgentRuntime, resolveAgentCommand, } from './agent-resolution.js';
16
+ /**
17
+ * Apply the ยง10.2a matrix. Throws `GguiJsonLoadError` for cases 3
18
+ * (malformed JSON, schema failure) and a generic `Error` for case
19
+ * 4 (unsupported agent.entry extension / missing file hint).
20
+ *
21
+ * Returns a `ResolvedAgentPlan` for cases 1, 2, 5, and 6:
22
+ *
23
+ * 1. `--mcp-only` โ†’ disabled, silent
24
+ * 2. no ggui.json found โ†’ disabled, warn
25
+ * 5. ggui.json present, no agent.entry โ†’ disabled, warn
26
+ * 6. valid agent.entry โ†’ running, no warn
27
+ */
28
+ export function resolveAgentPlan(opts) {
29
+ // `--mcp-only` short-circuits agent supervision but still loads the
30
+ // manifest so the caller sees the storage block (and the project
31
+ // root, used to resolve relative storage paths against the manifest
32
+ // dir rather than CWD). Malformed manifest still escalates.
33
+ if (opts.mcpOnly) {
34
+ const { manifest, projectRoot } = tryLoadManifest(opts.cwd);
35
+ return {
36
+ status: { kind: 'disabled', reason: '--mcp-only' },
37
+ warnings: [],
38
+ manifest,
39
+ projectRoot,
40
+ };
41
+ }
42
+ const manifestPath = findGguiJson(opts.cwd);
43
+ if (!manifestPath) {
44
+ return {
45
+ status: { kind: 'disabled', reason: 'no ggui.json' },
46
+ warnings: [
47
+ 'no ggui.json found โ€” running MCP-only. ' +
48
+ 'create ggui.json with agent.entry to run your agent alongside.',
49
+ ],
50
+ manifest: null,
51
+ projectRoot: null,
52
+ };
53
+ }
54
+ const loaded = safeLoadGguiJson(manifestPath);
55
+ if (!loaded.success) {
56
+ throw loaded.error;
57
+ }
58
+ const manifest = loaded.data;
59
+ const projectRoot = dirname(manifestPath);
60
+ if (!manifest.agent?.entry) {
61
+ return {
62
+ status: { kind: 'disabled', reason: 'ggui.json has no agent.entry' },
63
+ warnings: [
64
+ `${manifestPath} found but agent.entry is not set โ€” running MCP-only. ` +
65
+ 'add agent.entry to run your agent.',
66
+ ],
67
+ manifest,
68
+ projectRoot,
69
+ };
70
+ }
71
+ const resolution = resolveAgentCommand(manifest.agent.entry, projectRoot);
72
+ if (!resolution.ok) {
73
+ throw new Error(`ggui.json agent.entry: ${resolution.error}`);
74
+ }
75
+ const adapter = buildAgentRuntime(resolution);
76
+ const supervision = {
77
+ adapter,
78
+ startInput: {
79
+ projectRoot,
80
+ project: {
81
+ slug: manifest.app.slug,
82
+ name: manifest.app.name,
83
+ protocol: manifest.protocol,
84
+ },
85
+ entry: resolution.entry,
86
+ },
87
+ ...(opts.onAgentEvent ? { onEvent: opts.onAgentEvent } : {}),
88
+ };
89
+ return {
90
+ status: {
91
+ kind: 'running',
92
+ entry: manifest.agent.entry,
93
+ language: resolution.language,
94
+ },
95
+ supervision,
96
+ warnings: [],
97
+ manifest,
98
+ projectRoot,
99
+ };
100
+ }
101
+ /**
102
+ * Best-effort manifest load for the `--mcp-only` path. Returns
103
+ * `{manifest: null, projectRoot: null}` when no manifest is on disk
104
+ * (MCP-only runs fine without a manifest, so we don't warn here).
105
+ * Malformed / schema-invalid manifests still throw to the CLI
106
+ * wrapper โ€” operators running `--mcp-only` with a broken file want
107
+ * to know.
108
+ */
109
+ function tryLoadManifest(cwd) {
110
+ const manifestPath = findGguiJson(cwd);
111
+ if (!manifestPath)
112
+ return { manifest: null, projectRoot: null };
113
+ const loaded = safeLoadGguiJson(manifestPath);
114
+ if (!loaded.success)
115
+ throw loaded.error;
116
+ return { manifest: loaded.data, projectRoot: dirname(manifestPath) };
117
+ }
118
+ /**
119
+ * Re-export for tests that want to assert on malformed-config
120
+ * handling without importing project-config transitively.
121
+ */
122
+ export { GguiJsonLoadError };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `spawnCloudflaredTunnel` โ€” auto-tunnel helper for `ggui serve --public`.
3
+ *
4
+ * The local-dev โ†’ claude.ai integration loop has a well-known trap:
5
+ * every demo session would otherwise need to manually run
6
+ * `cloudflared tunnel --url http://localhost:6781`, grep the URL from
7
+ * its noisy stderr, then restart `ggui serve --public-base-url <url>`
8
+ * with that value pinned. `runtimeUrl` is auto-derived from
9
+ * `X-Forwarded-Host` and no longer depends on `publicBaseUrl`, but
10
+ * production deployments still need the URL pinned for OAuth callbacks
11
+ * and email magic-links. This helper closes the gap: one `--public`
12
+ * flag spawns cloudflared, parses its URL, and threads it into the
13
+ * boot config.
14
+ *
15
+ * **Trust posture.** This helper does NOT mask the public exposure. It
16
+ * is the operator's explicit opt-in (`--public`); when combined with
17
+ * `--dev-allow-all` the CLI front-end requires a second flag
18
+ * (`--i-know-its-public`) to prevent accidentally serving any-bearer
19
+ * auth over the internet.
20
+ *
21
+ * **Process management.** The spawned child is the operator's only
22
+ * dependency on the host's `cloudflared` binary; we don't bundle it.
23
+ * On `close()`, SIGTERM โ†’ SIGKILL fallback after 5s. AbortSignal
24
+ * support lets the CLI lifecycle (Ctrl-C handler in `runServe`)
25
+ * tear down cloudflared in lockstep with the server.
26
+ */
27
+ export interface SpawnCloudflaredOptions {
28
+ /** Local port the tunnel forwards to. */
29
+ readonly port: number;
30
+ /** Optional override of the binary path. Defaults to `cloudflared`
31
+ * on PATH; operators wrap with `--cloudflared-bin <path>` when
32
+ * installed outside PATH. */
33
+ readonly binary?: string;
34
+ /** Optional AbortSignal โ€” fires SIGTERM on the child when aborted. */
35
+ readonly signal?: AbortSignal;
36
+ /** Optional logger seam. Default no-op. */
37
+ readonly onLog?: (line: string) => void;
38
+ /** URL-detection timeout. */
39
+ readonly urlTimeoutMs?: number;
40
+ }
41
+ export interface SpawnCloudflaredResult {
42
+ /** Public `https://<random>.trycloudflare.com` URL extracted from
43
+ * cloudflared's stderr. */
44
+ readonly url: string;
45
+ /** Tear down the tunnel. Idempotent; safe to call multiple times. */
46
+ close(): Promise<void>;
47
+ }
48
+ /**
49
+ * Spawn `cloudflared tunnel --url http://localhost:<port>` and resolve
50
+ * with the assigned trycloudflare URL. Rejects when:
51
+ *
52
+ * - The binary can't be spawned (cloudflared not installed / wrong
53
+ * path / permission denied). Error message includes the install
54
+ * hint.
55
+ * - cloudflared exits before printing a URL. Error includes any
56
+ * captured stderr tail.
57
+ * - URL detection times out. Error includes elapsed time + last
58
+ * ~10 lines of stderr so the operator can diagnose.
59
+ */
60
+ export declare function spawnCloudflaredTunnel(opts: SpawnCloudflaredOptions): Promise<SpawnCloudflaredResult>;
61
+ //# sourceMappingURL=spawn-cloudflared.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawn-cloudflared.d.ts","sourceRoot":"","sources":["../src/spawn-cloudflared.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAeH,MAAM,WAAW,uBAAuB;IACtC,yCAAyC;IACzC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;kCAE8B;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,sEAAsE;IACtE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IAC9B,2CAA2C;IAC3C,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,6BAA6B;IAC7B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,MAAM,WAAW,sBAAsB;IACrC;gCAC4B;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,qEAAqE;IACrE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,uBAAuB,GAC5B,OAAO,CAAC,sBAAsB,CAAC,CA6IjC"}
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `spawnCloudflaredTunnel` โ€” auto-tunnel helper for `ggui serve --public`.
3
+ *
4
+ * The local-dev โ†’ claude.ai integration loop has a well-known trap:
5
+ * every demo session would otherwise need to manually run
6
+ * `cloudflared tunnel --url http://localhost:6781`, grep the URL from
7
+ * its noisy stderr, then restart `ggui serve --public-base-url <url>`
8
+ * with that value pinned. `runtimeUrl` is auto-derived from
9
+ * `X-Forwarded-Host` and no longer depends on `publicBaseUrl`, but
10
+ * production deployments still need the URL pinned for OAuth callbacks
11
+ * and email magic-links. This helper closes the gap: one `--public`
12
+ * flag spawns cloudflared, parses its URL, and threads it into the
13
+ * boot config.
14
+ *
15
+ * **Trust posture.** This helper does NOT mask the public exposure. It
16
+ * is the operator's explicit opt-in (`--public`); when combined with
17
+ * `--dev-allow-all` the CLI front-end requires a second flag
18
+ * (`--i-know-its-public`) to prevent accidentally serving any-bearer
19
+ * auth over the internet.
20
+ *
21
+ * **Process management.** The spawned child is the operator's only
22
+ * dependency on the host's `cloudflared` binary; we don't bundle it.
23
+ * On `close()`, SIGTERM โ†’ SIGKILL fallback after 5s. AbortSignal
24
+ * support lets the CLI lifecycle (Ctrl-C handler in `runServe`)
25
+ * tear down cloudflared in lockstep with the server.
26
+ */
27
+ import { spawn } from 'node:child_process';
28
+ /** Configured upper bound on how long we wait for cloudflared to
29
+ * print its public URL. Cold quick-tunnel typically takes 2-5s;
30
+ * setting too low surfaces a flaky "no URL detected" error to the
31
+ * operator before the tunnel actually fails. 30s is generous. */
32
+ const DEFAULT_URL_TIMEOUT_MS = 30_000;
33
+ /** Matcher for the trycloudflare URL inside cloudflared's stderr.
34
+ * cloudflared's exact log format has been stable since 2023; if it
35
+ * changes, this regex is the single point of impact. */
36
+ const TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
37
+ /**
38
+ * Spawn `cloudflared tunnel --url http://localhost:<port>` and resolve
39
+ * with the assigned trycloudflare URL. Rejects when:
40
+ *
41
+ * - The binary can't be spawned (cloudflared not installed / wrong
42
+ * path / permission denied). Error message includes the install
43
+ * hint.
44
+ * - cloudflared exits before printing a URL. Error includes any
45
+ * captured stderr tail.
46
+ * - URL detection times out. Error includes elapsed time + last
47
+ * ~10 lines of stderr so the operator can diagnose.
48
+ */
49
+ export function spawnCloudflaredTunnel(opts) {
50
+ const binary = opts.binary ?? 'cloudflared';
51
+ const port = opts.port;
52
+ const timeout = opts.urlTimeoutMs ?? DEFAULT_URL_TIMEOUT_MS;
53
+ const log = opts.onLog ?? (() => { });
54
+ return new Promise((resolve, reject) => {
55
+ let child;
56
+ try {
57
+ child = spawn(binary, ['tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate'], { stdio: ['ignore', 'pipe', 'pipe'] });
58
+ }
59
+ catch (err) {
60
+ reject(new Error(`Failed to spawn ${binary}: ${err.message}. ` +
61
+ `Install cloudflared from https://github.com/cloudflare/cloudflared/releases.`));
62
+ return;
63
+ }
64
+ // Tail buffer for error context โ€” cap so a chatty cloudflared
65
+ // version doesn't balloon memory.
66
+ const stderrTail = [];
67
+ const MAX_TAIL = 50;
68
+ let url;
69
+ let urlResolved = false;
70
+ let closed = false;
71
+ const startedAt = Date.now();
72
+ const timer = setTimeout(() => {
73
+ if (!urlResolved) {
74
+ reject(new Error(`cloudflared did not emit a tunnel URL within ${timeout}ms. ` +
75
+ `Last stderr lines:\n${stderrTail.slice(-10).join('\n')}`));
76
+ void close();
77
+ }
78
+ }, timeout);
79
+ function handleLine(line) {
80
+ log(line);
81
+ stderrTail.push(line);
82
+ if (stderrTail.length > MAX_TAIL) {
83
+ stderrTail.shift();
84
+ }
85
+ if (urlResolved)
86
+ return;
87
+ const match = line.match(TRYCLOUDFLARE_URL_RE);
88
+ if (match) {
89
+ url = match[0];
90
+ urlResolved = true;
91
+ clearTimeout(timer);
92
+ resolve({
93
+ url,
94
+ close,
95
+ });
96
+ }
97
+ }
98
+ // cloudflared logs to BOTH stdout and stderr depending on version
99
+ // โ€” wire both through the same line-handler so the regex catches
100
+ // wherever it lands.
101
+ function attachLineReader(stream) {
102
+ let buf = '';
103
+ stream.setEncoding('utf8');
104
+ stream.on('data', (chunk) => {
105
+ buf += chunk;
106
+ let nl = buf.indexOf('\n');
107
+ while (nl !== -1) {
108
+ const line = buf.slice(0, nl).trimEnd();
109
+ buf = buf.slice(nl + 1);
110
+ if (line.length > 0)
111
+ handleLine(line);
112
+ nl = buf.indexOf('\n');
113
+ }
114
+ });
115
+ }
116
+ if (child.stderr)
117
+ attachLineReader(child.stderr);
118
+ if (child.stdout)
119
+ attachLineReader(child.stdout);
120
+ child.on('error', (err) => {
121
+ clearTimeout(timer);
122
+ if (!urlResolved) {
123
+ reject(new Error(`cloudflared spawn error: ${err.message}. ` +
124
+ `Verify the binary is installed and executable.`));
125
+ }
126
+ });
127
+ child.on('exit', (code, sig) => {
128
+ clearTimeout(timer);
129
+ if (!urlResolved) {
130
+ reject(new Error(`cloudflared exited (code=${code}, signal=${sig}) before ` +
131
+ `emitting a tunnel URL (elapsed ${Date.now() - startedAt}ms). ` +
132
+ `Last stderr lines:\n${stderrTail.slice(-10).join('\n')}`));
133
+ }
134
+ });
135
+ function close() {
136
+ if (closed)
137
+ return Promise.resolve();
138
+ closed = true;
139
+ return new Promise((resolveClose) => {
140
+ if (child.killed || child.exitCode !== null) {
141
+ resolveClose();
142
+ return;
143
+ }
144
+ const killTimer = setTimeout(() => {
145
+ if (!child.killed && child.exitCode === null) {
146
+ child.kill('SIGKILL');
147
+ }
148
+ }, 5000);
149
+ child.once('exit', () => {
150
+ clearTimeout(killTimer);
151
+ resolveClose();
152
+ });
153
+ child.kill('SIGTERM');
154
+ });
155
+ }
156
+ if (opts.signal) {
157
+ if (opts.signal.aborted) {
158
+ void close();
159
+ }
160
+ else {
161
+ opts.signal.addEventListener('abort', () => void close(), {
162
+ once: true,
163
+ });
164
+ }
165
+ }
166
+ });
167
+ }
@@ -0,0 +1,4 @@
1
+ export declare const THEME_HELP = "ggui theme \u2014 validate + inspect operator-authored DTCG themes\n\nUsage:\n ggui theme <subcommand> [options]\n\nSubcommands:\n validate <path> Validate a JSON theme file against the ThemeDocumentV1 schema.\n\nRun `ggui theme <subcommand> --help` for subcommand-specific options.\n";
2
+ export declare const THEME_VALIDATE_HELP = "ggui theme validate \u2014 validate a DTCG theme file\n\nUsage:\n ggui theme validate <path>\n\nArguments:\n <path> Path to a JSON theme document (e.g. `./theme.json`).\n\nExit codes:\n 0 theme is valid\n 1 theme file is unreadable, not JSON, or fails schema validation\n 2 bad CLI usage (missing path argument)\n";
3
+ export declare function runThemeCommand(args: readonly string[]): Promise<number>;
4
+ //# sourceMappingURL=theme-command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme-command.d.ts","sourceRoot":"","sources":["../src/theme-command.ts"],"names":[],"mappings":"AA2BA,eAAO,MAAM,UAAU,qSAStB,CAAC;AAEF,eAAO,MAAM,mBAAmB,4UAY/B,CAAC;AAEF,wBAAsB,eAAe,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAc9E"}
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `ggui theme <subcommand>` โ€” operator-facing theme tooling.
3
+ *
4
+ * Today only `validate <path>` ships: reads a DTCG `theme.json` from
5
+ * disk, runs `safeParseThemeDocument` from `@ggui-ai/project-config`,
6
+ * and prints either a one-line success summary or a per-issue error
7
+ * list. Schema lives in `@ggui-ai/project-config` and is the single
8
+ * source of truth โ€” this command never re-implements validation.
9
+ *
10
+ * Sibling of `gadget-command.ts` / `blueprint-command.ts`. Kept thin
11
+ * so future subcommands (`theme preview`, `theme diff`, โ€ฆ) drop in
12
+ * alongside `validate` without restructuring.
13
+ *
14
+ * Exit codes:
15
+ * 0 valid
16
+ * 1 invalid (schema violations rendered to stderr) or unreadable
17
+ * input file
18
+ * 2 bad CLI usage (missing path, unknown subcommand, `--help` from
19
+ * an empty router invocation)
20
+ */
21
+ import { readFile } from 'node:fs/promises';
22
+ import { resolve } from 'node:path';
23
+ import { safeParseThemeDocument, } from '@ggui-ai/project-config';
24
+ export const THEME_HELP = `ggui theme โ€” validate + inspect operator-authored DTCG themes
25
+
26
+ Usage:
27
+ ggui theme <subcommand> [options]
28
+
29
+ Subcommands:
30
+ validate <path> Validate a JSON theme file against the ThemeDocumentV1 schema.
31
+
32
+ Run \`ggui theme <subcommand> --help\` for subcommand-specific options.
33
+ `;
34
+ export const THEME_VALIDATE_HELP = `ggui theme validate โ€” validate a DTCG theme file
35
+
36
+ Usage:
37
+ ggui theme validate <path>
38
+
39
+ Arguments:
40
+ <path> Path to a JSON theme document (e.g. \`./theme.json\`).
41
+
42
+ Exit codes:
43
+ 0 theme is valid
44
+ 1 theme file is unreadable, not JSON, or fails schema validation
45
+ 2 bad CLI usage (missing path argument)
46
+ `;
47
+ export async function runThemeCommand(args) {
48
+ const [sub, ...rest] = args;
49
+ if (!sub || sub === '--help' || sub === '-h') {
50
+ process.stdout.write(THEME_HELP);
51
+ return sub ? 0 : 2;
52
+ }
53
+ switch (sub) {
54
+ case 'validate':
55
+ return runValidate(rest);
56
+ default:
57
+ process.stderr.write(`ggui theme: unknown subcommand "${sub}"\n\n`);
58
+ process.stderr.write(THEME_HELP);
59
+ return 2;
60
+ }
61
+ }
62
+ /* -------------------------------------------------------------------------- */
63
+ /* validate */
64
+ /* -------------------------------------------------------------------------- */
65
+ async function runValidate(args) {
66
+ if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) {
67
+ process.stdout.write(THEME_VALIDATE_HELP);
68
+ return 0;
69
+ }
70
+ if (args.length === 0) {
71
+ process.stderr.write(`ggui theme validate: missing <path> argument\n\n`);
72
+ process.stderr.write(THEME_VALIDATE_HELP);
73
+ return 2;
74
+ }
75
+ // Drop bare `--` if the operator used it as the long-flag terminator.
76
+ const positional = args.filter((a) => a !== '--');
77
+ const filePath = positional[0];
78
+ if (!filePath) {
79
+ process.stderr.write(`ggui theme validate: missing <path> argument\n\n`);
80
+ process.stderr.write(THEME_VALIDATE_HELP);
81
+ return 2;
82
+ }
83
+ if (positional.length > 1) {
84
+ process.stderr.write(`ggui theme validate: too many arguments (expected exactly one path)\n\n`);
85
+ process.stderr.write(THEME_VALIDATE_HELP);
86
+ return 2;
87
+ }
88
+ const absPath = resolve(process.cwd(), filePath);
89
+ let raw;
90
+ try {
91
+ raw = await readFile(absPath, 'utf-8');
92
+ }
93
+ catch (err) {
94
+ process.stderr.write(`ggui theme validate: cannot read ${absPath} โ€” ${err instanceof Error ? err.message : String(err)}\n`);
95
+ return 1;
96
+ }
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(raw);
100
+ }
101
+ catch (err) {
102
+ process.stderr.write(`ggui theme validate: ${absPath} is not valid JSON โ€” ${err instanceof Error ? err.message : String(err)}\n`);
103
+ return 1;
104
+ }
105
+ const result = safeParseThemeDocument(parsed);
106
+ if (!result.success) {
107
+ process.stderr.write(`ggui theme validate: ${absPath} failed schema validation (${result.error.issues.length} issue${result.error.issues.length === 1 ? '' : 's'})\n`);
108
+ for (const issue of result.error.issues) {
109
+ const path = issue.path.length === 0
110
+ ? '<root>'
111
+ : issue.path
112
+ .map((seg) => typeof seg === 'number' ? `[${seg}]` : String(seg))
113
+ .join('.');
114
+ process.stderr.write(` ยท ${path}: ${issue.message}\n`);
115
+ }
116
+ return 1;
117
+ }
118
+ for (const line of describeValidTheme(absPath, result.data)) {
119
+ process.stdout.write(`${line}\n`);
120
+ }
121
+ return 0;
122
+ }
123
+ /**
124
+ * Build the success-path stdout lines. Pure so tests can pin the copy.
125
+ * Green styling is applied only when stdout is a TTY โ€” pipes/captures
126
+ * stay clean for grep + assertions.
127
+ */
128
+ function describeValidTheme(absPath, theme) {
129
+ const lines = [];
130
+ const mark = process.stdout.isTTY ? 'โœ“' : 'โœ“';
131
+ const nameSuffix = theme.$name ? ` (${theme.$name})` : '';
132
+ lines.push(`${mark} Theme valid${nameSuffix}`);
133
+ lines.push(` file: ${absPath}`);
134
+ // Color palette count: each top-level entry in `color` is either a
135
+ // single-token role (e.g. `surface`) or a nested palette (e.g.
136
+ // `primary: { 50: โ€ฆ, 100: โ€ฆ }`). Both count as one palette/role โ€”
137
+ // operators care about "how many colour groups did I declare?", not
138
+ // "how many leaf colour tokens".
139
+ const paletteCount = Object.keys(theme.color).length;
140
+ lines.push(` color: ${paletteCount} palette${paletteCount === 1 ? '' : 's'}/role${paletteCount === 1 ? '' : 's'}`);
141
+ const optional = [];
142
+ if (theme.motion !== undefined)
143
+ optional.push('motion');
144
+ if (theme.accessibility !== undefined)
145
+ optional.push('accessibility');
146
+ if (theme.zIndex !== undefined)
147
+ optional.push('zIndex');
148
+ if (theme.canvas !== undefined)
149
+ optional.push('canvas');
150
+ lines.push(optional.length === 0
151
+ ? ` optional blocks: none`
152
+ : ` optional blocks: ${optional.join(', ')}`);
153
+ return lines;
154
+ }
@@ -0,0 +1,8 @@
1
+ import type { ThemeFileUploader } from '@ggui-ai/mcp-server';
2
+ /**
3
+ * Build a {@link ThemeFileUploader} bound to the directory holding
4
+ * `manifestPath`. The returned function writes
5
+ * `<dir>/<filename>` atomically. Idempotent for equal inputs.
6
+ */
7
+ export declare function createThemeFileUploader(manifestPath: string): ThemeFileUploader;
8
+ //# sourceMappingURL=theme-file-uploader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme-file-uploader.d.ts","sourceRoot":"","sources":["../src/theme-file-uploader.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAK7D;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,iBAAiB,CA6B/E"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `themeFileUploader` factory for `ggui serve`.
3
+ *
4
+ * Pairs with `createThemeWriter`: the upload route on the OSS server
5
+ * writes a DTCG theme document to a file alongside `ggui.json`, then
6
+ * calls the regular theme writer to point the manifest at that file.
7
+ *
8
+ * This factory ONLY handles the file-write half. The route ordering
9
+ * (uploader โ†’ writer) is the server's responsibility โ€” see
10
+ * `mountDevtoolThemeRoutes` in `@ggui-ai/mcp-server`.
11
+ *
12
+ * Behavior:
13
+ *
14
+ * - `filename` is gated upstream by the route (alphanumeric, `.json`
15
+ * suffix, no path separators, no reserved-name shadowing). We
16
+ * re-validate here as belt-and-braces โ€” programmatic embedders
17
+ * that called the seam directly should also get a clear refusal
18
+ * rather than a silent path-traversal write.
19
+ * - The file is written into `dirname(manifestPath)`, atomically
20
+ * (`tmp` + `rename` + `fsync`) โ€” same pattern the existing theme
21
+ * writer uses for `ggui.json`.
22
+ * - Content is serialised with 2-space indent and a trailing newline,
23
+ * matching the manifest's formatting so the operator's repo stays
24
+ * consistent.
25
+ */
26
+ import { dirname, join } from 'node:path';
27
+ import { promises as fs } from 'node:fs';
28
+ const SAFE_FILENAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
29
+ const FORBIDDEN_FILENAMES = new Set(['ggui.json', 'package.json']);
30
+ /**
31
+ * Build a {@link ThemeFileUploader} bound to the directory holding
32
+ * `manifestPath`. The returned function writes
33
+ * `<dir>/<filename>` atomically. Idempotent for equal inputs.
34
+ */
35
+ export function createThemeFileUploader(manifestPath) {
36
+ const projectDir = dirname(manifestPath);
37
+ return async (filename, content) => {
38
+ if (!SAFE_FILENAME_RE.test(filename)) {
39
+ throw new Error(`themeFileUploader: filename must match /^[a-zA-Z0-9._-]+\\.json$/ โ€” got ${JSON.stringify(filename)}`);
40
+ }
41
+ if (FORBIDDEN_FILENAMES.has(filename)) {
42
+ throw new Error(`themeFileUploader: filename "${filename}" would shadow a reserved file.`);
43
+ }
44
+ const target = join(projectDir, filename);
45
+ const next = `${JSON.stringify(content, null, 2)}\n`;
46
+ const tmp = `${target}.tmp`;
47
+ await fs.mkdir(projectDir, { recursive: true });
48
+ const handle = await fs.open(tmp, 'w');
49
+ try {
50
+ await handle.writeFile(next, 'utf-8');
51
+ await handle.sync();
52
+ }
53
+ finally {
54
+ await handle.close();
55
+ }
56
+ await fs.rename(tmp, target);
57
+ };
58
+ }
@@ -0,0 +1,8 @@
1
+ import type { ThemeWriter } from '@ggui-ai/mcp-server';
2
+ /**
3
+ * Build a {@link ThemeWriter} bound to the manifest at `manifestPath`.
4
+ * The returned function is async, idempotent for equal inputs, and
5
+ * crash-safe via a `tmp` + `rename` write.
6
+ */
7
+ export declare function createThemeWriter(manifestPath: string): ThemeWriter;
8
+ //# sourceMappingURL=theme-writer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme-writer.d.ts","sourceRoot":"","sources":["../src/theme-writer.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,WAAW,CA8DnE"}