@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
|
@@ -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 ? '[32mโ[0m' : 'โ';
|
|
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"}
|