@vxil/cli 0.4.3 → 0.5.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/vxil.js CHANGED
@@ -1,38 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/vxil.ts
4
- import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, existsSync as existsSync7, appendFileSync as appendFileSync2, readFileSync as readFileSync7, chmodSync as chmodSync2 } from "node:fs";
4
+ import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, existsSync as existsSync8, appendFileSync as appendFileSync2, readFileSync as readFileSync8, chmodSync as chmodSync2 } from "node:fs";
5
5
  import { spawnSync } from "node:child_process";
6
- import { resolve as resolve7, dirname as dirname4 } from "node:path";
6
+ import { resolve as resolve7, dirname as dirname5 } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { watch } from "node:fs";
9
9
  import { createServer } from "node:http";
10
- import { homedir as homedir2, hostname } from "node:os";
10
+ import { homedir as homedir3, hostname } from "node:os";
11
11
 
12
12
  // src/lib.ts
13
13
  import { pathToFileURL, fileURLToPath } from "node:url";
14
14
  import { resolve, dirname, basename, join } from "node:path";
15
15
  import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
16
+ var FN_LIMIT_BOUNDS = {
17
+ cpuMs: { min: 5, max: 3e5 },
18
+ timeoutMs: { min: 1e3, max: 12e4 }
19
+ };
20
+ function normalizeFnLimits(raw) {
21
+ if (!raw || typeof raw !== "object") return void 0;
22
+ const out = {};
23
+ for (const k of ["cpuMs", "timeoutMs"]) {
24
+ const v = raw[k];
25
+ if (typeof v !== "number" || !Number.isFinite(v)) continue;
26
+ const b2 = FN_LIMIT_BOUNDS[k];
27
+ out[k] = Math.min(b2.max, Math.max(b2.min, Math.floor(v)));
28
+ }
29
+ return Object.keys(out).length ? out : void 0;
30
+ }
16
31
  function lowerTriggerBindings(trigger) {
17
32
  const t = trigger?.kind ?? "http";
18
- const str = (k) => typeof trigger?.[k] === "string" ? trigger[k] : void 0;
19
- if (t === "cron") return [{ kind: "cron", ...str("schedule") ? { schedule: str("schedule") } : {} }];
20
- if (t === "queue") return [{ kind: "queue", ...str("source") ? { source: str("source") } : {} }];
21
- if (t === "webhook") return [{ kind: "webhook", ...str("source") ? { source: str("source") } : {} }];
33
+ const str2 = (k) => typeof trigger?.[k] === "string" ? trigger[k] : void 0;
34
+ if (t === "cron") return [{ kind: "cron", ...str2("schedule") ? { schedule: str2("schedule") } : {} }];
35
+ if (t === "queue") return [{ kind: "queue", ...str2("source") ? { source: str2("source") } : {} }];
36
+ if (t === "webhook") return [{ kind: "webhook", ...str2("source") ? { source: str2("source") } : {} }];
22
37
  if (t === "cmsHook" || t === "cms-hook") {
23
38
  return [{
24
39
  kind: "cmsHook",
25
- ...str("collection") ? { collection: str("collection") } : {},
26
- ...str("event") ? { event: str("event") } : {}
40
+ ...str2("collection") ? { collection: str2("collection") } : {},
41
+ ...str2("event") ? { event: str2("event") } : {}
27
42
  }];
28
43
  }
29
44
  if (t === "authHook" || t === "auth-hook") {
30
- return [{ kind: "authHook", ...str("event") ? { event: str("event") } : {} }];
45
+ return [{ kind: "authHook", ...str2("event") ? { event: str2("event") } : {} }];
31
46
  }
32
- return [{ kind: "http", ...str("path") ? { path: str("path") } : {} }];
47
+ return [{ kind: "http", ...str2("path") ? { path: str2("path") } : {} }];
33
48
  }
34
49
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
35
- var VXIL_CONFIG_PKG_VERSION = "0.3.1";
50
+ var VXIL_CONFIG_PKG_VERSION = "0.4.0";
36
51
  function ensureScaffoldPackageJson(cwd) {
37
52
  const file = resolve(cwd, "package.json");
38
53
  const spec = `^${VXIL_CONFIG_PKG_VERSION}`;
@@ -253,6 +268,7 @@ function assembleApplyBundle(cfg, fnSources) {
253
268
  const source = fnSources[name];
254
269
  if (source === void 0) throw new Error(`functions: no bundled source for '${name}'`);
255
270
  const bindings = lowerTriggerBindings(def.trigger);
271
+ const limits = normalizeFnLimits(def.limits);
256
272
  return {
257
273
  name,
258
274
  source,
@@ -261,7 +277,8 @@ function assembleApplyBundle(cfg, fnSources) {
261
277
  ...def.signature ? { signature: def.signature } : {},
262
278
  ...def.scopes ? { scopes: def.scopes } : {},
263
279
  ...def.egressAllow ? { egressAllow: def.egressAllow } : {},
264
- ...def.secrets ? { secrets: def.secrets } : {}
280
+ ...def.secrets ? { secrets: def.secrets } : {},
281
+ ...limits ? { limits } : {}
265
282
  };
266
283
  });
267
284
  return {
@@ -319,6 +336,16 @@ async function planOrPush({ api, features, apply }) {
319
336
  }
320
337
  const effective = dry.body.data?.manifest;
321
338
  const cur = await api("GET", `/v1/config/${feature}`);
339
+ const unavailable = readUnavailable(`GET /v1/config/${feature}`, cur);
340
+ if (unavailable) {
341
+ if (apply) {
342
+ throw new Error(
343
+ `config: cannot read ${unavailable.route} (${unavailable.code ?? unavailable.status}${unavailable.message ? ` \u2014 ${unavailable.message}` : ""}) \u2014 refusing to push ${feature} against a remote it could not read`
344
+ );
345
+ }
346
+ results.push({ feature, version: 0, changes: [], applied: null, remoteUnavailable: unavailable });
347
+ continue;
348
+ }
322
349
  const exists = cur.status === 200;
323
350
  const remote = exists ? cur.body.data?.manifest : {};
324
351
  const version = exists ? cur.body.data?.version ?? 0 : 0;
@@ -341,9 +368,179 @@ async function planOrPush({ api, features, apply }) {
341
368
  }
342
369
  return results;
343
370
  }
371
+ function classifyRead(status, code) {
372
+ if (status === 200) return "ok";
373
+ if (status === 404) return "empty";
374
+ if (status === 501 && (code === void 0 || code === "capability_not_enabled")) return "empty";
375
+ return "unavailable";
376
+ }
377
+ function readUnavailable(route, res) {
378
+ const e = res.body?.error ?? {};
379
+ if (classifyRead(res.status, e.code) !== "unavailable") return null;
380
+ return {
381
+ route,
382
+ status: res.status,
383
+ ...e.code ? { code: e.code } : {},
384
+ ...e.message ? { message: e.message } : {}
385
+ };
386
+ }
387
+ function formatNotCompared(u, hint) {
388
+ return ` ! not compared: ${u.route} \u2192 ${u.code ?? u.status}${u.message ? ` \u2014 ${u.message}` : ""}${hint ? ` (${hint})` : ""}`;
389
+ }
390
+
391
+ // src/apiState.ts
392
+ var POLICY_LIST_CAP = 100;
393
+ var CAMPAIGN_LIST_CAP = 200;
394
+ var LEGACY_POLICY_ALGORITHM = "sliding_window";
395
+ var DEFAULT_SUBSCRIPTION_STATE = "active";
396
+ function str(v) {
397
+ return typeof v === "string" ? v : void 0;
398
+ }
399
+ function num(v) {
400
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
401
+ }
402
+ function strList(v) {
403
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
404
+ }
405
+ function defined(o) {
406
+ const out = {};
407
+ for (const [k, v] of Object.entries(o)) if (v !== void 0) out[k] = v;
408
+ return out;
409
+ }
410
+ async function fetchApiState(api) {
411
+ const snap = { policies: [], subscriptions: [], campaigns: [], unavailable: [], truncated: [] };
412
+ const read = async (route, take) => {
413
+ let res;
414
+ try {
415
+ res = await api("GET", route);
416
+ } catch (e) {
417
+ snap.unavailable.push({ route: `GET ${route}`, status: 0, code: "network_error", message: e.message.split("\n")[0] ?? "" });
418
+ return;
419
+ }
420
+ const u = readUnavailable(`GET ${route}`, res);
421
+ if (u) {
422
+ snap.unavailable.push(u);
423
+ return;
424
+ }
425
+ if (classifyRead(res.status, res.body.error?.code) !== "ok") return;
426
+ take(res.body.data ?? {});
427
+ };
428
+ await read("/v1/rate-limits/policies", (d) => {
429
+ const rows = Array.isArray(d.policies) ? d.policies : [];
430
+ for (const raw of rows) {
431
+ const p = raw;
432
+ const name = str(p.name);
433
+ if (!name) continue;
434
+ snap.policies.push(defined({
435
+ name,
436
+ limit: num(p.limit),
437
+ window_seconds: num(p.window_seconds),
438
+ behavior: str(p.behavior),
439
+ key_template: str(p.key_template),
440
+ algorithm: str(p.algorithm) ?? LEGACY_POLICY_ALGORITHM
441
+ }));
442
+ }
443
+ if (rows.length >= POLICY_LIST_CAP) snap.truncated.push("policy");
444
+ });
445
+ await read("/v1/webhooks/subscriptions", (d) => {
446
+ for (const raw of Array.isArray(d.subscriptions) ? d.subscriptions : []) {
447
+ const s = raw;
448
+ const url = str(s.target_url);
449
+ if (!url) continue;
450
+ snap.subscriptions.push({
451
+ target_url: url,
452
+ event_prefixes: strList(s.event_prefixes),
453
+ state: str(s.state) ?? DEFAULT_SUBSCRIPTION_STATE
454
+ });
455
+ }
456
+ });
457
+ await read("/v1/notifications/campaigns", (d) => {
458
+ const rows = Array.isArray(d.campaigns) ? d.campaigns : [];
459
+ for (const raw of rows) {
460
+ const c = raw;
461
+ const name = str(c.name);
462
+ if (name) snap.campaigns.push({ name });
463
+ }
464
+ if (rows.length >= CAMPAIGN_LIST_CAP) snap.truncated.push("campaign");
465
+ });
466
+ return snap;
467
+ }
468
+ function apiStateDriftKey(row2) {
469
+ return `api:${row2.section}:${row2.key}`;
470
+ }
471
+ function sameSet(a, b2) {
472
+ const A = new Set(a);
473
+ const B = new Set(b2);
474
+ return A.size === B.size && [...A].every((x) => B.has(x));
475
+ }
476
+ function diffBy(section, target, against, keyOf, fields) {
477
+ const rows = [];
478
+ const A = new Map(target.map((t) => [keyOf(t), t]));
479
+ const B = new Map(against.map((t) => [keyOf(t), t]));
480
+ for (const key of [.../* @__PURE__ */ new Set([...A.keys(), ...B.keys()])].sort()) {
481
+ const a = A.get(key);
482
+ const b2 = B.get(key);
483
+ if (a && !b2) {
484
+ rows.push({ section, key, kind: "only_in_target", detail: `${section} '${key}' exists on the target, not on --against` });
485
+ continue;
486
+ }
487
+ if (!a && b2) {
488
+ rows.push({ section, key, kind: "only_in_against", detail: `${section} '${key}' exists on --against, not on the target` });
489
+ continue;
490
+ }
491
+ for (const f of fields) {
492
+ const av = f.of(a);
493
+ const bv = f.of(b2);
494
+ const equal = Array.isArray(av) && Array.isArray(bv) ? sameSet(av, bv) : JSON.stringify(av ?? null) === JSON.stringify(bv ?? null);
495
+ if (equal) continue;
496
+ rows.push({
497
+ section,
498
+ key,
499
+ kind: "differs",
500
+ detail: `${section} '${key}' ${f.name}: ${JSON.stringify(av ?? null)} \u2192 ${JSON.stringify(bv ?? null)}` + (f.note ? ` ${f.note}` : "")
501
+ });
502
+ }
503
+ }
504
+ return rows;
505
+ }
506
+ function diffApiState(target, against) {
507
+ return [
508
+ ...diffBy("policy", target.policies, against.policies, (p) => p.name, [
509
+ { name: "limit", of: (p) => p.limit },
510
+ { name: "window_seconds", of: (p) => p.window_seconds },
511
+ { name: "behavior", of: (p) => p.behavior },
512
+ // PUT /v1/rate-limits/policies/:id cannot repair a key_template (the
513
+ // update body omits it) — the only fix is delete-and-recreate.
514
+ { name: "key_template", of: (p) => p.key_template, note: "(recreate to fix)" },
515
+ // Normalized at the accessor too, so a projection built by hand (or by an
516
+ // older CLI) can never compare `undefined` against the default.
517
+ { name: "algorithm", of: (p) => p.algorithm ?? LEGACY_POLICY_ALGORITHM }
518
+ ]),
519
+ ...diffBy("subscription", target.subscriptions, against.subscriptions, (s) => s.target_url, [
520
+ { name: "event_prefixes", of: (s) => s.event_prefixes },
521
+ { name: "state", of: (s) => s.state ?? DEFAULT_SUBSCRIPTION_STATE }
522
+ ]),
523
+ // Campaigns compare by PRESENCE only: everything else about a campaign
524
+ // (its schedule, its audience, its run state) is operational state that
525
+ // legitimately differs between a staging and a production tenant.
526
+ ...diffBy("campaign", target.campaigns, against.campaigns, (c) => c.name, [])
527
+ ];
528
+ }
529
+ function truncationWarnings(snap, side) {
530
+ return snap.truncated.map((s) => {
531
+ const cap = s === "policy" ? POLICY_LIST_CAP : CAMPAIGN_LIST_CAP;
532
+ return ` ! ${s} list on ${side} returned ${cap} rows \u2014 that is the route's HARD cap (no cursor), so the remainder cannot be diffed at all`;
533
+ });
534
+ }
535
+ function formatApiStateRows(rows) {
536
+ if (!rows.length) return " (api state in sync)";
537
+ return rows.map((r) => ` ${r.kind === "differs" ? "~" : r.kind === "only_in_target" ? "+" : "-"} ${r.detail}`).join("\n");
538
+ }
344
539
 
345
540
  // src/functions.ts
346
- import { resolve as resolve2, relative, isAbsolute, sep } from "node:path";
541
+ import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync } from "node:fs";
542
+ import { homedir } from "node:os";
543
+ import { dirname as dirname2, resolve as resolve2, relative, isAbsolute, sep } from "node:path";
347
544
  import { createHash } from "node:crypto";
348
545
  var MAX_SOURCE_BYTES = 256e3;
