@yolo-labs/yolo-cli 0.24.0 → 0.30.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.
@@ -8,11 +8,21 @@
8
8
  * - **Worker module** — esbuild ESM bundle of the entry
9
9
  * (`{bundle, format:'esm', platform:'browser',
10
10
  * conditions:['workerd','worker'], target:'es2022', write:false,
11
- * minify:true}`). If the entry already points at a BUILT `.js`/`.mjs`
12
- * module (vinext/OpenNext output, skill-directed), esbuild is
13
- * SKIPPED and the module ships byte-for-byte as-is the size
14
- * ceilings still apply. esbuild is lazy-imported so `yolo plan`
15
- * startup never pays for it (the `serve.ts` lazy-load precedent).
11
+ * minify:true, define:{'process.env.NODE_ENV':'"production"'}}` +
12
+ * `nodeBuiltinExternalPlugin`). `NODE_ENV` is pinned to `production` so
13
+ * libraries that branch on it (React et al.) never ship their dev build on
14
+ * workerd (which has no runtime `process.env`). The plugin leaves every real
15
+ * `node:` builtin external (nodejs_compat serves the supported ones); a
16
+ * misspelled non-builtin is the only `node:` case that fails the build. It
17
+ * WARNS (not fails) when nodejs_compat is absent and when a builtin is loaded
18
+ * via `require()` (throws in an ESM Worker unless guarded). If the entry
19
+ * already points at a
20
+ * BUILT `.js`/`.mjs` module (vinext/OpenNext output, skill-directed),
21
+ * esbuild is SKIPPED and the module ships byte-for-byte as-is — the size
22
+ * ceilings still apply and the shipped text is scanned to WARN on retained
23
+ * `node:` imports and an unpinned `process.env.NODE_ENV`. esbuild is
24
+ * lazy-imported so `yolo plan` startup never pays for it (the `serve.ts`
25
+ * lazy-load precedent).
16
26
  * - **Asset manifest** — walk `assetsDir` (skip dotfiles +
17
27
  * node_modules), URL-style path → `{hash: sha256hex, size}`.
18
28
  * Pure-static ships NO module; common-api attaches the canonical
@@ -32,6 +42,7 @@
32
42
  */
33
43
  import { createHash } from 'node:crypto';
34
44
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
45
+ import { isBuiltin } from 'node:module';
35
46
  import path from 'node:path';
36
47
  import { fileURLToPath } from 'node:url';
37
48
  import { gzipSync } from 'node:zlib';
@@ -95,6 +106,98 @@ function vendoredFlexdbPlugin() {
95
106
  },
96
107
  };
97
108
  }
109
+ // ─── node: builtin externalization ──────────────────────────────────────────
110
+ //
111
+ // A `node:` builtin can't be bundled — esbuild's browser resolver would hard-
112
+ // fail it. We leave every REAL builtin external (workerd's nodejs_compat serves
113
+ // the ones it supports at the pinned compatibility date); the ONLY `node:` case
114
+ // that stays a build error is a non-builtin specifier (a `node:async_hook` typo
115
+ // → esbuild "Could not resolve"). Everything else externalizes and, where risky,
116
+ // WARNS post-bundle:
117
+ // - a require()-kind load → esbuild emits __require(...), which throws in an
118
+ // ESM Worker UNLESS guarded by try/catch → warn (collectExternalNodeRequires),
119
+ // don't fail — a hard error would break guarded optional-dependency probes
120
+ // - node: builtins with no nodejs_compat → warn (warnMissingNodejsCompat)
121
+ //
122
+ // Deliberately NOT a workerd compatibility oracle: which builtins a given
123
+ // compatibility-date + flag set actually serves (tls, node:fs behind
124
+ // enable_nodejs_fs_module, modules dropped by no_* flags, …) is a moving matrix
125
+ // that belongs to the runtime, not the bundler. Modeling it here only produces
126
+ // false build rejections of valid Workers. The post-ship boot probe / runtime is
127
+ // the authority on whether a module is really served.
128
+ /**
129
+ * esbuild plugin that leaves every real `node:` builtin external (any import
130
+ * kind, including require-call), so a non-builtin typo is the only `node:` case
131
+ * that stays a build error. A require()-kind load is externalized rather than
132
+ * rejected: esbuild emits `__require(...)`, which a GUARDED probe
133
+ * (`try { require("node:fs") } catch { …fallback… }`) can still catch — a hard
134
+ * rejection would break that common optional-dependency pattern. An UNGUARDED
135
+ * require of a builtin throws in an ESM Worker; that's surfaced as a post-bundle
136
+ * warning (see collectExternalNodeRequires), not a build failure.
137
+ */
138
+ function nodeBuiltinExternalPlugin() {
139
+ return {
140
+ name: 'yolo-node-builtin-external',
141
+ setup(build) {
142
+ build.onResolve({ filter: /^node:/ }, (args) => {
143
+ if (isBuiltin(args.path))
144
+ return { path: args.path, external: true };
145
+ return null; // not a builtin at all → esbuild reports "Could not resolve"
146
+ });
147
+ },
148
+ };
149
+ }
150
+ /**
151
+ * The distinct `node:<builtin>` specifiers referenced by already-bundled text
152
+ * (prebuilt worker path, which never touches esbuild). A `node:` literal in
153
+ * call position — `require(…)`, esbuild's `__require(…)`, dynamic `import(…)` —
154
+ * or after `from`/bare `import` counts. Matching any call `(` (rather than a
155
+ * specific helper name) is deliberate: bundlers rename the require helper.
156
+ * Deduped, sorted.
157
+ */
158
+ export function collectNodeBuiltinsFromText(text) {
159
+ const found = new Set();
160
+ const re = /(?:\bfrom\s*|\bimport\s*|\(\s*)['"](node:[a-zA-Z0-9_/.-]+)['"]/g;
161
+ let m;
162
+ while ((m = re.exec(text)) !== null)
163
+ found.add(m[1]);
164
+ return [...found].sort();
165
+ }
166
+ /**
167
+ * The distinct `node:` builtins loaded via a CommonJS `require(…)` / esbuild's
168
+ * `__require(…)` (any `<ident>require(` helper) in already-bundled text. These
169
+ * throw in an ESM Worker regardless of `nodejs_compat` — the esbuild path
170
+ * rejects them; the prebuilt path (esbuild skipped) can only WARN. Deduped, sorted.
171
+ */
172
+ export function collectNodeRequireCalls(text) {
173
+ const found = new Set();
174
+ // A require-CALL of a node: literal: `require("node:x")`, `__require("node:x")`.
175
+ // The leading \w* covers renamed helpers; it won't match the shim's own
176
+ // definition (that has no node: string argument).
177
+ const re = /\w*require\s*\(\s*['"](node:[a-zA-Z0-9_/.-]+)['"]/g;
178
+ let m;
179
+ while ((m = re.exec(text)) !== null)
180
+ found.add(m[1]);
181
+ return [...found].sort();
182
+ }
183
+ /**
184
+ * Push the "missing nodejs_compat" warning when a Worker references a `node:`
185
+ * builtin but that flag isn't set. Shared by the esbuild and prebuilt paths so
186
+ * `BundleOptions`' promise holds for both.
187
+ *
188
+ * Keyed strictly on `nodejs_compat` — the ONLY compatibility flag the hosting
189
+ * service accepts at ship (`ship-session.ts` ALLOWED_COMPATIBILITY_FLAGS). We
190
+ * deliberately do NOT recognize narrower flags (`nodejs_als`) or per-module
191
+ * enable flags here: suppressing the warning for a flag the server rejects
192
+ * would pass local bundling only to fail at ship with `bundle-invalid`.
193
+ */
194
+ function warnMissingNodejsCompat(nodeBuiltins, compatibilityFlags, warnings) {
195
+ if (nodeBuiltins.length === 0 || compatibilityFlags.includes('nodejs_compat'))
196
+ return;
197
+ warnings.push(`worker imports node: builtins (${nodeBuiltins.join(', ')}) but compatibilityFlags does not ` +
198
+ `include "nodejs_compat" — they resolve to nothing at runtime on workerd. Add "nodejs_compat" ` +
199
+ `to .yolo/deploy.json compatibilityFlags.`);
200
+ }
98
201
  // ─── Public types ─────────────────────────────────────────────────────────
99
202
  export const DEPLOY_CEILINGS = {
100
203
  /** CF hard per-file limit. */
@@ -106,20 +209,28 @@ export const DEPLOY_CEILINGS = {
106
209
  maxModuleBytes: 10 * 1024 * 1024,
107
210
  /** Gzipped-module size that triggers a warning (not a failure). */
108
211
  warnModuleGzipBytes: 1 * 1024 * 1024,
212
+ /**
213
+ * Sourcemap sidecar cap — mirrors the server's FINALIZE_SOURCEMAPS_MAX_BYTES
214
+ * (common-api `routes/deploy.ts`). Over this we DROP the map (+ warn) rather
215
+ * than fail the deploy or 413 late at finalize — symbolication is optional.
216
+ */
217
+ maxSourceMapBytes: 40 * 1024 * 1024,
109
218
  };
110
- // ─── Public entry ─────────────────────────────────────────────────────────
111
- export async function bundleProject(shape, cwd, ceilings = {}) {
219
+ export async function bundleProject(shape, cwd, ceilings = {}, options = {}) {
112
220
  const caps = { ...DEPLOY_CEILINGS, ...definedOnly(ceilings) };
113
221
  const warnings = [];
222
+ const compatibilityFlags = options.compatibilityFlags ?? [];
114
223
  // ── Worker module (esbuild, or as-is for pre-built entries) ────────────
115
224
  let module = null;
116
225
  let moduleSource = null;
226
+ let sourceMap = null;
117
227
  if (shape.type === 'worker') {
118
- const built = await buildWorkerModule(shape.entry, cwd, shape.prebuilt === true, caps, warnings);
228
+ const built = await buildWorkerModule(shape.entry, cwd, shape.prebuilt === true, caps, warnings, compatibilityFlags, options.sourcemaps === true);
119
229
  if (!built.ok)
120
230
  return built;
121
231
  module = built.module;
122
232
  moduleSource = built.source;
233
+ sourceMap = built.sourceMap ?? null;
123
234
  }
124
235
  // ── Asset walk + manifest + ceilings ────────────────────────────────────
125
236
  const manifest = {};
@@ -195,6 +306,7 @@ export async function bundleProject(shape, cwd, ceilings = {}) {
195
306
  module,
196
307
  workerModules: module === null ? [] : [module],
197
308
  moduleSource,
309
+ sourceMap, // sidecar — intentionally excluded from workerModules + the digest below
198
310
  fileCount,
199
311
  totalAssetBytes,
200
312
  bundleDigest: computeBundleDigest(module === null ? [] : [module], manifest),
@@ -228,11 +340,68 @@ export function computeBundleDigest(modules, manifest) {
228
340
  hash.update(JSON.stringify(entries), 'utf8');
229
341
  return `sha256:${hash.digest('hex')}`;
230
342
  }
231
- async function buildWorkerModule(entry, cwd, prebuilt, caps, warnings) {
343
+ // ─── Internals ────────────────────────────────────────────────────────────
344
+ /**
345
+ * The distinct `node:` builtin specifiers esbuild left external for this
346
+ * bundle (deduped, sorted). Empty when the Worker imports none. Used to warn
347
+ * when `nodejs_compat` is absent — the runtime, not the bundler, is where a
348
+ * missing flag bites.
349
+ */
350
+ function collectExternalNodeBuiltins(metafile) {
351
+ if (!metafile)
352
+ return [];
353
+ const found = new Set();
354
+ for (const input of Object.values(metafile.inputs)) {
355
+ for (const imp of input.imports) {
356
+ if (imp.external && imp.path.startsWith('node:'))
357
+ found.add(imp.path);
358
+ }
359
+ }
360
+ return [...found].sort();
361
+ }
362
+ /**
363
+ * Ensure the sourcemap JSON carries a `file` field naming the module it maps
364
+ * (`index.js`) — CF associates a map to its module by this field (verified live)
365
+ * and esbuild omits it. Returns the map text unchanged if it isn't parseable
366
+ * JSON (defensive; the map still ships, just possibly un-symbolicated).
367
+ */
368
+ function withMapFile(mapText, moduleName) {
369
+ try {
370
+ const parsed = JSON.parse(mapText);
371
+ if (parsed.file === moduleName)
372
+ return mapText;
373
+ parsed.file = moduleName;
374
+ return JSON.stringify(parsed);
375
+ }
376
+ catch {
377
+ return mapText;
378
+ }
379
+ }
380
+ /**
381
+ * The distinct `node:` builtins loaded via a CommonJS `require()` (esbuild
382
+ * import kind `require-call`) in this bundle. esbuild compiles these to
383
+ * `__require(...)`, which throws in an ESM Worker unless the call is guarded by
384
+ * try/catch — so we WARN (not fail), preserving the guarded optional-dependency
385
+ * pattern. Deduped, sorted.
386
+ */
387
+ function collectExternalNodeRequires(metafile) {
388
+ if (!metafile)
389
+ return [];
390
+ const found = new Set();
391
+ for (const input of Object.values(metafile.inputs)) {
392
+ for (const imp of input.imports) {
393
+ if (imp.external && imp.kind === 'require-call' && imp.path.startsWith('node:'))
394
+ found.add(imp.path);
395
+ }
396
+ }
397
+ return [...found].sort();
398
+ }
399
+ async function buildWorkerModule(entry, cwd, prebuilt, caps, warnings, compatibilityFlags, sourcemaps) {
232
400
  const entryAbs = path.resolve(cwd, entry);
233
401
  let contents;
234
402
  let name;
235
403
  let source;
404
+ let sourceMap;
236
405
  // Ship as-is ONLY for an explicitly-configured built output (deploy.json
237
406
  // worker.entry → .js/.mjs). Auto-detected `src/index.js` is SOURCE and must
238
407
  // be bundled or its imports are dropped (codex P2 r6).
@@ -250,6 +419,28 @@ async function buildWorkerModule(entry, cwd, prebuilt, caps, warnings) {
250
419
  }
251
420
  name = path.basename(entryAbs);
252
421
  source = 'prebuilt';
422
+ // esbuild never runs for prebuilt output (vinext/OpenNext), so its define +
423
+ // node: externalization can't apply — scan the shipped text directly and
424
+ // WARN instead. Mutating an opaque framework bundle would break the
425
+ // ship-as-is contract; the fix belongs in the framework build.
426
+ const prebuiltText = Buffer.from(contents).toString('utf8');
427
+ warnMissingNodejsCompat(collectNodeBuiltinsFromText(prebuiltText), compatibilityFlags, warnings);
428
+ // A require()/__require() of a node: builtin throws in an ESM Worker no
429
+ // matter what nodejs_compat is — the esbuild path rejects it, so warn here
430
+ // independently of the missing-compat warning above (which nodejs_compat
431
+ // suppresses). The framework build should emit ESM imports instead.
432
+ const nodeRequires = collectNodeRequireCalls(prebuiltText);
433
+ if (nodeRequires.length > 0) {
434
+ warnings.push(`prebuilt worker uses require() of node: builtins (${nodeRequires.join(', ')}) — a CommonJS ` +
435
+ `require of a builtin throws in an ESM Worker. Rebuild it to emit ESM \`import\`s.`);
436
+ }
437
+ if (prebuiltText.includes('process.env.NODE_ENV')) {
438
+ // A pinned build would have replaced this literal with "production"; its
439
+ // survival means NODE_ENV is unpinned → risks shipping a dev build.
440
+ warnings.push(`prebuilt worker references process.env.NODE_ENV — workerd has no runtime process.env, so an ` +
441
+ `unpinned NODE_ENV can ship a dev build (e.g. development React). Pin it in your framework ` +
442
+ `build (esbuild define 'process.env.NODE_ENV'='"production"').`);
443
+ }
253
444
  }
