@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.
- package/README.md +57 -0
- package/dist/artifact-get.js +131 -0
- package/dist/artifact-list.js +133 -0
- package/dist/auth-context.js +60 -0
- package/dist/canonicalizer.js +208 -0
- package/dist/cli.js +1403 -0
- package/dist/context.js +58 -0
- package/dist/deploy-bundle.js +394 -0
- package/dist/deploy-cli.js +1341 -0
- package/dist/deploy-client.js +460 -0
- package/dist/deploy-config.js +301 -0
- package/dist/deploy-detect.js +498 -0
- package/dist/deploy-ship.js +389 -0
- package/dist/lockfile.js +203 -0
- package/dist/plan-diff.js +162 -0
- package/dist/plan-export.js +261 -0
- package/dist/plan-frontmatter.schema.json +184 -0
- package/dist/plan-get.js +244 -0
- package/dist/plan-import.js +420 -0
- package/dist/plan-list.js +153 -0
- package/dist/plan-open.js +100 -0
- package/dist/plan-state.js +228 -0
- package/dist/plan-validate.js +270 -0
- package/dist/revision.js +43 -0
- package/dist/run-get.js +130 -0
- package/dist/run-lifecycle.js +133 -0
- package/dist/run-list.js +148 -0
- package/dist/run-start.js +110 -0
- package/dist/run-transfer.js +128 -0
- package/dist/serve.js +227 -0
- package/dist/tileapp-developer.js +222 -0
- package/dist/tileapp-oci-assembler.js +591 -0
- package/dist/tileapp-personal.js +461 -0
- package/dist/tileapp-publisher.js +134 -0
- package/dist/tileapp-validator.js +239 -0
- package/dist/vendored/flexdb.mjs +1087 -0
- package/dist/webapp-url.js +61 -0
- package/dist/work-client.js +195 -0
- package/package.json +39 -0
|
@@ -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
|
+
}
|