@yolo-labs/yolo-cli 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,498 @@
1
+ /**
2
+ * Project-shape detection for `yolo deploy` (spec
3
+ * `docs/MANAGED_HOSTING_CLI_SPEC.md` §2, `deploy-detect`).
4
+ *
5
+ * Pure DETECTION — running the build command belongs to the ship
6
+ * orchestration; this module only RETURNS `buildCommand` when one
7
+ * applies. First hit wins, in spec order:
8
+ *
9
+ * 1. `.yolo/deploy.json` with `type` → authoritative.
10
+ * 2. `wrangler.jsonc|toml` — read `main` + `assets.directory` via a
11
+ * minimal jsonc comment-strip / toml key-extract (NO new deps).
12
+ * `main` ⇒ worker; assets-only wrangler config ⇒ static.
13
+ * 3. `package.json` with a `build` script (npm/pnpm/yarn/bun chosen
14
+ * via lockfile) → static, output dir resolved as
15
+ * `config build.outputDir` > first of dist/build/out/public/_site
16
+ * containing index.html. No resolvable output dir ⇒ fall through
17
+ * (a TS worker repo with a `build` script must still reach step 5).
18
+ * 4. Plain static: `dist/` or `public/` containing index.html, or the
19
+ * cwd itself when it has a root index.html and no package.json.
20
+ * 5. `src/index.ts|js` matching `export default` + `fetch(` → worker
21
+ * (esbuild handles TS at bundle time).
22
+ * 6. `FAIL [detect-failed]` + the init hint.
23
+ *
24
+ * Everything is driven through the injectable `readFileImpl`
25
+ * (auth-context convention) — existence checks are "can I read the
26
+ * marker file", so unit tests can run against pure in-memory maps and
27
+ * integration tests against tmp-dir fixtures.
28
+ */
29
+ import { readFileSync } from 'node:fs';
30
+ import path from 'node:path';
31
+ import { readDeployConfig } from './deploy-config.js';
32
+ export const DETECT_INIT_HINT = "run 'yolo deploy init --type static|worker'";
33
+ const OUTPUT_DIR_CANDIDATES = ['dist', 'build', 'out', 'public', '_site'];
34
+ const WORKER_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.js'];
35
+ // ─── Public entry ─────────────────────────────────────────────────────────
36
+ export function detectProjectShape(options) {
37
+ const { cwd } = options;
38
+ const read = options.readFileImpl ?? defaultReadFile;
39
+ // Resolve the deploy config (step-1 input AND the outputDir/command
40
+ // overrides steps 3 uses even when `type` is unset).
41
+ let config;
42
+ if (options.config !== undefined) {
43
+ config = options.config;
44
+ }
45
+ else {
46
+ const result = readDeployConfig(cwd, read);
47
+ if (!result.ok) {
48
+ // A malformed/invalid deploy.json is an explicit user artifact —
49
+ // heuristics must not silently override it.
50
+ return fail(result.message);
51
+ }
52
+ config = result.config;
53
+ }
54
+ // Package build command — computed up front so the pinned-static branch can
55
+ // fall back to it (codex P2 r11), same as the untyped package-build path.
56
+ const pkg = readPackageJson(cwd, read);
57
+ const pkgBuildCommand = pkg?.scripts?.build !== undefined ? buildCommandFor(cwd, read) : undefined;
58
+ // ── 1. deploy.json `type` is authoritative ─────────────────────────────
59
+ if (config?.type === 'static') {
60
+ const buildCommand = config.build?.command ?? pkgBuildCommand;
61
+ const assetsDir = config.build?.outputDir ??
62
+ resolveOutputDir(cwd, read) ??
63
+ (read(path.join(cwd, 'index.html')) !== undefined ? '.' : undefined) ??
64
+ // A clean checkout whose build hasn't run yet: with a build command we
65
+ // can default the output dir (build creates it, then bundle reads it) —
66
+ // `yolo deploy init --type static` writes the pin but no build.command.
67
+ (buildCommand !== undefined ? OUTPUT_DIR_CANDIDATES[0] : undefined);
68
+ if (assetsDir === undefined) {
69
+ return fail(".yolo/deploy.json sets type 'static' but no output dir was found: set build.outputDir, add a package.json build script, or build the project so one of " +
70
+ `${OUTPUT_DIR_CANDIDATES.join('/')} contains index.html`);
71
+ }
72
+ return hit({
73
+ type: 'static',
74
+ assetsDir,
75
+ ...optional('buildCommand', buildCommand),
76
+ }, 'deploy-json');
77
+ }
78
+ if (config?.type === 'worker') {
79
+ const explicitEntry = config.worker?.entry;
80
+ const entry = explicitEntry ?? findWorkerEntry(cwd, read, /* requireSignature */ false);
81
+ if (entry === undefined) {
82
+ return fail(".yolo/deploy.json sets type 'worker' but no entry was found: set worker.entry (source or a pre-built .js/.mjs module)");
83
+ }
84
+ // Prebuilt skip is OPT-IN via deploy.json worker.prebuilt (codex P2 r9) —
85
+ // the extension is not a reliable signal (a built bundle and a source
86
+ // file can both be .js, and skipping esbuild on source drops its
87
+ // imports). Only an explicitly-set entry can be prebuilt.
88
+ const prebuilt = config.worker?.prebuilt === true && explicitEntry !== undefined;
89
+ return hit({
90
+ type: 'worker',
91
+ entry,
92
+ ...(prebuilt && { prebuilt: true }),
93
+ ...optional('assetsDir', config.worker?.assetsDir),
94
+ // Symmetric with the static branch + the wrangler branch: fall back to
95
+ // the package.json build script when deploy.json omits an explicit
96
+ // build.command. Without this, an explicit-worker project would NOT
97
+ // auto-build (the asymmetry users hit — static built, worker didn't),
98
+ // and a `prebuilt` worker whose dist is produced by `npm run build`
99
+ // (no explicit build.command) would ship a missing entry.
100
+ ...optional('buildCommand', config.build?.command ?? pkgBuildCommand),
101
+ }, 'deploy-json');
102
+ }
103
+ // pkg / pkgBuildCommand are computed above (reused by steps 2–6).
104
+ // ── 2. wrangler.jsonc / wrangler.toml ──────────────────────────────────
105
+ const wrangler = readWranglerConfig(cwd, read);
106
+ if (wrangler !== undefined) {
107
+ const buildCommand = config?.build?.command ?? pkgBuildCommand;
108
+ if (wrangler.main !== undefined) {
109
+ return hit({
110
+ type: 'worker',
111
+ entry: wrangler.main,
112
+ ...optional('assetsDir', wrangler.assetsDirectory),
113
+ ...optional('buildCommand', buildCommand),
114
+ }, 'wrangler');
115
+ }
116
+ if (wrangler.assetsDirectory !== undefined) {
117
+ // Assets-only wrangler project (no `main`) — nothing to bundle as
118
+ // a module; ship it static and let common-api attach the shim.
119
+ return hit({
120
+ type: 'static',
121
+ assetsDir: wrangler.assetsDirectory,
122
+ ...optional('buildCommand', buildCommand),
123
+ }, 'wrangler');
124
+ }
125
+ // wrangler file present but carries neither key — fall through.
126
+ }
127
+ // ── 3. package.json `build` script → static ────────────────────────────
128
+ if (pkgBuildCommand !== undefined) {
129
+ const outputDir = config?.build?.outputDir ?? resolveOutputDir(cwd, read);
130
+ if (outputDir !== undefined) {
131
+ return hit({
132
+ type: 'static',
133
+ assetsDir: outputDir,
134
+ buildCommand: config?.build?.command ?? pkgBuildCommand,
135
+ }, 'package-build');
136
+ }
137
+ // Build script but no resolvable output dir — keep falling: this may
138
+ // be a worker repo whose `build` is typecheck/compile (step 5).
139
+ }
140
+ // ── 4. plain static ─────────────────────────────────────────────────────
141
+ for (const dir of ['dist', 'public']) {
142
+ if (read(path.join(cwd, dir, 'index.html')) !== undefined) {
143
+ return hit({ type: 'static', assetsDir: dir }, 'plain-static');
144
+ }
145
+ }
146
+ if (read(path.join(cwd, 'index.html')) !== undefined && pkg === undefined) {
147
+ return hit({ type: 'static', assetsDir: '.' }, 'plain-static');
148
+ }
149
+ // ── 5. worker entry signature ───────────────────────────────────────────
150
+ const entry = findWorkerEntry(cwd, read, /* requireSignature */ true);
151
+ if (entry !== undefined) {
152
+ return hit({ type: 'worker', entry }, 'worker-entry');
153
+ }
154
+ // ── 6. build-script static with no output yet (clean checkout) ──────────
155
+ // Detection is offline and runs BEFORE the build, so a fresh static app's
156
+ // output dir doesn't exist yet (codex P2 r8). We've now ruled out a worker
157
+ // entry, so a `build` script means static: default the assets dir to the
158
+ // configured value or `dist`. The orchestrator runs the build, then bundle
159
+ // reads it — a wrong guess fails later with a clear "assets dir not found"
160
+ // pointing the user to set build.outputDir.
161
+ if (pkgBuildCommand !== undefined) {
162
+ return hit({
163
+ type: 'static',
164
+ assetsDir: config?.build?.outputDir ?? OUTPUT_DIR_CANDIDATES[0],
165
+ buildCommand: config?.build?.command ?? pkgBuildCommand,
166
+ }, 'package-build');
167
+ }
168
+ // ── 6. detect-failed ────────────────────────────────────────────────────
169
+ return fail(`could not detect a deployable project in ${cwd}: no .yolo/deploy.json type, ` +
170
+ 'wrangler config, build script with an output dir, static dir with index.html, ' +
171
+ 'or src/index.ts|js worker entry');
172
+ }
173
+ function readPackageJson(cwd, read) {
174
+ const text = read(path.join(cwd, 'package.json'));
175
+ if (text === undefined)
176
+ return undefined;
177
+ try {
178
+ const parsed = JSON.parse(text);
179
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
180
+ return undefined;
181
+ return parsed;
182
+ }
183
+ catch {
184
+ return undefined;
185
+ }
186
+ }
187
+ /** Package-manager-aware `run build`, chosen via lockfile presence. */
188
+ function buildCommandFor(cwd, read) {
189
+ if (read(path.join(cwd, 'pnpm-lock.yaml')) !== undefined)
190
+ return 'pnpm run build';
191
+ if (read(path.join(cwd, 'yarn.lock')) !== undefined)
192
+ return 'yarn run build';
193
+ if (read(path.join(cwd, 'bun.lockb')) !== undefined || read(path.join(cwd, 'bun.lock')) !== undefined) {
194
+ return 'bun run build';
195
+ }
196
+ return 'npm run build';
197
+ }
198
+ function resolveOutputDir(cwd, read) {
199
+ for (const dir of OUTPUT_DIR_CANDIDATES) {
200
+ if (read(path.join(cwd, dir, 'index.html')) !== undefined)
201
+ return dir;
202
+ }
203
+ return undefined;
204
+ }
205
+ /**
206
+ * Find a conventional worker entry. With `requireSignature` (step 5)
207
+ * the file must contain both `export default` and `fetch(`; without it
208
+ * (deploy.json says worker but omitted `entry`) existence is enough.
209
+ */
210
+ function findWorkerEntry(cwd, read, requireSignature) {
211
+ for (const candidate of WORKER_ENTRY_CANDIDATES) {
212
+ const content = read(path.join(cwd, ...candidate.split('/')));
213
+ if (content === undefined)
214
+ continue;
215
+ if (!requireSignature)
216
+ return candidate;
217
+ if (/export\s+default/.test(content) && content.includes('fetch('))
218
+ return candidate;
219
+ }
220
+ return undefined;
221
+ }
222
+ // ─── wrangler config extraction (minimal, no deps) ───────────────────────
223
+ // Wrangler binding stanzas we recognize. We only DETECT their presence (to
224
+ // warn) — we NEVER import them: wrangler bindings reference the user's OWN
225
+ // Cloudflare account resources, which don't exist in YOLO Host's tenancy.
226
+ // D1/KV/R2 are re-provisionable through our deploy.* tools; the rest are
227
+ // unsupported on the platform.
228
+ const PROVISIONABLE_BINDING_KEYS = {
229
+ d1_databases: 'D1',
230
+ kv_namespaces: 'KV',
231
+ r2_buckets: 'R2',
232
+ };
233
+ const UNSUPPORTED_BINDING_KEYS = [
234
+ 'durable_objects',
235
+ 'queues',
236
+ 'services',
237
+ 'service', // defensive: the canonical key is `services`, but accept singular too
238
+ 'hyperdrive',
239
+ 'vectorize',
240
+ 'ai',
241
+ 'analytics_engine_datasets',
242
+ 'dispatch_namespaces',
243
+ 'mtls_certificates',
244
+ 'send_email',
245
+ 'browser',
246
+ 'workflows',
247
+ 'pipelines',
248
+ 'version_metadata',
249
+ 'unsafe',
250
+ 'wasm_modules',
251
+ 'text_blobs',
252
+ 'data_blobs',
253
+ ];
254
+ const ALL_BINDING_KEYS = [...Object.keys(PROVISIONABLE_BINDING_KEYS), ...UNSUPPORTED_BINDING_KEYS];
255
+ /**
256
+ * Binding stanzas present-and-non-empty in a parsed JSON wrangler config —
257
+ * including per-environment overrides under `env.<name>` (codex P2), since
258
+ * wrangler lets bindings live there with no top-level equivalent.
259
+ */
260
+ function detectJsonBindingKinds(parsed) {
261
+ const nonEmpty = (v) => Array.isArray(v) ? v.length > 0 : v !== null && typeof v === 'object' ? Object.keys(v).length > 0 : Boolean(v);
262
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
263
+ const found = new Set();
264
+ const scan = (obj) => {
265
+ for (const k of ALL_BINDING_KEYS)
266
+ if (nonEmpty(obj[k]))
267
+ found.add(k);
268
+ };
269
+ scan(parsed);
270
+ if (isObj(parsed.env))
271
+ for (const envCfg of Object.values(parsed.env))
272
+ if (isObj(envCfg))
273
+ scan(envCfg);
274
+ return [...found];
275
+ }
276
+ /**
277
+ * Binding stanzas present in a toml wrangler config — detected by SECTION
278
+ * HEADER (`[[d1_databases]]`, `[queues.producers]`, `[ai]`), not parsed, and
279
+ * including env-scoped sections (`[[env.production.kv_namespaces]]`). The
280
+ * minimal toml reader can't read the values, but it can notice the keys exist:
281
+ * any segment of the dotted section name that is a known binding key counts.
282
+ */
283
+ function detectTomlBindingKinds(text) {
284
+ const found = new Set();
285
+ for (const raw of text.split('\n')) {
286
+ // Capture the full dotted section name incl. hyphenated env names
287
+ // (`[[env.prod-us.kv_namespaces]]`) before splitting on '.' (codex P3).
288
+ const m = /^\[+([A-Za-z0-9_.-]+)/.exec(raw.trim());
289
+ if (!m)
290
+ continue;
291
+ const segs = m[1].split('.');
292
+ // The binding key is POSITIONAL, not "any segment": top-level `[[key]]` →
293
+ // segs[0]; env-scoped `[[env.<name>.key]]` → segs[2]. Scanning every
294
+ // segment would mis-read an env NAMED after a binding (`[env.ai.vars]`,
295
+ // `[env.queues.vars]`) as a binding (codex P3).
296
+ const key = segs[0] === 'env' ? segs[2] : segs[0];
297
+ if (key && ALL_BINDING_KEYS.includes(key))
298
+ found.add(key);
299
+ }
300
+ return [...found];
301
+ }
302
+ function readWranglerConfig(cwd, read) {
303
+ const jsoncName = read(path.join(cwd, 'wrangler.jsonc')) !== undefined ? 'wrangler.jsonc' : 'wrangler.json';
304
+ const jsoncText = read(path.join(cwd, 'wrangler.jsonc')) ?? read(path.join(cwd, 'wrangler.json'));
305
+ if (jsoncText !== undefined) {
306
+ const parsed = parseJsonc(jsoncText);
307
+ if (parsed !== undefined) {
308
+ const main = typeof parsed.main === 'string' ? parsed.main : undefined;
309
+ const assets = parsed.assets;
310
+ const assetsDirectory = assets !== null && typeof assets === 'object' && !Array.isArray(assets) &&
311
+ typeof assets.directory === 'string'
312
+ ? assets.directory
313
+ : undefined;
314
+ const compatibilityFlags = Array.isArray(parsed.compatibility_flags)
315
+ ? parsed.compatibility_flags.filter((f) => typeof f === 'string')
316
+ : undefined;
317
+ const bindingKinds = detectJsonBindingKinds(parsed);
318
+ return {
319
+ main,
320
+ assetsDirectory,
321
+ ...(compatibilityFlags && compatibilityFlags.length > 0 ? { compatibilityFlags } : {}),
322
+ ...(bindingKinds.length > 0 ? { bindingKinds } : {}),
323
+ sourceFile: jsoncName,
324
+ };
325
+ }
326
+ // Unparseable wrangler file: treat as absent (fall through to toml).
327
+ }
328
+ const tomlText = read(path.join(cwd, 'wrangler.toml'));
329
+ if (tomlText !== undefined) {
330
+ const bindingKinds = detectTomlBindingKinds(tomlText);
331
+ return {
332
+ ...extractTomlKeys(tomlText),
333
+ ...(bindingKinds.length > 0 ? { bindingKinds } : {}),
334
+ sourceFile: 'wrangler.toml',
335
+ };
336
+ }
337
+ return undefined;
338
+ }
339
+ /**
340
+ * Adapt an existing wrangler config into the canonical `.yolo/deploy.json`
341
+ * shape — the migration `yolo deploy init` performs once when a project has a
342
+ * wrangler config but no deploy.json. Returns the deploy.json fields to merge,
343
+ * the source filename, what was migrated, and any honesty warnings (e.g. toml
344
+ * compatibility_flags aren't parsed by our minimal reader). Returns undefined
345
+ * when no wrangler config is present or it carries nothing adaptable.
346
+ */
347
+ export function adaptWrangler(cwd, readFileImpl) {
348
+ const read = readFileImpl ?? defaultReadFile;
349
+ const w = readWranglerConfig(cwd, read);
350
+ if (w === undefined || w.sourceFile === undefined)
351
+ return undefined;
352
+ const config = {};
353
+ const migrated = [];
354
+ const warnings = [];
355
+ if (w.main !== undefined) {
356
+ config.type = 'worker';
357
+ config.worker = { entry: w.main, ...(w.assetsDirectory !== undefined ? { assetsDir: w.assetsDirectory } : {}) };
358
+ migrated.push(`type=worker`, `worker.entry=${w.main}`);
359
+ if (w.assetsDirectory !== undefined)
360
+ migrated.push(`worker.assetsDir=${w.assetsDirectory}`);
361
+ }
362
+ else if (w.assetsDirectory !== undefined) {
363
+ config.type = 'static';
364
+ config.build = { outputDir: w.assetsDirectory };
365
+ migrated.push(`type=static`, `build.outputDir=${w.assetsDirectory}`);
366
+ }
367
+ if (w.compatibilityFlags && w.compatibilityFlags.length > 0) {
368
+ config.compatibilityFlags = w.compatibilityFlags;
369
+ migrated.push(`compatibilityFlags=[${w.compatibilityFlags.join(', ')}]`);
370
+ }
371
+ else if (w.sourceFile === 'wrangler.toml') {
372
+ // The minimal toml reader extracts only top-level main/[assets].directory,
373
+ // not the compatibility_flags array — be honest rather than silently drop.
374
+ warnings.push('compatibility_flags (if any) were NOT read from wrangler.toml — add them to .yolo/deploy.json `compatibilityFlags` manually if your Worker needs them (e.g. nodejs_compat)');
375
+ }
376
+ // Binding stanzas are NEVER imported (they reference the user's own CF
377
+ // account). Warn so a declared D1/KV/R2 doesn't silently leave env.<NAME>
378
+ // undefined at runtime, and so unsupported bindings aren't assumed to work.
379
+ if (w.bindingKinds && w.bindingKinds.length > 0) {
380
+ const provisionable = w.bindingKinds
381
+ .filter((k) => k in PROVISIONABLE_BINDING_KEYS)
382
+ .map((k) => PROVISIONABLE_BINDING_KEYS[k]);
383
+ const unsupported = w.bindingKinds.filter((k) => !(k in PROVISIONABLE_BINDING_KEYS));
384
+ if (provisionable.length > 0) {
385
+ warnings.push(`wrangler config declares ${provisionable.join(', ')} binding(s) — NOT imported (they reference your own Cloudflare account); ` +
386
+ 'provision them on YOLO Host with deploy.db_provision / deploy.kv_create / deploy.bucket_create so env.<NAME> exists at runtime');
387
+ }
388
+ if (unsupported.length > 0) {
389
+ warnings.push(`wrangler config declares ${unsupported.join(', ')} which YOLO Host does not support — dropped`);
390
+ }
391
+ }
392
+ if (migrated.length === 0)
393
+ return undefined;
394
+ return { config, sourceFile: w.sourceFile, migrated, warnings };
395
+ }
396
+ /**
397
+ * Minimal jsonc: strip line and block comments outside string
398
+ * literals, drop trailing commas, then JSON.parse. NOT a general jsonc
399
+ * parser — exactly enough for wrangler configs, by spec.
400
+ */
401
+ function parseJsonc(text) {
402
+ let out = '';
403
+ let i = 0;
404
+ let inString = false;
405
+ while (i < text.length) {
406
+ const c = text[i];
407
+ if (inString) {
408
+ out += c;
409
+ if (c === '\\') {
410
+ out += text[i + 1] ?? '';
411
+ i += 2;
412
+ continue;
413
+ }
414
+ if (c === '"')
415
+ inString = false;
416
+ i++;
417
+ continue;
418
+ }
419
+ if (c === '"') {
420
+ inString = true;
421
+ out += c;
422
+ i++;
423
+ continue;
424
+ }
425
+ if (c === '/' && text[i + 1] === '/') {
426
+ while (i < text.length && text[i] !== '\n')
427
+ i++;
428
+ continue;
429
+ }
430
+ if (c === '/' && text[i + 1] === '*') {
431
+ i += 2;
432
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
433
+ i++;
434
+ i += 2;
435
+ continue;
436
+ }
437
+ out += c;
438
+ i++;
439
+ }
440
+ out = out.replace(/,\s*([}\]])/g, '$1');
441
+ try {
442
+ const parsed = JSON.parse(out);
443
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
444
+ return undefined;
445
+ return parsed;
446
+ }
447
+ catch {
448
+ return undefined;
449
+ }
450
+ }
451
+ /**
452
+ * Minimal toml: line-based extraction of top-level `main = "…"` and
453
+ * `[assets] directory = "…"`. NOT a toml parser — exactly the two keys
454
+ * the spec names, by spec.
455
+ */
456
+ function extractTomlKeys(text) {
457
+ let section = '';
458
+ let main;
459
+ let assetsDirectory;
460
+ for (const raw of text.split('\n')) {
461
+ const line = raw.trim();
462
+ if (line === '' || line.startsWith('#'))
463
+ continue;
464
+ const sectionMatch = /^\[([^\]]+)\]/.exec(line);
465
+ if (sectionMatch) {
466
+ section = sectionMatch[1].trim();
467
+ continue;
468
+ }
469
+ const kv = /^([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"/.exec(line) ?? /^([A-Za-z0-9_-]+)\s*=\s*'([^']*)'/.exec(line);
470
+ if (!kv)
471
+ continue;
472
+ if (section === '' && kv[1] === 'main' && main === undefined)
473
+ main = kv[2];
474
+ if (section === 'assets' && kv[1] === 'directory' && assetsDirectory === undefined) {
475
+ assetsDirectory = kv[2];
476
+ }
477
+ }
478
+ return { main, assetsDirectory };
479
+ }
480
+ // ─── Helpers ──────────────────────────────────────────────────────────────
481
+ function hit(shape, source) {
482
+ return { ok: true, shape, source };
483
+ }
484
+ function fail(message) {
485
+ return { ok: false, kind: 'detect-failed', message: `${message} | hint: ${DETECT_INIT_HINT}` };
486
+ }
487
+ /** Conditional-spread helper so absent optionals are OMITTED, not `undefined`. */
488
+ function optional(key, value) {
489
+ return value === undefined ? {} : { [key]: value };
490
+ }
491
+ function defaultReadFile(filePath) {
492
+ try {
493
+ return readFileSync(filePath, 'utf8');
494
+ }
495
+ catch {
496
+ return undefined;
497
+ }
498
+ }