349
546
  function sourceSha12(source) {
@@ -353,25 +550,89 @@ function escapesRoot(root, path) {
353
550
  const rel = relative(root, path);
354
551
  return rel.startsWith("..") || isAbsolute(rel);
355
552
  }
356
- function assertBundleContained(projectRoot, inputs) {
553
+ function realpathSafe(p) {
554
+ try {
555
+ return realpathSync.native(resolve2(p));
556
+ } catch {
557
+ return resolve2(p);
558
+ }
559
+ }
560
+ function isWorkspaceMarker(dir) {
561
+ if (existsSync2(resolve2(dir, "pnpm-workspace.yaml"))) return true;
562
+ if (existsSync2(resolve2(dir, "turbo.json"))) return true;
563
+ if (existsSync2(resolve2(dir, "lerna.json"))) return true;
564
+ const pkg = resolve2(dir, "package.json");
565
+ if (!existsSync2(pkg)) return false;
566
+ try {
567
+ return JSON.parse(readFileSync2(pkg, "utf8")).workspaces !== void 0;
568
+ } catch {
569
+ return false;
570
+ }
571
+ }
572
+ function workspaceRootFor(projectRoot) {
573
+ const start = realpathSafe(projectRoot);
574
+ const home = realpathSafe(homedir());
575
+ const underHome = !escapesRoot(home, start);
576
+ let gitRoot = null;
577
+ let dir = start;
578
+ for (; ; ) {
579
+ if (isWorkspaceMarker(dir)) return dir;
580
+ if (existsSync2(resolve2(dir, ".git")) && existsSync2(resolve2(dir, "package.json"))) gitRoot = dir;
581
+ if (underHome && dir === home) break;
582
+ const up = dirname2(dir);
583
+ if (up === dir) break;
584
+ dir = up;
585
+ }
586
+ return gitRoot ?? start;
587
+ }
588
+ var WORKSPACE_ROOTS = /* @__PURE__ */ new Map();
589
+ function workspaceRootCached(projectRoot) {
590
+ const key = resolve2(projectRoot);
591
+ let v = WORKSPACE_ROOTS.get(key);
592
+ if (v === void 0) {
593
+ v = workspaceRootFor(key);
594
+ WORKSPACE_ROOTS.set(key, v);
595
+ }
596
+ return v;
597
+ }
598
+ function assertBundleContained(projectRoot, inputs, opts = {}) {
599
+ const what = opts.what ?? "bundled import";
357
600
  const root = resolve2(projectRoot);
601
+ const rootReal = realpathSafe(root);
602
+ const wsReal = realpathSafe(opts.workspaceRoot ?? workspaceRootCached(root));
603
+ const note = opts.onEscape ?? ((msg) => console.log(msg));
358
604
  for (const input of inputs) {
359
605
  const abs = resolve2(root, input);
360
- if (!escapesRoot(root, abs)) continue;
361
- if (abs.split(sep).includes("node_modules")) continue;
606
+ const absReal = realpathSafe(abs);
607
+ if (!escapesRoot(rootReal, absReal) || !escapesRoot(root, abs)) continue;
608
+ if (absReal.split(sep).includes("node_modules") || abs.split(sep).includes("node_modules")) continue;
609
+ const pkg = workspacePackageFor(wsReal, absReal);
610
+ if (pkg !== null) {
611
+ note(`! ${what} '${input}' comes from the workspace package ${pkg}`);
612
+ continue;
613
+ }
362
614
  throw new Error(
363
- `functions: bundled import '${input}' resolves outside the project root (${root}) \u2014 function source (and everything it imports) must live inside the project or node_modules`
615
+ `functions: ${what} '${input}' resolves outside the project root (${rootReal})${wsReal === rootReal ? "" : ` and outside any package of its workspace (${wsReal})`} \u2014 function source (and everything it imports) must live inside the project, a workspace PACKAGE (a directory with its own package.json), or node_modules${wsReal === rootReal ? "" : " (run `vxil push` from the workspace root if the import is a sibling package)"}`
364
616
  );
365
617
  }
366
618
  }
619
+ function workspacePackageFor(workspaceRoot, path) {
620
+ const wsReal = realpathSafe(workspaceRoot);
621
+ if (escapesRoot(wsReal, path)) return null;
622
+ let dir = dirname2(path);
623
+ for (; ; ) {
624
+ if (dir === wsReal || !dir.startsWith(wsReal + sep)) return null;
625
+ if (existsSync2(resolve2(dir, "package.json"))) return dir;
626
+ const up = dirname2(dir);
627
+ if (up === dir) return null;
628
+ dir = up;
629
+ }
630
+ }
367
631
  async function bundleFunction(entryPath, opts = {}) {
368
632
  const root = resolve2(opts.projectRoot ?? process.cwd());
633
+ const workspaceRoot = workspaceRootCached(root);
369
634
  const entry = resolve2(entryPath);
370
- if (escapesRoot(root, entry)) {
371
- throw new Error(
372
- `functions: entry '${entryPath}' resolves outside the project root (${root}) \u2014 declare function entries as paths inside the project`
373
- );
374
- }
635
+ assertBundleContained(root, [entry], { workspaceRoot, what: "entry" });
375
636
  const esbuild = await import("esbuild");
376
637
  const result = await esbuild.build({
377
638
  entryPoints: [entry],
@@ -390,7 +651,7 @@ async function bundleFunction(entryPath, opts = {}) {
390
651
  });
391
652
  const out = result.outputFiles?.[0];
392
653
  if (!out) throw new Error(`esbuild produced no output for ${entryPath}`);
393
- assertBundleContained(root, Object.keys(result.metafile?.inputs ?? {}));
654
+ assertBundleContained(root, Object.keys(result.metafile?.inputs ?? {}), { workspaceRoot });
394
655
  const source = out.text;
395
656
  const bytes = Buffer.byteLength(source, "utf8");
396
657
  if (bytes > MAX_SOURCE_BYTES) {
@@ -418,8 +679,9 @@ function normalizeSecretRefs(refs) {
418
679
  async function readRemote(api) {
419
680
  const res = await api("GET", "/v1/functions");
420
681
  const map = /* @__PURE__ */ new Map();
421
- if (res.status !== 200) return map;
422
- const fns = res.body.data?.functions ?? [];
682
+ const unavailable = readUnavailable("GET /v1/functions", res);
683
+ if (res.status !== 200) return { map, unavailable };
684
+ const fns = Array.isArray(res.body.data?.functions) ? res.body.data.functions : [];
423
685
  for (const f of fns) {
424
686
  map.set(f.name, {
425
687
  scriptRef: f.scriptRef ?? "",
@@ -427,10 +689,11 @@ async function readRemote(api) {
427
689
  scopes: f.scopes ?? [],
428
690
  egressAllow: f.egressAllow ?? [],
429
691
  bindings: f.bindings ?? [],
430
- signature: f.signature ?? null
692
+ signature: f.signature ?? null,
693
+ limits: f.limits ?? null
431
694
  });
432
695
  }
433
- return map;
696
+ return { map, unavailable: null };
434
697
  }
435
698
  function sameSecretSet(a, b2) {
436
699
  return a.length === b2.length && a.every((x) => b2.includes(x));
@@ -445,7 +708,16 @@ function clampFunctionScopes(scopes) {
445
708
  return (scopes ?? []).map(String).filter((s) => s && !DENY_FUNCTION_SCOPES.has(s));
446
709
  }
447
710
  async function planFunctions({ api, functions, cwd, apply }) {
448
- const remote = await readRemote(api);
711
+ const read = await readRemote(api);
712
+ if (read.unavailable) {
713
+ if (apply) {
714
+ throw new Error(
715
+ `functions: cannot read ${read.unavailable.route} (${read.unavailable.code ?? read.unavailable.status}${read.unavailable.message ? ` \u2014 ${read.unavailable.message}` : ""}) \u2014 refusing to deploy a plan computed against an unread target`
716
+ );
717
+ }
718
+ return { changes: [], applied: 0, cmsHookSubscriptions: null, remoteUnavailable: read.unavailable };
719
+ }
720
+ const remote = read.map;
449
721
  const results = await mapPool(Object.entries(functions), 1, async ([name, def]) => {
450
722
  if (!/^[a-z][a-z0-9-]{0,47}$/.test(name)) {
451
723
  throw new Error(`functions: invalid name '${name}' (must match /^[a-z][a-z0-9-]{0,47}$/)`);
@@ -454,10 +726,11 @@ async function planFunctions({ api, functions, cwd, apply }) {
454
726
  const triggerKind = def.trigger?.kind ?? "http";
455
727
  const secrets = normalizeSecretRefs(def.secrets);
456
728
  const bindings = lowerTriggerBindings(def.trigger);
729
+ const limits = normalizeFnLimits(def.limits);
457
730
  const remoteFn = remote.get(name);
458
731
  const sha = sourceSha12(source);
459
- const label = `${name} (${Math.round(bytes / 100) / 10} KB) [${triggerKind}${def.scopes?.length ? " \xB7 " + def.scopes.join(",") : ""}${secrets.length ? " \xB7 secrets:" + secrets.map((s) => s.slice("secret:".length)).join(",") : ""}]`;
460
- if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && sameStringSet(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null)) {
732
+ const label = `${name} (${Math.round(bytes / 100) / 10} KB) [${triggerKind}${def.scopes?.length ? " \xB7 " + def.scopes.join(",") : ""}${secrets.length ? " \xB7 secrets:" + secrets.map((s) => s.slice("secret:".length)).join(",") : ""}${limits?.cpuMs !== void 0 ? " \xB7 cpu:" + limits.cpuMs + "ms" : ""}${limits?.timeoutMs !== void 0 ? " \xB7 egress:" + limits.timeoutMs + "ms" : ""}]`;
733
+ if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && sameStringSet(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null) && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null)) {
461
734
  return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null };
462
735
  }
463
736
  const change = { name, kind: remoteFn === void 0 ? "deploy-new" : "deploy-update", detail: label };
@@ -467,6 +740,7 @@ async function planFunctions({ api, functions, cwd, apply }) {
467
740
  if (def.signature) body.signature = def.signature;
468
741
  if (def.scopes) body.scopes = def.scopes;
469
742
  if (def.egressAllow) body.egressAllow = def.egressAllow;
743
+ if (limits) body.limits = limits;
470
744
  const res = await api("POST", `/v1/functions/${encodeURIComponent(name)}`, body);
471
745
  if (res.status !== 200 && res.status !== 201) {
472
746
  const e = res.body.error ?? {};
@@ -510,21 +784,21 @@ function normalizeActions(raw) {
510
784
  return raw.filter((a) => !!a && typeof a === "object").map((a) => ({ key: String(a.key), label: String(a.label), fn: String(a.fn) }));
511
785
  }
512
786
  var FIELD_ATTRS = [
513
- { config: "required", wire: "required", remote: "required", blank: false, alterable: false },
514
- { config: "validation", wire: "validation", remote: "validation", blank: {}, alterable: false },
515
- { config: "indexSlot", wire: "index_slot", remote: "index_slot", blank: null, alterable: false },
516
- { config: "relationTo", wire: "relation_to", remote: "relation_to", blank: null, alterable: false },
517
- { config: "computed", wire: "computed", remote: "computed", blank: false, alterable: false },
518
- { config: "compute", wire: "compute", remote: "compute", blank: null, alterable: false },
519
- { config: "unique", wire: "unique", remote: "is_unique", blank: false, alterable: true },
520
- { config: "onDelete", wire: "on_delete", remote: "on_delete", blank: null, alterable: true },
787
+ { config: "required", wire: "required", remote: "required", blank: false, alter: "in-place" },
788
+ { config: "validation", wire: "validation", remote: "validation", blank: {}, alter: "in-place" },
789
+ { config: "indexSlot", wire: "index_slot", remote: "index_slot", blank: null, alter: "reindex" },
790
+ { config: "relationTo", wire: "relation_to", remote: "relation_to", blank: null, alter: "destructive" },
791
+ { config: "computed", wire: "computed", remote: "computed", blank: false, alter: "in-place" },
792
+ { config: "compute", wire: "compute", remote: "compute", blank: null, alter: "in-place" },
793
+ { config: "unique", wire: "unique", remote: "is_unique", blank: false, alter: "tighten-only" },
794
+ { config: "onDelete", wire: "on_delete", remote: "on_delete", blank: null, alter: "tighten-only" },
521
795
  // cms.md §18 (RB-3) field-level read security. ALTERABLE: cms-v1 rewrites the
522
796
  // gate in place on a same-type re-POST (the unique/on_delete flag-alter path),
523
797
  // so config-as-code can TIGHTEN a gate on an existing field. Blank is `null`
524
798
  // (the server stores NULL for "ungated" and normalizes `[]` to NULL), so an
525
799
  // omitted attribute diffs clean; CLEARING it WIDENS access and therefore rides
526
800
  // the same --allow-destructive gate as clearing unique/on_delete/owner_field.
527
- { config: "readRoles", wire: "read_roles", remote: "read_roles", blank: null, alterable: true }
801
+ { config: "readRoles", wire: "read_roles", remote: "read_roles", blank: null, alter: "tighten-only" }
528
802
  ];
529
803
  function fieldToInput(name, def) {
530
804
  const out = { field: name, type: def.type };
@@ -547,41 +821,110 @@ function remoteFieldToConfig(rf) {
547
821
  function fieldAttrDelta(def, rf) {
548
822
  const d = def;
549
823
  const r = rf;
550
- const flags = [];
551
- const clearing = [];
552
- const drift = [];
824
+ const out = { flags: [], clearing: [], inPlace: [], reindex: [], retarget: [], drift: [] };
553
825
  for (const a of FIELD_ATTRS) {
554
826
  const wantBlank = stableStringify(a.blank);
555
827
  const want = stableStringify(d[a.config] ?? a.blank);
556
828
  const have = stableStringify(r[a.remote] ?? a.blank);
557
829
  if (want === have) continue;
558
- if (!a.alterable) {
559
- drift.push(`${a.config} ${have} \u2192 ${want}`);
830
+ const line = `${a.config} ${have} \u2192 ${want}`;
831
+ if (a.alter === "tighten-only") {
832
+ (want === wantBlank ? out.clearing : out.flags).push(line);
560
833
  continue;
561
834
  }
562
- if (want === wantBlank) clearing.push(`${a.config} ${have} \u2192 ${want}`);
563
- else flags.push(`${a.config} ${have} \u2192 ${want}`);
835
+ if (d[a.config] === void 0) {
836
+ out.drift.push(line);
837
+ continue;
838
+ }
839
+ if (a.alter === "reindex") out.reindex.push(line);
840
+ else if (a.alter === "destructive") out.retarget.push(line);
841
+ else out.inPlace.push(line);
842
+ }
843
+ return out;
844
+ }
845
+ function vacateSlotBody(rf) {
846
+ const out = { field: rf.field, type: rf.type, index_slot: null };
847
+ if (rf.is_unique) out.unique = true;
848
+ if (rf.on_delete) out.on_delete = rf.on_delete;
849
+ if (rf.read_roles && rf.read_roles.length > 0) out.read_roles = rf.read_roles;
850
+ return out;
851
+ }
852
+ async function liveRowCount(api, collection) {
853
+ try {
854
+ const res = await api("GET", `/v1/cms/items/${encodeURIComponent(collection)}?count=true`);
855
+ const n = res.body.data?.count;
856
+ return typeof n === "number" ? n : null;
857
+ } catch {
858
+ return null;
564
859
  }
565
- return { flags, clearing, drift };
566
860
  }
567
861
  async function readRemote2(api) {
568
862
  const res = await api("GET", "/v1/cms/collections");
569
863
  const map = /* @__PURE__ */ new Map();
570
- if (res.status !== 200) return map;
571
- const cols = res.body.data?.collections ?? [];
864
+ const unavailable = readUnavailable("GET /v1/cms/collections", res);
865
+ if (res.status !== 200) return { map, unavailable };
866
+ const cols = Array.isArray(res.body.data?.collections) ? res.body.data.collections : [];
572
867
  for (const c of cols) {
573
868
  map.set(c.collection, {
574
869
  collection: c.collection,
575
870
  ownerField: c.owner_field ?? null,
576
871
  public: c.public === true,
577
872
  actions: normalizeActions(c.actions),
578
- fields: c.fields ?? []
873
+ fields: Array.isArray(c.fields) ? c.fields : []
579
874
  });
580
875
  }
581
- return map;
876
+ return { map, unavailable: null };
877
+ }
878
+ var REINDEX_PAGE_CAP = 1e4;
879
+ async function reindexCollection({ api, collection, field, onProgress }) {
880
+ const rerun = ` \u2014 re-run \`vxil cms reindex ${collection}${field ? ` --field ${field}` : ""}\` (the alter is applied; only the projection lags)`;
881
+ let cursor;
882
+ let scanned = 0;
883
+ let updated = 0;
884
+ let pages = 0;
885
+ let skipped = 0;
886
+ const page = async () => {
887
+ const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(collection)}/reindex`, {
888
+ ...field ? { field } : {},
889
+ ...cursor ? { cursor } : {}
890
+ });
891
+ if (res.status < 200 || res.status >= 300) {
892
+ const e = res.body.error ?? {};
893
+ throw new Error(`cms: re-index ${collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`.trimEnd() + rerun);
894
+ }
895
+ const d = res.body.data ?? {};
896
+ scanned += d.scanned ?? 0;
897
+ updated += d.updated ?? 0;
898
+ pages++;
899
+ return d;
900
+ };
901
+ for (; ; ) {
902
+ let d = await page();
903
+ if ((d.skipped ?? 0) > 0) d = await page();
904
+ skipped += d.skipped ?? 0;
905
+ onProgress?.({ scanned, updated, pages, skipped });
906
+ if (d.complete === true || !d.next_cursor) break;
907
+ if (d.next_cursor === cursor) {
908
+ throw new Error(`cms: re-index ${collection} stalled \u2014 the server returned the same cursor twice after ${scanned} row(s)${rerun}`);
909
+ }
910
+ if (pages >= REINDEX_PAGE_CAP) {
911
+ throw new Error(`cms: re-index ${collection} exceeded ${REINDEX_PAGE_CAP} pages (${scanned} row(s) scanned) without completing${rerun}`);
912
+ }
913
+ cursor = d.next_cursor;
914
+ }
915
+ return { scanned, updated, pages, skipped };
582
916
  }
583
- async function planCmsSchema({ api, collections, apply, allowDestructive = false }) {
584
- const remote = await readRemote2(api);
917
+ async function planCmsSchema({ api, collections, apply, allowDestructive = false, onProgress }) {
918
+ const read = await readRemote2(api);
919
+ if (read.unavailable) {
920
+ if (apply) {
921
+ throw new Error(
922
+ `cms: cannot read ${read.unavailable.route} (${read.unavailable.code ?? read.unavailable.status}${read.unavailable.message ? ` \u2014 ${read.unavailable.message}` : ""}) \u2014 refusing to apply a schema plan computed against an unread target`
923
+ );
924
+ }
925
+ return { changes: [], applied: 0, remoteUnavailable: read.unavailable };
926
+ }
927
+ const remote = read.map;
585
928
  const changes = [];
586
929
  for (const [name, def] of Object.entries(collections)) {
587
930
  const declaredFields = Object.entries(def.fields ?? {});
@@ -594,13 +937,30 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
594
937
  });
595
938
  continue;
596
939
  }
940
+ const pre = [];
941
+ const collChanges = [];
942
+ const post = [];
943
+ const slotMoved = [];
597
944
  const remoteByName = new Map(rc.fields.map((f) => [f.field, f]));
598
945
  for (const [fn, fd] of declaredFields) {
599
946
  const existing = remoteByName.get(fn);
600
947
  if (!existing) {
601
- changes.push({ collection: name, kind: "add-field", field: fn, detail: `${name}.${fn} (${fd.type})` });
948
+ collChanges.push({ collection: name, kind: "add-field", field: fn, detail: `${name}.${fn} (${fd.type})` });
602
949
  } else if (existing.type !== fd.type) {
603
- changes.push({
950
+ if (allowDestructive && (fd.indexSlot ?? null) !== (existing.index_slot ?? null)) {
951
+ if (existing.index_slot) {
952
+ pre.push({
953
+ collection: name,
954
+ kind: "destructive",
955
+ op: "vacate-slot",
956
+ field: fn,
957
+ remote: existing,
958
+ detail: `${name}.${fn} vacate index_slot ${existing.index_slot} (two-pass: vacate \u2192 claim)`
959
+ });
960
+ }
961
+ slotMoved.push(fn);
962
+ }
963
+ collChanges.push({
604
964
  collection: name,
605
965
  kind: "destructive",
606
966
  op: "alter-field",
@@ -608,61 +968,73 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
608
968
  detail: `${name}.${fn} type ${existing.type} \u2192 ${fd.type} (destructive alter)`
609
969
  });
610
970
  } else {
611
- const { flags, clearing, drift } = fieldAttrDelta(fd, existing);
612
- if (flags.length && clearing.length === 0) {
613
- changes.push({
614
- collection: name,
615
- kind: "alter-flags",
616
- field: fn,
617
- detail: `${name}.${fn} ${flags.join(", ")} (non-destructive in-place alter)`
618
- });
619
- }
620
- if (clearing.length) {
621
- const both = [...clearing, ...flags];
971
+ const { flags, clearing, inPlace, reindex, retarget, drift } = fieldAttrDelta(fd, existing);
972
+ const destructiveAttrs = [...clearing, ...reindex, ...retarget];
973
+ if (destructiveAttrs.length) {
974
+ const all = [...destructiveAttrs, ...flags, ...inPlace];
622
975
  if (allowDestructive) {
623
- changes.push({
976
+ if (reindex.length && existing.index_slot) {
977
+ pre.push({
978
+ collection: name,
979
+ kind: "destructive",
980
+ op: "vacate-slot",
981
+ field: fn,
982
+ remote: existing,
983
+ detail: `${name}.${fn} vacate index_slot ${existing.index_slot} (two-pass: vacate \u2192 claim)`
984
+ });
985
+ }
986
+ collChanges.push({
624
987
  collection: name,
625
988
  kind: "destructive",
626
989
  op: "alter-field",
627
990
  field: fn,
628
- detail: `${name}.${fn} ${both.join(", ")} \u2014 clearing a live invariant (unique claims / on_delete cascade) (full-config reconciliation)`
991
+ detail: `${name}.${fn} ${all.join(", ")}` + (clearing.length ? " \u2014 clearing a live invariant (unique claims / on_delete cascade / read_roles gate)" : "") + (reindex.length ? " \u2014 an index_slot move: stored rows keep the OLD projection until the re-index runs" : "") + (retarget.length ? " \u2014 a relation retarget: every stored id now names a different collection" : "") + " (full-config reconciliation)"
629
992
  });
993
+ if (reindex.length) slotMoved.push(fn);
630
994
  } else {
631
- changes.push({
995
+ collChanges.push({
632
996
  collection: name,
633
997
  kind: "warn",
634
998
  field: fn,
635
- detail: `${name}.${fn} ${both.join(", ")}: this CLEARS a live invariant \u2014 clearing unique DROPS the uniqueness claims (duplicates then accepted, unrecoverable) and clearing on_delete stops cascades, so it is NOT applied (nor is any co-declared tightening on this field). Declare the attribute to keep it, or re-run with --allow-destructive to clear.`
999
+ detail: `${name}.${fn} ${all.join(", ")}: this is DESTRUCTIVE-SHAPED \u2014 ` + (clearing.length ? "it CLEARS a live invariant (clearing unique DROPS the uniqueness claims \u2014 duplicates then accepted, unrecoverable; clearing on_delete stops cascades; clearing read_roles WIDENS the field to every end-user); " : "") + (reindex.length ? "an index_slot move leaves stored rows on the OLD projection until a re-index, so queries on the field return the WRONG rows; " : "") + (retarget.length ? "a relation retarget repoints every stored id at another collection; " : "") + "it is NOT applied (nor is any co-declared in-place change on this field). Re-run with --allow-destructive to apply."
636
1000
  });
637
1001
  }
1002
+ } else if (flags.length || inPlace.length) {
1003
+ collChanges.push({
1004
+ collection: name,
1005
+ kind: "alter-flags",
1006
+ field: fn,
1007
+ detail: `${name}.${fn} ${[...flags, ...inPlace].join(", ")} (non-destructive in-place alter)`
1008
+ });
638
1009
  }
639
1010
  if (drift.length) {
640
- changes.push({
1011
+ collChanges.push({
641
1012
  collection: name,
642
1013
  kind: "warn",
643
1014
  field: fn,
644
- detail: `${name}.${fn} drift: ${drift.join("; ")} \u2014 cms-v1 has no same-type in-place alter for these (only unique/on_delete alter in place); the difference is NOT applied. Match the config to the live definition, or re-create the field.`
1015
+ detail: `${name}.${fn} drift: ${drift.join("; ")} \u2014 the config does NOT declare these, so they are left exactly as the live schema has them (an omitted attribute is never blanked). Declare them in vxil.config.ts to make config authoritative.`
645
1016
  });
646
1017
  }
1018
+ continue;
647
1019
  }
648
1020
  }
649
1021
  const declaredOwner = def.ownerField ?? null;
650
1022
  const remoteOwner = rc.ownerField;
651
1023
  if (declaredOwner !== remoteOwner) {
652
1024
  if (declaredOwner !== null) {
653
- changes.push({
1025
+ collChanges.push({
654
1026
  collection: name,
655
1027
  kind: "set-owner-field",
656
1028
  detail: `${name} owner_field \u2192 '${declaredOwner}'${remoteOwner ? ` (was '${remoteOwner}')` : ""} (end-user owner-scoping, non-destructive)`
657
1029
  });
658
1030
  } else if (allowDestructive) {
659
- changes.push({
1031
+ collChanges.push({
660
1032
  collection: name,
661
1033
  kind: "set-owner-field",
662
1034
  detail: `${name} owner_field cleared (was '${remoteOwner}') \u2014 end-user owner-scoping OFF (full-config reconciliation)`
663
1035
  });
664
1036
  } else {
665
- changes.push({
1037
+ collChanges.push({
666
1038
  collection: name,
667
1039
  kind: "warn",
668
1040
  detail: `${name}: owner_field '${remoteOwner}' is set remotely but absent in config \u2014 clearing it WIDENS access to every same-tenant end user, so it is NOT applied. Declare ownerField to keep it, or re-run with --allow-destructive to clear.`
@@ -671,7 +1043,7 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
671
1043
  }
672
1044
  const declaredPublic = def.public === true;
673
1045
  if (declaredPublic !== rc.public) {
674
- changes.push({
1046
+ collChanges.push({
675
1047
  collection: name,
676
1048
  kind: "set-public",
677
1049
  detail: `${name} public \u2192 ${declaredPublic} (keyless public delivery ${declaredPublic ? "ON" : "OFF"})`
@@ -679,7 +1051,7 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
679
1051
  }
680
1052
  const declaredActions = normalizeActions(def.actions);
681
1053
  if (stableStringify(declaredActions) !== stableStringify(rc.actions)) {
682
- changes.push({
1054
+ collChanges.push({
683
1055
  collection: name,
684
1056
  kind: "set-actions",
685
1057
  detail: `${name} actions \u2192 [${declaredActions.map((a) => `${a.key}\u2192${a.fn}`).join(", ")}] (was [${rc.actions.map((a) => `${a.key}\u2192${a.fn}`).join(", ")}])`
@@ -687,7 +1059,7 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
687
1059
  }
688
1060
  for (const f of rc.fields) {
689
1061
  if (!Object.prototype.hasOwnProperty.call(def.fields ?? {}, f.field)) {
690
- changes.push({
1062
+ collChanges.push({
691
1063
  collection: name,
692
1064
  kind: "destructive",
693
1065
  op: "drop-field",
@@ -696,6 +1068,15 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
696
1068
  });
697
1069
  }
698
1070
  }
1071
+ if (slotMoved.length) {
1072
+ const n = await liveRowCount(api, name);
1073
+ post.push({
1074
+ collection: name,
1075
+ kind: "reindex",
1076
+ detail: `${name} re-index ${n === null ? "live rows" : `${n} live rows`} after the index_slot move on ${slotMoved.join(", ")}`
1077
+ });
1078
+ }
1079
+ changes.push(...pre, ...collChanges, ...post);
699
1080
  }
700
1081
  if (allowDestructive) {
701
1082
  for (const rname of remote.keys()) {
@@ -773,6 +1154,16 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
773
1154
  throw new Error(`cms: set actions on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
774
1155
  }
775
1156
  applied++;
1157
+ } else if (c.kind === "reindex") {
1158
+ const done = await reindexCollection({
1159
+ api,
1160
+ collection: c.collection,
1161
+ onProgress: (p) => onProgress?.(` \u2026 re-indexed ${p.scanned} row(s) of ${c.collection}` + (p.skipped > 0 ? ` (${p.skipped} skipped \u2014 written concurrently)` : ""))
1162
+ });
1163
+ if (done.skipped > 0) {
1164
+ onProgress?.(` \u26A0 ${done.skipped} row(s) of ${c.collection} were written while the re-index ran and were NOT re-projected \u2014 re-run \`vxil cms reindex ` + c.collection + "` when writes are quiet");
1165
+ }
1166
+ applied++;
776
1167
  } else if (c.kind === "destructive") {
777
1168
  applied += await applyDestructive(api, collections, c);
778
1169
  }
@@ -799,6 +1190,15 @@ async function applyDestructive(api, collections, c) {
799
1190
  }
800
1191
  return 1;
801
1192
  }
1193
+ if (c.op === "vacate-slot") {
1194
+ const rf = c.remote;
1195
+ const res2 = await api("POST", path, vacateSlotBody(rf));
1196
+ if (!ok2xx(res2.status) && res2.body.error?.code !== "already_exists") {
1197
+ const e = res2.body.error ?? {};
1198
+ throw new Error(`cms: vacate ${c.collection}.${c.field} slot failed: ${e.code ?? res2.status} ${e.message ?? ""}`);
1199
+ }
1200
+ return 1;
1201
+ }
802
1202
  const fd = collections[c.collection].fields[c.field];
803
1203
  const res = await api("POST", path, { ...fieldToInput(c.field, fd), allow_destructive: true });
804
1204
  if (!ok2xx(res.status)) {
@@ -812,6 +1212,7 @@ function formatCmsChanges(changes) {
812
1212
  return changes.map((c) => {
813
1213
  if (c.kind === "destructive") return ` ! ${c.detail}`;
814
1214
  if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
1215
+ if (c.kind === "reindex") return ` \u21BB ${c.detail}`;
815
1216
  if (c.kind === "alter-flags" || c.kind === "set-owner-field") return ` ~ ${c.detail}`;
816
1217
  return ` + ${c.detail}`;
817
1218
  }).join("\n");
@@ -1334,8 +1735,8 @@ function typeHandlers(types2) {
1334
1735
  function escapeIdentifiers(xs, { transform: { column } }) {
1335
1736
  return xs.map((x) => escapeIdentifier(column.to ? column.to(x) : x)).join(",");
1336
1737
  }
1337
- var escapeIdentifier = function escape(str) {
1338
- return '"' + str.replace(/"/g, '""').replace(/\./g, '"."') + '"';
1738
+ var escapeIdentifier = function escape(str2) {
1739
+ return '"' + str2.replace(/"/g, '""').replace(/\./g, '"."') + '"';
1339
1740
  };
1340
1741
  var inferType = function inferType2(x) {
1341
1742
  return x instanceof Parameter ? x.type : x instanceof Date ? 1184 : x instanceof Uint8Array ? 17 : x === true || x === false ? 16 : typeof x === "bigint" ? 20 : Array.isArray(x) ? inferType2(x[0]) : 0;
@@ -1410,16 +1811,16 @@ function arrayParserLoop(s, x, parser, typarray) {
1410
1811
  return xs;
1411
1812
  }
1412
1813
  var toCamel = (x) => {
1413
- let str = x[0];
1814
+ let str2 = x[0];
1414
1815
  for (let i = 1; i < x.length; i++)
1415
- str += x[i] === "_" ? x[++i].toUpperCase() : x[i];
1416
- return str;
1816
+ str2 += x[i] === "_" ? x[++i].toUpperCase() : x[i];
1817
+ return str2;
1417
1818
  };
1418
1819
  var toPascal = (x) => {
1419
- let str = x[0].toUpperCase();
1820
+ let str2 = x[0].toUpperCase();
1420
1821
  for (let i = 1; i < x.length; i++)
1421
- str += x[i] === "_" ? x[++i].toUpperCase() : x[i];
1422
- return str;
1822
+ str2 += x[i] === "_" ? x[++i].toUpperCase() : x[i];
1823
+ return str2;
1423
1824
  };
1424
1825
  var toKebab = (x) => x.replace(/_/g, "-");
1425
1826
  var fromCamel = (x) => x.replace(/([A-Z])/g, "_$1").toLowerCase();
@@ -2330,8 +2731,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
2330
2731
  bytes_default.i16(0);
2331
2732
  return bytes_default.end();
2332
2733
  }
2333
- function Parse(str, parameters, types2, name = "") {
2334
- bytes_default().P().str(name + bytes_default.N).str(str + bytes_default.N).i16(parameters.length);
2734
+ function Parse(str2, parameters, types2, name = "") {
2735
+ bytes_default().P().str(name + bytes_default.N).str(str2 + bytes_default.N).i16(parameters.length);
2335
2736
  parameters.forEach((x, i) => bytes_default.i32(types2[i] || 0));
2336
2737
  return bytes_default.end();
2337
2738
  }
@@ -3282,8 +3683,28 @@ function slottedFields(c) {
3282
3683
  function isFilterableField(f) {
3283
3684
  return !(f.computed && !f.index_slot);
3284
3685
  }
3686
+ var MAX_ENUM_UNION = 50;
3687
+ function enumUnionFor(f) {
3688
+ const raw = f.validation?.enum;
3689
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_ENUM_UNION) return null;
3690
+ const base = tsTypeFor(f.type);
3691
+ if (base !== "string" && base !== "number" && base !== "boolean") return null;
3692
+ const members = [];
3693
+ for (const v of raw) {
3694
+ const t = typeof v;
3695
+ if (t !== "string" && t !== "number" && t !== "boolean") return null;
3696
+ if (t !== base) return null;
3697
+ members.push(JSON.stringify(v));
3698
+ }
3699
+ return members.join(" | ");
3700
+ }
3701
+ function hasEnumFilter(c) {
3702
+ return c.fields.some((f) => isFilterableField(f) && enumUnionFor(f) !== null);
3703
+ }
3285
3704
  function filterTypeFor(f) {
3286
3705
  const slot = f.index_slot ?? "";
3706
+ const u = enumUnionFor(f);
3707
+ if (u) return slot ? `VxilFilterRangeEnum<${u}>` : `VxilFilterEnum<${u}>`;
3287
3708
  if (slot.startsWith("s")) return "VxilFilterRangeText";
3288
3709
  if (slot.startsWith("n")) return "VxilFilterRange<number>";
3289
3710
  if (slot.startsWith("t")) return "VxilFilterRangeDatetime";
@@ -3308,15 +3729,16 @@ function unionOf(values2) {
3308
3729
  function collectionType(c) {
3309
3730
  const lines = [];
3310
3731
  const ind = " ";
3732
+ const typeOf = (f) => enumUnionFor(f) ?? tsTypeFor(f.type);
3311
3733
  const rowFields = c.fields.map((f) => {
3312
- const t = tsTypeFor(f.type);
3734
+ const t = typeOf(f);
3313
3735
  return f.required ? `${ind}${quoteKey(f.field)}: ${t};` : `${ind}${quoteKey(f.field)}: ${t} | null;`;
3314
3736
  });
3315
3737
  const insertFields = c.fields.filter((f) => !f.computed).map((f) => {
3316
- const t = tsTypeFor(f.type);
3738
+ const t = typeOf(f);
3317
3739
  return f.required ? `${ind}${quoteKey(f.field)}: ${t};` : `${ind}${quoteKey(f.field)}?: ${t} | null;`;
3318
3740
  });
3319
- const patchFields = c.fields.filter((f) => !f.computed).map((f) => `${ind}${quoteKey(f.field)}?: ${tsTypeFor(f.type)} | null;`);
3741
+ const patchFields = c.fields.filter((f) => !f.computed).map((f) => `${ind}${quoteKey(f.field)}?: ${typeOf(f)} | null;`);
3320
3742
  const slotted = slottedFields(c);
3321
3743
  const sortable = unionOf([...slotted.map((f) => f.field), ...META_SORTABLE]);
3322
3744
  const statusUnion = unionOf([...CMS_STATUS_VALUES]);
@@ -3340,6 +3762,10 @@ ${patchFields.join("\n") || `${ind}/* (no patchable fields) */`}
3340
3762
  };`);
3341
3763
  lines.push(` Sortable: ${sortable};`);
3342
3764
  lines.push(` Filterable: ${filterable};`);
3765
+ const relLines = c.fields.filter((f) => f.type === "relation" || f.type === "file").map((f) => `${ind}${quoteKey(f.field)}: ${f.type === "file" ? "'files'" : JSON.stringify(f.relation_to ?? null)};`);
3766
+ lines.push(` Relations: ${relLines.length ? `{
3767
+ ${relLines.join("\n")}
3768
+ }` : "{}"};`);
3343
3769
  lines.push(` };`);
3344
3770
  return lines.join("\n");
3345
3771
  }
@@ -3382,6 +3808,9 @@ export const API_VERSIONS = {
3382
3808
  ` + apiVers.map(([f, v]) => ` ${quoteKey(f)}: [${v.map((x) => JSON.stringify(x)).join(", ")}],`).join("\n") + `
3383
3809
  } as const;` : "";
3384
3810
  const cmsBody = colls.length ? colls.map(collectionType).join("\n") : " // (no collections \u2014 `vx.from(...)` is empty until you declare one)";
3811
+ const enumAliases = colls.some(hasEnumFilter) ? `
3812
+ export type VxilFilterEnum<U extends string | number | boolean> = U | { $eq?: U; $in?: U[]; $contains?: string };
3813
+ export type VxilFilterRangeEnum<U extends string | number | boolean> = U | { $eq?: U; $ne?: U; $gt?: U; $gte?: U; $lt?: U; $lte?: U; $in?: U[]; $contains?: string };` : "";
3385
3814
  const fnBody = fns.length ? fns.map(functionType).join("\n") : " // (no deployed functions)";
3386
3815
  return `${header}
3387
3816
 
@@ -3414,7 +3843,7 @@ export type VxilFilterEqText = string | { $eq?: string; $in?: string[]; $contain
3414
3843
  export type VxilFilterRange<T> = T | { $eq?: T; $ne?: T; $gt?: T; $gte?: T; $lt?: T; $lte?: T; $in?: T[] };
3415
3844
  export type VxilFilterRangeText = string | { $eq?: string; $ne?: string; $gt?: string; $gte?: string; $lt?: string; $lte?: string; $in?: string[]; $contains?: string };
3416
3845
  export type VxilFilterRangeDatetime = string | { $eq?: string; $ne?: string; $gt?: string; $gte?: string; $lt?: string; $lte?: string; $in?: string[]; $contains?: string };
3417
- export type VxilFilterJson = string | number | boolean | { $eq?: string | number | boolean; $in?: (string | number | boolean)[]; $contains?: string };
3846
+ export type VxilFilterJson = string | number | boolean | { $eq?: string | number | boolean; $in?: (string | number | boolean)[]; $contains?: string };${enumAliases}
3418
3847
 
3419
3848
  // Usage \u2014 narrow the client to THIS tenant's enabled features (a disabled feature
3420
3849
  // becomes a compile error):
@@ -3446,12 +3875,12 @@ var TOOLS = [
3446
3875
  {
3447
3876
  name: "notifications_send",
3448
3877
  feature: "notifications",
3449
- description: "Send a transactional email to an end-user by ID. Returns a delivery_id immediately (202); delivery is asynchronous. Templates: magic-link (data: url, expires_minutes), welcome (data: app_name, first_name?), transactional (data: subject, paragraph, cta_label?, cta_url?).",
3878
+ description: "Send a transactional email to an end-user by ID. Returns a delivery_id immediately (202); delivery is asynchronous. Templates: magic-link (data: url, expires_minutes), otp-code (data: code, expires_minutes), welcome (data: app_name, first_name?), transactional (data: subject, paragraph, cta_label?, cta_url?).",
3450
3879
  inputSchema: {
3451
3880
  type: "object",
3452
3881
  properties: {
3453
3882
  user_id: { type: "string", description: "Opaque end_user_id (your user id)." },
3454
- template: { type: "string", enum: ["magic-link", "welcome", "transactional"] },
3883
+ template: { type: "string", enum: ["magic-link", "otp-code", "welcome", "transactional"] },
3455
3884
  data: { type: "object", additionalProperties: true },
3456
3885
  locale: {
3457
3886
  type: "string",
@@ -3537,6 +3966,42 @@ var TOOLS = [
3537
3966
  path: "/v1/notifications/deliveries",
3538
3967
  queryArgs: ["user_id", "status", "engagement", "limit"]
3539
3968
  },
3969
+ {
3970
+ name: "notifications_get_preferences",
3971
+ feature: "notifications",
3972
+ description: "Read one end-user's notification mutes. Returns { preferences: [{ user_id, template_id, muted, effective, updated_at }], count } \u2014 the all-templates row (template_id null) first. NO row means NOT muted. MUTE WINS: the all-templates row SHADOWS a per-template muted:false, and `effective` is what enforcement actually does with that row (render `effective`, not `muted`). A mute suppresses the user on EVERY channel before the send leaves vxil (the delivery is recorded with status 'suppressed' and last_error_code 'muted'), except auth mail (magic-link, otp-code), which is never muteable. Read-only.",
3973
+ inputSchema: {
3974
+ type: "object",
3975
+ properties: {
3976
+ user_id: { type: "string", description: "Opaque end_user_id (your user id)." }
3977
+ },
3978
+ required: ["user_id"]
3979
+ },
3980
+ method: "GET",
3981
+ path: "/v1/notifications/preferences",
3982
+ queryArgs: ["user_id"]
3983
+ },
3984
+ {
3985
+ name: "notifications_set_preference",
3986
+ feature: "notifications",
3987
+ description: "Mute or un-mute an end-user's notifications. OMIT template_id to mute every MUTEABLE template for that user (welcome, transactional); pass one of those ids to mute just that one. Auth mail (magic-link, otp-code) is NEVER muteable \u2014 passing it is a 422, and the all-templates row does not cover it, so a user can never mute itself out of sign-in. MUTE WINS: the all-templates row shadows a per-template muted:false, so clear it before re-enabling one template. Idempotent upsert \u2014 there is no delete route, muted:false is the un-mute. 404 if the user id has no live record. Needs notifications:send (a server key writing another principal's row).",
3988
+ inputSchema: {
3989
+ type: "object",
3990
+ properties: {
3991
+ user_id: { type: "string", description: "Opaque end_user_id (your user id)." },
3992
+ template_id: {
3993
+ type: "string",
3994
+ // MUTEABLE ids only — auth mail is rejected by the route (422).
3995
+ enum: ["welcome", "transactional"],
3996
+ description: "Omit for the ALL-templates row (muteable templates only)."
3997
+ },
3998
+ muted: { type: "boolean" }
3999
+ },
4000
+ required: ["user_id", "muted"]
4001
+ },
4002
+ method: "PUT",
4003
+ path: "/v1/notifications/preferences"
4004
+ },
3540
4005
  {
3541
4006
  name: "notifications_campaign_run",
3542
4007
  feature: "notifications",
@@ -3997,7 +4462,7 @@ var TOOLS = [
3997
4462
  {
3998
4463
  name: "cms_query_items",
3999
4464
  feature: "cms",
4000
- description: 'Query content items with the bounded DSL. filter is a JSON object (\u22648 terms): {"price":{"$gte":10},"status":"published"}; ops $eq $ne $gt $gte $lt $lte $in $contains $startsWith (substring/prefix, LIKE-escaped) $arrayContains/$anyOf (array fields: all-of / any-of). sort: "-field" (slot-indexed fields + created_at/updated_at/published_at). count=true returns {count} instead of a page (filter only \u2014 no sort/limit).',
4465
+ description: 'Query content items with the bounded DSL. filter is a JSON object (\u22648 terms): {"price":{"$gte":10},"status":"published"}; ops $eq $ne $gt $gte $lt $lte $in $contains $startsWith (substring/prefix, LIKE-escaped) $arrayContains/$anyOf (array fields: all-of / any-of). sort: "-field" (slot-indexed fields + created_at/updated_at/published_at). $expand inlines relation/file fields. count=true returns {count} instead of a page (filter only \u2014 no sort/limit/$expand).',
4001
4466
  inputSchema: {
4002
4467
  type: "object",
4003
4468
  properties: {
@@ -4005,13 +4470,17 @@ var TOOLS = [
4005
4470
  filter: { type: "string", description: "JSON filter object as a string" },
4006
4471
  sort: { type: "string" },
4007
4472
  limit: { type: "number", default: 25 },
4473
+ $expand: {
4474
+ type: "string",
4475
+ description: "comma-separated relation/file field names to inline (bounded depth; see cms_list_collections for relation_to). Cannot be combined with count=true."
4476
+ },
4008
4477
  count: { type: "boolean", description: "true \u2192 return { count } only (no items/paging)" }
4009
4478
  },
4010
4479
  required: ["collection"]
4011
4480
  },
4012
4481
  method: "GET",
4013
4482
  path: (a) => `/v1/cms/items/${encodeURIComponent(String(a.collection))}`,
4014
- queryArgs: ["filter", "sort", "limit", "count"]
4483
+ queryArgs: ["filter", "sort", "limit", "$expand", "count"]
4015
4484
  },
4016
4485
  {
4017
4486
  name: "cms_inc_item",
@@ -4575,6 +5044,22 @@ var TOOLS = [
4575
5044
  maxItems: 8,
4576
5045
  description: "Vision inputs: a public https URL, a data:image/...;base64 URL, or file:<object_id> (a files object). Provider must support the 'vision' capability."
4577
5046
  },
5047
+ documents: {
5048
+ type: "array",
5049
+ items: { type: "string" },
5050
+ maxItems: 8,
5051
+ description: "Document inputs (pdf / plain text): a public https URL, a data:application/pdf;base64 URL, or file:<object_id>. Fetched and inlined server-side. Provider must support the 'documents' capability (anthropic)."
5052
+ },
5053
+ cache: {
5054
+ type: "object",
5055
+ additionalProperties: false,
5056
+ properties: {
5057
+ system: { type: "boolean" },
5058
+ messages: { type: "boolean" },
5059
+ ttl: { type: "string", enum: ["5m", "1h"] }
5060
+ },
5061
+ description: "Prompt-cache breakpoints (anthropic; ignored elsewhere): system caches the system prompt, messages caches the transcript prefix, ttl picks 5m (default) or 1h. A cache hit only lowers input_tokens."
5062
+ },
4578
5063
  user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." }
4579
5064
  }
4580
5065
  },
@@ -4740,7 +5225,7 @@ var TOOLS = [
4740
5225
  {
4741
5226
  name: "payments_list_webhook_events",
4742
5227
  feature: "payments",
4743
- description: "List provider webhook deliveries from the event log, newest-first: outcome (received|processed|error|sig_failed|parse_failed|reprocessed|ignored|unowned|rejected_environment), the provider-reported environment (production|sandbox), signature status, error, and timestamps per delivery. Use it to diagnose billing sync issues (e.g. an unmapped price or product \u2192 outcome error, a misconfigured webhook secret producing sig_failed rows, sandbox deliveries landing as rejected_environment). Read-only \u2014 needs payments:read. Payloads are detail-only (console surface).",
5228
+ description: "List provider webhook deliveries from the event log, newest-first: outcome (received|processed|error|sig_failed|parse_failed|reprocessed|ignored|unowned|rejected_environment), the provider-reported environment (production|sandbox), signature status, error, and timestamps per delivery. Use it to diagnose billing sync issues (e.g. an unmapped price or product \u2192 outcome error, a misconfigured webhook secret producing sig_failed rows, sandbox deliveries landing as rejected_environment). An unmapped one-off product lands `error` unless you set ledger.unmappedProduct:'ignore', which lands it `ignored` instead. Read-only \u2014 needs payments:read. Server-side key only (403 server_only on a thin-client/end-user key) \u2014 the log is tenant-wide and carries provider payloads. Payloads are detail-only (console surface).",
4744
5229
  inputSchema: {
4745
5230
  type: "object",
4746
5231
  properties: {
@@ -4768,7 +5253,7 @@ var TOOLS = [
4768
5253
  // Substrate tool (no `feature` — always-on like users_upsert): usage is a
4769
5254
  // platform property, not a feature you enable.
4770
5255
  name: "usage_current",
4771
- description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), and month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers are never cut off mid-month. Needs usage:read or features:read.",
5256
+ description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically), and `routes` \u2014 the same request count split by the surface that served it (the resolved upstream; /v1/fn/* reports as 'fn-invoke', busiest first, observational only). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers are never cut off mid-month. Needs usage:read or features:read.",
4772
5257
  inputSchema: { type: "object", properties: {} },
4773
5258
  method: "GET",
4774
5259
  path: "/v1/usage"
@@ -4828,12 +5313,13 @@ var TOOLS = [
4828
5313
  // Substrate meta-tool (no `feature` — always-on, subject to the caller's
4829
5314
  // scopes). Maps to POST /v1/plan (features:read). Advisory: proposes, never applies.
4830
5315
  name: "plan_backend",
4831
- description: 'Describe an app in plain English and get an ADVISORY vxil plan: which features to enable, a materialized config draft, and which truly-unique logic needs a tenant function. Read-only \u2014 it proposes a plan; you review and apply it via config push. e.g. "a marketplace where sellers list products and buyers leave reviews, emailing both on a sale".',
5316
+ description: 'Describe an app in plain English and get an ADVISORY vxil plan: which features to enable, a materialized config draft, and which truly-unique logic needs a tenant function. Read-only \u2014 it proposes a plan; you review and apply it via config push. e.g. "a marketplace where sellers list products and buyers leave reviews, emailing both on a sale". The response also carries an architecture brief: the platform rules this plan implies (identity, data, money path, background work, limits) and the older patterns they replace.',
4832
5317
  inputSchema: {
4833
5318
  type: "object",
4834
5319
  properties: {
4835
5320
  description: { type: "string", description: "Plain-English description of the app you want to build." },
4836
- name: { type: "string", description: "Optional app name for the plan." }
5321
+ name: { type: "string", description: "Optional app name for the plan." },
5322
+ brief: { type: "boolean", description: "Include the architecture brief (identity, data, money path, background work, limits, and the old patterns vxil replaces). Default true; pass false for the plan alone." }
4837
5323
  },
4838
5324
  required: ["description"]
4839
5325
  },
@@ -4934,18 +5420,18 @@ function buildMcpCatalog(input, tools = TOOLS) {
4934
5420
  }
4935
5421
 
4936
5422
  // src/store.ts
4937
- import { homedir } from "node:os";
5423
+ import { homedir as homedir2 } from "node:os";
4938
5424
  import { join as join2, resolve as resolve3 } from "node:path";
4939
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, chmodSync } from "node:fs";
5425
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2, chmodSync } from "node:fs";
4940
5426
  function credDir() {
4941
- return join2(homedir(), ".vxil");
5427
+ return join2(homedir2(), ".vxil");
4942
5428
  }
4943
5429
  function credFile() {
4944
5430
  return join2(credDir(), "credentials.json");
4945
5431
  }
4946
5432
  function loadCredentials() {
4947
5433
  try {
4948
- return JSON.parse(readFileSync2(credFile(), "utf8"));
5434
+ return JSON.parse(readFileSync3(credFile(), "utf8"));
4949
5435
  } catch {
4950
5436
  return {};
4951
5437
  }
@@ -4963,7 +5449,7 @@ function projectFile(cwd = process.cwd()) {
4963
5449
  }
4964
5450
  function loadProject(cwd = process.cwd()) {
4965
5451
  try {
4966
- return JSON.parse(readFileSync2(projectFile(cwd), "utf8"));
5452
+ return JSON.parse(readFileSync3(projectFile(cwd), "utf8"));
4967
5453
  } catch {
4968
5454
  return null;
4969
5455
  }
@@ -4999,7 +5485,15 @@ function bindNamedSlot(existing, name, slot) {
4999
5485
  );
5000
5486
  }
5001
5487
  if (name === "dev") {
5002
- return { ...existing, dev: { tenant_id: slot.tenant_id, slug: slot.slug, edge_url: slot.edge_url } };
5488
+ return {
5489
+ ...existing,
5490
+ dev: {
5491
+ tenant_id: slot.tenant_id,
5492
+ slug: slot.slug,
5493
+ edge_url: slot.edge_url,
5494
+ ...slot.expires_at ? { expires_at: slot.expires_at } : {}
5495
+ }
5496
+ };
5003
5497
  }
5004
5498
  const { expires_at: _x, ...permanent } = slot;
5005
5499
  void _x;
@@ -5265,6 +5759,28 @@ function driftExitCode(r) {
5265
5759
  if (r.errors > 0) return 2;
5266
5760
  return r.drift > 0 ? 1 : 0;
5267
5761
  }
5762
+ function newComparedCounts(opts = {}) {
5763
+ const base = { features: 0, collections: 0, functions: 0, secrets: 0 };
5764
+ return opts.apiState ? { ...base, policies: 0, subscriptions: 0, campaigns: 0 } : base;
5765
+ }
5766
+ var COMPARED_LABELS = [
5767
+ ["features", "feature"],
5768
+ ["collections", "collection"],
5769
+ ["functions", "function"],
5770
+ ["secrets", "secret ref"],
5771
+ ["policies", "policy"],
5772
+ ["subscriptions", "subscription"],
5773
+ ["campaigns", "campaign"]
5774
+ ];
5775
+ function formatCompared(c) {
5776
+ const parts = [];
5777
+ for (const [key, label] of COMPARED_LABELS) {
5778
+ const v = c[key];
5779
+ if (v === void 0) continue;
5780
+ parts.push(label === "policy" ? `${v} policy(ies)` : `${v} ${label}(s)`);
5781
+ }
5782
+ return parts.join(" \xB7 ");
5783
+ }
5268
5784
 
5269
5785
  // src/doctor.ts
5270
5786
  var SECRETS_ADVISORY_NOTE = "advisory only: a config push with an unstored `secret:<name>` ref is a WARNING, never a 422 \u2014 vxil's promotion order sets production secrets AFTER the config push (`vxil secrets set <feature>/<name>`); doctor never deletes a stored secret";
@@ -5307,6 +5823,17 @@ function notificationsProviderChecks(manifest) {
5307
5823
  }
5308
5824
  return [{ name: "notifications provider", ok: true, warn: true, detail: MOCK_PROVIDER_NOTE }];
5309
5825
  }
5826
+ var PAYMENTS_WEBHOOK_SECRET_NOTE = "payments provider is real but no per-tenant webhook secret is declared: every inbound provider delivery will be rejected 401 bad_signature and recorded as a sig_failed row (those rows can never be reprocessed, so the money events behind them are lost). There is NO platform-wide fallback. Declare <provider>.webhookSecretRef in your payments config and store the value with `vxil secrets set payments/<name>`";
5827
+ function paymentsWebhookSecretChecks(manifest) {
5828
+ if (!manifest || manifest.enabled === false) return [];
5829
+ const provider = String(manifest.provider ?? "");
5830
+ if (provider === "mock" || provider === "") return [];
5831
+ const declared = provider === "paypal" ? !!manifest.paypal?.webhookId && !!manifest.paypal?.clientIdRef && !!manifest.paypal?.secretRef : provider === "stripe" ? !!manifest.stripe?.webhookSecretRef : provider === "paddle" ? !!manifest.paddle?.webhookSecretRef : provider === "revenuecat" ? !!manifest.revenuecat?.webhookSecretRef : true;
5832
+ if (declared) {
5833
+ return [{ name: "payments webhook secret", ok: true, detail: `${provider} \xB7 webhook credential declared` }];
5834
+ }
5835
+ return [{ name: "payments webhook secret", ok: true, warn: true, detail: `${provider}: ${PAYMENTS_WEBHOOK_SECRET_NOTE}` }];
5836
+ }
5310
5837
  function sha12Of(scriptRef) {
5311
5838
  if (!scriptRef) return void 0;
5312
5839
  const m = /-([0-9a-f]{12})$/.exec(scriptRef);
@@ -5353,6 +5880,36 @@ function functionChecks(rows) {
5353
5880
  ...r.verdict === "local-changed" || r.verdict === "not-deployed" || r.verdict === "remote-only" || r.verdict === "served-unpublished" ? { warn: true } : {}
5354
5881
  }));
5355
5882
  }
5883
+ function devSlotExpiryCheck(dev, now) {
5884
+ if (!dev) return null;
5885
+ if (!dev.expires_at) {
5886
+ return {
5887
+ name: "dev slot expiry",
5888
+ ok: true,
5889
+ warn: true,
5890
+ detail: `unknown for '${dev.slug}' \u2014 run \`vxil dev up\` to refresh it (a slot bound with \`vxil link --as dev\`, or written before the CLI tracked TTLs, carries none)`
5891
+ };
5892
+ }
5893
+ const ms = new Date(dev.expires_at).getTime();
5894
+ if (!Number.isFinite(ms)) {
5895
+ return { name: "dev slot expiry", ok: true, warn: true, detail: `unreadable expiry '${dev.expires_at}' on '${dev.slug}' \u2014 run \`vxil dev up\`` };
5896
+ }
5897
+ const hours = Math.round((ms - now.getTime()) / 36e5);
5898
+ if (hours <= 0) {
5899
+ return {
5900
+ name: "dev slot expiry",
5901
+ ok: true,
5902
+ warn: true,
5903
+ detail: `'${dev.slug}' EXPIRED ${dev.expires_at} (${Math.abs(hours)}h ago) \u2014 the nightly reaper deletes it; run \`vxil dev up\``
5904
+ };
5905
+ }
5906
+ return {
5907
+ name: "dev slot expiry",
5908
+ ok: true,
5909
+ ...hours < 72 ? { warn: true } : {},
5910
+ detail: `'${dev.slug}' expires ${dev.expires_at} (in ${hours}h)` + (hours < 72 ? " \u2014 run `vxil dev up` to extend" : "")
5911
+ };
5912
+ }
5356
5913
 
5357
5914
  // src/link.ts
5358
5915
  function data(r) {
@@ -5441,6 +5998,37 @@ async function devDown(dash, cookie, dev) {
5441
5998
  `dev down failed (${e?.code ?? r.status}${e?.message ? ` \u2014 ${e.message}` : ""})`
5442
5999
  );
5443
6000
  }
6001
+ async function devExtend(dash, cookie, dev, ttlHours) {
6002
+ const r = await dash(
6003
+ "POST",
6004
+ `/dashboard/tenants/${dev.tenant_id}/extend`,
6005
+ ttlHours === void 0 ? {} : { ttl_hours: ttlHours },
6006
+ cookie
6007
+ );
6008
+ if (r.status === 200) {
6009
+ const d = r.json.data ?? r.json;
6010
+ return { extended: true, ...d.expires_at ? { expires_at: d.expires_at } : {} };
6011
+ }
6012
+ if (r.status === 404) return { gone: true };
6013
+ const e = r.json.error;
6014
+ if (r.status === 409 && e?.code === "not_a_dev_tenant") return { notDev: true };
6015
+ throw new Error(
6016
+ `dev tenant extend failed (${e?.code ?? r.status}${e?.message ? ` - ${e.message}` : ""})`
6017
+ );
6018
+ }
6019
+ var TTL_MIN_HOURS = 1;
6020
+ var TTL_MAX_HOURS = 720;
6021
+ function parseTtlFlag(raw, fallback) {
6022
+ if (raw === void 0) return { hours: fallback };
6023
+ const n = Number(raw);
6024
+ if (!Number.isInteger(n) || n < TTL_MIN_HOURS || n > TTL_MAX_HOURS) {
6025
+ return { error: `--ttl '${raw}': expected a whole number of HOURS, ${TTL_MIN_HOURS}..${TTL_MAX_HOURS} (default ${fallback})` };
6026
+ }
6027
+ return { hours: n };
6028
+ }
6029
+ function haveDashSession(creds, email, password) {
6030
+ return Boolean(creds.session) || Boolean(email && password);
6031
+ }
5444
6032
 
5445
6033
  // src/envPull.ts
5446
6034
  var MANAGED_HEADER = "# vxil (managed by `vxil env pull`)";
@@ -5502,6 +6090,13 @@ function pickBranchSlot(binding, name, now, hasKey) {
5502
6090
  if (!hasKey(slot.slug)) return { create: true };
5503
6091
  return { reuse: slot };
5504
6092
  }
6093
+ function pickDevSlot(binding, now, hasKey) {
6094
+ const slot = binding?.dev;
6095
+ if (!slot) return { create: true };
6096
+ if (slot.expires_at && new Date(slot.expires_at).getTime() <= now.getTime()) return { create: true };
6097
+ if (!hasKey(slot.slug)) return { create: true };
6098
+ return { reuse: slot };
6099
+ }
5505
6100
  async function createBranchTenant(dash, opts) {
5506
6101
  const r = await dash("POST", "/dashboard/quickstart", {
5507
6102
  email: opts.email,
@@ -5528,6 +6123,159 @@ async function createBranchTenant(dash, opts) {
5528
6123
  };
5529
6124
  }
5530
6125
 
6126
+ // src/promotionGate.ts
6127
+ var NON_PRODUCTION_ENV_LABELS = /* @__PURE__ */ new Set([
6128
+ "staging",
6129
+ "stage",
6130
+ "dev",
6131
+ "development",
6132
+ "test",
6133
+ "qa",
6134
+ "preview",
6135
+ "sandbox",
6136
+ "local"
6137
+ ]);
6138
+ function normalizeLabel(label) {
6139
+ return label.trim().toLowerCase();
6140
+ }
6141
+ function isNonProductionLabel(label) {
6142
+ return NON_PRODUCTION_ENV_LABELS.has(normalizeLabel(label));
6143
+ }
6144
+ function isDevSlot(t) {
6145
+ return t.slot === "dev" || t.slot === "named" && t.selector.kind === "named" && t.selector.name === "dev";
6146
+ }
6147
+ function isProductionTarget(t) {
6148
+ if (isDevSlot(t)) return false;
6149
+ if (normalizeLabel(t.envLabel) === "production") return true;
6150
+ return envLabelFor(t.baseUrl) === "production" && !isNonProductionLabel(t.envLabel);
6151
+ }
6152
+ function hostOf(baseUrl) {
6153
+ try {
6154
+ return new URL(baseUrl).host;
6155
+ } catch {
6156
+ return baseUrl;
6157
+ }
6158
+ }
6159
+ function unrecognizedLabelNotice(t) {
6160
+ if (isDevSlot(t)) return null;
6161
+ if (normalizeLabel(t.envLabel) === "production") return null;
6162
+ if (envLabelFor(t.baseUrl) !== "production") return null;
6163
+ if (isNonProductionLabel(t.envLabel)) return null;
6164
+ return `env label '${t.envLabel}' is not a recognized environment and the host is ${hostOf(t.baseUrl)} \u2014 treating this target as PRODUCTION (use --env staging|dev|test|qa|preview|sandbox|local to say otherwise)`;
6165
+ }
6166
+ function envLabelWarning(label, baseUrl) {
6167
+ const l = normalizeLabel(label);
6168
+ const host = hostOf(baseUrl);
6169
+ if (envLabelFor(baseUrl) === "production") {
6170
+ if (l === "production") return null;
6171
+ if (isNonProductionLabel(l)) {
6172
+ return `slot labelled '${label}' on the PRODUCTION host ${host} \u2014 the push promotion gate will NOT fire for it (the documented staging-twin pattern; re-link with --env production if this IS a production tenant)`;
6173
+ }
6174
+ return `env label '${label}' is not a recognized environment \u2014 on the PRODUCTION host ${host} it is treated as PRODUCTION (the push promotion gate WILL fire). Use production|staging|dev|test|qa|preview|sandbox|local.`;
6175
+ }
6176
+ if (l === "production") {
6177
+ return `slot labelled 'production' on the non-production host ${host} \u2014 the push promotion gate WILL fire for it`;
6178
+ }
6179
+ return null;
6180
+ }
6181
+ function isPlainObject2(v) {
6182
+ return v !== null && typeof v === "object" && !Array.isArray(v);
6183
+ }
6184
+ function leafAt(manifest, dotted) {
6185
+ let cur = manifest;
6186
+ for (const seg of dotted.split(".")) {
6187
+ if (!isPlainObject2(cur)) return void 0;
6188
+ cur = cur[seg];
6189
+ }
6190
+ return cur;
6191
+ }
6192
+ function mockFeatures(cfg) {
6193
+ return Object.entries(cfg.features ?? {}).filter(([, conf]) => {
6194
+ if (!isPlainObject2(conf)) return false;
6195
+ const c = conf;
6196
+ return c.provider === "mock" || c.defaultProvider === "mock" || (isPlainObject2(c.embed) ? c.embed.provider === "mock" : false);
6197
+ }).map(([f]) => f);
6198
+ }
6199
+ var SANDBOX_LEAVES = [
6200
+ { feature: "payments", path: "paddle.sandbox" },
6201
+ { feature: "payments", path: "paypal.sandbox" },
6202
+ { feature: "payments", path: "revenuecat.acceptSandbox" }
6203
+ ];
6204
+ function sandboxFeatures(cfg) {
6205
+ const out = [];
6206
+ for (const leaf of SANDBOX_LEAVES) {
6207
+ const manifest = (cfg.features ?? {})[leaf.feature];
6208
+ if (leafAt(manifest, leaf.path) === true) out.push(`${leaf.feature}.${leaf.path}`);
6209
+ }
6210
+ return out;
6211
+ }
6212
+ function isDevHost(host) {
6213
+ const h = host.toLowerCase().replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
6214
+ if (h === "localhost" || h.endsWith(".localhost")) return true;
6215
+ if (h === "127.0.0.1" || h.startsWith("127.")) return true;
6216
+ if (h === "::1") return true;
6217
+ if (h === "local" || h.endsWith(".local")) return true;
6218
+ if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
6219
+ if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
6220
+ if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
6221
+ return false;
6222
+ }
6223
+ function devRedirectOrigins(origins = []) {
6224
+ const out = [];
6225
+ for (const raw of origins) {
6226
+ if (typeof raw !== "string" || !raw) continue;
6227
+ if (/^http:\/\//i.test(raw)) {
6228
+ out.push(raw);
6229
+ continue;
6230
+ }
6231
+ const host = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/[/?#].*$/, "");
6232
+ if (host && isDevHost(host)) out.push(raw);
6233
+ }
6234
+ return out;
6235
+ }
6236
+ function redirectOrigins(cfg) {
6237
+ const v = leafAt((cfg.features ?? {}).auth, "security.allowedRedirectOrigins");
6238
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
6239
+ }
6240
+ var PROMOTION_OVERRIDE_FLAGS = {
6241
+ mock: "allow-mock-in-prod",
6242
+ sandbox: "allow-sandbox-in-prod",
6243
+ origins: "allow-dev-origins-in-prod"
6244
+ };
6245
+ function promotionRefusals(cfg, allowed) {
6246
+ const out = [];
6247
+ const mocks = mockFeatures(cfg);
6248
+ if (mocks.length && !allowed(PROMOTION_OVERRIDE_FLAGS.mock)) {
6249
+ out.push({
6250
+ kind: "mock",
6251
+ flag: PROMOTION_OVERRIDE_FLAGS.mock,
6252
+ message: `mock providers on [${mocks.join(", ")}] would ship to production (mock email/payments silently no-op). Set a real provider, or override with --${PROMOTION_OVERRIDE_FLAGS.mock}.`
6253
+ });
6254
+ }
6255
+ const sandboxes = sandboxFeatures(cfg);
6256
+ if (sandboxes.length && !allowed(PROMOTION_OVERRIDE_FLAGS.sandbox)) {
6257
+ out.push({
6258
+ kind: "sandbox",
6259
+ flag: PROMOTION_OVERRIDE_FLAGS.sandbox,
6260
+ message: `sandbox provider flags [${sandboxes.join(", ")}] would ship to a live money path (a sandbox key signs with the wrong secret; RevenueCat folds test purchases as outcome rejected_environment). Set them false, or override with --${PROMOTION_OVERRIDE_FLAGS.sandbox}.`
6261
+ });
6262
+ }
6263
+ const devOrigins = devRedirectOrigins(redirectOrigins(cfg));
6264
+ if (devOrigins.length && !allowed(PROMOTION_OVERRIDE_FLAGS.origins)) {
6265
+ out.push({
6266
+ kind: "origins",
6267
+ flag: PROMOTION_OVERRIDE_FLAGS.origins,
6268
+ message: `auth.security.allowedRedirectOrigins contains non-production origin(s) [${devOrigins.join(", ")}] \u2014 a loopback / .local / http:// redirect target on a production auth tenant is a token-exfiltration footgun. Remove them, or override with --${PROMOTION_OVERRIDE_FLAGS.origins}.`
6269
+ });
6270
+ }
6271
+ return out;
6272
+ }
6273
+ function redirectOriginNotice(cfg) {
6274
+ const origins = redirectOrigins(cfg);
6275
+ if (!origins.length) return null;
6276
+ return `auth.security.allowedRedirectOrigins (${origins.length}): ${origins.join(", ")}`;
6277
+ }
6278
+
5531
6279
  // src/try.ts
5532
6280
  async function mintTry(fetchImpl, base, opts = {}) {
5533
6281
  const url = `${base.replace(/\/$/, "")}/v1/try`;
@@ -5649,11 +6397,11 @@ function formatSimulate(r) {
5649
6397
  }
5650
6398
 
5651
6399
  // src/migrate/paymentsSync.ts
5652
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
5653
- import { dirname as dirname3, resolve as resolve5 } from "node:path";
6400
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
6401
+ import { dirname as dirname4, resolve as resolve5 } from "node:path";
5654
6402
 
5655
6403
  // src/migrate/commands.ts
5656
- import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
6404
+ import { appendFileSync, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
5657
6405
  import { basename as basename2, resolve as resolve4 } from "node:path";
5658
6406
 
5659
6407
  // src/migrate/inference.ts
@@ -6135,8 +6883,8 @@ function topoOrder(cmsNames, fks, profileTable, warnings) {
6135
6883
  }
6136
6884
 
6137
6885
  // src/migrate/loaders/index.ts
6138
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
6139
- import { dirname as dirname2 } from "node:path";
6886
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
6887
+ import { dirname as dirname3 } from "node:path";
6140
6888
 
6141
6889
  // src/migrate/types.ts
6142
6890
  function newMigrateState() {
@@ -6949,10 +7697,10 @@ async function verifyMigration({ deps, plan, adapter, state }) {
6949
7697
 
6950
7698
  // src/migrate/loaders/index.ts
6951
7699
  function loadMigrateState(stateFile) {
6952
- if (!existsSync3(stateFile)) return newMigrateState();
7700
+ if (!existsSync4(stateFile)) return newMigrateState();
6953
7701
  let parsed;
6954
7702
  try {
6955
- parsed = JSON.parse(readFileSync3(stateFile, "utf8"));
7703
+ parsed = JSON.parse(readFileSync4(stateFile, "utf8"));
6956
7704
  } catch (e) {
6957
7705
  throw new Error(
6958
7706
  `migrate state ${stateFile} is not parseable JSON (${e.message.split("\n")[0]}) \u2014 fix or delete the file (deleting restarts the migration; loaded cms rows are still deduped only if the id-map inside it is preserved).`
@@ -6970,7 +7718,7 @@ function loadMigrateState(stateFile) {
6970
7718
  };
6971
7719
  }
6972
7720
  function saveMigrateState(stateFile, state) {
6973
- mkdirSync2(dirname2(stateFile), { recursive: true });
7721
+ mkdirSync2(dirname3(stateFile), { recursive: true });
6974
7722
  writeFileSync3(stateFile, JSON.stringify(state, null, 2) + "\n");
6975
7723
  }
6976
7724
  function guardReadOnly(api) {
@@ -7098,7 +7846,7 @@ function writeMigrateArtifact(path, content) {
7098
7846
  function ensureStateIgnored(cwd, log) {
7099
7847
  const entry = `${MIGRATE_DIR}/state.json`;
7100
7848
  const gi = resolve4(cwd, ".gitignore");
7101
- const existing = existsSync4(gi) ? readFileSync4(gi, "utf8") : "";
7849
+ const existing = existsSync5(gi) ? readFileSync5(gi, "utf8") : "";
7102
7850
  if (gitignoreCovers(existing, entry)) return;
7103
7851
  const sep2 = existing === "" || existing.endsWith("\n") ? "" : "\n";
7104
7852
  appendFileSync(gi, `${sep2}
@@ -7196,12 +7944,12 @@ async function runMigratePlan({ adapter, cwd, log, sourceLabel, infer }) {
7196
7944
  }
7197
7945
  function readPlanFile(cwd) {
7198
7946
  const p = migratePaths(cwd);
7199
- if (!existsSync4(p.planFile)) {
7947
+ if (!existsSync5(p.planFile)) {
7200
7948
  throw new Error(`no ${MIGRATE_DIR}/plan.json \u2014 run \`vxil migrate plan --from <provider> \u2026\` first.`);
7201
7949
  }
7202
7950
  let parsed;
7203
7951
  try {
7204
- parsed = JSON.parse(readFileSync4(p.planFile, "utf8"));
7952
+ parsed = JSON.parse(readFileSync5(p.planFile, "utf8"));
7205
7953
  } catch (e) {
7206
7954
  throw new Error(`${MIGRATE_DIR}/plan.json is not parseable JSON (${e.message.split("\n")[0]}) \u2014 re-run \`vxil migrate plan\`.`);
7207
7955
  }
@@ -7219,10 +7967,10 @@ function readPlanFile(cwd) {
7219
7967
  }
7220
7968
  function readPaymentsSpecs(cwd) {
7221
7969
  const p = migratePaths(cwd);
7222
- if (!existsSync4(p.paymentsFile)) return void 0;
7970
+ if (!existsSync5(p.paymentsFile)) return void 0;
7223
7971
  let parsed;
7224
7972
  try {
7225
- parsed = JSON.parse(readFileSync4(p.paymentsFile, "utf8"));
7973
+ parsed = JSON.parse(readFileSync5(p.paymentsFile, "utf8"));
7226
7974
  } catch (e) {
7227
7975
  throw new Error(`${MIGRATE_DIR}/payments.json is not parseable JSON (${e.message.split("\n")[0]})`);
7228
7976
  }
@@ -7304,10 +8052,10 @@ function paymentsSyncStatePath(cwd) {
7304
8052
  return resolve5(cwd, MIGRATE_DIR, "payments-sync.state.json");
7305
8053
  }
7306
8054
  function loadPaymentsSyncState(path, provider) {
7307
- if (!existsSync5(path)) return { version: 1, provider, done: {} };
8055
+ if (!existsSync6(path)) return { version: 1, provider, done: {} };
7308
8056
  let parsed;
7309
8057
  try {
7310
- parsed = JSON.parse(readFileSync5(path, "utf8"));
8058
+ parsed = JSON.parse(readFileSync6(path, "utf8"));
7311
8059
  } catch (e) {
7312
8060
  throw new Error(`${path} is not parseable JSON (${e.message.split("\n")[0]}) \u2014 fix or delete it (deleting re-syncs every customer; the server fold is idempotent).`);
7313
8061
  }
@@ -7319,7 +8067,7 @@ function loadPaymentsSyncState(path, provider) {
7319
8067
  return { version: 1, provider, done: s.done ?? {}, ...s.usersCursor !== void 0 ? { usersCursor: s.usersCursor } : {} };
7320
8068
  }
7321
8069
  function savePaymentsSyncState(path, state) {
7322
- mkdirSync4(dirname3(path), { recursive: true });
8070
+ mkdirSync4(dirname4(path), { recursive: true });
7323
8071
  writeFileSync5(path, JSON.stringify(state, null, 2) + "\n");
7324
8072
  }
7325
8073
  function parseCustomersCsv(text) {
@@ -8008,7 +8756,7 @@ export default defineConfig({
8008
8756
  "id": "agent-desk",
8009
8757
  "title": "Agent Desk (mcp \xB7 copilot \xB7 classify \xB7 judge \xB7 rag)",
8010
8758
  "vertical": "ai",
8011
- "summary": "The whole AI half on a deliberately small support desk \u2014 forced-label classification on every new ticket, a grounded and cited draft scored by a second judging pass, a knowledge index kept in step with a cms collection, an in-app assistant whose writes are proposed and human-confirmed, the same backend exposed as a narrowed MCP tool set for a least-privilege agent key, and a capability probe that reads the 170-event catalog so an agent can discover what it is able to react to.",
8759
+ "summary": "The whole AI half on a deliberately small support desk \u2014 forced-label classification on every new ticket, a grounded and cited draft scored by a second judging pass, a knowledge index kept in step with a cms collection, an in-app assistant whose writes are proposed and human-confirmed, the same backend exposed as a narrowed MCP tool set for a least-privilege agent key, and a capability probe that reads the platform event catalog so an agent can discover what it is able to react to.",
8012
8760
  "collections": [
8013
8761
  "tickets",
8014
8762
  "kb"
@@ -9125,7 +9873,7 @@ export default defineConfig({
9125
9873
  ];
9126
9874
 
9127
9875
  // src/migrate/adapters/csv-json.ts
9128
- import { readFileSync as readFileSync6, readdirSync, existsSync as existsSync6, statSync } from "node:fs";
9876
+ import { readFileSync as readFileSync7, readdirSync, existsSync as existsSync7, statSync } from "node:fs";
9129
9877
  import { basename as basename3, extname, join as join3, resolve as resolve6 } from "node:path";
9130
9878
  function parseCsv(text) {
9131
9879
  const src = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
@@ -9244,11 +9992,11 @@ var CsvJsonAdapter = class {
9244
9992
  }
9245
9993
  load() {
9246
9994
  if (this.cache) return this.cache;
9247
- if (!existsSync6(this.dir)) throw new Error(`csv-json: directory not found: ${this.dir}`);
9995
+ if (!existsSync7(this.dir)) throw new Error(`csv-json: directory not found: ${this.dir}`);
9248
9996
  let overrides = {};
9249
9997
  const schemaFile = join3(this.dir, "_schema.json");
9250
- if (existsSync6(schemaFile)) {
9251
- overrides = JSON.parse(readFileSync6(schemaFile, "utf8"));
9998
+ if (existsSync7(schemaFile)) {
9999
+ overrides = JSON.parse(readFileSync7(schemaFile, "utf8"));
9252
10000
  }
9253
10001
  const warnings = [
9254
10002
  `csv-json: column types inferred from a \u2264${this.sampleSize}-row value sample \u2014 confirm the plan before load`
@@ -9301,7 +10049,7 @@ var CsvJsonAdapter = class {
9301
10049
  warnings
9302
10050
  };
9303
10051
  const objectsRoot = join3(this.dir, OBJECTS_DIR);
9304
- if (existsSync6(objectsRoot)) {
10052
+ if (existsSync7(objectsRoot)) {
9305
10053
  const buckets = readdirSync(objectsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => ({ name: e.name, objectCount: walkFiles(join3(objectsRoot, e.name)).length })).sort((a, b2) => a.name.localeCompare(b2.name));
9306
10054
  if (buckets.length > 0) schema.buckets = buckets;
9307
10055
  }
@@ -9324,7 +10072,7 @@ var CsvJsonAdapter = class {
9324
10072
  return this.cache;
9325
10073
  }
9326
10074
  loadCsv(file, schemaName, tableName, overrides, warnings) {
9327
- const parsed = parseCsv(readFileSync6(file, "utf8"));
10075
+ const parsed = parseCsv(readFileSync7(file, "utf8"));
9328
10076
  const header = parsed[0];
9329
10077
  if (!header || header.length === 0) {
9330
10078
  warnings.push(`csv-json: ${basename3(file)} is empty (no header row) \u2014 skipped`);
@@ -9362,7 +10110,7 @@ var CsvJsonAdapter = class {
9362
10110
  return { schema: schemaName, name: tableName, columns, rows };
9363
10111
  }
9364
10112
  loadJson(file, schemaName, tableName, overrides, warnings) {
9365
- const parsed = JSON.parse(readFileSync6(file, "utf8"));
10113
+ const parsed = JSON.parse(readFileSync7(file, "utf8"));
9366
10114
  if (!Array.isArray(parsed)) {
9367
10115
  warnings.push(`csv-json: ${basename3(file)} is not a JSON array of row objects \u2014 skipped`);
9368
10116
  return void 0;
@@ -9436,7 +10184,7 @@ var CsvJsonAdapter = class {
9436
10184
  * read LAZILY per object via stream() — a dry-run never opens them. */
9437
10185
  async *readObjects(bucket) {
9438
10186
  const root = join3(this.dir, OBJECTS_DIR, bucket);
9439
- if (!existsSync6(root)) {
10187
+ if (!existsSync7(root)) {
9440
10188
  throw new Error(`csv-json: unknown bucket '${bucket}' \u2014 no ${OBJECTS_DIR}/${bucket}/ directory in the export`);
9441
10189
  }
9442
10190
  for (const rel of walkFiles(root)) {
@@ -9446,7 +10194,7 @@ var CsvJsonAdapter = class {
9446
10194
  path: rel,
9447
10195
  contentType: EXT_CONTENT_TYPES[ext] ?? "application/octet-stream",
9448
10196
  size: statSync(abs).size,
9449
- stream: async () => readFileSync6(abs)
10197
+ stream: async () => readFileSync7(abs)
9450
10198
  };
9451
10199
  }
9452
10200
  }
@@ -10212,6 +10960,11 @@ function hasFlag(name) {
10212
10960
  return rest.includes(`--${name}`);
10213
10961
  }
10214
10962
  var jsonOut = rest.includes("--json");
10963
+ function ttlFlag(fallback) {
10964
+ const parsed = parseTtlFlag(flag("ttl"), fallback);
10965
+ if ("error" in parsed) fail(parsed.error);
10966
+ return parsed.hours;
10967
+ }
10215
10968
  function fail(msg, code = 1) {
10216
10969
  console.error(`vxil: ${msg}`);
10217
10970
  process.exit(code);
@@ -10303,6 +11056,8 @@ function persistLink(slug, tenantId, apiKey, base) {
10303
11056
  saveCredentials(creds);
10304
11057
  const as = flag("as");
10305
11058
  const env = flag("env") ?? envLabelFor(base);
11059
+ const envWarn = envLabelWarning(env, base);
11060
+ if (envWarn) console.error(`vxil: ${envWarn}`);
10306
11061
  const existing = loadProject();
10307
11062
  if (as !== void 0) {
10308
11063
  if (!TARGET_NAME_RE.test(as)) fail(`--as '${as}': a target name is 1\u201340 chars of [a-z0-9-], starting with a letter`);
@@ -10416,7 +11171,7 @@ function againstSelector(name) {
10416
11171
  }
10417
11172
  function loadDriftIgnore() {
10418
11173
  const f = resolve7(process.cwd(), ".vxil", "drift-ignore");
10419
- return parseIgnorePatterns(flag("ignore"), existsSync7(f) ? readFileSync7(f, "utf8") : void 0);
11174
+ return parseIgnorePatterns(flag("ignore"), existsSync8(f) ? readFileSync8(f, "utf8") : void 0);
10420
11175
  }
10421
11176
  async function runApply(apply, sel = "prod", opts = {}) {
10422
11177
  const gate = !apply && !!opts.gate;
@@ -10424,6 +11179,7 @@ async function runApply(apply, sel = "prod", opts = {}) {
10424
11179
  const { api, target } = requireApi(sel, apply ? { write: "push" } : gate ? { exitCode: 2 } : {});
10425
11180
  if (!apply) console.error(targetBanner(target, gate ? "diff" : "plan"));
10426
11181
  const report = { drift: 0, ignored: 0, errors: 0 };
11182
+ const compared = newComparedCounts({ apiState: !!opts.apiState });
10427
11183
  const ignore = apply ? [] : loadDriftIgnore();
10428
11184
  const ignoredKeys = [];
10429
11185
  const count = (key) => {
@@ -10441,18 +11197,55 @@ async function runApply(apply, sel = "prod", opts = {}) {
10441
11197
  process.exit(2);
10442
11198
  }
10443
11199
  const cwd = process.cwd();
10444
- if (cfg.cms?.collections && Object.keys(cfg.cms.collections).length) {
10445
- const r = await planCmsSchema({ api, collections: cfg.cms.collections, apply, allowDestructive: hasFlag("allow-destructive") });
11200
+ const notCompared = (u, hint) => {
11201
+ console.log(formatNotCompared(u, hint));
11202
+ if (gate) report.errors++;
11203
+ };
11204
+ const runSection = async (label, fn) => {
11205
+ if (!gate) {
11206
+ await fn();
11207
+ return;
11208
+ }
11209
+ try {
11210
+ await fn();
11211
+ } catch (e) {
11212
+ console.log(` ! not compared: ${label} \u2192 ${e.message.split("\n")[0]}`);
11213
+ report.errors++;
11214
+ }
11215
+ };
11216
+ await runSection("cms.schema", async () => {
11217
+ if (!cfg.cms?.collections || !Object.keys(cfg.cms.collections).length) return;
11218
+ const r = await planCmsSchema({
11219
+ api,
11220
+ collections: cfg.cms.collections,
11221
+ apply,
11222
+ allowDestructive: hasFlag("allow-destructive"),
11223
+ // the C-1 re-index loop can take several pages on a big collection —
11224
+ // print progress so a long push is never a silent stall
11225
+ onProgress: (line) => console.log(line)
11226
+ });
10446
11227
  console.log("\ncms.schema:");
11228
+ if (r.remoteUnavailable) {
11229
+ notCompared(r.remoteUnavailable, "needs cms:read, features:read or admin");
11230
+ return;
11231
+ }
10447
11232
  console.log(formatCmsChanges(r.changes));
10448
11233
  if (apply && r.applied) console.log(` \u2713 applied ${r.applied} schema change(s)`);
10449
11234
  for (const c of r.changes) count(`cms:${c.collection}${c.field ? `.${c.field}` : ""}`);
10450
- }
11235
+ compared.collections += Object.keys(cfg.cms.collections).length;
11236
+ });
10451
11237
  const { functions: _fnFeature, ...configFeatures } = cfg.features;
10452
11238
  void _fnFeature;
10453
- if (Object.keys(configFeatures).length) {
11239
+ await runSection("config", async () => {
11240
+ if (!Object.keys(configFeatures).length) return;
10454
11241
  const results = await planOrPush({ api, features: configFeatures, apply });
10455
11242
  for (const r of results) {
11243
+ if (r.remoteUnavailable) {
11244
+ console.log(`
11245
+ ${r.feature}:`);
11246
+ notCompared(r.remoteUnavailable, "needs features:read or admin");
11247
+ continue;
11248
+ }
10456
11249
  console.log(`
10457
11250
  ${r.feature} (remote v${r.version}):`);
10458
11251
  const { kept, ignored } = partitionConfigChanges(r.feature, r.changes, ignore);
@@ -10464,29 +11257,42 @@ ${r.feature} (remote v${r.version}):`);
10464
11257
  console.log(" provenance (declared in vxil.config vs schema default):");
10465
11258
  console.log(formatExplain(r.feature, explainLeaves(configFeatures[r.feature], r.effective), new Set(r.changes.map((c) => c.path))));
10466
11259
  }
11260
+ compared.features++;
11261
+ }
11262
+ });
11263
+ await runSection("functions", async () => {
11264
+ if (!cfg.functions || !Object.keys(cfg.functions).length) {
11265
+ if (cfg.features.functions) {
11266
+ console.log("\nfunctions: features.functions is declared but no functions/ are defined to deploy \u2014 the feature enables per-function on deploy (no feature-level config namespace).");
11267
+ }
11268
+ return;
10467
11269
  }
10468
- }
10469
- if (cfg.functions && Object.keys(cfg.functions).length) {
10470
11270
  const r = await planFunctions({ api, functions: cfg.functions, cwd, apply });
10471
11271
  console.log("\nfunctions:");
11272
+ if (r.remoteUnavailable) {
11273
+ notCompared(r.remoteUnavailable, "needs functions:read, features:read or admin");
11274
+ return;
11275
+ }
10472
11276
  console.log(formatFnChanges(r.changes));
10473
11277
  if (apply && r.applied) console.log(` \u2713 deployed ${r.applied} function(s)`);
10474
11278
  if (apply && r.cmsHookSubscriptions && (r.cmsHookSubscriptions.created || r.cmsHookSubscriptions.deleted)) {
10475
11279
  console.log(` \u2713 cms-hook subscription reconciled (+${r.cmsHookSubscriptions.created} / -${r.cmsHookSubscriptions.deleted})`);
10476
11280
  }
10477
11281
  for (const c of r.changes) if (c.kind !== "unchanged") count(`function:${c.name}`);
10478
- } else if (cfg.features.functions) {
10479
- console.log("\nfunctions: features.functions is declared but no functions/ are defined to deploy \u2014 the feature enables per-function on deploy (no feature-level config namespace).");
10480
- }
11282
+ compared.functions += Object.keys(cfg.functions).length;
11283
+ });
10481
11284
  if (!apply && (gate || explain)) {
10482
- const refs = collectSecretRefs(cfg);
10483
- const res = await api("GET", "/v1/secrets");
10484
- console.log("\nsecrets (names only \u2014 values are never read or compared):");
10485
- if (res.status !== 200) {
10486
- const e = res.body.error ?? {};
10487
- console.log(` ! not compared: GET /v1/secrets \u2192 ${e.code ?? res.status}${e.message ? ` \u2014 ${e.message}` : ""} (needs features:write, secrets:write or admin)`);
10488
- if (gate) report.errors++;
10489
- } else {
11285
+ await runSection("secrets", async () => {
11286
+ const refs = collectSecretRefs(cfg);
11287
+ const res = await api("GET", "/v1/secrets");
11288
+ console.log("\nsecrets (names only \u2014 values are never read or compared):");
11289
+ if (res.status !== 200) {
11290
+ notCompared(
11291
+ { route: "GET /v1/secrets", status: res.status, ...res.body.error?.code ? { code: res.body.error.code } : {}, ...res.body.error?.message ? { message: res.body.error.message } : {} },
11292
+ "needs features:write, secrets:write or admin"
11293
+ );
11294
+ return;
11295
+ }
10490
11296
  const stored = res.body.data?.secrets ?? [];
10491
11297
  const cmp = compareSecretNames(refs, stored);
10492
11298
  if (!cmp.missing.length && !cmp.unreferenced.length) console.log(` (in sync \u2014 ${cmp.present.length} ref(s), all stored)`);
@@ -10497,15 +11303,61 @@ ${r.feature} (remote v${r.version}):`);
10497
11303
  }
10498
11304
  for (const u of cmp.unreferenced) console.log(` \xB7 ${u.feature}/${u.secret_name} stored but not referenced by this config (informational)`);
10499
11305
  if (cmp.missing.length) console.log(" note: a push with an unstored ref is a WARNING, never a 422 \u2014 set values after the push with `vxil secrets set <feature>/<name>`");
10500
- }
11306
+ compared.secrets += refs.length;
11307
+ });
11308
+ }
11309
+ const apiStateCounts = {
11310
+ policies: { compared: 0, drift: 0 },
11311
+ subscriptions: { compared: 0, drift: 0 },
11312
+ campaigns: { compared: 0, drift: 0 }
11313
+ };
11314
+ if (opts.apiState) {
11315
+ await runSection("api-state", async () => {
11316
+ const self = requireApi(opts.apiState.self, { exitCode: 2 });
11317
+ const other = requireApi(opts.apiState.against, { exitCode: 2 });
11318
+ console.log(`
11319
+ api state \u2014 ${self.target.tenantSlug ?? "(env key)"} vs ${other.target.tenantSlug ?? "(env key)"} (read-only: \`vxil push\` does NOT converge API state, and a config rollback does not restore it):`);
11320
+ const [a, b2] = await Promise.all([fetchApiState(self.api), fetchApiState(other.api)]);
11321
+ for (const u of a.unavailable) notCompared(u, `target ${self.target.tenantSlug ?? ""}`.trim());
11322
+ for (const u of b2.unavailable) notCompared(u, `--against ${other.target.tenantSlug ?? ""}`.trim());
11323
+ for (const w of [...truncationWarnings(a, "the target"), ...truncationWarnings(b2, "--against")]) {
11324
+ console.log(w);
11325
+ if (gate) report.errors++;
11326
+ }
11327
+ if (a.unavailable.length || b2.unavailable.length) return;
11328
+ const rows = diffApiState(a, b2);
11329
+ console.log(formatApiStateRows(rows));
11330
+ for (const r of rows) count(apiStateDriftKey(r));
11331
+ const union = (x, y, k) => (/* @__PURE__ */ new Set([...x.map(k), ...y.map(k)])).size;
11332
+ apiStateCounts.policies.compared = union(a.policies, b2.policies, (p) => p.name);
11333
+ apiStateCounts.subscriptions.compared = union(a.subscriptions, b2.subscriptions, (x) => x.target_url);
11334
+ apiStateCounts.campaigns.compared = union(a.campaigns, b2.campaigns, (c) => c.name);
11335
+ for (const r of rows) {
11336
+ if (r.section === "policy") apiStateCounts.policies.drift++;
11337
+ else if (r.section === "subscription") apiStateCounts.subscriptions.drift++;
11338
+ else apiStateCounts.campaigns.drift++;
11339
+ }
11340
+ compared.policies = apiStateCounts.policies.compared;
11341
+ compared.subscriptions = apiStateCounts.subscriptions.compared;
11342
+ compared.campaigns = apiStateCounts.campaigns.compared;
11343
+ });
10501
11344
  }
10502
11345
  if (gate) {
10503
11346
  const code = driftExitCode(report);
10504
11347
  if (jsonOut) {
10505
- console.log(JSON.stringify({ ok: code === 0, exit_code: code, target: target.tenantSlug ?? null, env: target.envLabel, ...report, ignored_keys: ignoredKeys }));
11348
+ console.log(JSON.stringify({
11349
+ ok: code === 0,
11350
+ exit_code: code,
11351
+ target: target.tenantSlug ?? null,
11352
+ env: target.envLabel,
11353
+ ...report,
11354
+ compared,
11355
+ ignored_keys: ignoredKeys,
11356
+ ...opts.apiState ? { api_state: apiStateCounts } : {}
11357
+ }));
10506
11358
  } else {
10507
11359
  console.log(`
10508
- vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${report.errors} error(s) \u2014 exit ${code}`);
11360
+ vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${report.errors} error(s) \u2014 compared ${formatCompared(compared)} \u2014 exit ${code}`);
10509
11361
  }
10510
11362
  process.exit(code);
10511
11363
  }
@@ -10603,7 +11455,12 @@ async function runGen(sel = "prod", opts = {}) {
10603
11455
  required: !!f.required,
10604
11456
  index_slot: f.indexSlot ?? null,
10605
11457
  ...f.relationTo ? { relation_to: f.relationTo } : {},
10606
- ...f.computed ? { computed: f.computed } : {}
11458
+ ...f.computed ? { computed: f.computed } : {},
11459
+ // `validation.enum` becomes a string-literal union in the generated
11460
+ // types, so the OFFLINE collector must carry it too (the online path
11461
+ // gets it from GET /v1/cms/collections, which returns the whole row) —
11462
+ // else `vxil gen --offline` and `vxil gen` would disagree.
11463
+ ...f.validation ? { validation: f.validation } : {}
10607
11464
  }))
10608
11465
  }));
10609
11466
  functions = Object.entries(cfg.functions ?? {}).map(([name, def]) => ({
@@ -10651,15 +11508,15 @@ async function runGen(sel = "prod", opts = {}) {
10651
11508
  ...catalog ? { tools: catalog.tools.length } : {}
10652
11509
  };
10653
11510
  if (hasFlag("check")) {
10654
- const existing = existsSync7(outPath) ? readFileSync7(outPath, "utf8") : "";
11511
+ const existing = existsSync8(outPath) ? readFileSync8(outPath, "utf8") : "";
10655
11512
  if (body(existing) !== body(src)) {
10656
11513
  fail(`${out} is out of date (drift) \u2014 run \`vxil gen\` and commit. A collection/feature/function changed since it was generated.`);
10657
11514
  }
10658
11515
  if (catalog) {
10659
11516
  let upToDate = false;
10660
- if (existsSync7(mcpPath)) {
11517
+ if (existsSync8(mcpPath)) {
10661
11518
  try {
10662
- const cur = JSON.parse(readFileSync7(mcpPath, "utf8"));
11519
+ const cur = JSON.parse(readFileSync8(mcpPath, "utf8"));
10663
11520
  const want = JSON.parse(JSON.stringify(catalog));
10664
11521
  delete cur.generated_at;
10665
11522
  delete want.generated_at;
@@ -10704,11 +11561,11 @@ async function runWatch(sel) {
10704
11561
  }, 300);
10705
11562
  };
10706
11563
  const watchers = [];
10707
- const cfgFile = CONFIG_FILENAMES.map((n) => resolve7(cwd, n)).find((f) => existsSync7(f));
11564
+ const cfgFile = CONFIG_FILENAMES.map((n) => resolve7(cwd, n)).find((f) => existsSync8(f));
10708
11565
  if (cfgFile) watchers.push(watch(cfgFile, trigger));
10709
11566
  for (const dir of ["functions", "cms"]) {
10710
11567
  const d = resolve7(cwd, dir);
10711
- if (existsSync7(d)) watchers.push(watch(d, trigger));
11568
+ if (existsSync8(d)) watchers.push(watch(d, trigger));
10712
11569
  }
10713
11570
  process.on("SIGINT", () => {
10714
11571
  for (const w of watchers) w.close();
@@ -10718,22 +11575,19 @@ async function runWatch(sel) {
10718
11575
  await new Promise(() => {
10719
11576
  });
10720
11577
  }
10721
- function mockFeatures(cfg) {
10722
- return Object.entries(cfg.features).filter(([, conf]) => {
10723
- const c = conf;
10724
- return c.provider === "mock" || c.defaultProvider === "mock" || c.embed?.provider === "mock";
10725
- }).map(([f]) => f);
10726
- }
10727
- function isProductionTarget(t) {
10728
- return t.envLabel === "production" && t.slot !== "dev" && !(t.slot === "named" && t.selector.kind === "named" && t.selector.name === "dev");
10729
- }
10730
11578
  async function promotionGate(t, explicitProd) {
10731
11579
  const resolvedProd = isProductionTarget(t);
10732
11580
  if (!resolvedProd && !explicitProd) return;
11581
+ const labelNotice = unrecognizedLabelNotice(t);
11582
+ if (labelNotice) console.error(`vxil: ${labelNotice}`);
10733
11583
  const cfg = await loadVxilConfig();
10734
- const mocks = mockFeatures(cfg);
10735
- if (mocks.length && !hasFlag("allow-mock-in-prod")) {
10736
- fail(`refusing to push to PRODUCTION tenant '${t.tenantSlug ?? "(env key)"}' (${t.envLabel} \xB7 resolved via ${describeSelector(t.selector)}): mock providers on [${mocks.join(", ")}] would ship to production (mock email/payments silently no-op). Set a real provider, or override with --allow-mock-in-prod.`);
11584
+ const where = `'${t.tenantSlug ?? "(env key)"}' (${t.envLabel} \xB7 resolved via ${describeSelector(t.selector)})`;
11585
+ const notice = redirectOriginNotice(cfg);
11586
+ if (notice) console.error(`vxil: production review \u2014 ${notice}`);
11587
+ const refusals = promotionRefusals(cfg, hasFlag);
11588
+ if (refusals.length) {
11589
+ fail(`refusing to push to PRODUCTION tenant ${where}:
11590
+ ` + refusals.map((r) => ` - ${r.message}`).join("\n"));
10737
11591
  }
10738
11592
  if (hasFlag("yes")) return;
10739
11593
  if (!process.stdin.isTTY && !explicitProd) {
@@ -10771,6 +11625,8 @@ async function runDoctor() {
10771
11625
  if (proj) {
10772
11626
  const slots = bindingSlots(proj).map((s) => `${s.label}=${s.slug}`);
10773
11627
  checks.push({ name: "bound slots", ok: true, detail: `${slots.join(" \xB7 ")}${proj.dev ? "" : " (no dev slot: `--dev` fails closed until `vxil dev up` / `vxil link <slug> --as dev`)"}` });
11628
+ const expiry = devSlotExpiryCheck(proj.dev, /* @__PURE__ */ new Date());
11629
+ if (expiry) checks.push(expiry);
10774
11630
  }
10775
11631
  if (t?.apiKey) {
10776
11632
  const api = makeApi({ apiKey: t.apiKey, baseUrl: t.baseUrl });
@@ -10791,6 +11647,12 @@ async function runDoctor() {
10791
11647
  ));
10792
11648
  }
10793
11649
  }
11650
+ if (live.has("payments")) {
11651
+ const pres = await api("GET", "/v1/config/payments");
11652
+ if (pres.status === 200) {
11653
+ checks.push(...paymentsWebhookSecretChecks(pres.body.data?.manifest));
11654
+ }
11655
+ }
10794
11656
  } else {
10795
11657
  checks.push({ name: "edge reachable", ok: false, detail: `GET /v1/features \u2192 ${fres.status}` });
10796
11658
  }
@@ -10836,7 +11698,7 @@ async function runDoctor() {
10836
11698
  }
10837
11699
  if (cfg?.functions) {
10838
11700
  for (const [n, def] of Object.entries(cfg.functions)) {
10839
- const ok = existsSync7(resolve7(process.cwd(), def.entry));
11701
+ const ok = existsSync8(resolve7(process.cwd(), def.entry));
10840
11702
  checks.push({ name: `function '${n}' entry`, ok, detail: ok ? def.entry : `missing file: ${def.entry}` });
10841
11703
  }
10842
11704
  }
@@ -10894,13 +11756,13 @@ function scaffoldProject(configSrc, fns) {
10894
11756
  const entries = Object.entries(fns ?? {});
10895
11757
  if (entries.length > 0) {
10896
11758
  for (const [file, src] of entries) writeFileSync6(resolve7(cwd, "functions", file), src);
10897
- } else if (!existsSync7(resolve7(cwd, "functions/hello.ts"))) {
11759
+ } else if (!existsSync8(resolve7(cwd, "functions/hello.ts"))) {
10898
11760
  writeFileSync6(resolve7(cwd, "functions/hello.ts"), STARTER_FN);
10899
11761
  }
10900
11762
  mkdirSync5(resolve7(cwd, ".vxil"), { recursive: true });
10901
11763
  const gi = resolve7(cwd, ".gitignore");
10902
11764
  const ignore = ["", "# vxil", ".vxil/", ".env.local", ""].join("\n");
10903
- if (!existsSync7(gi) || !readFileSync7(gi, "utf8").includes(".vxil/")) appendFileSync2(gi, ignore);
11765
+ if (!existsSync8(gi) || !readFileSync8(gi, "utf8").includes(".vxil/")) appendFileSync2(gi, ignore);
10904
11766
  const pkg = ensureScaffoldPackageJson(cwd);
10905
11767
  if (pkg.action === "skipped") console.log(` ! package.json not patched: ${pkg.reason}`);
10906
11768
  return pkg;
@@ -10908,7 +11770,7 @@ function scaffoldProject(configSrc, fns) {
10908
11770
  function initProject() {
10909
11771
  const cwd = process.cwd();
10910
11772
  const cfgPath = resolve7(cwd, "vxil.config.ts");
10911
- if (existsSync7(cfgPath) && !hasFlag("force")) fail("vxil.config.ts already exists (use --force to overwrite)");
11773
+ if (existsSync8(cfgPath) && !hasFlag("force")) fail("vxil.config.ts already exists (use --force to overwrite)");
10912
11774
  const template = flag("template") ?? "tasks";
10913
11775
  const tpl = TEMPLATE_CATALOG.find((t) => t.id === template);
10914
11776
  if (!tpl) fail(`unknown template '${template}' \u2014 run \`vxil templates\` to list the gallery, or choose: ${TEMPLATE_CATALOG.map((t) => t.id).join(", ")}`);
@@ -10950,7 +11812,7 @@ try {
10950
11812
  const base = process.env.VXIL_BASE_URL ?? DEFAULT_BASE;
10951
11813
  const dash = dashboardBase(base);
10952
11814
  const featuresFlag = flag("features");
10953
- const hasConfig = CONFIG_FILENAMES.some((n) => existsSync7(resolve7(process.cwd(), n)));
11815
+ const hasConfig = CONFIG_FILENAMES.some((n) => existsSync8(resolve7(process.cwd(), n)));
10954
11816
  const features = quickstartFeatures(featuresFlag, featuresFlag === void 0 && hasConfig ? await loadVxilConfig() : null);
10955
11817
  const email = flag("email") ?? await prompt("email: ");
10956
11818
  const password = flag("password") ?? await prompt("password (>=10 chars): ", { hidden: true });
@@ -10962,7 +11824,7 @@ try {
10962
11824
  features,
10963
11825
  // --dev mints an ephemeral preview tenant the server-side TTL reaper
10964
11826
  // tears down after --ttl hours (default 72) — the per-PR CI backend.
10965
- ...hasFlag("dev") ? { kind: "dev", ttl_hours: Number(flag("ttl") ?? 72) } : {},
11827
+ ...hasFlag("dev") ? { kind: "dev", ttl_hours: ttlFlag(72) } : {},
10966
11828
  // --invite: required for ACCOUNT CREATION while the backend runs the
10967
11829
  // invite-only launch gate (existing accounts sign in ungated).
10968
11830
  ...flag("invite") ? { invite_code: flag("invite") } : {}
@@ -10986,7 +11848,7 @@ try {
10986
11848
  const slot = { tenant_id: d.tenant_id, slug: d.app_slug, edge_url: d.edge_url ?? base, env: flag("env") ?? envLabelFor(base) };
10987
11849
  const merged = mergePrimaryBinding(loadProject(), slot);
10988
11850
  saveProject(hasFlag("dev") ? { ...merged, dev: { tenant_id: slot.tenant_id, slug: slot.slug, edge_url: slot.edge_url } } : merged);
10989
- if (!existsSync7(resolve7(process.cwd(), "vxil.config.ts"))) initProject();
11851
+ if (!existsSync8(resolve7(process.cwd(), "vxil.config.ts"))) initProject();
10990
11852
  console.log(`
10991
11853
  \u2713 tenant '${d.app_slug}' created \xB7 enabled: ${(d.enabled ?? []).join(", ")}`);
10992
11854
  if (d.expires_at) console.log(` dev tenant \u2014 expires ${d.expires_at} (the nightly reaper tears it down; extend by recreating)`);
@@ -11054,7 +11916,7 @@ try {
11054
11916
  ephemeral: true,
11055
11917
  ...result.expires_at ? { expires_at: result.expires_at } : {}
11056
11918
  });
11057
- if (!existsSync7(resolve7(process.cwd(), "vxil.config.ts"))) scaffoldProject(TRY_CONFIG);
11919
+ if (!existsSync8(resolve7(process.cwd(), "vxil.config.ts"))) scaffoldProject(TRY_CONFIG);
11058
11920
  let genOk = true;
11059
11921
  try {
11060
11922
  await runGen("prod", { offline: true });
@@ -11175,7 +12037,14 @@ ${formatSummary(state)}`);
11175
12037
  case "diff": {
11176
12038
  const against = flag("against");
11177
12039
  const sel = against !== void 0 ? againstSelector(against) : selector();
11178
- await runApply(false, sel, { explain: hasFlag("explain"), gate: against !== void 0 || hasFlag("exit-code") });
12040
+ if (hasFlag("api-state") && against === void 0) {
12041
+ fail("--api-state has no local source of truth \u2014 compare two live targets: `vxil diff --api-state --against <name>`", 2);
12042
+ }
12043
+ await runApply(false, sel, {
12044
+ explain: hasFlag("explain"),
12045
+ gate: against !== void 0 || hasFlag("exit-code"),
12046
+ ...hasFlag("api-state") ? { apiState: { self: selector(), against: againstSelector(against) } } : {}
12047
+ });
11179
12048
  break;
11180
12049
  }
11181
12050
  case "push": {
@@ -11277,7 +12146,7 @@ ${plan.rationale}`);
11277
12146
  export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { collections: cmsBlock } }, null, 2)});
11278
12147
  `;
11279
12148
  const out = flag("out") ?? "vxil.config.ts";
11280
- if (existsSync7(resolve7(process.cwd(), out)) && !hasFlag("force")) fail(`${out} exists (use --force or --out <file>)`);
12149
+ if (existsSync8(resolve7(process.cwd(), out)) && !hasFlag("force")) fail(`${out} exists (use --force or --out <file>)`);
11281
12150
  writeFileSync6(resolve7(process.cwd(), out), src);
11282
12151
  const pkgRes = ensureScaffoldPackageJson(process.cwd());
11283
12152
  if (pkgRes.action === "created" || pkgRes.action === "added") {
@@ -11387,7 +12256,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
11387
12256
  const file = flag("file");
11388
12257
  if (!feature || !file) fail("usage: vxil import <feature> --file <bundle.json> [--mode merge|replace]");
11389
12258
  const { api } = requireApi(selector(), { write: `import ${feature}` });
11390
- const bundle = JSON.parse(readFileSync7(resolve7(process.cwd(), file), "utf8"));
12259
+ const bundle = JSON.parse(readFileSync8(resolve7(process.cwd(), file), "utf8"));
11391
12260
  printEnvelope(await api("POST", `/v1/import/${feature}`, { bundle, mode: flag("mode") ?? "merge" }));
11392
12261
  break;
11393
12262
  }
@@ -11401,7 +12270,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
11401
12270
  if (!name) fail("usage: vxil functions new <name>");
11402
12271
  mkdirSync5(resolve7(process.cwd(), "functions"), { recursive: true });
11403
12272
  const p = resolve7(process.cwd(), `functions/${name}.ts`);
11404
- if (existsSync7(p) && !hasFlag("force")) fail(`functions/${name}.ts exists`);
12273
+ if (existsSync8(p) && !hasFlag("force")) fail(`functions/${name}.ts exists`);
11405
12274
  writeFileSync6(p, STARTER_FN);
11406
12275
  console.log(`scaffolded functions/${name}.ts \u2014 declare it in vxil.config.ts \`functions\` and \`vxil push\``);
11407
12276
  } else if (sub === "list") {
@@ -11481,8 +12350,22 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
11481
12350
  printEnvelope(await api("GET", "/v1/cms/collections"));
11482
12351
  } else if (sub === "pull") {
11483
12352
  printEnvelope(await api("GET", "/v1/cms/collections"));
12353
+ } else if (sub === "reindex") {
12354
+ const [collection] = positional(1);
12355
+ if (!collection) fail("usage: vxil cms reindex <collection> [--field <field>]");
12356
+ const field = flag("field");
12357
+ const done = await reindexCollection({
12358
+ api,
12359
+ collection,
12360
+ ...field ? { field } : {},
12361
+ onProgress: (p) => console.log(` \u2026 scanned ${p.scanned}, re-projected ${p.updated}` + (p.skipped > 0 ? `, ${p.skipped} skipped (written concurrently)` : ""))
12362
+ }).catch((e) => fail(e.message));
12363
+ console.log(`\u2713 re-indexed ${collection}${field ? `.${field}` : ""}: ${done.scanned} row(s) scanned, ${done.updated} re-projected` + (done.skipped > 0 ? `, ${done.skipped} skipped` : ""));
12364
+ if (done.skipped > 0) {
12365
+ console.log(` \u26A0 ${done.skipped} row(s) were written while the re-index ran and were NOT re-projected \u2014 re-run \`vxil cms reindex ${collection}\` when writes are quiet`);
12366
+ }
11484
12367
  } else {
11485
- fail("usage: vxil cms status|pull");
12368
+ fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]");
11486
12369
  }
11487
12370
  break;
11488
12371
  }
@@ -11578,7 +12461,8 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
11578
12461
  init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--dev --ttl <h>] \xB7 login \xB7 link <slug> [--as dev|<name>]
11579
12462
  architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
11580
12463
  plan [--explain] \xB7 push [--dev|--target <name>|--prod] [--server [--resume <apply_id>]] [--no-gen] \xB7 pull \xB7 gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>]
11581
- diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (secrets by name only)
12464
+ push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
12465
+ diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only)
11582
12466
  doctor [--dev|--target <name>] \u2014 + secrets referenced-vs-stored (advisory) + per-function served/published/local hash
11583
12467
  versions <feature> \xB7 rollback <feature> --to <v>
11584
12468
  secrets set|list [--all-projects]|rm \xB7 seed \xB7 export|import <feature>
@@ -11587,7 +12471,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
11587
12471
  migrate payments --from-provider stripe|paddle|paypal|revenuecat (--customers <f.csv> | --all) [--dry-run] \u2014 backfill subscriptions via the server sync leg (resumable)
11588
12472
  payments simulate --scenario refund-pair|cross-platform-unlock|renewal|expiry|past-due-grace|transfer --user <id> [--tier <t>] \u2014 mock/dev projects only
11589
12473
  env pull [--dev] [--file <f>] [--print] [--no-gitignore]
11590
- functions new|deploy|list|delete|invoke [--async]|logs [--tail]|dev <name> [--port <n>] \xB7 cms status|pull \xB7 dev up [--ttl <h>]|down [--yes]|seed|reset
12474
+ functions new|deploy|list|delete|invoke [--async]|logs [--tail]|dev <name> [--port <n>] \xB7 cms status|pull|reindex <collection> \xB7 dev up [--ttl <h>] [--new]|down [--yes]|seed|reset
11591
12475
  listen --forward-to <url> [--dev] [--source <id>] [--events <prefix,\u2026>] [--replay-last <n>] [--interval <s>] [--show-runs] [--raw] \u2014 forward inbound webhook events to a local server
11592
12476
  mcp install [--client cursor|claude|vscode] [--project] [--print] [--key <k>] [--scopes a,b] [--name <server>] \u2014 connect your editor's agent to this backend's MCP server
11593
12477
  dev branch [<name>] [--ttl <h=24>] [--no-env] [--print] | --list | --rm <name> [--yes]
@@ -11621,13 +12505,7 @@ async function runDev(sub) {
11621
12505
  const dash = dashboardBase(base);
11622
12506
  const proj = loadProject();
11623
12507
  if (sub === "up") {
11624
- const name = flag("name") ?? `${proj?.slug ?? "app"}-dev`;
11625
- const d = await devUpCreate(dash, name, Number(flag("ttl") ?? 72));
11626
- const binding = proj ?? { tenant_id: d.tenant_id, slug: d.slug, edge_url: d.edge_url ?? base };
11627
- binding.dev = { tenant_id: d.tenant_id, slug: d.slug, edge_url: d.edge_url ?? base };
11628
- saveProject(binding);
11629
- console.log(`\u2713 dev tenant '${d.slug}' ready (keyless mock providers). Use \`vxil push --dev\` / \`vxil gen\` against it.`);
11630
- if (d.expires_at) console.log(` expires ${d.expires_at} \u2014 the nightly reaper deletes it then (re-run \`vxil dev up\` to extend).`);
12508
+ await runDevUp(dash, base, proj);
11631
12509
  } else if (sub === "branch") {
11632
12510
  await runDevBranch(dash, base);
11633
12511
  } else if (sub === "seed") {
@@ -11646,6 +12524,61 @@ async function runDev(sub) {
11646
12524
  fail("usage: vxil dev up|down|seed|reset|branch");
11647
12525
  }
11648
12526
  }
12527
+ async function runDevUp(dash, base, proj) {
12528
+ const ttlHours = ttlFlag(72);
12529
+ const creds = loadCredentials();
12530
+ const decision = hasFlag("new") ? { create: true } : pickDevSlot(proj, /* @__PURE__ */ new Date(), (slug) => Boolean(creds.keys?.[slug]));
12531
+ if ("reuse" in decision) {
12532
+ const slot = decision.reuse;
12533
+ let expiresAt = slot.expires_at;
12534
+ let reaped = false;
12535
+ try {
12536
+ const dashFn = makeDash(dash);
12537
+ if (!haveDashSession(creds, flag("email") ?? process.env.VXIL_DEV_EMAIL, flag("password") ?? process.env.VXIL_DEV_PASSWORD)) {
12538
+ throw new Error("no dashboard session \u2014 run `vxil login` (or pass --email/--password) to extend the TTL");
12539
+ }
12540
+ const cookie = await resolveDashCookie(dashFn);
12541
+ const r = await devExtend(dashFn, cookie, { tenant_id: slot.tenant_id }, ttlHours);
12542
+ if ("gone" in r) {
12543
+ console.log(` ! dev tenant '${slot.slug}' is gone (already reaped) \u2014 creating a fresh one`);
12544
+ reaped = true;
12545
+ } else if ("notDev" in r) {
12546
+ console.log(`\u2713 reusing '${slot.slug}' as the dev slot \u2014 it is a STANDARD project (bound with vxil link --as dev), so it has no TTL to extend`);
12547
+ } else {
12548
+ expiresAt = r.expires_at ?? expiresAt;
12549
+ console.log(`\u2713 reusing dev tenant '${slot.slug}'${expiresAt ? ` \u2014 TTL extended to ${expiresAt}` : " \u2014 TTL extended"}`);
12550
+ }
12551
+ } catch (e) {
12552
+ console.log(` ! could not extend the dev TTL (${e.message.split("\n")[0]}) \u2014 reusing '${slot.slug}' as-is`);
12553
+ }
12554
+ if (!reaped) {
12555
+ const kept = loadProject() ?? { tenant_id: slot.tenant_id, slug: slot.slug, edge_url: slot.edge_url };
12556
+ kept.dev = {
12557
+ tenant_id: slot.tenant_id,
12558
+ slug: slot.slug,
12559
+ edge_url: slot.edge_url,
12560
+ ...expiresAt ? { expires_at: expiresAt } : {}
12561
+ };
12562
+ saveProject(kept);
12563
+ console.log(" use `vxil push --dev` / `vxil gen` against it; --new forces a fresh tenant.");
12564
+ return;
12565
+ }
12566
+ }
12567
+ const name = flag("name") ?? `${proj?.slug ?? "app"}-dev`;
12568
+ const d = await devUpCreate(dash, name, ttlHours);
12569
+ const binding = loadProject() ?? { tenant_id: d.tenant_id, slug: d.slug, edge_url: d.edge_url ?? base };
12570
+ binding.dev = {
12571
+ tenant_id: d.tenant_id,
12572
+ slug: d.slug,
12573
+ edge_url: d.edge_url ?? base,
12574
+ ...d.expires_at ? { expires_at: d.expires_at } : {}
12575
+ };
12576
+ saveProject(binding);
12577
+ console.log(`\u2713 dev tenant '${d.slug}' ready (keyless mock providers). Use \`vxil push --dev\` / \`vxil gen\` against it.`);
12578
+ if (d.expires_at) {
12579
+ console.log(` expires ${d.expires_at} \u2014 the nightly reaper deletes it then; re-run \`vxil dev up\` to extend the TTL (the same tenant is reused; --new forces a fresh one).`);
12580
+ }
12581
+ }
11649
12582
  async function runDevBranch(dash, base) {
11650
12583
  const proj = loadProject();
11651
12584
  if (hasFlag("list")) {
@@ -11683,7 +12616,7 @@ async function runDevBranch(dash, base) {
11683
12616
  slot = decision.reuse;
11684
12617
  console.log(`\u2713 reusing dev branch '${name}' (tenant '${slot.slug}'${slot.expires_at ? ` \xB7 expires ${slot.expires_at}` : ""})`);
11685
12618
  } else {
11686
- const d = await devUpCreate(dash, `${proj?.slug ?? "app"}-${name}`, Number(flag("ttl") ?? 24));
12619
+ const d = await devUpCreate(dash, `${proj?.slug ?? "app"}-${name}`, ttlFlag(24));
11687
12620
  slot = {
11688
12621
  tenant_id: d.tenant_id,
11689
12622
  slug: d.slug,
@@ -11771,7 +12704,7 @@ function runEnvPull(sel) {
11771
12704
  const fileArg = flag("file") ?? ".env.local";
11772
12705
  const file = resolve7(process.cwd(), fileArg);
11773
12706
  const note = guardEnvFile(fileArg);
11774
- const existing = existsSync7(file) ? readFileSync7(file, "utf8") : "";
12707
+ const existing = existsSync8(file) ? readFileSync8(file, "utf8") : "";
11775
12708
  writeFileSync6(file, mergeEnvFile(existing, updates));
11776
12709
  try {
11777
12710
  chmodSync2(file, 384);
@@ -11853,7 +12786,7 @@ function guardEnvFile(fileArg) {
11853
12786
  const inRepo = git(["rev-parse", "--is-inside-work-tree"]);
11854
12787
  if (inRepo === "no-git") {
11855
12788
  const gi = resolve7(cwd, ".gitignore");
11856
- const text = existsSync7(gi) ? readFileSync7(gi, "utf8") : "";
12789
+ const text = existsSync8(gi) ? readFileSync8(gi, "utf8") : "";
11857
12790
  return appendOrWarn(gitignoreCovers(text, fileArg));
11858
12791
  }
11859
12792
  if (inRepo !== 0) return void 0;
@@ -11874,9 +12807,9 @@ async function runMcpInstall() {
11874
12807
  client = clientFlag;
11875
12808
  } else {
11876
12809
  const hits = [];
11877
- if (existsSync7(resolve7(cwd, ".cursor"))) hits.push("cursor");
11878
- if (existsSync7(resolve7(cwd, ".vscode"))) hits.push("vscode");
11879
- if (existsSync7(resolve7(cwd, ".claude")) || existsSync7(resolve7(cwd, ".mcp.json"))) hits.push("claude");
12810
+ if (existsSync8(resolve7(cwd, ".cursor"))) hits.push("cursor");
12811
+ if (existsSync8(resolve7(cwd, ".vscode"))) hits.push("vscode");
12812
+ if (existsSync8(resolve7(cwd, ".claude")) || existsSync8(resolve7(cwd, ".mcp.json"))) hits.push("claude");
11880
12813
  if (hits.length !== 1) {
11881
12814
  fail(hits.length === 0 ? "could not detect an editor in this directory \u2014 pass --client cursor|claude|vscode" : `multiple editor markers found (${hits.join(", ")}) \u2014 pass --client cursor|claude|vscode`);
11882
12815
  }
@@ -11889,7 +12822,7 @@ async function runMcpInstall() {
11889
12822
  }
11890
12823
  const t = resolve_target({ which: "prod", defaultBase: DEFAULT_BASE });
11891
12824
  const url = mcpUrlFor(t.baseUrl, process.env.VXIL_MCP_URL);
11892
- const target = configTarget(client, scope, { home: homedir2(), cwd, platform: process.platform });
12825
+ const target = configTarget(client, scope, { home: homedir3(), cwd, platform: process.platform });
11893
12826
  const explicitKey = flag("key");
11894
12827
  const inline = needsInlineKey(client, scope);
11895
12828
  if (hasFlag("print")) {
@@ -11977,7 +12910,7 @@ hint: ${e.hint}` : ""}`);
11977
12910
  return;
11978
12911
  }
11979
12912
  const ref = keyRef(client, scope, key);
11980
- const existing = existsSync7(target.path) ? readFileSync7(target.path, "utf8") : null;
12913
+ const existing = existsSync8(target.path) ? readFileSync8(target.path, "utf8") : null;
11981
12914
  let merged;
11982
12915
  try {
11983
12916
  merged = mergeMcpConfig(existing, { url, header: ref.header, ...ref.inputs ? { inputs: ref.inputs } : {} }, target.format, serverName);
@@ -11987,7 +12920,7 @@ hint: ${e.hint}` : ""}`);
11987
12920
  }
11988
12921
  let guardNote;
11989
12922
  if (client === "cursor" && scope === "project") guardNote = guardEnvFile(".cursor/mcp.json");
11990
- mkdirSync5(dirname4(target.path), { recursive: true });
12923
+ mkdirSync5(dirname5(target.path), { recursive: true });
11991
12924
  writeFileSync6(target.path, merged);
11992
12925
  if (inline) {
11993
12926
  try {
@@ -12106,7 +13039,7 @@ async function runFunctionsDev(name) {
12106
13039
  });
12107
13040
  });
12108
13041
  let timer2 = null;
12109
- const watcher = watch(dirname4(entry), () => {
13042
+ const watcher = watch(dirname5(entry), () => {
12110
13043
  if (timer2) clearTimeout(timer2);
12111
13044
  timer2 = setTimeout(() => {
12112
13045
  void (async () => {
@@ -12211,7 +13144,7 @@ ${migrateUsage()}`);
12211
13144
  const { api } = requireApi(selector(), dryRun ? {} : { write: `migrate payments --from-provider ${provider}` });
12212
13145
  const report = await runPaymentsSync({ api, cwd, log }, {
12213
13146
  provider,
12214
- ...customersFile ? { customers: parseCustomersCsv(readFileSync7(resolve7(cwd, customersFile), "utf8")) } : {},
13147
+ ...customersFile ? { customers: parseCustomersCsv(readFileSync8(resolve7(cwd, customersFile), "utf8")) } : {},
12215
13148
  ...all ? { all: true } : {},
12216
13149
  ...dryRun ? { dryRun: true } : {}
12217
13150
  });