@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.
- package/dist/deploy-bundle.js +249 -12
- package/dist/deploy-cli.js +229 -1
- package/dist/deploy-client.js +6 -1
- package/dist/deploy-dev.js +215 -0
- package/dist/deploy-ship.js +10 -4
- package/dist/tileapp-validator.js +5 -1
- package/package.json +3 -2
package/dist/deploy-bundle.js
CHANGED
|
@@ -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}`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/deploy-cli.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/deploy-client.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/deploy-ship.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|