@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,591 @@
1
+ /**
2
+ * skopeo-based OCI image assembler — a container-engine-free builder for
3
+ * RUN-less ("COPY-only") Dockerfiles (PERSONAL_TILE_APPS_PLAN §7.1 #2).
4
+ *
5
+ * Rootless `podman build` is non-functional in the session pod (Kata blocks
6
+ * newuidmap uid_map writes; /dev/fuse is absent), so the normal runtime publish
7
+ * path can't build. But the vast majority of personal runtime apps are just
8
+ * `FROM <base>` + `COPY <app files>` + `CMD …` — which needs no build engine at
9
+ * all: we can PULL the base with skopeo (pure blob copy — no namespaces, no
10
+ * fuse), append ONE filesystem layer containing the COPY'd files, patch the
11
+ * image config (Cmd/Entrypoint/WorkingDir/ExposedPorts/Env/User), and EXPORT an
12
+ * oci-archive — byte-compatible with what `podman save --format oci-archive`
13
+ * produces, so the rest of the publish path (mediated push) is unchanged.
14
+ *
15
+ * Supported Dockerfile subset: a single `FROM`, `COPY`/`ADD` (local files only),
16
+ * `CMD`, `ENTRYPOINT`, `WORKDIR`, `EXPOSE`, `ENV`, `USER`. Anything that needs to
17
+ * EXECUTE in the image — `RUN`, multi-stage (`FROM … AS` / `COPY --from=`),
18
+ * `ADD <url>`, glob sources — is rejected as not-assemblable (the caller falls
19
+ * back to podman for those).
20
+ *
21
+ * Layers are written UNCOMPRESSED (mediaType …layer.v1.tar) so the layer digest
22
+ * and its diffID are identical — no gzip bookkeeping. All blob/config/manifest
23
+ * digests are computed from the exact bytes written, so skopeo's copy-time
24
+ * digest validation passes.
25
+ *
26
+ * KNOWN LIMITATIONS (inherent to assembling without the base image's filesystem;
27
+ * the common personal-app case — base + COPY app files + CMD/ENV/EXPOSE — is
28
+ * exact, and anything that needs a real build is rejected as not-assemblable so
29
+ * it never silently produces a wrong image):
30
+ * - COPY of a SINGLE file to an existing base-image directory WITHOUT a
31
+ * trailing slash (e.g. `COPY index.html /usr/share/nginx/html`) lands the
32
+ * file AT that path rather than inside the dir — we can't see the base FS to
33
+ * know it's a directory. Workaround = the Docker best practice: write a
34
+ * trailing slash (`…/html/`) or a WORKDIR; both are honored.
35
+ * - Cosmetic config directives that don't affect the running surface — LABEL,
36
+ * VOLUME, HEALTHCHECK, STOPSIGNAL — are dropped (not applied to the config).
37
+ * - Symlinks INSIDE a copied directory are skipped (not preserved as links).
38
+ */
39
+ import fs from 'node:fs';
40
+ import path from 'node:path';
41
+ import { createHash } from 'node:crypto';
42
+ /** Normalize a POSIX path (collapse `.`/`..`/`//`), preserving absoluteness. */
43
+ function posixNormalize(p) {
44
+ const isAbs = p.startsWith('/');
45
+ const parts = [];
46
+ for (const seg of p.split('/')) {
47
+ if (seg === '' || seg === '.')
48
+ continue;
49
+ if (seg === '..') {
50
+ if (parts.length && parts[parts.length - 1] !== '..')
51
+ parts.pop();
52
+ else if (!isAbs)
53
+ parts.push('..');
54
+ }
55
+ else
56
+ parts.push(seg);
57
+ }
58
+ return (isAbs ? '/' : '') + parts.join('/');
59
+ }
60
+ /** Split a token list (ws-separated) honoring nothing fancy — Dockerfiles here are simple. */
61
+ function tokenize(s) {
62
+ return s.trim().split(/\s+/).filter(Boolean);
63
+ }
64
+ /** Parse a CMD/ENTRYPOINT value: JSON-array (exec) form, else shell form. */
65
+ function parseExecOrShell(rest) {
66
+ const t = rest.trim();
67
+ if (t.startsWith('[')) {
68
+ try {
69
+ const arr = JSON.parse(t);
70
+ if (Array.isArray(arr) && arr.every((x) => typeof x === 'string'))
71
+ return arr;
72
+ }
73
+ catch { /* fall through to shell form */ }
74
+ }
75
+ // Shell form → run via /bin/sh -c (matches Docker semantics).
76
+ return ['/bin/sh', '-c', t];
77
+ }
78
+ export function parseDockerfile(text) {
79
+ const out = {
80
+ from: null, copies: [], cmd: null, entrypoint: null, workdir: null,
81
+ user: null, exposes: [], env: [], assemblable: false, unsupported: [],
82
+ };
83
+ // Join continuation lines (trailing backslash) into logical lines.
84
+ const logical = [];
85
+ let buf = '';
86
+ for (const raw of text.split('\n')) {
87
+ const line = raw.replace(/\r$/, '');
88
+ const trimmedStart = line.trimStart();
89
+ if (buf === '' && (trimmedStart === '' || trimmedStart.startsWith('#')))
90
+ continue; // blank / comment
91
+ if (/\\\s*$/.test(line)) {
92
+ buf += line.replace(/\\\s*$/, '') + ' ';
93
+ continue;
94
+ }
95
+ buf += line;
96
+ logical.push(buf.trim());
97
+ buf = '';
98
+ }
99
+ if (buf.trim())
100
+ logical.push(buf.trim());
101
+ let fromCount = 0;
102
+ let currentWorkdir = '/'; // WORKDIR active at the current line (Docker default /)
103
+ const workdirs = new Set(); // declared WORKDIRs — they exist as dirs, so a COPY into one copies INTO it
104
+ for (const line of logical) {
105
+ const m = /^(\w+)\s+(.*)$/.exec(line);
106
+ if (!m)
107
+ continue;
108
+ const instr = m[1].toUpperCase();
109
+ const rest = m[2].trim();
110
+ switch (instr) {
111
+ case 'FROM': {
112
+ fromCount += 1;
113
+ if (fromCount > 1) {
114
+ out.unsupported.push('multi-stage build (multiple FROM)');
115
+ break;
116
+ }
117
+ if (/\sAS\s/i.test(` ${rest} `)) {
118
+ out.unsupported.push('multi-stage build (FROM … AS …)');
119
+ break;
120
+ }
121
+ // A pinned platform would need the right per-arch base; we pull the host
122
+ // platform, so fall back rather than risk a wrong-arch image.
123
+ if (/--platform[=\s]/.test(rest)) {
124
+ out.unsupported.push('FROM --platform (cross-arch needs a full build)');
125
+ break;
126
+ }
127
+ const toks = tokenize(rest).filter((t) => !t.startsWith('--'));
128
+ const base = toks[0] ?? null;
129
+ // ARG-substituted base (`FROM ${BASE}`) can't be resolved here → fall back.
130
+ if (base && base.includes('$')) {
131
+ out.unsupported.push('FROM uses an ARG/variable base');
132
+ break;
133
+ }
134
+ out.from = base;
135
+ break;
136
+ }
137
+ case 'RUN':
138
+ out.unsupported.push('RUN steps require a full container build');
139
+ break;
140
+ case 'COPY':
141
+ case 'ADD': {
142
+ const toks = tokenize(rest);
143
+ const flags = toks.filter((t) => t.startsWith('--'));
144
+ const args = toks.filter((t) => !t.startsWith('--'));
145
+ if (flags.some((f) => f.startsWith('--from'))) {
146
+ out.unsupported.push(`${instr} --from= (multi-stage copy)`);
147
+ break;
148
+ }
149
+ if (args.length < 2) {
150
+ out.unsupported.push(`${instr} needs a source and a destination`);
151
+ break;
152
+ }
153
+ // Only --chmod is implemented. Any other flag (--chown changes ownership,
154
+ // --link changes layer semantics, …) would make the assembled image
155
+ // differ from `podman build`, so treat it as not-assemblable → fall back.
156
+ const unhandled = flags.filter((f) => !f.startsWith('--chmod='));
157
+ if (unhandled.length) {
158
+ out.unsupported.push(`${instr} ${unhandled.join(' ')} (unsupported flag — needs a full build)`);
159
+ break;
160
+ }
161
+ const rawDest = args[args.length - 1];
162
+ if (rawDest.includes('$')) {
163
+ out.unsupported.push(`${instr} uses an ARG/variable destination '${rawDest}'`);
164
+ break;
165
+ }
166
+ const srcs = args.slice(0, -1);
167
+ let mode;
168
+ const chmod = flags.find((f) => f.startsWith('--chmod='));
169
+ if (chmod) {
170
+ const v = parseInt(chmod.split('=')[1], 8);
171
+ if (!Number.isNaN(v))
172
+ mode = v;
173
+ }
174
+ // Resolve a relative dest against the WORKDIR in effect here (Docker:
175
+ // relative COPY destinations are relative to WORKDIR). Record whether the
176
+ // author meant a directory target (trailing slash / `.` / `/`).
177
+ const dest = rawDest.startsWith('/') ? posixNormalize(rawDest) : posixNormalize(`${currentWorkdir}/${rawDest}`);
178
+ // A dir target when the author wrote a trailing slash / `.` / `/`, OR the
179
+ // dest is a declared WORKDIR (which exists as a dir, so Docker copies INTO it).
180
+ const destIsDir = /\/$/.test(rawDest) || rawDest === '.' || rawDest === '/' || workdirs.has(dest);
181
+ for (const src of srcs) {
182
+ if (src.includes('$')) {
183
+ out.unsupported.push(`${instr} uses an ARG/variable path '${src}'`);
184
+ continue;
185
+ }
186
+ if (/[*?[\]]/.test(src)) {
187
+ out.unsupported.push(`${instr} glob source '${src}'`);
188
+ continue;
189
+ }
190
+ if (instr === 'ADD' && /^https?:\/\//i.test(src)) {
191
+ out.unsupported.push(`ADD <url> '${src}'`);
192
+ continue;
193
+ }
194
+ // ADD auto-extracts local archives — we'd stage the tarball verbatim, so fall back.
195
+ if (instr === 'ADD' && /\.(tar|tgz|tbz2?|txz|tar\.(gz|bz2|xz|zst))$/i.test(src)) {
196
+ out.unsupported.push(`ADD archive '${src}' (auto-extract needs a full build)`);
197
+ continue;
198
+ }
199
+ out.copies.push({ src, dest, destIsDir, mode });
200
+ }
201
+ break;
202
+ }
203
+ case 'CMD':
204
+ out.cmd = parseExecOrShell(rest);
205
+ break;
206
+ case 'ENTRYPOINT':
207
+ out.entrypoint = parseExecOrShell(rest);
208
+ break;
209
+ case 'WORKDIR': {
210
+ const w = rest.trim();
211
+ // ARG/ENV-expanded WORKDIR (`WORKDIR $APP_HOME`) can't be resolved here.
212
+ if (w.includes('$')) {
213
+ out.unsupported.push('WORKDIR uses an ARG/variable path');
214
+ break;
215
+ }
216
+ currentWorkdir = w.startsWith('/') ? posixNormalize(w) : posixNormalize(`${currentWorkdir}/${w}`);
217
+ out.workdir = currentWorkdir;
218
+ workdirs.add(currentWorkdir);
219
+ break;
220
+ }
221
+ case 'USER':
222
+ out.user = tokenize(rest)[0] ?? null;
223
+ break;
224
+ case 'EXPOSE':
225
+ for (const t of tokenize(rest)) {
226
+ const [pStr, protoRaw] = t.split('/');
227
+ const n = parseInt(pStr, 10);
228
+ const proto = (protoRaw || 'tcp').toLowerCase();
229
+ const key = `${n}/${proto}`;
230
+ if (Number.isInteger(n) && n > 0 && !out.exposes.includes(key))
231
+ out.exposes.push(key);
232
+ }
233
+ break;
234
+ case 'ENV': {
235
+ // `ENV K=V [K2=V2 …]` or legacy `ENV K rest-of-line`. A quoted value may
236
+ // contain spaces (`ENV NODE_OPTIONS="--a --b"`), which the whitespace
237
+ // tokenizer would split — so when quotes are present treat the line as a
238
+ // SINGLE assignment and strip the surrounding quotes (the common form).
239
+ const stripQuotes = (v) => v.replace(/^(['"])([\s\S]*)\1$/, '$2');
240
+ if (/["']/.test(rest) && rest.includes('=')) {
241
+ const m2 = /^(\S+?)=([\s\S]*)$/.exec(rest);
242
+ if (m2)
243
+ out.env.push({ k: m2[1], v: stripQuotes(m2[2].trim()) });
244
+ }
245
+ else if (rest.includes('=')) {
246
+ for (const t of tokenize(rest)) {
247
+ const i = t.indexOf('=');
248
+ if (i > 0)
249
+ out.env.push({ k: t.slice(0, i), v: t.slice(i + 1) });
250
+ }
251
+ }
252
+ else {
253
+ const t = tokenize(rest);
254
+ if (t.length >= 2)
255
+ out.env.push({ k: t[0], v: stripQuotes(t.slice(1).join(' ')) });
256
+ }
257
+ break;
258
+ }
259
+ case 'SHELL':
260
+ // SHELL changes how shell-form CMD/ENTRYPOINT are wrapped; we hard-code
261
+ // /bin/sh -c, so fall back rather than run a different command.
262
+ out.unsupported.push('SHELL overrides the default shell (needs a full build)');
263
+ break;
264
+ default:
265
+ // LABEL/VOLUME/HEALTHCHECK/STOPSIGNAL/ARG/… don't affect the running
266
+ // surface (see the module header's known-limitations note) — dropped.
267
+ break;
268
+ }
269
+ }
270
+ if (!out.from)
271
+ out.unsupported.push('no FROM instruction');
272
+ out.assemblable = out.from !== null && out.unsupported.length === 0;
273
+ return out;
274
+ }
275
+ function tarHeader(name, size, mode, type) {
276
+ const h = Buffer.alloc(512, 0);
277
+ let nm = name;
278
+ let prefix = '';
279
+ if (Buffer.byteLength(name) > 100) {
280
+ const cut = name.lastIndexOf('/', name.length - 1 - (name.length - 100));
281
+ if (cut > 0 && Buffer.byteLength(name.slice(cut + 1)) <= 100 && Buffer.byteLength(name.slice(0, cut)) <= 155) {
282
+ prefix = name.slice(0, cut);
283
+ nm = name.slice(cut + 1);
284
+ }
285
+ else {
286
+ throw new Error(`path too long for tar (>100 bytes, unsplittable): ${name}`);
287
+ }
288
+ }
289
+ h.write(nm, 0, 100, 'utf8');
290
+ h.write((mode & 0o7777).toString(8).padStart(7, '0') + '\0', 100, 8, 'ascii');
291
+ h.write('0000000\0', 108, 8, 'ascii'); // uid
292
+ h.write('0000000\0', 116, 8, 'ascii'); // gid
293
+ h.write(size.toString(8).padStart(11, '0') + '\0', 124, 12, 'ascii');
294
+ h.write('00000000000\0', 136, 12, 'ascii'); // mtime = 0 (deterministic)
295
+ h.write(' ', 148, 8, 'ascii'); // checksum placeholder (8 spaces)
296
+ h.write(type, 156, 1, 'ascii');
297
+ h.write('ustar\0', 257, 6, 'ascii');
298
+ h.write('00', 263, 2, 'ascii');
299
+ if (prefix)
300
+ h.write(prefix, 345, 155, 'utf8');
301
+ let sum = 0;
302
+ for (let i = 0; i < 512; i++)
303
+ sum += h[i];
304
+ h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
305
+ return h;
306
+ }
307
+ function buildTar(entries) {
308
+ const chunks = [];
309
+ for (const e of entries) {
310
+ const size = e.type === '0' ? (e.content?.length ?? 0) : 0;
311
+ chunks.push(tarHeader(e.name, size, e.mode, e.type));
312
+ if (e.type === '0' && e.content && e.content.length) {
313
+ chunks.push(e.content);
314
+ const pad = (512 - (e.content.length % 512)) % 512;
315
+ if (pad)
316
+ chunks.push(Buffer.alloc(pad, 0));
317
+ }
318
+ }
319
+ chunks.push(Buffer.alloc(1024, 0)); // two trailing zero blocks
320
+ return Buffer.concat(chunks);
321
+ }
322
+ function walkFiles(root, onFile) {
323
+ for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
324
+ const abs = path.join(root, ent.name);
325
+ if (ent.isDirectory())
326
+ walkFiles(abs, onFile);
327
+ else if (ent.isFile())
328
+ onFile(abs);
329
+ }
330
+ }
331
+ /**
332
+ * Build a `.dockerignore` matcher (a subset: `#` comments, blank lines, `*`/`**`/
333
+ * `?` globs, and `!` negation; last matching rule wins). Returns a predicate over
334
+ * a context-relative POSIX path. Honoring this is load-bearing: `auto` routes
335
+ * `COPY . /app` through the assembler, so without it an ignored `.env` /
336
+ * `node_modules` would be packed into the published image.
337
+ */
338
+ function loadDockerignore(contextDir) {
339
+ let text;
340
+ try {
341
+ text = fs.readFileSync(path.join(contextDir, '.dockerignore'), 'utf-8');
342
+ }
343
+ catch {
344
+ return () => false;
345
+ }
346
+ const rules = [];
347
+ for (const raw of text.split('\n')) {
348
+ let line = raw.trim();
349
+ if (!line || line.startsWith('#'))
350
+ continue;
351
+ let negate = false;
352
+ if (line.startsWith('!')) {
353
+ negate = true;
354
+ line = line.slice(1).trim();
355
+ }
356
+ line = line.replace(/^\/+/, '').replace(/\/+$/, '');
357
+ if (!line)
358
+ continue;
359
+ const hasSlash = line.includes('/');
360
+ if (/[*?]/.test(line)) {
361
+ const re = new RegExp('^' + line.split('').map((ch) => {
362
+ if (ch === '*')
363
+ return 'STAR';
364
+ if (ch === '?')
365
+ return '[^/]';
366
+ return ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
367
+ }).join('').replace(/STARSTAR/g, '.*').replace(/STAR/g, '[^/]*') + '$');
368
+ // Slash-less glob (`*.log`) matches by BASENAME at any depth; a glob with
369
+ // `/` matches the full path.
370
+ rules.push({ negate, test: (rel) => hasSlash ? re.test(rel) : re.test(rel.split('/').pop() ?? rel) });
371
+ }
372
+ else if (hasSlash) {
373
+ rules.push({ negate, test: (rel) => rel === line || rel.startsWith(line + '/') });
374
+ }
375
+ else {
376
+ // Slash-less literal (`node_modules`, `.env`) matches that name as ANY path
377
+ // component at any depth — so `COPY . /app` can't leak a nested
378
+ // `packages/x/node_modules` or `src/.env`.
379
+ rules.push({ negate, test: (rel) => rel.split('/').includes(line) });
380
+ }
381
+ }
382
+ if (!rules.length)
383
+ return () => false;
384
+ return (rel) => {
385
+ let ignored = false;
386
+ for (const r of rules)
387
+ if (r.test(rel))
388
+ ignored = !r.negate;
389
+ return ignored;
390
+ };
391
+ }
392
+ /** Stage the Dockerfile's COPY instructions into a deterministic layer tar. */
393
+ export function buildCopyLayer(contextDir, copies) {
394
+ const files = [];
395
+ const ignored = loadDockerignore(contextDir);
396
+ for (const c of copies) {
397
+ const srcAbs = path.resolve(contextDir, c.src);
398
+ const rel = path.relative(contextDir, srcAbs);
399
+ if (rel.startsWith('..') || path.isAbsolute(rel))
400
+ return { ok: false, message: `COPY source escapes the build context: ${c.src}` };
401
+ // A COPY source that is a symlink (to a file OR dir) escaping the context
402
+ // would otherwise have its TARGET read/walked — leaking host files (~/.ssh,
403
+ // /etc, a sibling .env) into the published image. statSync FOLLOWS links, so
404
+ // check the link target's real path is still inside the context first. (The
405
+ // dir-walk below skips symlinks entirely, so this covers the explicit-source
406
+ // vector.)
407
+ try {
408
+ if (fs.lstatSync(srcAbs).isSymbolicLink()) {
409
+ const realRel = path.relative(contextDir, fs.realpathSync(srcAbs));
410
+ if (realRel.startsWith('..') || path.isAbsolute(realRel)) {
411
+ return { ok: false, message: `COPY source symlink escapes the build context: ${c.src}` };
412
+ }
413
+ }
414
+ }
415
+ catch {
416
+ return { ok: false, message: `COPY source not found: ${c.src}` };
417
+ }
418
+ let st;
419
+ try {
420
+ st = fs.statSync(srcAbs);
421
+ }
422
+ catch {
423
+ return { ok: false, message: `COPY source not found: ${c.src}` };
424
+ }
425
+ const destClean = c.dest.replace(/^\/+/, '').replace(/\/+$/, '');
426
+ const destIsDir = c.destIsDir ?? (/\/$/.test(c.dest) || c.dest === '/' || c.dest === '.');
427
+ if (st.isDirectory()) {
428
+ // Docker: COPY <dir> <dest> copies the CONTENTS of <dir> into <dest>.
429
+ walkFiles(srcAbs, (fileAbs) => {
430
+ const relToCtx = path.relative(contextDir, fileAbs).split(path.sep).join('/');
431
+ if (ignored(relToCtx))
432
+ return; // honor .dockerignore (don't leak ignored files)
433
+ const relFromSrc = path.relative(srcAbs, fileAbs).split(path.sep).join('/');
434
+ const name = (destClean ? destClean + '/' : '') + relFromSrc;
435
+ // Preserve the source mode (Docker default) unless --chmod overrode it.
436
+ files.push({ name, content: fs.readFileSync(fileAbs), mode: c.mode ?? (fs.statSync(fileAbs).mode & 0o777) });
437
+ });
438
+ }
439
+ else {
440
+ // Honor .dockerignore for an explicitly-named file too — an ignored `.env`
441
+ // / key is NOT in the build context, so it must not be packaged even when
442
+ // the Dockerfile names it directly (leak parity with the dir-walk above).
443
+ if (ignored(rel.split(path.sep).join('/')))
444
+ continue;
445
+ const name = destIsDir ? (destClean ? destClean + '/' : '') + path.basename(srcAbs) : (destClean || path.basename(srcAbs));
446
+ files.push({ name, content: fs.readFileSync(srcAbs), mode: c.mode ?? (st.mode & 0o777) });
447
+ }
448
+ }
449
+ if (files.length === 0)
450
+ return { ok: false, message: 'the Dockerfile COPYs no files into the image' };
451
+ // Synthesize directory entries for every ancestor path (deduped, sorted).
452
+ const dirSet = new Set();
453
+ for (const f of files) {
454
+ const parts = f.name.split('/');
455
+ parts.pop();
456
+ let acc = '';
457
+ for (const p of parts) {
458
+ acc += p + '/';
459
+ dirSet.add(acc);
460
+ }
461
+ }
462
+ const entries = [
463
+ ...[...dirSet].sort().map((d) => ({ name: d, mode: 0o755, type: '5' })),
464
+ ...files.sort((a, b) => a.name.localeCompare(b.name)).map((f) => ({ name: f.name, mode: f.mode, type: '0', content: f.content })),
465
+ ];
466
+ return { ok: true, tar: buildTar(entries) };
467
+ }
468
+ // ── OCI image config patching ────────────────────────────────────────────────
469
+ /** Apply the Dockerfile's runtime directives onto an OCI image config's `.config`. */
470
+ export function applyDockerfileToConfig(cfg, parsed) {
471
+ if (parsed.entrypoint)
472
+ cfg.Entrypoint = parsed.entrypoint;
473
+ if (parsed.cmd)
474
+ cfg.Cmd = parsed.cmd;
475
+ if (parsed.workdir)
476
+ cfg.WorkingDir = parsed.workdir;
477
+ if (parsed.user)
478
+ cfg.User = parsed.user;
479
+ if (parsed.exposes.length) {
480
+ const ep = { ...cfg.ExposedPorts };
481
+ for (const portProto of parsed.exposes)
482
+ ep[portProto] = {}; // already `port/proto`
483
+ cfg.ExposedPorts = ep;
484
+ }
485
+ if (parsed.env.length) {
486
+ const envArr = Array.isArray(cfg.Env) ? [...cfg.Env] : [];
487
+ for (const { k, v } of parsed.env) {
488
+ const i = envArr.findIndex((e) => e.startsWith(`${k}=`));
489
+ if (i >= 0)
490
+ envArr[i] = `${k}=${v}`;
491
+ else
492
+ envArr.push(`${k}=${v}`);
493
+ }
494
+ cfg.Env = envArr;
495
+ }
496
+ }
497
+ // ── assembler ────────────────────────────────────────────────────────────────
498
+ function sha256hex(buf) { return createHash('sha256').update(buf).digest('hex'); }
499
+ function blobPath(layoutDir, digest) { return path.join(layoutDir, 'blobs', 'sha256', digest.replace('sha256:', '')); }
500
+ function writeBlob(layoutDir, digest, buf) { fs.writeFileSync(blobPath(layoutDir, digest), buf); }
501
+ /** Materialize a minimal empty OCI layout (a `FROM scratch` base). */
502
+ function writeScratchBase(layoutDir, tag) {
503
+ fs.mkdirSync(path.join(layoutDir, 'blobs', 'sha256'), { recursive: true });
504
+ fs.writeFileSync(path.join(layoutDir, 'oci-layout'), JSON.stringify({ imageLayoutVersion: '1.0.0' }));
505
+ const config = { architecture: 'amd64', os: 'linux', config: {}, rootfs: { type: 'layers', diff_ids: [] }, history: [] };
506
+ const cBuf = Buffer.from(JSON.stringify(config));
507
+ const cDig = 'sha256:' + sha256hex(cBuf);
508
+ writeBlob(layoutDir, cDig, cBuf);
509
+ const manifest = { schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', config: { mediaType: 'application/vnd.oci.image.config.v1+json', digest: cDig, size: cBuf.length }, layers: [] };
510
+ const mBuf = Buffer.from(JSON.stringify(manifest));
511
+ const mDig = 'sha256:' + sha256hex(mBuf);
512
+ writeBlob(layoutDir, mDig, mBuf);
513
+ fs.writeFileSync(path.join(layoutDir, 'index.json'), JSON.stringify({ schemaVersion: 2, manifests: [{ mediaType: 'application/vnd.oci.image.manifest.v1+json', digest: mDig, size: mBuf.length, annotations: { 'org.opencontainers.image.ref.name': tag } }] }));
514
+ }
515
+ /**
516
+ * Assemble + export an oci-archive for a COPY-only Dockerfile, no container
517
+ * engine. Caller passes an already-empty `ociLayoutDir` and is responsible for
518
+ * reaping it + `outArchivePath`.
519
+ */
520
+ export async function assembleOciArchive(opts) {
521
+ const { baseRef, contextDir, parsed, ociLayoutDir, outArchivePath, tag, exec, log } = opts;
522
+ if (baseRef.toLowerCase() === 'scratch') {
523
+ // `scratch` is a Dockerfile sentinel for an EMPTY base, not a pullable image
524
+ // — synthesize a minimal OCI layout (no base layer) instead of skopeo-pulling.
525
+ log('Base is `scratch` — starting from an empty image …');
526
+ writeScratchBase(ociLayoutDir, tag);
527
+ }
528
+ else {
529
+ log(`Pulling base image ${baseRef} (skopeo) …`);
530
+ const pull = await exec('skopeo', ['copy', '--quiet', `docker://${baseRef}`, `oci:${ociLayoutDir}:${tag}`]);
531
+ if (pull.code !== 0) {
532
+ return { ok: false, kind: 'io', message: `skopeo pull of base '${baseRef}' failed (code ${pull.code}): ${(pull.stderr || '').trim().slice(-4000)}` };
533
+ }
534
+ }
535
+ let index, manifest, config;
536
+ try {
537
+ index = JSON.parse(fs.readFileSync(path.join(ociLayoutDir, 'index.json'), 'utf-8'));
538
+ const mdesc = index.manifests?.[0];
539
+ if (!mdesc?.digest)
540
+ throw new Error('base OCI layout has no manifest in index.json');
541
+ manifest = JSON.parse(fs.readFileSync(blobPath(ociLayoutDir, mdesc.digest), 'utf-8'));
542
+ config = JSON.parse(fs.readFileSync(blobPath(ociLayoutDir, manifest.config.digest), 'utf-8'));
543
+ }
544
+ catch (e) {
545
+ return { ok: false, kind: 'io', message: `reading base OCI layout failed (the base may be a manifest list skopeo couldn't resolve to one platform): ${e.message}` };
546
+ }
547
+ // Append the COPY layer (skip when the Dockerfile COPYs nothing — base as-is).
548
+ if (parsed.copies.length > 0) {
549
+ const layer = buildCopyLayer(contextDir, parsed.copies);
550
+ if (!layer.ok)
551
+ return { ok: false, kind: 'validation', message: layer.message };
552
+ const layerDigest = 'sha256:' + sha256hex(layer.tar);
553
+ writeBlob(ociLayoutDir, layerDigest, layer.tar);
554
+ manifest.layers = Array.isArray(manifest.layers) ? manifest.layers : [];
555
+ manifest.layers.push({ mediaType: 'application/vnd.oci.image.layer.v1.tar', digest: layerDigest, size: layer.tar.length });
556
+ config.rootfs = config.rootfs || { type: 'layers', diff_ids: [] };
557
+ config.rootfs.diff_ids = Array.isArray(config.rootfs.diff_ids) ? config.rootfs.diff_ids : [];
558
+ config.rootfs.diff_ids.push(layerDigest); // uncompressed tar → diffID == layer digest
559
+ config.history = Array.isArray(config.history) ? config.history : [];
560
+ config.history.push({ created_by: 'yolo tileapp (skopeo-assembler): COPY app bundle', empty_layer: false });
561
+ }
562
+ config.config = (config.config && typeof config.config === 'object') ? config.config : {};
563
+ applyDockerfileToConfig(config.config, parsed);
564
+ // Rewrite config → manifest.config → index, recomputing each digest from the
565
+ // exact bytes (orphaned old blobs are harmless; the index points at the new ones).
566
+ const configBuf = Buffer.from(JSON.stringify(config));
567
+ const configDigest = 'sha256:' + sha256hex(configBuf);
568
+ writeBlob(ociLayoutDir, configDigest, configBuf);
569
+ manifest.config = { mediaType: 'application/vnd.oci.image.config.v1+json', digest: configDigest, size: configBuf.length };
570
+ const manifestBuf = Buffer.from(JSON.stringify(manifest));
571
+ const manifestDigest = 'sha256:' + sha256hex(manifestBuf);
572
+ writeBlob(ociLayoutDir, manifestDigest, manifestBuf);
573
+ index.manifests = [{
574
+ mediaType: 'application/vnd.oci.image.manifest.v1+json',
575
+ digest: manifestDigest,
576
+ size: manifestBuf.length,
577
+ annotations: { 'org.opencontainers.image.ref.name': tag },
578
+ }];
579
+ try {
580
+ fs.writeFileSync(path.join(ociLayoutDir, 'index.json'), JSON.stringify(index));
581
+ }
582
+ catch (e) {
583
+ return { ok: false, kind: 'io', message: `writing patched OCI index failed: ${e.message}` };
584
+ }
585
+ log('Exporting OCI archive (skopeo) …');
586
+ const out = await exec('skopeo', ['copy', '--quiet', `oci:${ociLayoutDir}:${tag}`, `oci-archive:${outArchivePath}:${tag}`]);
587
+ if (out.code !== 0) {
588
+ return { ok: false, kind: 'io', message: `skopeo export to oci-archive failed (code ${out.code}): ${(out.stderr || '').trim().slice(-4000)}` };
589
+ }
590
+ return { ok: true };
591
+ }