254
445
  else {
255
446
  // Lazy-load so non-deploy verbs never pay esbuild's startup cost.
@@ -268,9 +459,23 @@ async function buildWorkerModule(entry, cwd, prebuilt, caps, warnings) {
268
459
  target: 'es2022',
269
460
  write: false,
270
461
  minify: true,
462
+ metafile: true,
463
+ // Pin production so libraries that gate on process.env.NODE_ENV (React
464
+ // et al.) never emit their dev build — workerd has no runtime
465
+ // process.env, so an unpinned NODE_ENV silently ships dev code.
466
+ define: { 'process.env.NODE_ENV': '"production"' },
271
467
  absWorkingDir: path.resolve(cwd),
272
468
  logLevel: 'silent',
273
- plugins: flexdbPlugin ? [flexdbPlugin] : [],
469
+ // sourcemaps: 'linked' adds a `//# sourceMappingURL=index.js.map`
470
+ // comment (which CF uses to associate the uploaded map) + emits the
471
+ // .map as a second output. A fixed outfile makes that comment's name
472
+ // deterministic so it matches the uploaded sidecar. OFF → the exact
473
+ // prior options → byte-identical module → stable digest.
474
+ ...(sourcemaps ? { sourcemap: 'linked', outfile: path.resolve(cwd, 'index.js') } : {}),
475
+ // nodeBuiltinExternalPlugin leaves real node: builtins external
476
+ // (nodejs_compat serves them); only a non-builtin misspelling fails the
477
+ // build. Ordered first so it wins over default resolve.
478
+ plugins: [nodeBuiltinExternalPlugin(), ...(flexdbPlugin ? [flexdbPlugin] : [])],
274
479
  });
