@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,461 @@
1
+ /**
2
+ * `yolo tileapp init` + `yolo tileapp publish --personal` — the PERSONAL
3
+ * (owner-only) app path. Unlike the marketplace `sign`/`publish` flow, this
4
+ * needs no signing, no review, no publisher entity: it registers the manifest
5
+ * under the caller's own namespace and uploads the static bundle, both via the
6
+ * user-authed `/v1/tileapps/personal*` routes. See docs/PERSONAL_TILE_APPS_PLAN.md.
7
+ *
8
+ * yolo tileapp init <name>
9
+ * → scaffold ./<name>/tileapp.json + ./<name>/index.html (a renderable
10
+ * pure-UI starter). Offline, no auth.
11
+ *
12
+ * yolo tileapp publish <manifest.json> --personal [--bundle-dir <dir>]
13
+ * → POST /v1/tileapps/personal (register the manifest, returns the namespaced
14
+ * appId) then PUT /v1/tileapps/personal/<appId>/bundle (upload the static
15
+ * files). Prints the appId. Auth: user JWT (no SESSION_ID needed).
16
+ */
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { spawn } from 'node:child_process';
21
+ import { resolveUserToken } from './auth-context.js';
22
+ import { validateManifest, isRuntimeManifest } from './tileapp-validator.js';
23
+ import { resolveBundleDir } from './tileapp-developer.js';
24
+ import { parseDockerfile, assembleOciArchive } from './tileapp-oci-assembler.js';
25
+ function defaultExec(file, args) {
26
+ return new Promise((resolve) => {
27
+ let proc;
28
+ try {
29
+ proc = spawn(file, args, { stdio: ['ignore', 'inherit', 'pipe'] });
30
+ }
31
+ catch (err) {
32
+ return resolve({ code: null, stderr: err.message });
33
+ }
34
+ let stderr = '';
35
+ proc.stderr?.on('data', (d) => { stderr += d; process.stderr.write(d); });
36
+ proc.on('error', (err) => resolve({ code: null, stderr: stderr + err.message }));
37
+ proc.on('close', (code) => resolve({ code, stderr }));
38
+ });
39
+ }
40
+ export function exitCodeForFailure(kind) {
41
+ if (kind === 'http')
42
+ return 1;
43
+ if (kind === 'validation')
44
+ return 65; // EX_DATAERR
45
+ return 64; // EX_USAGE / auth / io
46
+ }
47
+ const LOCAL_ID_RE = /^[a-z0-9][a-z0-9-]{1,31}$/;
48
+ // Extensions served as text (utf8); everything else is uploaded base64.
49
+ const TEXT_EXTS = new Set(['.html', '.htm', '.js', '.mjs', '.css', '.json', '.svg', '.txt', '.map', '.xml', '.webmanifest']);
50
+ const MAX_FILES = 100;
51
+ const MAX_TOTAL_BYTES = 3 * 1024 * 1024;
52
+ function apiBase(commonApiUrl) {
53
+ const base = commonApiUrl.replace(/\/$/, '');
54
+ return base.endsWith('/v1') ? base : `${base}/v1`;
55
+ }
56
+ function resolveAuth(env) {
57
+ const commonApiUrl = env.YOLO_COMMON_API_URL || env.YOLO_API_URL;
58
+ if (!commonApiUrl)
59
+ return { ok: false, message: 'YOLO_COMMON_API_URL (or YOLO_API_URL) env var is required' };
60
+ const userToken = resolveUserToken(env);
61
+ if (!userToken)
62
+ return { ok: false, message: 'no user token: set YOLO_API_TOKEN or sign in (~/.config/yolo/token)' };
63
+ return { ok: true, commonApiUrl, userToken };
64
+ }
65
+ async function safeText(res) {
66
+ try {
67
+ return await res.text();
68
+ }
69
+ catch {
70
+ return '<no body>';
71
+ }
72
+ }
73
+ function readManifest(p) {
74
+ let raw;
75
+ try {
76
+ raw = fs.readFileSync(p, 'utf-8');
77
+ }
78
+ catch (e) {
79
+ return { ok: false, message: `cannot read manifest '${p}': ${e.message}` };
80
+ }
81
+ try {
82
+ return { ok: true, manifest: JSON.parse(raw) };
83
+ }
84
+ catch (e) {
85
+ return { ok: false, message: `manifest '${p}' is not valid JSON: ${e.message}` };
86
+ }
87
+ }
88
+ /** Scaffold a renderable pure-UI personal app under ./<name>/. */
89
+ export function runTileAppInit(opts) {
90
+ const name = opts.name;
91
+ if (!LOCAL_ID_RE.test(name)) {
92
+ return { ok: false, kind: 'usage', message: `name must be a lowercase kebab-case slug, 2-32 chars (got '${name}')` };
93
+ }
94
+ const dir = path.resolve(opts.cwd ?? process.cwd(), name);
95
+ if (fs.existsSync(dir))
96
+ return { ok: false, kind: 'io', message: `directory already exists: ${dir}` };
97
+ const title = name.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
98
+ const manifest = {
99
+ id: name,
100
+ version: '1.0.0',
101
+ displayName: title,
102
+ publisher: 'personal',
103
+ description: `${title} — a personal tile-app.`,
104
+ ui: { icon: '🧩', color: '#4f46e5', label: title },
105
+ surface: { kind: 'iframe', entry: 'index.html', tilePrefersSize: { rowSpan: 2, colSpan: 2 } },
106
+ permissions: { required: [] },
107
+ };
108
+ const indexHtml = `<!doctype html>
109
+ <html lang="en">
110
+ <head>
111
+ <meta charset="utf-8" />
112
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
113
+ <title>${title}</title>
114
+ <!--
115
+ ⚠️ CONTENT-SECURITY-POLICY — read this before you build.
116
+ Tile-app bundles are served with \`script-src 'self'\`. That means:
117
+ • NO inline <script> — put ALL your JavaScript in app.js (loaded below).
118
+ • NO eval() / new Function() — they are blocked. If your app needs to
119
+ evaluate expressions (a calculator, grapher, template engine, …), write
120
+ a real tokenizer/parser in app.js; you cannot shortcut with eval.
121
+ • NO network — no CDN scripts, remote fonts, or fetch() to other origins.
122
+ Keep the app fully self-contained (only the files in this folder).
123
+ Inline <style> is fine; CSS may also live in a bundled .css file.
124
+ -->
125
+ <style>
126
+ body { font: 15px/1.5 system-ui, sans-serif; margin: 0; display: grid; place-items: center;
127
+ height: 100vh; color: #e5e7eb; background: #111827; }
128
+ </style>
129
+ </head>
130
+ <body>
131
+ <main>
132
+ <h1>${title}</h1>
133
+ <p id="status">Loading…</p>
134
+ <p>Edit <code>index.html</code> + <code>app.js</code>, then re-run
135
+ <code>yolo tileapp publish tileapp.json --personal</code>.</p>
136
+ </main>
137
+ <!-- All JS lives in app.js (the CSP forbids inline scripts). type="module" so
138
+ you can \`import\` the app-sdk (a same-origin module is allowed under 'self'). -->
139
+ <script src="app.js" type="module"></script>
140
+ </body>
141
+ </html>
142
+ `;
143
+ const appJs = `// ${title} — personal tile-app logic.
144
+ //
145
+ // ⚠️ CSP: served under \`script-src 'self'\` → eval() and new Function() are
146
+ // BLOCKED, and there is no network. If you need to evaluate user input (e.g. a
147
+ // calculator/grapher), write a real parser here (tokenizer → shunting-yard →
148
+ // RPN eval) rather than reaching for eval. Keep everything self-contained.
149
+ //
150
+ // To call HOST capabilities (LLM, MCP tools, files), vendor @yololabs/app-sdk's
151
+ // browser build into ./vendor/app-sdk/ and request matching permissions in
152
+ // tileapp.json (permissions.required / optional):
153
+ // import { createTileApp } from './vendor/app-sdk/index.js';
154
+ // const app = createTileApp();
155
+ // const res = await app.call('llm', 'complete', { prompt: '...' });
156
+
157
+ const status = document.getElementById('status');
158
+ if (status) status.textContent = 'Ready — edit app.js to build your app.';
159
+ `;
160
+ try {
161
+ fs.mkdirSync(dir, { recursive: false });
162
+ fs.writeFileSync(path.join(dir, 'tileapp.json'), `${JSON.stringify(manifest, null, 2)}\n`);
163
+ fs.writeFileSync(path.join(dir, 'index.html'), indexHtml);
164
+ fs.writeFileSync(path.join(dir, 'app.js'), appJs);
165
+ }
166
+ catch (e) {
167
+ return { ok: false, kind: 'io', message: `scaffold failed: ${e.message}` };
168
+ }
169
+ return {
170
+ ok: true,
171
+ output: `Created ${name}/ (tileapp.json + index.html + app.js)\n note: bundle CSP is \`script-src 'self'\` — keep JS in app.js (no inline <script>, no eval, no network)\n next: cd ${name} && yolo tileapp publish tileapp.json --personal`,
172
+ };
173
+ }
174
+ /** Recursively collect a bundle dir's files as upload entries. Skips VCS/dep
175
+ * dirs and the manifest itself; classifies text vs binary by extension. */
176
+ function collectBundleFiles(rootDir, manifestPath) {
177
+ const out = [];
178
+ const manifestAbs = path.resolve(manifestPath);
179
+ const root = path.resolve(rootDir);
180
+ let total = 0;
181
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.yolo']);
182
+ const walk = (abs) => {
183
+ for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
184
+ // Skip dotfiles/dotdirs entirely — the bundle is served PUBLICLY, so a
185
+ // stray .env/.npmrc/.git from the project dir must never be uploaded (the
186
+ // server's sanitizeBundlePath also rejects dot-leading paths). codex Round-3 P1.
187
+ if (entry.name.startsWith('.'))
188
+ continue;
189
+ const childAbs = path.join(abs, entry.name);
190
+ if (entry.isDirectory()) {
191
+ if (SKIP_DIRS.has(entry.name))
192
+ continue;
193
+ const err = walk(childAbs);
194
+ if (err)
195
+ return err;
196
+ continue;
197
+ }
198
+ if (!entry.isFile())
199
+ continue;
200
+ if (path.resolve(childAbs) === manifestAbs)
201
+ continue; // never upload the manifest
202
+ const rel = path.relative(root, childAbs).split(path.sep).join('/');
203
+ const buf = fs.readFileSync(childAbs);
204
+ total += buf.byteLength;
205
+ if (out.length + 1 > MAX_FILES)
206
+ return `too many files (max ${MAX_FILES})`;
207
+ if (total > MAX_TOTAL_BYTES)
208
+ return `bundle exceeds ${MAX_TOTAL_BYTES} bytes — use a smaller bundle`;
209
+ const ext = path.extname(rel).toLowerCase();
210
+ if (TEXT_EXTS.has(ext))
211
+ out.push({ path: rel, content: buf.toString('utf8'), encoding: 'utf8' });
212
+ else
213
+ out.push({ path: rel, content: buf.toString('base64'), encoding: 'base64' });
214
+ }
215
+ return null;
216
+ };
217
+ try {
218
+ const err = walk(root);
219
+ if (err)
220
+ return { ok: false, message: err };
221
+ }
222
+ catch (e) {
223
+ return { ok: false, message: `reading bundle failed: ${e.message}` };
224
+ }
225
+ if (out.length === 0)
226
+ return { ok: false, message: `no files found in bundle dir: ${rootDir}` };
227
+ return { ok: true, files: out, totalBytes: total };
228
+ }
229
+ export async function runTileAppPublishPersonal(opts) {
230
+ const env = opts.env ?? process.env;
231
+ const auth = resolveAuth(env);
232
+ if (!auth.ok)
233
+ return { ok: false, kind: 'auth', message: auth.message };
234
+ const read = readManifest(opts.manifestPath);
235
+ if (!read.ok)
236
+ return { ok: false, kind: 'io', message: read.message };
237
+ // Guard non-object manifests (JSON `null`/array/scalar) BEFORE dereferencing —
238
+ // the runtime branch + id read below assume an object (the strict validator
239
+ // only runs on the pure-UI path).
240
+ if (typeof read.manifest !== 'object' || read.manifest === null || Array.isArray(read.manifest)) {
241
+ return { ok: false, kind: 'validation', message: 'manifest must be a JSON object' };
242
+ }
243
+ const localId = typeof read.manifest.id === 'string' ? read.manifest.id : '';
244
+ if (!LOCAL_ID_RE.test(localId)) {
245
+ return { ok: false, kind: 'validation', message: `manifest "id" must be a lowercase kebab-case slug, 2-32 chars (got '${localId}')` };
246
+ }
247
+ // Runtime app (has `runtime`/`image`): build the image in-pod + mediated push.
248
+ // Branch BEFORE the strict pure-UI lint, since a runtime manifest legitimately
249
+ // omits `image` (the server fills it from the actual pushed digest).
250
+ if (isRuntimeManifest(read.manifest)) {
251
+ return publishRuntime({
252
+ manifest: read.manifest, localId, manifestPath: opts.manifestPath,
253
+ context: opts.context, dockerfile: opts.dockerfile, builder: opts.builder,
254
+ auth, env, fetchImpl: opts.fetchImpl ?? fetch, execImpl: opts.execImpl ?? defaultExec,
255
+ });
256
+ }
257
+ // Pure-UI path: strict schema lint (the server re-validates authoritatively).
258
+ const v = validateManifest(read.manifest);
259
+ if (v.errors.length > 0) {
260
+ return { ok: false, kind: 'validation', message: `manifest invalid:\n - ${v.errors.join('\n - ')}` };
261
+ }
262
+ // Resolve + collect the static bundle BEFORE registering, so a bad bundle
263
+ // fails before we create a registry doc.
264
+ const resolved = resolveBundleDir(opts.manifestPath, read.manifest, opts.bundleDir);
265
+ if (!resolved.ok)
266
+ return { ok: false, kind: 'io', message: `bundle: ${resolved.message}` };
267
+ const collected = collectBundleFiles(resolved.dir, opts.manifestPath);
268
+ if (!collected.ok)
269
+ return { ok: false, kind: 'io', message: collected.message };
270
+ const fetchImpl = opts.fetchImpl ?? fetch;
271
+ const base = apiBase(auth.commonApiUrl);
272
+ const headers = { 'content-type': 'application/json', authorization: `Bearer ${auth.userToken}` };
273
+ // 1) Register the manifest (id + publisher are stamped server-side).
274
+ let appId;
275
+ try {
276
+ const res = await fetchImpl(`${base}/tileapps/personal`, {
277
+ method: 'POST',
278
+ headers,
279
+ body: JSON.stringify({ id: localId, manifest: read.manifest }),
280
+ });
281
+ if (!res.ok)
282
+ return { ok: false, kind: 'http', message: `register failed: HTTP ${res.status} — ${await safeText(res)}` };
283
+ const json = (await res.json());
284
+ if (!json.appId)
285
+ return { ok: false, kind: 'http', message: 'register response missing appId' };
286
+ appId = json.appId;
287
+ }
288
+ catch (e) {
289
+ return { ok: false, kind: 'http', message: `register request failed: ${e.message}` };
290
+ }
291
+ // 2) Upload the bundle.
292
+ try {
293
+ const res = await fetchImpl(`${base}/tileapps/personal/${encodeURIComponent(appId)}/bundle`, {
294
+ method: 'PUT',
295
+ headers,
296
+ body: JSON.stringify({ files: collected.files }),
297
+ });
298
+ if (!res.ok)
299
+ return { ok: false, kind: 'http', message: `bundle upload failed: HTTP ${res.status} — ${await safeText(res)}` };
300
+ }
301
+ catch (e) {
302
+ return { ok: false, kind: 'http', message: `bundle upload request failed: ${e.message}` };
303
+ }
304
+ return {
305
+ ok: true,
306
+ output: `Published ${appId} (${collected.files.length} file${collected.files.length === 1 ? '' : 's'}, ${collected.totalBytes} bytes)\n next: install it + add a tile from the workspace (or via the studio.install_app / studio.create_app_tile MCP tools).`,
307
+ };
308
+ }
309
+ /**
310
+ * Build the app image in-pod with podman, `save` it to an OCI archive in the
311
+ * workspace, and hand it to common-api's MEDIATED push endpoint — common-api
312
+ * mints the registry token + chooses the destination and directs container-api
313
+ * to push it. We never see a registry credential and never choose the dest.
314
+ */
315
+ async function publishRuntime(p) {
316
+ const { manifest, localId } = p;
317
+ const version = typeof manifest.version === 'string' ? manifest.version : '';
318
+ if (!version)
319
+ return { ok: false, kind: 'validation', message: 'manifest.version is required for a runtime app' };
320
+ // PRE-FLIGHT manifest validation BEFORE the (expensive) build + multi-MB
321
+ // upload — so a trivial manifest error (a missing `ui` block, bad surface,
322
+ // etc.) fails locally in milliseconds instead of after a full image build,
323
+ // push, and a server 400 (dogfood feedback 2026-06-18). A runtime manifest
324
+ // legitimately OMITS `image` (the server fills it from the pushed digest), so
325
+ // we drop ONLY that one expected error; every other field is validated exactly
326
+ // as the server will, surfacing all problems at once.
327
+ // SCOPE: this mirrors the shared manifest SCHEMA validator (catches the common
328
+ // dogfood errors — missing ui/surface/displayName — before the build). It does
329
+ // NOT duplicate common-api's personal-specific gates (the runtime.exec ban,
330
+ // per-permission tier/grantability rules) — those stay server-authoritative to
331
+ // avoid CLI↔server validator drift, so a manifest that passes here can still be
332
+ // rejected server-side. Acceptable: this strictly improves on the prior
333
+ // no-preflight behavior and never false-rejects.
334
+ // Validate with `image` STRIPPED: the publish-image path ignores any
335
+ // author-supplied image and the server fills ref/tag/digest from the actual
336
+ // pushed image, so a placeholder/partial `image` block (e.g. `image: {}`)
337
+ // must NOT trip a local image.ref/tag failure. Stripping it also leaves the
338
+ // "runtime requires either `image` or exec" error, which we filter (the push
339
+ // supplies the image). Everything else (ui, surface, …) is validated as the
340
+ // server will, surfacing all problems at once before the build.
341
+ const { image: _serverFilledImage, ...rest } = manifest;
342
+ const forValidation = {
343
+ ...rest,
344
+ // The server OVERWRITES publisher with `personal:<ownerId>` before validating
345
+ // (the author's value is ignored entirely), so ALWAYS stamp a valid
346
+ // placeholder here — an absent OR empty-string author publisher must not
347
+ // cause a false local rejection that the server wouldn't.
348
+ publisher: 'personal',
349
+ };
350
+ const v = validateManifest(forValidation);
351
+ if (!v.ok) {
352
+ const real = v.errors.filter((e) => !e.startsWith('runtime requires either `image`'));
353
+ if (real.length > 0) {
354
+ return { ok: false, kind: 'validation', message: `manifest invalid (fix before build):\n - ${real.join('\n - ')}` };
355
+ }
356
+ }
357
+ const manifestDir = path.dirname(path.resolve(p.manifestPath));
358
+ const context = p.context ? path.resolve(p.context) : manifestDir;
359
+ const dockerfile = p.dockerfile ? path.resolve(p.dockerfile) : path.join(context, 'Dockerfile');
360
+ try {
361
+ if (!fs.statSync(dockerfile).isFile())
362
+ return { ok: false, kind: 'io', message: `Dockerfile not found: ${dockerfile}` };
363
+ }
364
+ catch {
365
+ return { ok: false, kind: 'io', message: `Dockerfile not found: ${dockerfile}` };
366
+ }
367
+ const localTag = `localhost/tileapp-${localId}:${version}`;
368
+ // Archive goes to a LOCAL temp file — common-api does the push, so it never
369
+ // needs to be in the workspace / reachable by another in-pod process.
370
+ const archiveTmp = path.join(os.tmpdir(), `tileapp-build-${localId}-${version}-${Date.now()}.tar`);
371
+ const parsed = parseDockerfile(fs.readFileSync(dockerfile, 'utf-8'));
372
+ const builder = p.builder ?? 'auto';
373
+ let ociTmpDir = null;
374
+ // archiveTmp (and the assembler's ociTmpDir, when used) may exist even on a
375
+ // partial failure past this point, so the finally always reaps both.
376
+ try {
377
+ // Produce `archiveTmp` (an oci-archive) via the selected builder.
378
+ if (builder === 'podman' || (builder === 'auto' && !parsed.assemblable)) {
379
+ if (builder === 'auto') {
380
+ process.stdout.write(`Dockerfile needs a full container build (${parsed.unsupported.join('; ')}) — using podman.\n`);
381
+ }
382
+ process.stdout.write(`Building ${localTag} …\n`);
383
+ const build = await p.execImpl('podman', ['build', '-t', localTag, '-f', dockerfile, context]);
384
+ if (build.code !== 0)
385
+ return { ok: false, kind: 'io', message: `podman build failed (code ${build.code}): ${build.stderr.trim().slice(-4000)}` };
386
+ process.stdout.write(`Exporting OCI archive …\n`);
387
+ const save = await p.execImpl('podman', ['save', '--format', 'oci-archive', '-o', archiveTmp, localTag]);
388
+ if (save.code !== 0)
389
+ return { ok: false, kind: 'io', message: `podman save failed (code ${save.code}): ${save.stderr.trim().slice(-4000)}` };
390
+ }
391
+ else {
392
+ // skopeo assembler — no container engine (works under the in-pod Kata
393
+ // uid_map/fuse limits that break rootless podman, PERSONAL_TILE_APPS §7.1 #2).
394
+ if (!parsed.assemblable) {
395
+ return { ok: false, kind: 'validation', message: `--builder skopeo can't assemble this Dockerfile (it needs a full container build): ${parsed.unsupported.join('; ')}` };
396
+ }
397
+ ociTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `tileapp-oci-${localId}-`));
398
+ process.stdout.write(`Assembling ${localTag} via skopeo (no container engine) …\n`);
399
+ const asm = await assembleOciArchive({
400
+ baseRef: parsed.from, contextDir: context, parsed,
401
+ ociLayoutDir: ociTmpDir, outArchivePath: archiveTmp, tag: version,
402
+ exec: p.execImpl, log: (m) => process.stdout.write(`${m}\n`),
403
+ });
404
+ if (!asm.ok)
405
+ return { ok: false, kind: asm.kind, message: asm.message };
406
+ }
407
+ // Strip any `image` the author put in — common-api computes ref + the actual
408
+ // pushed digest and fills it in. `runtime` is carried through. The manifest
409
+ // travels in a header; the body is the raw archive (streamed, never buffered).
410
+ const sentManifest = { ...manifest };
411
+ delete sentManifest.image;
412
+ const manifestHeader = Buffer.from(JSON.stringify({ id: localId, manifest: sentManifest })).toString('base64');
413
+ // The manifest rides in a header (the body is the archive). Keep it well
414
+ // under HTTP header limits — a runtime manifest is small; if you've packed in
415
+ // large store-listing metadata (screenshots/changelog), trim it.
416
+ if (Buffer.byteLength(manifestHeader) > 7000) {
417
+ return { ok: false, kind: 'validation', message: 'manifest is too large to publish as a runtime app — remove bulky fields (screenshots/changelog) or shorten description/permissions' };
418
+ }
419
+ let size = 0;
420
+ try {
421
+ size = fs.statSync(archiveTmp).size;
422
+ }
423
+ catch { /* server validates the upload */ }
424
+ process.stdout.write(`Uploading + pushing (mediated, ${size} bytes) …\n`);
425
+ const res = await p.fetchImpl(`${apiBase(p.auth.commonApiUrl)}/tileapps/personal/publish-image`, {
426
+ method: 'POST',
427
+ headers: {
428
+ 'content-type': 'application/octet-stream',
429
+ authorization: `Bearer ${p.auth.userToken}`,
430
+ 'x-tileapp-manifest': manifestHeader,
431
+ },
432
+ body: fs.createReadStream(archiveTmp),
433
+ // Node/undici requires duplex for a streaming request body.
434
+ duplex: 'half',
435
+ });
436
+ if (!res.ok)
437
+ return { ok: false, kind: 'http', message: `publish-image failed: HTTP ${res.status} — ${await safeText(res)}` };
438
+ const json = (await res.json());
439
+ if (!json.appId)
440
+ return { ok: false, kind: 'http', message: 'publish-image response missing appId' };
441
+ return {
442
+ ok: true,
443
+ output: `Published runtime app ${json.appId}\n image: ${json.ref}:${json.tag}@${json.digest}\n next: install it + add a tile (studio.install_app / studio.create_app_tile).`,
444
+ };
445
+ }
446
+ catch (e) {
447
+ return { ok: false, kind: 'http', message: `publish-image request failed: ${e.message}` };
448
+ }
449
+ finally {
450
+ try {
451
+ fs.unlinkSync(archiveTmp);
452
+ }
453
+ catch { /* best effort */ }
454
+ if (ociTmpDir) {
455
+ try {
456
+ fs.rmSync(ociTmpDir, { recursive: true, force: true });
457
+ }
458
+ catch { /* best effort */ }
459
+ }
460
+ }
461
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `yolo tileapp sign|publish` — the publisher submission flow (M1).
3
+ *
4
+ * yolo tileapp sign <manifest.json> --publisher <id> [--key <keyId>] [--stdout]
5
+ * → POST /v1/publisher/sign — the platform KMS-signs the manifest on the
6
+ * publisher's behalf and the CLI writes `signature`/`publisherKeyId` back
7
+ * into the manifest file (or prints it with --stdout).
8
+ *
9
+ * yolo tileapp publish <manifest.json> [--channel beta|stable] [--image-digest <d>]
10
+ * → POST /v1/publisher/publish — submits the SIGNED manifest as a quarantine
11
+ * (`submitted`) release; review promotes it later. Prints the releaseId.
12
+ *
13
+ * Both use the user JWT directly (the publisher endpoints are user-authed), so
14
+ * no MCP delegation. The HTTP transport is injectable for unit tests.
15
+ */
16
+ import fs from 'node:fs';
17
+ import { resolveUserToken } from './auth-context.js';
18
+ function resolvePublisherAuth(env) {
19
+ const commonApiUrl = env.YOLO_COMMON_API_URL || env.YOLO_API_URL;
20
+ if (!commonApiUrl)
21
+ return { ok: false, message: 'YOLO_COMMON_API_URL (or YOLO_API_URL) env var is required' };
22
+ const userToken = resolveUserToken(env);
23
+ if (!userToken) {
24
+ return { ok: false, message: 'no user token: set YOLO_API_TOKEN or sign in (~/.config/yolo/token)' };
25
+ }
26
+ return { ok: true, commonApiUrl, userToken };
27
+ }
28
+ /** publisher routes live under `/v1`; `commonApiUrl` is the host base. */
29
+ function apiBase(commonApiUrl) {
30
+ const base = commonApiUrl.replace(/\/$/, '');
31
+ return base.endsWith('/v1') ? base : `${base}/v1`;
32
+ }
33
+ function readManifest(path) {
34
+ let raw;
35
+ try {
36
+ raw = fs.readFileSync(path, 'utf-8');
37
+ }
38
+ catch (e) {
39
+ return { ok: false, message: `cannot read manifest '${path}': ${e.message}` };
40
+ }
41
+ try {
42
+ return { ok: true, manifest: JSON.parse(raw) };
43
+ }
44
+ catch (e) {
45
+ return { ok: false, message: `manifest '${path}' is not valid JSON: ${e.message}` };
46
+ }
47
+ }
48
+ async function safeText(res) {
49
+ try {
50
+ return await res.text();
51
+ }
52
+ catch {
53
+ return '<no body>';
54
+ }
55
+ }
56
+ export async function runTileAppSign(opts) {
57
+ const env = opts.env ?? process.env;
58
+ if (!opts.publisherId)
59
+ return { ok: false, kind: 'usage', message: '--publisher <id> is required' };
60
+ const auth = resolvePublisherAuth(env);
61
+ if (!auth.ok)
62
+ return { ok: false, kind: 'auth', message: auth.message };
63
+ const read = readManifest(opts.manifestPath);
64
+ if (!read.ok)
65
+ return { ok: false, kind: 'io', message: read.message };
66
+ const fetchImpl = opts.fetchImpl ?? fetch;
67
+ let res;
68
+ try {
69
+ res = await fetchImpl(`${apiBase(auth.commonApiUrl)}/publisher/sign`, {
70
+ method: 'POST',
71
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${auth.userToken}` },
72
+ body: JSON.stringify({ publisherId: opts.publisherId, keyId: opts.keyId, manifest: read.manifest }),
73
+ });
74
+ }
75
+ catch (e) {
76
+ return { ok: false, kind: 'http', message: `sign request failed: ${e.message}` };
77
+ }
78
+ if (!res.ok)
79
+ return { ok: false, kind: 'http', message: `sign failed: HTTP ${res.status} — ${await safeText(res)}` };
80
+ const json = (await res.json());
81
+ if (!json.signature || !json.publisherKeyId)
82
+ return { ok: false, kind: 'http', message: 'sign response missing signature/publisherKeyId' };
83
+ const signed = { ...read.manifest, signature: json.signature, publisherKeyId: json.publisherKeyId };
84
+ if (opts.toStdout)
85
+ return { ok: true, output: JSON.stringify(signed, null, 2) };
86
+ try {
87
+ fs.writeFileSync(opts.manifestPath, `${JSON.stringify(signed, null, 2)}\n`);
88
+ }
89
+ catch (e) {
90
+ return { ok: false, kind: 'io', message: `cannot write signed manifest: ${e.message}` };
91
+ }
92
+ return { ok: true, output: `Signed ${String(read.manifest.id)}@${String(read.manifest.version)} with key ${json.publisherKeyId} → ${opts.manifestPath}` };
93
+ }
94
+ export async function runTileAppPublish(opts) {
95
+ const env = opts.env ?? process.env;
96
+ const auth = resolvePublisherAuth(env);
97
+ if (!auth.ok)
98
+ return { ok: false, kind: 'auth', message: auth.message };
99
+ const read = readManifest(opts.manifestPath);
100
+ if (!read.ok)
101
+ return { ok: false, kind: 'io', message: read.message };
102
+ if (!read.manifest.signature) {
103
+ return { ok: false, kind: 'usage', message: 'manifest is not signed — run `yolo tileapp sign` first' };
104
+ }
105
+ const fetchImpl = opts.fetchImpl ?? fetch;
106
+ let res;
107
+ try {
108
+ // Only send `channel` when the user explicitly passed --channel, so the
109
+ // CLI default can't diverge from the server's (the publish endpoint defaults
110
+ // to `stable`). Same for imageDigest.
111
+ const body = { manifest: read.manifest };
112
+ if (opts.channel)
113
+ body.channel = opts.channel;
114
+ if (opts.imageDigest)
115
+ body.imageDigest = opts.imageDigest;
116
+ res = await fetchImpl(`${apiBase(auth.commonApiUrl)}/publisher/publish`, {
117
+ method: 'POST',
118
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${auth.userToken}` },
119
+ body: JSON.stringify(body),
120
+ });
121
+ }
122
+ catch (e) {
123
+ return { ok: false, kind: 'http', message: `publish request failed: ${e.message}` };
124
+ }
125
+ if (!res.ok)
126
+ return { ok: false, kind: 'http', message: `publish failed: HTTP ${res.status} — ${await safeText(res)}` };
127
+ const json = (await res.json());
128
+ if (!json.releaseId)
129
+ return { ok: false, kind: 'http', message: 'publish response missing releaseId' };
130
+ return { ok: true, output: `Submitted ${json.releaseId} (status: ${json.status ?? 'submitted'}) — awaiting review` };
131
+ }
132
+ export function exitCodeForFailure(kind) {
133
+ return kind === 'http' ? 1 : 64;
134
+ }