@yolo-labs/yolo-cli 0.23.0

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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `yolo tileapp validate|dev` — the LOCAL developer harness (M1 slice 6).
3
+ *
4
+ * yolo tileapp validate <manifest.json> [--bundle-dir <dir>]
5
+ * → Offline lint: manifest schema (mirrors the server's validateManifest)
6
+ * + bundle layout (a pure-UI app's `surface.entry` must exist on disk).
7
+ * Exit 0 clean, 65 on validation errors, 64 usage, 66 I/O. Prints every
8
+ * error at once — the same taxonomy `POST /v1/publisher/publish` enforces.
9
+ *
10
+ * yolo tileapp dev <manifest.json> [--port N] [--host H] [--bundle-dir <dir>] [--deny]
11
+ * → Validate, then serve the bundle on a local HTTP server with a MOCK
12
+ * broker at `/__yolo/broker` (POST) so a partner can run + click through
13
+ * their app offline, without production review or a session pod. Allow-all
14
+ * by default; `--deny` makes the mock broker deny every request (to test
15
+ * the app's graceful-degradation path). Long-running until killed.
16
+ *
17
+ * Offline + auth-free by design (unlike sign/publish): a partner iterates here
18
+ * with no token, no SESSION_ID, no network.
19
+ */
20
+ import * as fs from 'node:fs';
21
+ import * as path from 'node:path';
22
+ import * as http from 'node:http';
23
+ import { createServeHandler } from './serve.js';
24
+ import { validateManifest, isRuntimeManifest, publishGateErrors } from './tileapp-validator.js';
25
+ /** The mock-broker + helper routes the dev server mounts (under a reserved
26
+ * prefix so they can't collide with a real bundle asset path). */
27
+ export const DEV_BROKER_PATH = '/__yolo/broker';
28
+ export const DEV_MANIFEST_PATH = '/__yolo/manifest';
29
+ /** Map a failure kind to a process exit code. */
30
+ export function devExitCode(kind) {
31
+ if (kind === 'usage')
32
+ return 64; // EX_USAGE
33
+ if (kind === 'io')
34
+ return 66; // EX_NOINPUT
35
+ return 65; // EX_DATAERR (validation)
36
+ }
37
+ function readManifestFile(p) {
38
+ let raw;
39
+ try {
40
+ raw = fs.readFileSync(p, 'utf-8');
41
+ }
42
+ catch (e) {
43
+ return { ok: false, message: `cannot read manifest '${p}': ${e.message}` };
44
+ }
45
+ try {
46
+ return { ok: true, manifest: JSON.parse(raw) };
47
+ }
48
+ catch (e) {
49
+ return { ok: false, message: `manifest '${p}' is not valid JSON: ${e.message}` };
50
+ }
51
+ }
52
+ /**
53
+ * Resolve the static-bundle directory for a pure-UI manifest. Tries, in order:
54
+ * an explicit `--bundle-dir`, `<manifestDir>/bundles/<id>` (the registry layout),
55
+ * then `<manifestDir>` (a flat project where index.html sits beside the
56
+ * manifest). Returns the first that EXISTS and contains `surface.entry`.
57
+ */
58
+ export function resolveBundleDir(manifestPath, manifest, override) {
59
+ const manifestDir = path.dirname(path.resolve(manifestPath));
60
+ const id = typeof manifest.id === 'string' ? manifest.id : '';
61
+ const entry = manifest.surface?.entry || 'index.html';
62
+ // An EXPLICIT --bundle-dir is authoritative: it's the ONLY candidate, so a
63
+ // missing entry there fails loudly rather than silently validating/serving a
64
+ // stale fallback bundle. Only auto-discover when no override is given.
65
+ const candidates = override
66
+ ? [path.resolve(override)]
67
+ : [
68
+ id ? path.join(manifestDir, 'bundles', id) : null,
69
+ manifestDir,
70
+ ].filter((c) => !!c);
71
+ for (const dir of candidates) {
72
+ try {
73
+ const root = path.resolve(dir);
74
+ if (!fs.statSync(root).isDirectory())
75
+ continue;
76
+ // The entry must stay WITHIN the bundle root — production's bundle route
77
+ // rejects `..`-escaping paths with 403, so an entry like `../index.html`
78
+ // that resolves outside must not pass here.
79
+ const entryAbs = path.resolve(root, entry);
80
+ if (entryAbs !== root && !entryAbs.startsWith(root + path.sep))
81
+ continue;
82
+ // …and it must be a FILE (production 404s a directory entry).
83
+ if (fs.statSync(entryAbs).isFile())
84
+ return { ok: true, dir };
85
+ }
86
+ catch { /* try next */ }
87
+ }
88
+ return {
89
+ ok: false,
90
+ candidates,
91
+ message: `could not find bundle entry '${entry}' in any of: ${candidates.join(', ')}`,
92
+ };
93
+ }
94
+ /**
95
+ * The mock broker's reply to a dev-time capability request. Returns the SAME
96
+ * shape + HTTP status the production broker does (`BrokerResult`:
97
+ * `{ ok: true, payload }` on allow; `{ ok: false, reason, message }` with a 4xx
98
+ * on deny) so an app written against the mock behaves identically against the
99
+ * real host bridge — including the deny/error path. Allow echoes the request as
100
+ * the payload; deny uses `permission-not-granted` (→ 403), matching the real
101
+ * broker's deny-reason → status mapping.
102
+ */
103
+ export function mockBrokerResponse(body, opts) {
104
+ if (opts.allow) {
105
+ return { httpStatus: 200, result: { ok: true, payload: { echo: body ?? null } } };
106
+ }
107
+ return { httpStatus: 403, result: { ok: false, reason: 'permission-not-granted', message: 'dev-mock: deny-all (--deny)' } };
108
+ }
109
+ export function runTileAppValidate(opts) {
110
+ const read = readManifestFile(opts.manifestPath);
111
+ if (!read.ok)
112
+ return { ok: false, kind: 'io', message: read.message };
113
+ const v = validateManifest(read.manifest);
114
+ const errors = [...v.errors, ...publishGateErrors(read.manifest)];
115
+ // A non-object manifest is fully reported by validateManifest; bail before the
116
+ // bundle/runtime checks (which deref manifest fields) so we never crash on it.
117
+ const manifestIsObject = typeof read.manifest === 'object' && read.manifest !== null && !Array.isArray(read.manifest);
118
+ if (!manifestIsObject) {
119
+ return { ok: false, kind: 'validation', message: `manifest invalid:\n - ${errors.join('\n - ')}` };
120
+ }
121
+ // Bundle layout: a PURE-UI app must ship its `surface.entry` on disk. A
122
+ // runtime/image app's surface comes from its container, so skip the disk check.
123
+ let bundleNote = '';
124
+ if (!isRuntimeManifest(read.manifest)) {
125
+ const resolved = resolveBundleDir(opts.manifestPath, read.manifest, opts.bundleDir);
126
+ if (!resolved.ok)
127
+ errors.push(`bundle: ${resolved.message}`);
128
+ else
129
+ bundleNote = `\n bundle: ${resolved.dir}`;
130
+ }
131
+ else {
132
+ bundleNote = '\n runtime app — surface served from its container image (no local bundle checked)';
133
+ }
134
+ if (errors.length > 0) {
135
+ return { ok: false, kind: 'validation', message: `manifest invalid (${errors.length} issue${errors.length === 1 ? '' : 's'}):\n - ${errors.join('\n - ')}` };
136
+ }
137
+ const id = String(read.manifest.id);
138
+ const version = String(read.manifest.version);
139
+ return {
140
+ ok: true,
141
+ output: `OK: ${id}@${version} is valid.${bundleNote}\n note: mcp-scope + secret-key NAMES are confirmed server-side at publish.`,
142
+ };
143
+ }
144
+ /**
145
+ * Build the dev HTTP handler: the mock-broker + manifest helper routes, with
146
+ * everything else delegated to the static bundle server. Exported for tests.
147
+ */
148
+ export function createDevHandler(opts) {
149
+ const staticHandler = createServeHandler({ dir: opts.dir, port: 0, host: '', spa: false, noCache: true });
150
+ return (req, res) => {
151
+ const url = (req.url ?? '/').split('?')[0];
152
+ if (url === DEV_MANIFEST_PATH) {
153
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
154
+ res.end(JSON.stringify(opts.manifest));
155
+ return;
156
+ }
157
+ if (url === DEV_BROKER_PATH) {
158
+ if (req.method !== 'POST') {
159
+ res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8', Allow: 'POST' });
160
+ res.end(JSON.stringify({ error: 'mock broker accepts POST only' }));
161
+ return;
162
+ }
163
+ const chunks = [];
164
+ req.on('data', (c) => chunks.push(c));
165
+ req.on('end', () => {
166
+ let parsed = null;
167
+ try {
168
+ parsed = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf-8')) : null;
169
+ }
170
+ catch {
171
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
172
+ res.end(JSON.stringify({ ok: false, reason: 'invalid-args', message: 'invalid JSON body' }));
173
+ return;
174
+ }
175
+ const { httpStatus, result } = mockBrokerResponse(parsed, { allow: opts.allow });
176
+ res.writeHead(httpStatus, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
177
+ res.end(JSON.stringify(result));
178
+ });
179
+ return;
180
+ }
181
+ staticHandler(req, res);
182
+ };
183
+ }
184
+ export async function runTileAppDev(opts) {
185
+ const read = readManifestFile(opts.manifestPath);
186
+ if (!read.ok) {
187
+ process.stderr.write(`FAIL [io]: ${read.message}\n`);
188
+ return devExitCode('io');
189
+ }
190
+ // Block on schema AND publish-gate errors before serving — `dev` claims to
191
+ // validate before serving, so it must reject anything publish would (e.g. an
192
+ // image-only manifest missing its pinned digest).
193
+ const gateErrors = [...validateManifest(read.manifest).errors, ...publishGateErrors(read.manifest)];
194
+ if (gateErrors.length) {
195
+ process.stderr.write(`FAIL [validation]: manifest invalid:\n - ${gateErrors.join('\n - ')}\n`);
196
+ return devExitCode('validation');
197
+ }
198
+ if (isRuntimeManifest(read.manifest)) {
199
+ process.stderr.write('FAIL [validation]: `yolo tileapp dev` serves a static (pure-UI) bundle; this manifest declares a runtime/image app — run your container locally and preview it yourself.\n');
200
+ return devExitCode('validation');
201
+ }
202
+ const resolved = resolveBundleDir(opts.manifestPath, read.manifest, opts.bundleDir);
203
+ if (!resolved.ok) {
204
+ process.stderr.write(`FAIL [io]: ${resolved.message}\n`);
205
+ return devExitCode('io');
206
+ }
207
+ const handler = createDevHandler({ dir: resolved.dir, manifest: read.manifest, allow: opts.allow });
208
+ const server = http.createServer(handler);
209
+ return new Promise((resolve) => {
210
+ server.on('error', (err) => {
211
+ process.stderr.write(`yolo tileapp dev: ${err.code === 'EADDRINUSE' ? `port ${opts.port} is already in use` : err.message}\n`);
212
+ resolve(70); // EX_SOFTWARE
213
+ });
214
+ server.listen(opts.port, opts.host, () => {
215
+ const entry = read.manifest.surface.entry || 'index.html';
216
+ process.stdout.write(`yolo tileapp dev: serving ${String(read.manifest.id)} from ${resolved.dir}\n`);
217
+ process.stdout.write(` open http://${opts.host}:${opts.port}/${entry}\n`);
218
+ process.stdout.write(` broker POST http://${opts.host}:${opts.port}${DEV_BROKER_PATH} (mock: ${opts.allow ? 'allow-all' : 'deny-all'})\n`);
219
+ // Long-running — never resolve; the process runs until killed.
220
+ });
221
+ });
222
+ }