275
480
  }
276
481
  catch (err) {
@@ -281,13 +486,45 @@ async function buildWorkerModule(entry, cwd, prebuilt, caps, warnings) {
281
486
  message: `esbuild failed for ${entry}: ${msg}`,
282
487
  };
283
488
  }
284
- const out = result.outputFiles?.[0];
489
+ const outputs = result.outputFiles ?? [];
490
+ // The module is the non-.map output; the .map (present only with sourcemaps)
491
+ // is the sidecar.
492
+ const out = outputs.find((o) => !o.path.endsWith('.map'));
493
+ const mapOut = outputs.find((o) => o.path.endsWith('.map'));
285
494
  if (out === undefined) {
286
495
  return { ok: false, kind: 'build-failed', message: `esbuild produced no output for ${entry}` };
287
496
  }
497
+ // node: builtins were left external above; if the Worker actually imports
498
+ // any and nodejs_compat isn't enabled, they resolve to nothing at runtime.
499
+ warnMissingNodejsCompat(collectExternalNodeBuiltins(result.metafile), compatibilityFlags, warnings);
500
+ // A require()-kind load of a builtin becomes __require(...), which throws in
501
+ // an ESM Worker unless guarded — advisory (guarded probes stay valid).
502
+ const nodeRequires = collectExternalNodeRequires(result.metafile);
503
+ if (nodeRequires.length > 0) {
504
+ warnings.push(`worker loads node: builtins via require() (${nodeRequires.join(', ')}) — require() throws in an ` +
505
+ `ESM Worker unless wrapped in try/catch. Prefer an ESM \`import\`.`);
506
+ }
288
507
  contents = out.contents;
289
508
  name = 'index.js';
290
509
  source = 'esbuild';
510
+ if (sourcemaps && mapOut !== undefined) {
511
+ // Name matches the `//# sourceMappingURL=index.js.map` comment esbuild
512
+ // wrote into the module (deterministic via the fixed outfile).
513
+ // CF associates the sourcemap to the module by the map's `file` field —
514
+ // verified against the live account: esbuild OMITS `file`, and without it
515
+ // CF returns minified exception stacks; adding `file` = the module name
516
+ // makes it symbolicate. (wrangler's map carries `file` too.)
517
+ const content = withMapFile(Buffer.from(mapOut.contents).toString('utf8'), name);
518
+ if (Buffer.byteLength(content, 'utf8') > caps.maxSourceMapBytes) {
519
+ // Too big to upload — drop it (the module is valid) so the deploy still
520
+ // ships, just without symbolicated stacks. Better than a late 413.
521
+ warnings.push(`sourcemap is ${formatBytes(Buffer.byteLength(content, 'utf8'))} (> ${formatBytes(caps.maxSourceMapBytes)}) — ` +
522
+ `skipping it; exceptions in deploy.logs won't be source-mapped for this release`);
523
+ }
524
+ else {
525
+ sourceMap = { name: 'index.js.map', content };
526
+ }
527
+ }
291
528
  }
292
529
  if (contents.byteLength > caps.maxModuleBytes) {
293
530
  return {
@@ -301,7 +538,7 @@ async function buildWorkerModule(entry, cwd, prebuilt, caps, warnings) {
301
538
  if (gzipBytes > caps.warnModuleGzipBytes) {
302
539
  warnings.push(`worker module is ${formatBytes(gzipBytes)} gzipped (> ${formatBytes(caps.warnModuleGzipBytes)}) — cold starts will suffer`);
303
540
  }
304
- return { ok: true, module: { name, contents }, source };
541
+ return { ok: true, module: { name, contents }, source, ...(sourceMap ? { sourceMap } : {}) };
305
542
  }
306
543
  /**
307
544
  * Depth-first walk returning sorted posix-relative file paths. Skips
@@ -35,12 +35,16 @@ import { runDeployShip, exitCodeForFailure, formatShipSuccess, formatPending, fo
35
35
  import { resolveDeployContext, createProject, getProjectStatus, listProjects, rollbackProject, unpublishProject, republishProject, renameProject, addAlias, removeAlias, setRedirect, deleteProject, cloneProject, getLogs, pollTail, queryD1, } from './deploy-client.js';
36
36
  import { readDeployConfig, writeDeployConfig } from './deploy-config.js';
37
37
  import { adaptWrangler, detectProjectShape } from './deploy-detect.js';
38
+ import { collectNodeBuiltinsFromText } from './deploy-bundle.js';
39
+ import { runDeployDev } from './deploy-dev.js';
38
40
  import { defaultReadFile } from './auth-context.js';
39
41
  const USAGE = [
40
42
  'Usage: yolo deploy [--env <staging|prod>] [--dry-run] [--json]',
41
43
  ' yolo deploy init [--slug <slug>] [--type <static|worker>]',
42
44
  ' yolo deploy link (--project-id <id> | --slug <slug>) [--type <static|worker>]',
43
45
  ' yolo deploy validate [--json]',
46
+ ' yolo deploy doctor [--json]',
47
+ ' yolo deploy dev [--port <n>] [--host <h>] [--var KEY=VALUE ...]',
44
48
  ' yolo deploy status [--json]',
45
49
  ' yolo deploy logs [--tail] [--since <dur>] [--json]',
46
50
  ' yolo deploy rollback [releaseId] [--json]',
@@ -69,6 +73,10 @@ export async function runDeployCmd(args, deps = {}) {
69
73
  return runLinkCmd(args.slice(1), deps, io);
70
74
  if (sub === 'validate')
71
75
  return runValidateCmd(args.slice(1), deps, io);
76
+ if (sub === 'doctor')
77
+ return runDoctorCmd(args.slice(1), deps, io);
78
+ if (sub === 'dev')
79
+ return runDevCmd(args.slice(1), deps, io);
72
80
  if (sub === 'status')
73
81
  return runStatusCmd(args.slice(1), deps, io);
74
82
  if (sub === 'logs')
@@ -251,6 +259,11 @@ async function runInitCmd(args, deps, io) {
251
259
  const created = await create(auth.context, {
252
260
  name: parsed.slug ?? path.basename(cwd),
253
261
  ...(parsed.slug ? { slug: parsed.slug } : {}),
262
+ // Session pods provide WORKSPACE_ID. Persisting it on the hosting project
263
+ // lets server-side deploy lifecycle events route back to the workspace
264
+ // (and therefore to VibeAssist event watchers). Local CLI use remains
265
+ // unchanged because the field is optional.
266
+ ...(deps.env?.WORKSPACE_ID ? { workspaceId: deps.env.WORKSPACE_ID } : {}),
254
267
  });
255
268
  if (!created.ok) {
256
269
  // create-or-LINK: a `slug-taken` on a slug the caller ALREADY OWNS means a
@@ -577,6 +590,212 @@ async function runValidateCmd(args, deps, io) {
577
590
  io.out(` note: ${n}\n`);
578
591
  return 0;
579
592
  }
593
+ /**
594
+ * `yolo deploy doctor [--json]` — a fast OFFLINE lint of the worker entry
595
+ * against the workerd runtime contract (see the Runtime Contract doc). Catches
596
+ * the field-report gotchas before a ship: `process.env` reads (workerd has no
597
+ * runtime process.env), `node:` imports without `nodejs_compat`, `require()` of
598
+ * a builtin, and an unpinned `NODE_ENV` in a prebuilt bundle.
599
+ *
600
+ * It scans only the ENTRY file (no bundling, no dep walk) — advisory, so
601
+ * warnings do NOT fail the command; only a broken config / missing source entry
602
+ * exits non-zero. Run `yolo deploy --dry-run` for a full bundle-level check.
603
+ */
604
+ async function runDoctorCmd(args, deps, io) {
605
+ const jsonOutput = args.includes('--json');
606
+ const unexpected = args.filter((a) => a !== '--json');
607
+ if (unexpected.length > 0) {
608
+ io.err(`yolo deploy doctor: unexpected argument(s): ${unexpected.join(' ')}\nUsage: yolo deploy doctor [--json]\n`);
609
+ return 64;
610
+ }
611
+ const cwd = deps.cwd ?? process.cwd();
612
+ const readFile = deps.readFileImpl ?? defaultReadFile;
613
+ const statPath = deps.statPathImpl ?? defaultStatPath;
614
+ const readConfig = deps.readDeployConfigImpl ?? readDeployConfig;
615
+ // Resolve config + shape (a broken config is a hard error, like validate).
616
+ const readResult = readConfig(cwd, deps.readFileImpl);
617
+ if (!readResult.ok) {
618
+ const msg = readResult.errors?.map((e) => `${e.path ? `${e.path}: ` : ''}${e.message}`).join('; ') ?? readResult.message;
619
+ if (jsonOutput)
620
+ io.out(`${JSON.stringify({ ok: false, error: msg })}\n`);
621
+ else
622
+ io.err(`FAIL: ${msg}\n hint: run 'yolo deploy validate' for full config diagnostics\n`);
623
+ return 1;
624
+ }
625
+ const config = readResult.config;
626
+ const det = detectProjectShape({ cwd, config: config ?? null, readFileImpl: deps.readFileImpl });
627
+ if (!det.ok) {
628
+ if (jsonOutput)
629
+ io.out(`${JSON.stringify({ ok: false, error: det.message })}\n`);
630
+ else
631
+ io.err(`FAIL: ${det.message}\n`);
632
+ return 1;
633
+ }
634
+ const shape = det.shape;
635
+ const hasNodejsCompat = (config?.compatibilityFlags ?? []).includes('nodejs_compat');
636
+ const checks = [];
637
+ let hardError = false;
638
+ if (shape.type === 'static') {
639
+ checks.push({ ok: true, title: 'static site — the workerd runtime contract applies to Workers only' });
640
+ }
641
+ else {
642
+ // Read the entry text (source or prebuilt). A missing SOURCE entry is a hard
643
+ // error; a missing build OUTPUT is a can't-scan note (build hasn't run yet).
644
+ const entryAbs = path.resolve(cwd, shape.entry);
645
+ const kind = statPath(entryAbs);
646
+ if (kind === 'missing') {
647
+ // Only a PREBUILT entry is a build OUTPUT that may not exist yet; a SOURCE
648
+ // entry (src/index.ts) is committed input esbuild bundles in place — the
649
+ // build doesn't create it — so a missing source entry is a typo, a hard
650
+ // error, even when a build command is configured (matches `validate`).
651
+ if (shape.prebuilt === true) {
652
+ checks.push({ ok: true, title: `entry '${shape.entry}' not built yet — run your build, then re-run doctor` });
653
+ }
654
+ else {
655
+ checks.push({ ok: false, title: `entry '${shape.entry}' does not exist`, detail: 'fix worker.entry in .yolo/deploy.json or create the file' });
656
+ hardError = true;
657
+ }
658
+ }
659
+ else {
660
+ const text = readFile(entryAbs);
661
+ if (text === undefined) {
662
+ checks.push({ ok: false, title: `entry '${shape.entry}' could not be read`, detail: 'check file permissions' });
663
+ hardError = true;
664
+ }
665
+ else {
666
+ checks.push({ ok: true, title: `entry '${shape.entry}' resolves` });
667
+ for (const c of lintWorkerEntry(text, shape.entry, shape.prebuilt === true, hasNodejsCompat))
668
+ checks.push(c);
669
+ }
670
+ }
671
+ }
672
+ const anyWarn = checks.some((c) => !c.ok);
673
+ if (jsonOutput) {
674
+ io.out(`${JSON.stringify({ ok: !hardError, hardError, checks, scannedEntryOnly: true })}\n`);
675
+ return hardError ? 1 : 0;
676
+ }
677
+ io.out(`yolo deploy doctor — ${shape.type} project\n`);
678
+ for (const c of checks) {
679
+ io.out(` ${c.ok ? '✓' : '⚠'} ${c.title}\n`);
680
+ if (c.detail)
681
+ io.out(` → ${c.detail}\n`);
682
+ }
683
+ if (shape.type === 'worker') {
684
+ io.out(' note: doctor scans your entry file only — run `yolo deploy --dry-run` to bundle and check dependencies too\n');
685
+ }
686
+ io.out(hardError ? '\nFAIL: fix the errors above before shipping\n' : anyWarn ? '\nOK with warnings — review the ⚠ items above\n' : '\nOK: no runtime-contract issues found\n');
687
+ return hardError ? 1 : 0;
688
+ }
689
+ /** The workerd-runtime-contract checks over one entry file's text. */
690
+ function lintWorkerEntry(text, entry, prebuilt, hasNodejsCompat) {
691
+ const out = [];
692
+ // process.env reads other than NODE_ENV — workerd has no runtime process.env.
693
+ const envNames = new Set();
694
+ const envRe = /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
695
+ let m;
696
+ while ((m = envRe.exec(text)) !== null) {
697
+ if (m[1] !== 'NODE_ENV')
698
+ envNames.add(m[1]);
699
+ }
700
+ if (envNames.size > 0) {
701
+ const names = [...envNames].sort();
702
+ out.push({
703
+ ok: false,
704
+ title: `${entry} reads process.env.${names.join(', process.env.')}`,
705
+ detail: `workerd has no runtime process.env — read these from the env fetch arg (env.${names[0]})`,
706
+ });
707
+ }
708
+ else {
709
+ out.push({ ok: true, title: 'no process.env reads (other than NODE_ENV)' });
710
+ }
711
+ // node: builtins vs nodejs_compat.
712
+ const builtins = collectNodeBuiltinsFromText(text);
713
+ if (builtins.length === 0) {
714
+ out.push({ ok: true, title: 'no node: builtins imported' });
715
+ }
716
+ else if (hasNodejsCompat) {
717
+ out.push({ ok: true, title: `node: builtins (${builtins.join(', ')}) covered by nodejs_compat` });
718
+ }
719
+ else {
720
+ out.push({
721
+ ok: false,
722
+ title: `imports node: builtins (${builtins.join(', ')}), deploy.json missing nodejs_compat`,
723
+ detail: 'add "nodejs_compat" to .yolo/deploy.json compatibilityFlags',
724
+ });
725
+ }
726
+ // require("node:…") — throws in an ESM Worker.
727
+ if (/\brequire\s*\(\s*['"]node:/.test(text)) {
728
+ out.push({ ok: false, title: `${entry} uses require() of a node: builtin`, detail: 'use an ESM `import` — require() throws at runtime in an ESM Worker' });
729
+ }
730
+ // NODE_ENV pinning. The bundler pins it for a SOURCE entry; a prebuilt bundle
731
+ // that still references it was not pinned in the framework build.
732
+ if (prebuilt) {
733
+ if (text.includes('process.env.NODE_ENV')) {
734
+ out.push({ ok: false, title: 'prebuilt bundle references an unpinned process.env.NODE_ENV', detail: 'pin NODE_ENV=production in your framework build or it may ship a dev build' });
735
+ }
736
+ else {
737
+ out.push({ ok: true, title: 'NODE_ENV not left unpinned in the prebuilt bundle' });
738
+ }
739
+ }
740
+ else {
741
+ out.push({ ok: true, title: 'NODE_ENV pinned to production by the bundler' });
742
+ }
743
+ return out;
744
+ }
745
+ /** Parse `yolo deploy dev` flags: --port <n>, --host <h>, repeatable --var KEY=VALUE. */
746
+ export function parseDevArgs(args) {
747
+ let port;
748
+ let host;
749
+ const vars = {};
750
+ for (let i = 0; i < args.length; i++) {
751
+ const a = args[i];
752
+ if (a === '--port' || a.startsWith('--port=')) {
753
+ const v = a.includes('=') ? a.slice('--port='.length) : args[++i];
754
+ const n = Number(v);
755
+ if (!v || !Number.isInteger(n) || n < 0 || n > 65535)
756
+ return { ok: false, message: `--port must be an integer 0–65535 (got '${v ?? ''}')` };
757
+ port = n;
758
+ }
759
+ else if (a === '--host' || a.startsWith('--host=')) {
760
+ const v = a.includes('=') ? a.slice('--host='.length) : args[++i];
761
+ if (!v || v.startsWith('--'))
762
+ return { ok: false, message: '--host requires a value' };
763
+ host = v;
764
+ }
765
+ else if (a === '--var' || a.startsWith('--var=')) {
766
+ const v = a.includes('=') && a.startsWith('--var=') ? a.slice('--var='.length) : args[++i];
767
+ const eq = v ? v.indexOf('=') : -1;
768
+ if (!v || eq <= 0)
769
+ return { ok: false, message: `--var must be KEY=VALUE (got '${v ?? ''}')` };
770
+ vars[v.slice(0, eq)] = v.slice(eq + 1);
771
+ }
772
+ else {
773
+ return { ok: false, message: `unexpected argument: ${a}` };
774
+ }
775
+ }
776
+ return { ok: true, port, host, vars };
777
+ }
778
+ /**
779
+ * `yolo deploy dev` — serve the project locally on workerd (miniflare) so the
780
+ * runtime-contract gotchas surface before a ship. Delegates to
781
+ * `deploy-dev.ts` (which lazy-imports miniflare). Runs until Ctrl-C.
782
+ */
783
+ async function runDevCmd(args, deps, io) {
784
+ const parsed = parseDevArgs(args);
785
+ if (!parsed.ok) {
786
+ io.err(`yolo deploy dev: ${parsed.message}\nUsage: yolo deploy dev [--port <n>] [--host <h>] [--var KEY=VALUE ...]\n`);
787
+ return 64;
788
+ }
789
+ const devOpts = {
790
+ cwd: deps.cwd,
791
+ io,
792
+ vars: parsed.vars,
793
+ ...(parsed.port !== undefined ? { port: parsed.port } : {}),
794
+ ...(parsed.host !== undefined ? { host: parsed.host } : {}),
795
+ ...(deps.devDeps ?? {}),
796
+ };
797
+ return runDeployDev(devOpts);
798
+ }
580
799
  // ─── status ───────────────────────────────────────────────────────────────
581
800
  async function runStatusCmd(args, deps, io) {
582
801
  const parsed = parseFlagOnlyArgs(args, 'status');
@@ -778,7 +997,16 @@ function formatLogEntry(entry) {
778
997
  const ts = str(e.timestamp) ?? str(e.ts) ?? str(e.time);
779
998
  const level = str(e.level);
780
999
  const message = str(e.message) ?? str(e.msg) ?? JSON.stringify(entry);
781
- return [ts ? `[${ts}]` : undefined, level, message].filter(Boolean).join(' ');
1000
+ const head = [ts ? `[${ts}]` : undefined, level, message].filter(Boolean).join(' ');
1001
+ // Surface the (CF-symbolicated) exception stack indented under the message —
1002
+ // this is what turns an opaque 1101 into a real source-mapped trace.
1003
+ const stack = str(e.stack);
1004
+ if (stack) {
1005
+ const errName = str(e.errorName);
1006
+ const lines = stack.split(/\r?\n/).map((l) => ` ${l.trim()}`).filter((l) => l.trim());
1007
+ return [head, errName ? ` ${errName}` : undefined, ...lines].filter(Boolean).join('\n');
1008
+ }
1009
+ return head;
782
1010
  }
783
1011
  export function parseRollbackArgs(args) {
784
1012
  let releaseId;
@@ -92,7 +92,7 @@ export async function uploadAssetBucket(ctx, projectId, shipId, files) {
92
92
  * success | 409 awaiting-approval (T3 prod gate — NOT an error)
93
93
  * | 410 upload-expired (ship session lapsed → rerun)
94
94
  */
95
- export async function finalizeShip(ctx, projectId, shipId, modules = []) {
95
+ export async function finalizeShip(ctx, projectId, shipId, modules = [], sourceMap) {
96
96
  // NOT auto-retried (unlike ship/start + assets). finalize is single-shot per
97
97
  // ship session: if the first request reached the server and created the
98
98
  // release but the response was lost to a transient 5xx/522, a blind retry
@@ -114,6 +114,11 @@ export async function finalizeShip(ctx, projectId, shipId, modules = []) {
114
114
  for (const module of modules) {
115
115
  form.append(module.name, new Blob([module.contents], { type: 'application/javascript+module' }), module.name);
116
116
  }
117
+ // Sourcemap sidecar — the server distinguishes it from modules by the
118
+ // `application/source-map` content-type (never a worker module / digest input).
119
+ if (sourceMap) {
120
+ form.append(sourceMap.name, new Blob([sourceMap.content], { type: 'application/source-map' }), sourceMap.name);
121
+ }
117
122
  // No explicit Content-Type — fetch sets the multipart boundary itself.
118
123
  response = await fetchImpl(url, {
119
124
  method: 'POST',
@@ -0,0 +1,215 @@
1
+ /**
2
+ * `yolo deploy dev` — run the project locally on the SAME runtime it deploys to
3
+ * (Cloudflare workerd, via miniflare) so the workerd-contract gotchas
4
+ * (`process.env`, `node:` builtins, `WeakRef`/`FinalizationRegistry`, an
5
+ * unpinned dev-React build) surface locally instead of only after a ship. A
6
+ * plain `node dist/server.js` runs on Node and gives false confidence — see the
7
+ * Runtime Contract doc.
8
+ *
9
+ * The worker is bundled with the SAME `bundleProject` recipe `yolo deploy` uses
10
+ * (so the NODE_ENV pin + node: handling match prod), and served under the SAME
11
+ * pinned compatibility date the hosting service uses. Static projects are served
12
+ * through the identical assets-only shim the server attaches in prod.
13
+ *
14
+ * miniflare is a heavy (workerd-carrying) dependency, so it's dynamically
15
+ * imported ONLY here — no other `yolo` verb pays for it.
16
+ */
17
+ import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
18
+ import * as os from 'node:os';
19
+ import * as path from 'node:path';
20
+ import { bundleProject } from './deploy-bundle.js';
21
+ import { readDeployConfig } from './deploy-config.js';
22
+ import { detectProjectShape } from './deploy-detect.js';
23
+ import { defaultRunBuild } from './deploy-ship.js';
24
+ /**
25
+ * The compatibility date the hosting runtime pins
26
+ * (`common-api/.../release-service.ts` COMPATIBILITY_DATE). Dev MUST match it so
27
+ * a feature that works locally works in prod and vice-versa.
28
+ */
29
+ export const DEV_COMPATIBILITY_DATE = '2024-12-01';
30
+ /** The assets-only shim the SERVER attaches for a pure-static project (kept in lockstep). */
31
+ const STATIC_DEV_SHIM = `export default { async fetch(request, env) { return env.ASSETS.fetch(request); } };`;
32
+ /**
33
+ * Map deploy.json + the bundled module text + an already-STAGED assets dir onto
34
+ * miniflare options. Pure — the whole config→runtime translation is unit-tested
35
+ * here without starting workerd. Local KV/D1/R2 are declared (in-memory) so a
36
+ * worker that reads `env.<NAME>` doesn't crash; they hold no real data.
37
+ *
38
+ * `stagedAssetsDir` MUST be a dir holding only the prod-shipped asset set (see
39
+ * stageProdAssets) — never the raw project dir, which would expose dotfiles
40
+ * (`.env`), `node_modules`, and escaped symlinks that prod excludes.
41
+ */
42
+ export function buildMiniflareInit(params) {
43
+ const { config, script, port, host, vars, stagedAssetsDir } = params;
44
+ const bindings = config?.bindings ?? [];
45
+ const byKind = (kind) => bindings.filter((b) => b.kind === kind).map((b) => b.binding).filter((n) => typeof n === 'string');
46
+ return {
47
+ port,
48
+ host,
49
+ script,
50
+ modules: true,
51
+ compatibilityDate: DEV_COMPATIBILITY_DATE,
52
+ compatibilityFlags: config?.compatibilityFlags ?? [],
53
+ bindings: { ...vars },
54
+ kvNamespaces: byKind('kv'),
55
+ d1Databases: byKind('d1'),
56
+ r2Buckets: byKind('r2'),
57
+ ...(stagedAssetsDir ? { assets: { directory: stagedAssetsDir, binding: 'ASSETS' } } : {}),
58
+ };
59
+ }
60
+ /**
61
+ * Copy the bundle's ALREADY-FILTERED asset set (bundleProject applies the prod
62
+ * exclusion rules: dotfiles, node_modules, escaping symlinks) into a fresh temp
63
+ * dir laid out by manifest path, so miniflare serves exactly what prod would —
64
+ * not the raw project dir.
65
+ *
66
+ * Returns null for an empty asset set UNLESS `alwaysDir` — a static project's
67
+ * shim unconditionally calls `env.ASSETS.fetch`, so an empty static site still
68
+ * needs an ASSETS binding (an empty dir → faithful 404s) rather than a runtime
69
+ * "ASSETS is undefined" throw on every request.
70
+ */
71
+ export function stageProdAssets(assets, alwaysDir = false) {
72
+ if (assets.length === 0 && !alwaysDir)
73
+ return null;
74
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'yolo-dev-assets-'));
75
+ for (const a of assets) {
76
+ const dest = path.join(dir, a.path); // a.path is a URL-style '/index.html'
77
+ mkdirSync(path.dirname(dest), { recursive: true });
78
+ copyFileSync(a.absPath, dest);
79
+ }
80
+ return dir;
81
+ }
82
+ /** Default server start — lazy-imports miniflare so no other verb loads workerd. */
83
+ async function startWithMiniflare(init) {
84
+ let Miniflare;
85
+ try {
86
+ ({ Miniflare } = await import('miniflare'));
87
+ }
88
+ catch {
89
+ throw new Error("`yolo deploy dev` needs the 'miniflare' package (it carries the workerd runtime). Install it with `npm i miniflare`.");
90
+ }
91
+ const mf = new Miniflare(init);
92
+ const url = (await mf.ready).toString();
93
+ return { url, dispose: () => mf.dispose() };
94
+ }
95
+ /** Block until SIGINT/SIGTERM. */
96
+ function waitForSigint() {
97
+ return new Promise((resolve) => {
98
+ const stop = () => resolve();
99
+ process.once('SIGINT', stop);
100
+ process.once('SIGTERM', stop);
101
+ });
102
+ }
103
+ /**
104
+ * Orchestrate a local dev run: resolve shape → bundle (worker) or shim (static)
105
+ * → assemble options → serve until stopped. Exit 0 on a clean stop, 1 on a
106
+ * bundle/config failure.
107
+ */
108
+ export async function runDeployDev(opts) {
109
+ const cwd = opts.cwd ?? process.cwd();
110
+ const io = opts.io;
111
+ const readConfig = opts.readDeployConfigImpl ?? readDeployConfig;
112
+ const detect = opts.detectProjectShapeImpl ?? detectProjectShape;
113
+ const bundle = opts.bundleProjectImpl ?? bundleProject;
114
+ const runBuild = opts.runBuildImpl ?? defaultRunBuild;
115
+ const stageAssets = opts.stageAssetsImpl ?? stageProdAssets;
116
+ const start = opts.startImpl ?? startWithMiniflare;
117
+ const waitForStop = opts.waitForStopImpl ?? waitForSigint;
118
+ const port = opts.port ?? 8787;
119
+ const host = opts.host ?? '127.0.0.1';
120
+ const readResult = readConfig(cwd);
121
+ if (!readResult.ok) {
122
+ io.err(`FAIL: ${readResult.message}\n hint: run 'yolo deploy validate' for full config diagnostics\n`);
123
+ return 1;
124
+ }
125
+ const config = readResult.config;
126
+ const det = detect({ cwd, config: config ?? null });
127
+ if (!det.ok) {
128
+ io.err(`FAIL: ${det.message}\n`);
129
+ return 1;
130
+ }
131
+ const shape = det.shape;
132
+ // Build first — a static/prebuilt project serves its build OUTPUT, so a clean
133
+ // checkout would otherwise miss (or serve stale) files. Mirrors the ship path.
134
+ if (shape.buildCommand) {
135
+ io.out(`building: ${shape.buildCommand}\n`);
136
+ const built = await runBuild(shape.buildCommand, cwd, (chunk) => {
137
+ for (const line of chunk.split(/\r?\n/))
138
+ if (line.trim())
139
+ io.err(`build> ${line}\n`);
140
+ });
141
+ if (built.code !== 0) {
142
+ io.err(`FAIL: build command \`${shape.buildCommand}\` exited with code ${built.code}\n`);
143
+ return 1;
144
+ }
145
+ }
146
+ // Bundle (both static + worker): this walks the assets dir with the SAME prod
147
+ // exclusion rules (dotfiles, node_modules, escaping symlinks) and, for a
148
+ // worker, produces the module. We serve the STAGED filtered set — never the
149
+ // raw dir — so dev can't expose files prod wouldn't.
150
+ const bundled = await bundle(shape, cwd, undefined, { compatibilityFlags: config?.compatibilityFlags });
151
+ if (!bundled.ok) {
152
+ io.err(`FAIL: ${bundled.message}${bundled.hint ? `\n hint: ${bundled.hint}` : ''}\n`);
153
+ return 1;
154
+ }
155
+ for (const w of bundled.warnings)
156
+ io.err(`warn: ${w}\n`);
157
+ let script;
158
+ if (shape.type === 'worker') {
159
+ if (!bundled.module) {
160
+ io.err('FAIL: worker produced no module to run\n');
161
+ return 1;
162
+ }
163
+ script = Buffer.from(bundled.module.contents).toString('utf8');
164
+ }
165
+ else {
166
+ script = STATIC_DEV_SHIM;
167
+ }
168
+ // Static projects always need an ASSETS binding (the shim calls it), even when
169
+ // the output dir is empty — stage an empty dir in that case.
170
+ const stagedAssetsDir = stageAssets(bundled.assets, shape.type === 'static');
171
+ const cleanup = () => {
172
+ if (stagedAssetsDir) {
173
+ try {
174
+ rmSync(stagedAssetsDir, { recursive: true, force: true });
175
+ }
176
+ catch {
177
+ /* best-effort temp cleanup */
178
+ }
179
+ }
180
+ };
181
+ const init = buildMiniflareInit({ config, script, port, host, vars: opts.vars ?? {}, stagedAssetsDir });
182
+ let server;
183
+ try {
184
+ server = await start(init);
185
+ }
186
+ catch (err) {
187
+ io.err(`FAIL: ${err instanceof Error ? err.message : String(err)}\n`);
188
+ cleanup();
189
+ return 1;
190
+ }
191
+ io.out(`yolo deploy dev — serving ${shape.type} on ${server.url} (workerd, compat ${DEV_COMPATIBILITY_DATE})\n`);
192
+ if (Object.keys(init.bindings).length === 0) {
193
+ io.out(' note: no local vars set — real env vars/secrets are server-side; pass `--var KEY=VALUE` for local values\n');
194
+ }
195
+ io.out(' press Ctrl-C to stop\n');
196
+ try {
197
+ await waitForStop();
198
+ }
199
+ finally {
200
+ // A dispose failure at shutdown is best-effort — warn, don't turn a clean
201
+ // Ctrl-C into a crash — and the inner finally guarantees the staged-asset
202
+ // cleanup runs regardless.
203
+ try {
204
+ await server.dispose();
205
+ }
206
+ catch (err) {
207
+ io.err(`warn: dev server dispose failed: ${err instanceof Error ? err.message : String(err)}\n`);
208
+ }
209
+ finally {
210
+ cleanup();
211
+ }
212
+ }
213
+ io.out('\ndev server stopped\n');
214
+ return 0;
215
+ }
@@ -76,7 +76,12 @@ export async function runDeployShip(options) {
76
76
  progress(`deploy: build \`${shape.buildCommand}\` ... ok (${((Date.now() - startedAt) / 1000).toFixed(1)}s)`);
77
77
  }
78
78
  // 4. Bundle — all size/count ceilings fire locally in here, pre-network.
79
- const bundled = await bundle(shape, cwd);
79
+ // Request a sourcemap so CF can symbolicate exception stacks in deploy.logs
80
+ // (the .map ships as a sidecar, out of the module + the digest).
81
+ const bundled = await bundle(shape, cwd, undefined, {
82
+ compatibilityFlags: config.compatibilityFlags,
83
+ sourcemaps: true,
84
+ });
80
85
  if (!bundled.ok) {
81
86
  return { ok: false, kind: bundled.kind, message: bundled.message, hint: bundled.hint, detail: bundled.detail };
82
87
  }
@@ -159,8 +164,9 @@ export async function runDeployShip(options) {
159
164
  progress(`deploy: assets ${i + 1}/${buckets.length} buckets ok (${formatMiB(uploadedBytes)})`);
160
165
  }
161
166
  }
162
- // 8. Finalize — worker modules multipart / empty JSON for pure-static.
163
- const finalized = await finalizeLeg(ctx, projectId, shipId, workerModules);
167
+ // 8. Finalize — worker modules multipart / empty JSON for pure-static. The
168
+ // sourcemap sidecar rides alongside the modules (out of the digest).
169
+ const finalized = await finalizeLeg(ctx, projectId, shipId, workerModules, bundled.sourceMap);
164
170
  if (!finalized.ok) {
165
171
  if (finalized.kind === 'awaiting-approval' && 'approvalId' in finalized) {
166
172
  progress('deploy: finalize → pending operator approval (T3 prod ship)');
@@ -325,7 +331,7 @@ export function shortDigest(digest) {
325
331
  function describeError(err) {
326
332
  return err instanceof Error ? err.message : String(err);
327
333
  }
328
- function defaultRunBuild(command, cwd, onOutput) {
334
+ export function defaultRunBuild(command, cwd, onOutput) {
329
335
  return new Promise((resolve) => {
330
336
  const child = spawn(command, { cwd, shell: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
331
337
  child.stdout?.on('data', (data) => onOutput(String(data)));
@@ -49,7 +49,11 @@ export function parsePermissionShape(raw) {
49
49
  return null; // arg = McpScope (membership checked server-side)
50
50
  return { raw, namespace: 'mcp', arg };
51
51
  case 'llm':
52
- if (action !== 'invoke' || !arg)
52
+ // llm.invoke — provider-agnostic (the router picks the model). A legacy
53
+ // `llm.invoke:<provider>` still parses; the provider is advisory only. Head
54
+ // must be EXACTLY `llm.invoke`: bare is valid, but `llm.invoke:` and extra
55
+ // segments `llm.invoke.foo` are rejected.
56
+ if (head !== 'llm.invoke' || arg === '')
53
57
  return null;
54
58
  return { raw, namespace: 'llm', action, arg };
55
59
  case 'media':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolo-cli",
3
- "version": "0.24.0",
3
+ "version": "0.30.0",
4
4
  "description": "YOLO Studio substrate CLI — owns yolo plan/workspace/artifact substrate-tooling commands. Distinct from yolo-code (the agent CLI) and yolo-router (the LLM gateway client).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,8 @@
29
29
  "dependencies": {
30
30
  "ajv": "^6.12.6",
31
31
  "esbuild": "^0.24.0",
32
- "js-yaml": "^4.1.0"
32
+ "js-yaml": "^4.1.0",
33
+ "miniflare": "^3.20250718.3"
33
34
  },
34
35
  "devDependencies": {
35
36
  "typescript": "^5.4.0",