@vxil/cli 0.5.1 → 0.5.3

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
@@ -47,7 +47,7 @@ function lowerTriggerBindings(trigger) {
47
47
  return [{ kind: "http", ...str2("path") ? { path: str2("path") } : {} }];
48
48
  }
49
49
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
50
- var VXIL_CONFIG_PKG_VERSION = "0.4.0";
50
+ var VXIL_CONFIG_PKG_VERSION = "0.4.1";
51
51
  function ensureScaffoldPackageJson(cwd) {
52
52
  const file = resolve(cwd, "package.json");
53
53
  const spec = `^${VXIL_CONFIG_PKG_VERSION}`;
@@ -551,7 +551,7 @@ import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync
551
551
  import { homedir } from "node:os";
552
552
  import { dirname as dirname2, resolve as resolve2, relative, isAbsolute, sep } from "node:path";
553
553
  import { createHash } from "node:crypto";
554
- var MAX_SOURCE_BYTES = 256e3;
554
+ var MAX_SOURCE_BYTES = 512e3;
555
555
  function sourceSha12(source) {
556
556
  return createHash("sha256").update(source, "utf8").digest("hex").slice(0, 12);
557
557
  }
@@ -609,7 +609,7 @@ function assertBundleContained(projectRoot, inputs, opts = {}) {
609
609
  const root = resolve2(projectRoot);
610
610
  const rootReal = realpathSafe(root);
611
611
  const wsReal = realpathSafe(opts.workspaceRoot ?? workspaceRootCached(root));
612
- const note = opts.onEscape ?? ((msg) => console.log(msg));
612
+ const note = opts.onEscape ?? ((msg) => console.error(msg));
613
613
  for (const input of inputs) {
614
614
  const abs = resolve2(root, input);
615
615
  const absReal = realpathSafe(abs);
@@ -641,9 +641,11 @@ async function bundleFunction(entryPath, opts = {}) {
641
641
  const root = resolve2(opts.projectRoot ?? process.cwd());
642
642
  const workspaceRoot = workspaceRootCached(root);
643
643
  const entry = resolve2(entryPath);
644
- assertBundleContained(root, [entry], { workspaceRoot, what: "entry" });
644
+ const contained = { workspaceRoot, ...opts.onNote ? { onEscape: opts.onNote } : {} };
645
+ assertBundleContained(root, [entry], { ...contained, what: "entry" });
645
646
  const esbuild = await import("esbuild");
646
- const result = await esbuild.build({
647
+ const minify = opts.minify ?? true;
648
+ const build = (min) => esbuild.build({
647
649
  entryPoints: [entry],
648
650
  bundle: true,
649
651
  format: "esm",
@@ -652,22 +654,45 @@ async function bundleFunction(entryPath, opts = {}) {
652
654
  target: "es2022",
653
655
  write: false,
654
656
  legalComments: "none",
657
+ minify: min,
658
+ keepNames: min,
659
+ // readable stack traces / log lines on the minified bundle
660
+ sourcemap: false,
661
+ // never uploaded, never emitted
655
662
  absWorkingDir: root,
656
663
  metafile: true,
657
664
  // drives the import-containment check below (non-executing)
658
665
  plugins: []
659
666
  // explicitly none — build plugins are code execution
660
667
  });
668
+ const result = await build(minify);
661
669
  const out = result.outputFiles?.[0];
662
670
  if (!out) throw new Error(`esbuild produced no output for ${entryPath}`);
663
- assertBundleContained(root, Object.keys(result.metafile?.inputs ?? {}), { workspaceRoot });
671
+ assertBundleContained(root, Object.keys(result.metafile?.inputs ?? {}), contained);
664
672
  const source = out.text;
665
673
  const bytes = Buffer.byteLength(source, "utf8");
666
674
  if (bytes > MAX_SOURCE_BYTES) {
667
- throw new Error(`bundled ${entryPath} is ${Math.round(bytes / 1e3)} KB \u2014 exceeds the ${MAX_SOURCE_BYTES / 1e3} KB deploy cap`);
675
+ let raw = null;
676
+ if (minify) {
677
+ try {
678
+ const rawOut = (await build(false)).outputFiles?.[0];
679
+ if (rawOut) raw = Buffer.byteLength(rawOut.text, "utf8");
680
+ } catch {
681
+ raw = null;
682
+ }
683
+ }
684
+ throw new Error(formatBundleTooLarge(entryPath, bytes, raw, minify));
668
685
  }
669
686
  return { source, bytes };
670
687
  }
688
+ function kb(n) {
689
+ return `${Math.round(n / 100) / 10} KB`;
690
+ }
691
+ function formatBundleTooLarge(entryPath, bytes, rawBytes, minified) {
692
+ const measured = minified ? `${kb(bytes)} minified` : kb(bytes);
693
+ const raw = rawBytes !== null && minified ? ` (${kb(rawBytes)} unminified)` : "";
694
+ return `bundled ${entryPath} is ${measured}${raw} \u2014 exceeds the ${MAX_SOURCE_BYTES / 1e3} KB deploy cap (the cap is measured on the minified bundle, which is what gets uploaded; trim imports or split the function)`;
695
+ }
671
696
  function stableStringify(v) {
672
697
  if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
673
698
  if (v !== null && typeof v === "object") {
@@ -716,7 +741,7 @@ var DENY_FUNCTION_SCOPES = /* @__PURE__ */ new Set(["admin", "*", "features:writ
716
741
  function clampFunctionScopes(scopes) {
717
742
  return (scopes ?? []).map(String).filter((s) => s && !DENY_FUNCTION_SCOPES.has(s));
718
743
  }
719
- async function planFunctions({ api, functions, cwd, apply }) {
744
+ async function planFunctions({ api, functions, cwd, apply, onNote }) {
720
745
  const read = await readRemote(api);
721
746
  if (read.unavailable) {
722
747
  if (apply) {
@@ -731,7 +756,7 @@ async function planFunctions({ api, functions, cwd, apply }) {
731
756
  if (!/^[a-z][a-z0-9-]{0,47}$/.test(name)) {
732
757
  throw new Error(`functions: invalid name '${name}' (must match /^[a-z][a-z0-9-]{0,47}$/)`);
733
758
  }
734
- const { source, bytes } = await bundleFunction(resolve2(cwd, def.entry), { projectRoot: cwd });
759
+ const { source, bytes } = await bundleFunction(resolve2(cwd, def.entry), { projectRoot: cwd, ...onNote ? { onNote } : {} });
735
760
  const triggerKind = def.trigger?.kind ?? "http";
736
761
  const secrets = normalizeSecretRefs(def.secrets);
737
762
  const bindings = lowerTriggerBindings(def.trigger);
@@ -755,12 +780,13 @@ async function planFunctions({ api, functions, cwd, apply }) {
755
780
  const e = res.body.error ?? {};
756
781
  throw new Error(`functions: deploy ${name} failed: ${e.code ?? res.status} ${e.message ?? ""} ${e.hint ?? ""}`);
757
782
  }
758
- const data2 = res.body.data;
759
- const cmsHooks = data2?.cms_hook_subscriptions ?? null;
760
- const webhooks = data2?.webhook_subscriptions ?? null;
761
- return { change, applied: 1, cmsHooks, webhooks };
783
+ const data3 = res.body.data;
784
+ const cmsHooks = data3?.cms_hook_subscriptions ?? null;
785
+ const webhooks = data3?.webhook_subscriptions ?? null;
786
+ const warnings2 = (data3?.warnings ?? []).map((message) => ({ fn: name, message: String(message) }));
787
+ return { change, applied: 1, cmsHooks, webhooks, warnings: warnings2 };
762
788
  }
763
- return { change, applied: 0, cmsHooks: null, webhooks: null };
789
+ return { change, applied: 0, cmsHooks: null, webhooks: null, warnings: [] };
764
790
  });
765
791
  const changes = results.map((r) => r.change);
766
792
  const applied = results.reduce((s, r) => s + r.applied, 0);
@@ -771,13 +797,47 @@ async function planFunctions({ api, functions, cwd, apply }) {
771
797
  }, null);
772
798
  const cmsHookSubscriptions = sum((r) => r.cmsHooks);
773
799
  const webhookSubscriptions = sum((r) => r.webhooks);
774
- return { changes, applied, cmsHookSubscriptions, webhookSubscriptions };
800
+ const warnings = results.flatMap((r) => r.warnings ?? []);
801
+ return { changes, applied, cmsHookSubscriptions, webhookSubscriptions, ...warnings.length ? { warnings } : {} };
775
802
  }
776
803
  function formatFnChanges(changes) {
777
804
  const actionable = changes.filter((c) => c.kind !== "unchanged");
778
805
  if (!actionable.length) return " (no function changes)";
779
806
  return actionable.map((c) => ` ${c.kind === "deploy-new" ? "+" : "~"} ${c.detail}`).join("\n");
780
807
  }
808
+ async function probeFunctionsEntitlement(api, name) {
809
+ const res = await api("POST", `/v1/functions/${encodeURIComponent(name)}`, {});
810
+ if (res.status === 402 || res.status === 403) {
811
+ const e = res.body.error ?? {};
812
+ return {
813
+ entitled: false,
814
+ status: res.status,
815
+ ...e.code ? { code: e.code } : {},
816
+ ...e.message ? { message: e.message } : {},
817
+ ...e.hint ? { hint: e.hint } : {},
818
+ ...e.fixUrl ? { fixUrl: e.fixUrl } : {}
819
+ };
820
+ }
821
+ return { entitled: true };
822
+ }
823
+ function billingPageFor(tenantId, baseUrl) {
824
+ const tid = tenantId && tenantId !== "(unknown)" ? tenantId : "<tenant>";
825
+ const path = `/dashboard/${tid}/billing`;
826
+ try {
827
+ if (/(^|\.)api\.vxil\.com$/.test(new URL(baseUrl).hostname)) return `https://vxil.com${path}`;
828
+ } catch {
829
+ }
830
+ return path;
831
+ }
832
+ function formatFunctionsPreflight(e, opts) {
833
+ const n = `${opts.declared} declared function(s)`;
834
+ if (e.code === "sandbox_restricted" || e.status === 403) {
835
+ return `functions: this project cannot deploy functions (${e.code ?? e.status}${e.message ? ` \u2014 ${e.message}` : ""}); ${n} would be refused.${e.hint ? ` ${e.hint}` : ""}` + (opts.verb === "push" ? " Run `vxil push --skip-functions` to push everything else." : "");
836
+ }
837
+ const plan = /'([a-z]+)' plan/.exec(e.message ?? "")?.[1];
838
+ const planLabel = plan ? `the ${plan[0].toUpperCase()}${plan.slice(1)} plan` : "a paid plan";
839
+ return `functions need ${planLabel}${e.message ? ` (${e.message})` : ""} \u2014 ${n} would be refused with 402 tier_required. Upgrade in the dashboard (${billingPageFor(opts.tenantId, opts.baseUrl)})` + (opts.verb === "push" ? ", or run `vxil push --skip-functions` to push the cms schema and feature config without them" : "") + ".";
840
+ }
781
841
  function formatLogLines(lines) {
782
842
  const out = [];
783
843
  for (const l of lines) {
@@ -1188,11 +1248,7 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
1188
1248
  async function applyDestructive(api, collections, c) {
1189
1249
  const ok2xx = (status) => status >= 200 && status < 300;
1190
1250
  if (c.op === "drop-collection") {
1191
- const res2 = await api("POST", "/v1/cms/collections", { drop_collection: c.collection, allow_destructive: true });
1192
- if (!ok2xx(res2.status) && res2.body.error?.code !== "not_found") {
1193
- const e = res2.body.error ?? {};
1194
- throw new Error(`cms: drop collection ${c.collection} failed: ${e.code ?? res2.status} ${e.message ?? ""}`);
1195
- }
1251
+ await dropCollection(api, c.collection);
1196
1252
  return 1;
1197
1253
  }
1198
1254
  const path = `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`;
@@ -1231,6 +1287,53 @@ function formatCmsChanges(changes) {
1231
1287
  return ` + ${c.detail}`;
1232
1288
  }).join("\n");
1233
1289
  }
1290
+ async function planCollectionDrop(api, collection) {
1291
+ const read = await readRemote2(api);
1292
+ if (read.unavailable) return { exists: false, fields: 0, rows: null, remoteUnavailable: read.unavailable };
1293
+ const rc = read.map.get(collection);
1294
+ if (!rc) return { exists: false, fields: 0, rows: null };
1295
+ return { exists: true, fields: rc.fields.length, rows: await liveRowCount(api, collection) };
1296
+ }
1297
+ async function dropCollection(api, collection) {
1298
+ const res = await api("POST", "/v1/cms/collections", { drop_collection: collection, allow_destructive: true });
1299
+ if ((res.status < 200 || res.status >= 300) && res.body.error?.code !== "not_found") {
1300
+ const e = res.body.error ?? {};
1301
+ throw new Error(`cms: drop collection ${collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
1302
+ }
1303
+ }
1304
+ function formatCollectionDrop(collection, plan, declaredLocally) {
1305
+ const rows = plan.rows === null ? "an unknown number of live rows" : `${plan.rows} live row(s)`;
1306
+ const lines = [
1307
+ ` ! drop collection ${collection} (${plan.fields} field(s), ${rows}) \u2014 the definition is removed; stored item rows are NOT deleted (they become unreachable through the API until a collection of that name exists again)`
1308
+ ];
1309
+ if (declaredLocally) {
1310
+ lines.push(` \u26A0 '${collection}' is still declared in vxil.config.ts \u2014 the next \`vxil push\` re-creates it (empty); remove it from the config too`);
1311
+ }
1312
+ return lines.join("\n");
1313
+ }
1314
+
1315
+ // src/apiCmd.ts
1316
+ var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
1317
+ var API_USAGE = "usage: vxil api <GET|POST|PUT|PATCH|DELETE> </v1/...> ['<json body>' | --data '<json body>']";
1318
+ function parseApiArgs(positionals, dataFlag) {
1319
+ const [rawMethod, path, ...extra] = positionals;
1320
+ const method = (rawMethod ?? "").toUpperCase();
1321
+ if (!rawMethod || !API_METHODS.includes(method)) throw new Error(API_USAGE);
1322
+ if (!path || !path.startsWith("/")) throw new Error(API_USAGE);
1323
+ const positionalBody = extra[0];
1324
+ if (extra.length > 1) throw new Error(`${API_USAGE} \u2014 too many arguments (${extra.length} after the path; quote the JSON body as ONE argument)`);
1325
+ if (positionalBody !== void 0 && dataFlag !== void 0) {
1326
+ throw new Error("pass the body either as the third argument or with --data, not both");
1327
+ }
1328
+ const raw = positionalBody ?? dataFlag;
1329
+ if (raw === void 0) return { method, path };
1330
+ try {
1331
+ return { method, path, body: JSON.parse(raw) };
1332
+ } catch {
1333
+ const via = positionalBody !== void 0 ? "the body argument" : "--data";
1334
+ throw new Error(`${via} must be valid JSON (got ${JSON.stringify(raw.length > 60 ? `${raw.slice(0, 57)}\u2026` : raw)})`);
1335
+ }
1336
+ }
1234
1337
 
1235
1338
  // ../types/src/ulid.ts
1236
1339
  var ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
@@ -1253,12 +1356,79 @@ function newId(prefix) {
1253
1356
  return `${prefix}_${ulid()}`;
1254
1357
  }
1255
1358
 
1359
+ // ../types/src/index.ts
1360
+ var KNOWN_SCOPES = [
1361
+ "features:read",
1362
+ "features:write",
1363
+ // Dedicated write scope for the BYO-secret path. PUT /v1/secrets/:feature/:name
1364
+ // requires THIS (or 'admin') as of 2026-07-17 (audit H2 — 'features:write' is no
1365
+ // longer secret-write-equivalent). GET/DELETE /v1/secrets still also accept
1366
+ // 'features:write'.
1367
+ "secrets:write",
1368
+ "users:read",
1369
+ "users:write",
1370
+ "notifications:send",
1371
+ "notifications:read",
1372
+ "jobs:read",
1373
+ "jobs:write",
1374
+ "auth:read",
1375
+ "auth:write",
1376
+ // Narrow least-privilege scope for the SIGN-IN surface only (2026-09-10,
1377
+ // F4-25/F8-56): every auth route an anonymous or self-authenticating client
1378
+ // must call to obtain, renew, verify or end ITS OWN session (sign-up/sign-in,
1379
+ // magic-link, OTP, anonymous, step-up, OAuth, refresh, by-token revoke,
1380
+ // sessions/verify, password reset). It does NOT reach the administrative
1381
+ // routes — GET /v1/auth/sessions (auth:read), POST /v1/auth/sessions/:id/
1382
+ // revoke and POST /v1/auth/users/:id/erase (auth:write). This is the scope a
1383
+ // key baked into a browser/mobile bundle carries for sign-in; 'auth:write'
1384
+ // still satisfies every route (any-of, the usage:read idiom) so existing keys
1385
+ // keep working.
1386
+ "auth:signin",
1387
+ "ratelimits:read",
1388
+ "ratelimits:write",
1389
+ "ratelimits:check",
1390
+ "files:read",
1391
+ "files:write",
1392
+ "webhooks:read",
1393
+ "webhooks:write",
1394
+ "comments:read",
1395
+ "comments:write",
1396
+ "cms:read",
1397
+ "cms:write",
1398
+ "realtime:read",
1399
+ "realtime:write",
1400
+ "presence:read",
1401
+ "orgs:read",
1402
+ "orgs:write",
1403
+ "payments:read",
1404
+ "payments:write",
1405
+ "vector-search:read",
1406
+ "vector-search:write",
1407
+ "ai:read",
1408
+ "ai:write",
1409
+ "rag:read",
1410
+ "rag:write",
1411
+ "activity-feed:read",
1412
+ "activity-feed:write",
1413
+ "functions:invoke",
1414
+ "functions:read",
1415
+ "functions:write",
1416
+ "copilot:read",
1417
+ "copilot:write",
1418
+ // Narrow least-privilege read for GET /v1/usage (2026-08-05, parity P0#4).
1419
+ // The route ALSO accepts 'features:read' (any-of, the secrets:write idiom)
1420
+ // so pre-existing keys keep working; mint this when a key should see usage
1421
+ // and nothing else.
1422
+ "usage:read",
1423
+ "admin"
1424
+ ];
1425
+
1256
1426
  // ../runtime/src/hash.ts
1257
1427
  async function sha256Hex(input) {
1258
1428
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
1259
1429
  return [...new Uint8Array(digest)].map((b2) => b2.toString(16).padStart(2, "0")).join("");
1260
1430
  }
1261
- async function hmacSha256Hex(secret, data2) {
1431
+ async function hmacSha256Hex(secret, data3) {
1262
1432
  const key = await crypto.subtle.importKey(
1263
1433
  "raw",
1264
1434
  new TextEncoder().encode(secret),
@@ -1266,7 +1436,7 @@ async function hmacSha256Hex(secret, data2) {
1266
1436
  false,
1267
1437
  ["sign"]
1268
1438
  );
1269
- const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data2));
1439
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data3));
1270
1440
  return [...new Uint8Array(sig)].map((b2) => b2.toString(16).padStart(2, "0")).join("");
1271
1441
  }
1272
1442
 
@@ -2198,7 +2368,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
2198
2368
  function drain() {
2199
2369
  !query && onopen(connection2);
2200
2370
  }
2201
- function data2(x) {
2371
+ function data3(x) {
2202
2372
  if (incomings) {
2203
2373
  incomings.push(x);
2204
2374
  remaining -= x.length;
@@ -2252,7 +2422,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
2252
2422
  statementId = Math.random().toString(36).slice(2);
2253
2423
  statementCount = 1;
2254
2424
  lifeTimer.start();
2255
- socket.on("data", data2);
2425
+ socket.on("data", data3);
2256
2426
  keep_alive && socket.setKeepAlive && socket.setKeepAlive(true, 1e3 * keep_alive);
2257
2427
  const s = StartupMessage();
2258
2428
  write(s);
@@ -2295,7 +2465,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
2295
2465
  error(Errors.connection("CONNECTION_DESTROYED", options));
2296
2466
  clearImmediate(nextWriteTimer);
2297
2467
  if (socket) {
2298
- socket.removeListener("data", data2);
2468
+ socket.removeListener("data", data3);
2299
2469
  socket.removeListener("connect", connected);
2300
2470
  socket.readyState === "open" && socket.end(bytes_default().X().end());
2301
2471
  }
@@ -2306,7 +2476,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
2306
2476
  remaining = 0;
2307
2477
  incomings = null;
2308
2478
  clearImmediate(nextWriteTimer);
2309
- socket.removeListener("data", data2);
2479
+ socket.removeListener("data", data3);
2310
2480
  socket.removeListener("connect", connected);
2311
2481
  idleTimer.cancel();
2312
2482
  lifeTimer.cancel();
@@ -2898,14 +3068,14 @@ function Subscribe(postgres2, options) {
2898
3068
  const state2 = {
2899
3069
  lsn: Buffer.concat(x.consistent_point.split("/").map((x2) => Buffer.from(("00000000" + x2).slice(-8), "hex")))
2900
3070
  };
2901
- stream2.on("data", data2);
3071
+ stream2.on("data", data3);
2902
3072
  stream2.on("error", error);
2903
3073
  stream2.on("close", sql2.close);
2904
3074
  return { stream: stream2, state: xs.state };
2905
3075
  function error(e) {
2906
3076
  console.error("Unexpected error during logical streaming - reconnecting", e);
2907
3077
  }
2908
- function data2(x2) {
3078
+ function data3(x2) {
2909
3079
  if (x2[0] === 119) {
2910
3080
  parse(x2.subarray(25), state2, sql2.options.parsers, handle, options.transform);
2911
3081
  } else if (x2[0] === 107 && x2[17]) {
@@ -3080,9 +3250,9 @@ function largeObject(sql, oid, mode = 131072 | 262144) {
3080
3250
  async read(size2) {
3081
3251
  const l = size2 > max ? size2 - max : size2;
3082
3252
  max -= size2;
3083
- const [{ data: data2 }] = await lo.read(l);
3084
- this.push(data2);
3085
- if (data2.length < size2)
3253
+ const [{ data: data3 }] = await lo.read(l);
3254
+ this.push(data3);
3255
+ if (data3.length < size2)
3086
3256
  this.push(null);
3087
3257
  }
3088
3258
  });
@@ -5074,7 +5244,13 @@ var TOOLS = [
5074
5244
  },
5075
5245
  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."
5076
5246
  },
5077
- user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." }
5247
+ user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." },
5248
+ correlation_id: {
5249
+ type: "string",
5250
+ minLength: 1,
5251
+ maxLength: 128,
5252
+ description: "Your own opaque handle for this generation (1..128 chars): stored on the row and echoed on the answer; on mode:job it also rides the job.generation.completed|failed events next to generation_id, so you never need a run\u2192record link."
5253
+ }
5078
5254
  }
5079
5255
  },
5080
5256
  method: "POST",
@@ -5247,6 +5423,8 @@ var TOOLS = [
5247
5423
  event_type: { type: "string", description: "Exact event type filter, e.g. 'subscription.updated'." },
5248
5424
  outcome: { type: "string", enum: ["received", "processed", "error", "sig_failed", "parse_failed", "reprocessed", "ignored", "unowned", "rejected_environment"] },
5249
5425
  environment: { type: "string", enum: ["production", "sandbox"], description: "The provider-reported environment of the delivery (a separate axis from the API key label)." },
5426
+ provider_charge_id: { type: "string", maxLength: 200, description: "Exact match on the normalised charge id (the same provider_charge_id a payments.charge.* event carries): the delivery that recorded that charge plus any refund/dispute against it \u2014 usually one row, so ONE detail read (GET /v1/payments/webhook-events/{eventId}) fetches the verified payload. Rows received before 2026-09-19 carry no charge id." },
5427
+ provider_evt_id: { type: "string", maxLength: 200, description: "Exact match on the provider's own event id (the provider_evt_id every row shows)." },
5250
5428
  since: { type: "string", description: "ISO-8601 lower bound on received_at." },
5251
5429
  cursor: { type: "string", description: "Opaque keyset cursor from a prior page (next_cursor)." },
5252
5430
  limit: { type: "number", default: 50, description: "Page size, clamped 1..100." }
@@ -5254,7 +5432,7 @@ var TOOLS = [
5254
5432
  },
5255
5433
  method: "GET",
5256
5434
  path: "/v1/payments/webhook-events",
5257
- queryArgs: ["provider", "event_type", "outcome", "environment", "since", "cursor", "limit"]
5435
+ queryArgs: ["provider", "event_type", "outcome", "environment", "provider_charge_id", "provider_evt_id", "since", "cursor", "limit"]
5258
5436
  },
5259
5437
  {
5260
5438
  name: "mcp_get_signing_secret",
@@ -5769,6 +5947,10 @@ function partitionConfigChanges(feature, changes, patterns) {
5769
5947
  for (const c of changes) (isIgnored(`config:${feature}.${c.path}`, patterns) ? ignored : kept).push(c);
5770
5948
  return { kept, ignored };
5771
5949
  }
5950
+ function unstoredSecretVerdict(opts) {
5951
+ if (opts.ignored) return "ignored";
5952
+ return opts.strict ? "drift" : "informational";
5953
+ }
5772
5954
  function driftExitCode(r) {
5773
5955
  if (r.errors > 0) return 2;
5774
5956
  return r.drift > 0 ? 1 : 0;
@@ -5825,14 +6007,15 @@ function secretsChecks(refs, stored) {
5825
6007
  }
5826
6008
  return out;
5827
6009
  }
5828
- var MOCK_PROVIDER_NOTE = "notifications provider is 'mock': sends succeed and are recorded, but NO email leaves vxil (sign-in codes, magic links and password resets are silently swallowed). Store your provider key (`vxil secrets set notifications/resend_api_key`) and set provider: 'resend' + resendApiKeyRef in your notifications config before you rely on delivery";
6010
+ var MOCK_PROVIDER_NOTE = "notifications provider is 'mock': sends succeed and are recorded, but NO email leaves vxil (sign-in codes, magic links and password resets are silently swallowed). Store your provider key (`vxil secrets set notifications/resend_api_key`) and set provider: 'resend' + resendApiKeyRef in your notifications config before you rely on delivery (or, for Amazon SES: store notifications/ses_access_key_id + notifications/ses_secret_access_key and set provider: 'ses' + ses: { region, accessKeyIdRef, secretAccessKeyRef })";
5829
6011
  function notificationsProviderChecks(manifest) {
5830
6012
  if (!manifest || manifest.enabled === false) return [];
5831
6013
  if (manifest.provider !== "mock") {
6014
+ const region = manifest.provider === "ses" && manifest.ses?.region ? ` (${manifest.ses.region})` : "";
5832
6015
  return [{
5833
6016
  name: "notifications provider",
5834
6017
  ok: true,
5835
- detail: `${String(manifest.provider ?? "resend")}${manifest.fromEmail ? ` \xB7 from ${manifest.fromEmail}` : ""}`
6018
+ detail: `${String(manifest.provider ?? "resend")}${region}${manifest.fromEmail ? ` \xB7 from ${manifest.fromEmail}` : ""}`
5836
6019
  }];
5837
6020
  }
5838
6021
  return [{ name: "notifications provider", ok: true, warn: true, detail: MOCK_PROVIDER_NOTE }];
@@ -5933,7 +6116,7 @@ function errText(r) {
5933
6116
  const e = r.json.error;
5934
6117
  return e ? `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}` : `${r.status}`;
5935
6118
  }
5936
- async function linkViaSession(dash, cookie, slug, keyName) {
6119
+ async function findTenantViaSession(dash, cookie, slug) {
5937
6120
  const list = await dash("GET", "/dashboard/tenants", void 0, cookie);
5938
6121
  if (list.status === 401) return { unauthorized: true };
5939
6122
  if (list.status !== 200) throw new Error(`could not list projects (${errText(list)})`);
@@ -5944,6 +6127,12 @@ async function linkViaSession(dash, cookie, slug, keyName) {
5944
6127
  `no project '${slug}' on this account \u2014 run \`vxil quickstart\`, or ask an admin to add you as a member`
5945
6128
  );
5946
6129
  }
6130
+ return { tenant: t };
6131
+ }
6132
+ async function linkViaSession(dash, cookie, slug, keyName) {
6133
+ const found = await findTenantViaSession(dash, cookie, slug);
6134
+ if ("unauthorized" in found) return found;
6135
+ const t = found.tenant;
5947
6136
  const mint = await dash("POST", `/dashboard/tenants/${t.id}/api-keys`, { name: keyName }, cookie);
5948
6137
  if (mint.status === 401) return { unauthorized: true };
5949
6138
  if (mint.status !== 201 && mint.status !== 200) {
@@ -5983,6 +6172,164 @@ async function linkViaDeviceFlow(dash, slug, io) {
5983
6172
  }
5984
6173
  throw new Error("link timed out waiting for browser approval \u2014 run `vxil link <slug>` again");
5985
6174
  }
6175
+ async function linkViaStoredKey(api, slug, storedKey) {
6176
+ let res;
6177
+ try {
6178
+ res = await api("GET", "/v1/features");
6179
+ } catch (e2) {
6180
+ return { invalid: `could not reach the edge (${e2.message})` };
6181
+ }
6182
+ if (res.status === 200) {
6183
+ const tenantId = res.body.meta?.tenant_id;
6184
+ return { result: { slug, tenant_id: tenantId && tenantId.length ? tenantId : "(unknown)", api_key: storedKey } };
6185
+ }
6186
+ const e = res.body.error;
6187
+ return { invalid: `${e?.code ?? res.status}${e?.message ? ` \u2014 ${e.message}` : ""}` };
6188
+ }
6189
+
6190
+ // src/keys.ts
6191
+ var MINTABLE_SCOPES = KNOWN_SCOPES.filter((s) => s !== "admin");
6192
+ function data2(r) {
6193
+ return r.json.data ?? r.json;
6194
+ }
6195
+ function errText2(r) {
6196
+ const e = r.json.error;
6197
+ if (!e) return `${r.status}`;
6198
+ return `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}${e.hint ? `
6199
+ hint: ${e.hint}` : ""}`;
6200
+ }
6201
+ function parseScopes(raw) {
6202
+ const list = (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
6203
+ if (!list.length) {
6204
+ return { error: `--scopes is required \u2014 a comma-separated list of scopes, e.g. --scopes cms:read,cms:write
6205
+ valid scopes: ${MINTABLE_SCOPES.join(", ")}` };
6206
+ }
6207
+ const scopes = [...new Set(list)];
6208
+ const god = scopes.filter((s) => s === "admin" || s === "*");
6209
+ if (god.length) {
6210
+ return { error: `'${god.join("', '")}' cannot be minted here (admin-class keys are operator-provisioned only) \u2014 name the feature scopes this key needs, e.g. --scopes cms:read,files:write` };
6211
+ }
6212
+ const unknown = scopes.filter((s) => !MINTABLE_SCOPES.includes(s));
6213
+ if (unknown.length) {
6214
+ return { error: `unknown scope${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}
6215
+ valid scopes: ${MINTABLE_SCOPES.join(", ")}` };
6216
+ }
6217
+ return { scopes };
6218
+ }
6219
+ function parseKeyEnv(raw) {
6220
+ if (raw === void 0) return { env: "live" };
6221
+ if (raw === "live" || raw === "test") return { env: raw };
6222
+ return { error: `--env must be 'live' or 'test' (got '${raw}') \u2014 this is the key's env prefix, not the project's environment label` };
6223
+ }
6224
+ function parseKeyName(raw) {
6225
+ const name = (raw ?? "").trim();
6226
+ if (!name) return { error: "--name is required \u2014 a label for this key, e.g. --name web-worker" };
6227
+ if (name.length > 100) return { error: `--name is at most 100 characters (got ${name.length})` };
6228
+ return { name };
6229
+ }
6230
+ async function mintKeyViaSession(dash, cookie, tenantId, req) {
6231
+ const r = await dash(
6232
+ "POST",
6233
+ `/dashboard/tenants/${tenantId}/api-keys`,
6234
+ { name: req.name, scopes: req.scopes, env: req.env },
6235
+ cookie
6236
+ );
6237
+ if (r.status === 401) return { unauthorized: true };
6238
+ if (r.status !== 201 && r.status !== 200) {
6239
+ throw new Error(`could not mint the key (${errText2(r)})`);
6240
+ }
6241
+ const d = data2(r);
6242
+ if (!d.api_key || !d.key_id) throw new Error("mint returned no api_key");
6243
+ return {
6244
+ result: {
6245
+ key_id: d.key_id,
6246
+ api_key: d.api_key,
6247
+ scopes: d.scopes ?? req.scopes,
6248
+ env: d.env ?? req.env,
6249
+ name: req.name,
6250
+ ...d.prefix ? { prefix: d.prefix } : {}
6251
+ }
6252
+ };
6253
+ }
6254
+ async function listKeysViaSession(dash, cookie, tenantId) {
6255
+ const r = await dash("GET", `/dashboard/tenants/${tenantId}/api-keys`, void 0, cookie);
6256
+ if (r.status === 401) return { unauthorized: true };
6257
+ if (r.status !== 200) throw new Error(`could not list keys (${errText2(r)})`);
6258
+ return { keys: data2(r).keys ?? [] };
6259
+ }
6260
+ async function revokeKeyViaSession(dash, cookie, tenantId, keyId) {
6261
+ const r = await dash("DELETE", `/dashboard/tenants/${tenantId}/api-keys/${encodeURIComponent(keyId)}`, void 0, cookie);
6262
+ if (r.status === 401) return { unauthorized: true };
6263
+ if (r.status === 404) throw new Error(`no active key '${keyId}' on this project (already revoked, or not this project's) \u2014 \`vxil keys list\` shows the ids`);
6264
+ if (r.status !== 200) throw new Error(`could not revoke the key (${errText2(r)})`);
6265
+ return { revoked: true };
6266
+ }
6267
+ function currentKeyIdFor(keys, apiKey) {
6268
+ if (!apiKey) return void 0;
6269
+ const hit = keys.find((k) => typeof k.prefix === "string" && k.prefix.length >= 12 && apiKey.startsWith(k.prefix));
6270
+ return hit?.key_id;
6271
+ }
6272
+ function revokeGuard(args) {
6273
+ const row2 = args.keys.find((k) => k.key_id === args.keyId);
6274
+ const isCurrent = currentKeyIdFor(args.keys, args.currentApiKey) === args.keyId;
6275
+ if (isCurrent && !args.yes) {
6276
+ return {
6277
+ refuse: `'${args.keyId}' is the key this CLI is using for '${args.slug}' (the one in ~/.vxil/credentials.json / VXIL_API_KEY) \u2014 revoking it breaks every vxil verb on this project until you re-link. Pass --yes to revoke it anyway, then run \`vxil link ${args.slug} --mint\` (or \`vxil keys mint \u2026 --store\`) for a replacement.`
6278
+ };
6279
+ }
6280
+ return { ok: true, row: row2, isCurrent };
6281
+ }
6282
+ function mintStdout(r, json2) {
6283
+ return json2 ? JSON.stringify({ api_key: r.api_key, key_id: r.key_id, scopes: r.scopes }) : r.api_key;
6284
+ }
6285
+ function mintNotices(r, json2, opts = {}) {
6286
+ const storage = opts.stored ? "It was stored as this project's CLI key (~/.vxil/credentials.json), replacing the previous one." : "It was NOT stored on this machine (pass --store to make it this project's CLI key).";
6287
+ const storeWarn = opts.storeError !== void 0 ? [`vxil: warning \u2014 --store failed: could not write ~/.vxil/credentials.json (${opts.storeError}). The key (${r.key_id}) was minted and is printed above \u2014 copy it from there now; this machine's CLI key is unchanged. To discard it instead: \`vxil keys revoke ${r.key_id}\`.`] : [];
6288
+ if (json2) {
6289
+ return [
6290
+ `minted '${r.name}' (${r.key_id}, env ${r.env}) \u2014 shown ONCE; it is not retrievable again. ${storage} Revoke with \`vxil keys revoke ${r.key_id}\`.`,
6291
+ ...storeWarn
6292
+ ];
6293
+ }
6294
+ return [
6295
+ `\u2713 minted '${r.name}' (${r.key_id}, env ${r.env}) with scopes [${r.scopes.join(", ")}]`,
6296
+ `This key is shown ONCE and is not retrievable again \u2014 copy it now. ${storage} Revoke with \`vxil keys revoke ${r.key_id}\`.`,
6297
+ ...storeWarn
6298
+ ];
6299
+ }
6300
+ function when(iso) {
6301
+ if (!iso) return "\u2014";
6302
+ const d = new Date(iso);
6303
+ return Number.isNaN(d.getTime()) ? String(iso) : d.toISOString().slice(0, 16).replace("T", " ");
6304
+ }
6305
+ function renderKeyTable(keys, currentKeyId) {
6306
+ if (!keys.length) return ["(no keys on this project)"];
6307
+ const rows = keys.map((k) => ({
6308
+ mark: k.key_id === currentKeyId ? "*" : " ",
6309
+ id: k.key_id,
6310
+ name: k.name ?? "",
6311
+ env: k.env ?? "",
6312
+ status: k.revoked_at ? "revoked" : k.expires_at && new Date(k.expires_at).getTime() < Date.now() ? "expired" : "active",
6313
+ created: when(k.created_at),
6314
+ used: when(k.last_used_at),
6315
+ scopes: (k.scopes ?? []).join(",")
6316
+ }));
6317
+ const w = (f, head2) => Math.max(head2.length, ...rows.map((r) => r[f].length));
6318
+ const cols = [
6319
+ ["id", "KEY_ID"],
6320
+ ["name", "NAME"],
6321
+ ["env", "ENV"],
6322
+ ["status", "STATUS"],
6323
+ ["created", "CREATED"],
6324
+ ["used", "LAST_USED"]
6325
+ ];
6326
+ const line = (r, pad) => `${r.mark ?? " "} ${cols.map(([f, h]) => pad ? r[f].padEnd(w(f, h)) : r[f]).join(" ")} ${r.scopes}`;
6327
+ const head = { mark: " ", scopes: "SCOPES" };
6328
+ for (const [f, h] of cols) head[f] = h;
6329
+ const out = [line(head, true), ...rows.map((r) => line(r, true))];
6330
+ if (currentKeyId) out.push("* = the key this CLI is using for this project");
6331
+ return out;
6332
+ }
5986
6333
 
5987
6334
  // src/secretsValue.ts
5988
6335
  var ARGV_SECRET_WARNING = `WARNING: --value <v> puts the secret on the command line \u2014 it lands in shell history and is
@@ -7028,7 +7375,7 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
7028
7375
  result.skipped++;
7029
7376
  continue;
7030
7377
  }
7031
- const data2 = {};
7378
+ const data3 = {};
7032
7379
  let rowErr;
7033
7380
  for (const f of mapping.fields) {
7034
7381
  const raw = row2[f.source];
@@ -7044,11 +7391,11 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
7044
7391
  unresolvedOptional.set(f.target, (unresolvedOptional.get(f.target) ?? 0) + 1);
7045
7392
  continue;
7046
7393
  }
7047
- data2[f.target] = mapped;
7394
+ data3[f.target] = mapped;
7048
7395
  continue;
7049
7396
  }
7050
7397
  if (f.storageObject) {
7051
- data2[f.target] = resolveObjectId(String(raw), filesMaps) ?? String(raw);
7398
+ data3[f.target] = resolveObjectId(String(raw), filesMaps) ?? String(raw);
7052
7399
  continue;
7053
7400
  }
7054
7401
  const coerced = coerceValue(raw, f);
@@ -7056,7 +7403,7 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
7056
7403
  rowErr = coerced.err;
7057
7404
  break;
7058
7405
  }
7059
- data2[f.target] = coerced.value;
7406
+ data3[f.target] = coerced.value;
7060
7407
  }
7061
7408
  if (rowErr) {
7062
7409
  result.skipped++;
@@ -7065,8 +7412,8 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
7065
7412
  continue;
7066
7413
  }
7067
7414
  if (deps.dryRun) continue;
7068
- if (dedupeField && data2[dedupeField.target] !== void 0) {
7069
- const filter = encodeURIComponent(JSON.stringify({ [dedupeField.target]: data2[dedupeField.target] }));
7415
+ if (dedupeField && data3[dedupeField.target] !== void 0) {
7416
+ const filter = encodeURIComponent(JSON.stringify({ [dedupeField.target]: data3[dedupeField.target] }));
7070
7417
  const q = await deps.api("GET", `/v1/cms/items/${enc}?filter=${filter}&limit=1`);
7071
7418
  const items = q.body.data?.items ?? [];
7072
7419
  if (q.status === 200 && items.length > 0 && typeof items[0].item_id === "string") {
@@ -7075,7 +7422,7 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
7075
7422
  continue;
7076
7423
  }
7077
7424
  }
7078
- const res = await deps.api("POST", `/v1/cms/items/${enc}`, { data: data2, status: itemStatus });
7425
+ const res = await deps.api("POST", `/v1/cms/items/${enc}`, { data: data3, status: itemStatus });
7079
7426
  if (res.status === 201 || res.status === 200) {
7080
7427
  const itemId = res.body.data?.item_id;
7081
7428
  if (typeof itemId !== "string") {
@@ -9367,8 +9714,8 @@ export default defineConfig({
9367
9714
  ],
9368
9715
  "hasFunctions": false,
9369
9716
  "byoKeys": [],
9370
- "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Sales CRM\" \u2014 companies \u2192 contacts \u2192 a deal pipeline, declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 companies \u2192 contacts \u2192 deals (resolved by relation), with a\n// Lane-A STATE MACHINE guarding the pipeline stage\n// \u2022 activity-feed \u2192 the per-deal timeline (calls, emails, notes) + the rep bell\n// \u2022 notifications \u2192 the email channel (mock provider until you bring a key)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // THE PIPELINE STATE MACHINE (Lane-A validate \u2014 runs inside the write\n // transaction): a deal may only move lead \u2192 qualified \u2192 proposal \u2192\n // won | lost. Any other transition is a clean 422, rolled back\n // atomically. Cross-row work (e.g. \"email the rep on won\") belongs in\n // a function or webhook, never in a hook \u2014 hooks are pure row logic.\n deal_stage_machine: {\n collection: 'deals',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.stage == before.stage' +\n \" || (before.stage == 'lead' && item.stage == 'qualified')\" +\n \" || (before.stage == 'qualified' && item.stage == 'proposal')\" +\n \" || (before.stage == 'proposal' && (item.stage == 'won' || item.stage == 'lost'))\",\n message: 'illegal pipeline transition (lead \u2192 qualified \u2192 proposal \u2192 won|lost)',\n },\n },\n },\n\n // The per-deal timeline. The DEFAULT feed groups already fit a CRM:\n // `timeline` (flat, chronological) keyed per deal \u2014 ingest with\n // POST /v1/feeds/timeline/{deal_item_id}/activities { actor, verb, object }\n // \u2014 and the aggregated `notification` bell per rep.\n 'activity-feed': {},\n\n // Email channel. `mock` is the zero-config staging provider; for real\n // sends switch to provider: 'resend' and set `resendApiKeyRef`.\n notifications: { provider: 'mock', fromEmail: 'noreply@crm.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n companies: {\n singular: 'company',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // Want `domain` unique? Declare `unique: true` right here \u2014 value\n // uniqueness (cms.md \xA79.3) is carried by `vxil push` on collection/field\n // CREATE. Flipping it on an ALREADY-pushed field is an in-place alter\n // via the dashboard field designer or a same-name+type re-add through\n // POST /v1/cms/collections/companies/fields (push diffs name+type only).\n domain: { type: 'string', indexSlot: 's2' },\n industry: { type: 'string', indexSlot: 's3' },\n employees: { type: 'int', indexSlot: 'n1' },\n },\n },\n contacts: {\n singular: 'contact',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n email: { type: 'string', indexSlot: 's2' }, // unique \u21D2 see the `domain` note above\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n last_touch: { type: 'datetime', indexSlot: 't1' },\n phone: { type: 'string' }, // stored, not indexed\n },\n },\n deals: {\n singular: 'deal',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // The pipeline stage: enum-validated on every write; the Lane-A hook\n // above additionally locks the TRANSITIONS between stages.\n stage: {\n type: 'string',\n required: true,\n indexSlot: 's2',\n validation: { enum: ['lead', 'qualified', 'proposal', 'won', 'lost'] },\n },\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n owner: { type: 'string', indexSlot: 's4' }, // the rep working the deal\n amount_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n close_date: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n },\n },\n\n // `vxil seed` POSTs each item verbatim \u2014 item_ids are assigned at runtime, so\n // the `company` relation on contacts/deals is left unset here; link records\n // after seeding (PATCH the created items with the company's item_id).\n seed: {\n cms: [\n {\n collection: 'companies',\n items: [{ name: 'Acme Corp', domain: 'acme.com', industry: 'Manufacturing', employees: 250 }],\n },\n {\n collection: 'contacts',\n items: [{ name: 'Jane Porter', email: 'jane@acme.com', phone: '+1 555 0100' }],\n },\n {\n collection: 'deals',\n items: [\n { title: 'Acme starter plan', stage: 'lead', owner: 'jordan', amount_cents: 480_000 },\n { title: 'Acme enterprise rollout', stage: 'proposal', owner: 'sam', amount_cents: 12_000_000 },\n ],\n },\n ],\n },\n});\n",
9371
- "readme": '# Sales CRM template\n\nA sales CRM backend \u2014 companies, contacts, and a deal pipeline \u2014 declared end-to-end in one typed\n`vxil.config.ts`. Stage transitions are guarded by a Lane-A state machine that runs inside the write\ntransaction, every deal gets an activity timeline via `activity-feed`, and `notifications` provides\nthe email channel (mock provider until you bring your own key).\n\n**What it provisions:**\n- `companies` \u2014 name, domain, industry, employee count (slot-bound for range/sort). To make\n `domain`/`email` unique, declare `unique: true` on the field \u2014 carried by `vxil push` on create\n (`cms.md` \xA79.3); flipping it on an already-pushed field is a dashboard/REST in-place alter.\n- `contacts` \u2014 name, email, `company` relation, `last_touch` datetime.\n- `deals` \u2014 title, enum-validated `stage`, `company` relation, `owner` (the rep), `amount_cents`,\n `close_date`. A Lane-A `validate` hook locks the pipeline: `lead \u2192 qualified \u2192 proposal \u2192 won|lost`.\n- Features: `cms` + `activity-feed` (per-deal timelines + the rep notification bell, default feed\n groups) + `notifications` (mock provider; swap to `resend` + `resendApiKeyRef` for real sends).\n\n**Apply it:**\n\n```bash\nvxil init --template crm\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen # then optionally: `vxil seed` (1 company, 1 contact, 2 deals)\n```\n\n**What to learn from this:**\n1. **The pipeline state machine** \u2014 a `validate` hook on `beforeUpdate` compares `item.stage` to\n `before.stage` and rejects any illegal transition with a clean 422, atomically in the same write.\n Field-level `validation.enum` handles membership; the hook handles the *transitions*.\n2. **Pipeline value by stage** \u2014 one bounded group-by aggregate (`stage` is slot-bound, `amount_cents`\n is an `n*` slot), no SQL:\n ```bash\n curl -X POST https://api.vxil.com/v1/cms/items/deals/aggregate \\\n -H "Authorization: Bearer $VXIL_KEY" -H \'content-type: application/json\' \\\n -d \'{"aggregates":[{"fn":"sum","field":"amount_cents","as":"pipeline_cents"},{"fn":"count"}],"groupBy":["stage"]}\'\n # \u2192 { "groups": [{ "key": {"stage":"proposal"}, "count": 1, "pipeline_cents": 12000000 }, \u2026], "scanned": \u2026 }\n ```\n3. **The deal timeline** \u2014 each call/email/note is one ingest to the default `timeline` feed group:\n `POST /v1/feeds/timeline/{deal_item_id}/activities` with `{ actor, verb, object }` (idempotent on\n `foreign_id` + `time`); read it back with keyset cursors, or typed via `vx.from(\'deals\').query(\u2026)`\n for the pipeline board itself.\n\n**Go deeper:** vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (aggregates), vxil.com/docs/guide/06-feature-catalog (activity-feed,\nnotifications), and `examples/ecommerce/` for a bigger state machine in the wild.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
9717
+ "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Sales CRM\" \u2014 companies \u2192 contacts \u2192 a deal pipeline, declared end-to-end in\n// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 companies \u2192 contacts \u2192 deals (resolved by relation), with a\n// Lane-A STATE MACHINE guarding the pipeline stage\n// \u2022 activity-feed \u2192 the per-deal timeline (calls, emails, notes) + the rep bell\n// \u2022 notifications \u2192 the email channel (mock provider until you bring a key)\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n hooks: {\n // THE PIPELINE STATE MACHINE (Lane-A validate \u2014 runs inside the write\n // transaction): a deal may only move lead \u2192 qualified \u2192 proposal \u2192\n // won | lost. Any other transition is a clean 422, rolled back\n // atomically. Cross-row work (e.g. \"email the rep on won\") belongs in\n // a function or webhook, never in a hook \u2014 hooks are pure row logic.\n deal_stage_machine: {\n collection: 'deals',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'item.stage == before.stage' +\n \" || (before.stage == 'lead' && item.stage == 'qualified')\" +\n \" || (before.stage == 'qualified' && item.stage == 'proposal')\" +\n \" || (before.stage == 'proposal' && (item.stage == 'won' || item.stage == 'lost'))\",\n message: 'illegal pipeline transition (lead \u2192 qualified \u2192 proposal \u2192 won|lost)',\n },\n },\n },\n\n // The per-deal timeline. The DEFAULT feed groups already fit a CRM:\n // `timeline` (flat, chronological) keyed per deal \u2014 ingest with\n // POST /v1/feeds/timeline/{deal_item_id}/activities { actor, verb, object }\n // \u2014 and the aggregated `notification` bell per rep.\n 'activity-feed': {},\n\n // Email channel. `mock` is the zero-config staging provider; for real\n // sends switch to provider: 'resend' and set `resendApiKeyRef`, or to\n // provider: 'ses' with `ses: { region, accessKeyIdRef, secretAccessKeyRef }`.\n notifications: { provider: 'mock', fromEmail: 'noreply@crm.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n companies: {\n singular: 'company',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n // Want `domain` unique? Declare `unique: true` right here \u2014 value\n // uniqueness (cms.md \xA79.3) is carried by `vxil push` on collection/field\n // CREATE. Flipping it on an ALREADY-pushed field is an in-place alter\n // via the dashboard field designer or a same-name+type re-add through\n // POST /v1/cms/collections/companies/fields (push diffs name+type only).\n domain: { type: 'string', indexSlot: 's2' },\n industry: { type: 'string', indexSlot: 's3' },\n employees: { type: 'int', indexSlot: 'n1' },\n },\n },\n contacts: {\n singular: 'contact',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n email: { type: 'string', indexSlot: 's2' }, // unique \u21D2 see the `domain` note above\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n last_touch: { type: 'datetime', indexSlot: 't1' },\n phone: { type: 'string' }, // stored, not indexed\n },\n },\n deals: {\n singular: 'deal',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n // The pipeline stage: enum-validated on every write; the Lane-A hook\n // above additionally locks the TRANSITIONS between stages.\n stage: {\n type: 'string',\n required: true,\n indexSlot: 's2',\n validation: { enum: ['lead', 'qualified', 'proposal', 'won', 'lost'] },\n },\n company: { type: 'relation', relationTo: 'companies', indexSlot: 's3' },\n owner: { type: 'string', indexSlot: 's4' }, // the rep working the deal\n amount_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n close_date: { type: 'datetime', indexSlot: 't1' },\n notes: { type: 'text' },\n },\n },\n },\n },\n\n // `vxil seed` POSTs each item verbatim \u2014 item_ids are assigned at runtime, so\n // the `company` relation on contacts/deals is left unset here; link records\n // after seeding (PATCH the created items with the company's item_id).\n seed: {\n cms: [\n {\n collection: 'companies',\n items: [{ name: 'Acme Corp', domain: 'acme.com', industry: 'Manufacturing', employees: 250 }],\n },\n {\n collection: 'contacts',\n items: [{ name: 'Jane Porter', email: 'jane@acme.com', phone: '+1 555 0100' }],\n },\n {\n collection: 'deals',\n items: [\n { title: 'Acme starter plan', stage: 'lead', owner: 'jordan', amount_cents: 480_000 },\n { title: 'Acme enterprise rollout', stage: 'proposal', owner: 'sam', amount_cents: 12_000_000 },\n ],\n },\n ],\n },\n});\n",
9718
+ "readme": '# Sales CRM template\n\nA sales CRM backend \u2014 companies, contacts, and a deal pipeline \u2014 declared end-to-end in one typed\n`vxil.config.ts`. Stage transitions are guarded by a Lane-A state machine that runs inside the write\ntransaction, every deal gets an activity timeline via `activity-feed`, and `notifications` provides\nthe email channel (mock provider until you bring your own key).\n\n**What it provisions:**\n- `companies` \u2014 name, domain, industry, employee count (slot-bound for range/sort). To make\n `domain`/`email` unique, declare `unique: true` on the field \u2014 carried by `vxil push` on create\n (`cms.md` \xA79.3); flipping it on an already-pushed field is a dashboard/REST in-place alter.\n- `contacts` \u2014 name, email, `company` relation, `last_touch` datetime.\n- `deals` \u2014 title, enum-validated `stage`, `company` relation, `owner` (the rep), `amount_cents`,\n `close_date`. A Lane-A `validate` hook locks the pipeline: `lead \u2192 qualified \u2192 proposal \u2192 won|lost`.\n- Features: `cms` + `activity-feed` (per-deal timelines + the rep notification bell, default feed\n groups) + `notifications` (mock provider; swap to `resend` + `resendApiKeyRef`, or `ses` +\n `ses: { region, accessKeyIdRef, secretAccessKeyRef }`, for real sends).\n\n**Apply it:**\n\n```bash\nvxil init --template crm\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen # then optionally: `vxil seed` (1 company, 1 contact, 2 deals)\n```\n\n**What to learn from this:**\n1. **The pipeline state machine** \u2014 a `validate` hook on `beforeUpdate` compares `item.stage` to\n `before.stage` and rejects any illegal transition with a clean 422, atomically in the same write.\n Field-level `validation.enum` handles membership; the hook handles the *transitions*.\n2. **Pipeline value by stage** \u2014 one bounded group-by aggregate (`stage` is slot-bound, `amount_cents`\n is an `n*` slot), no SQL:\n ```bash\n curl -X POST https://api.vxil.com/v1/cms/items/deals/aggregate \\\n -H "Authorization: Bearer $VXIL_KEY" -H \'content-type: application/json\' \\\n -d \'{"aggregates":[{"fn":"sum","field":"amount_cents","as":"pipeline_cents"},{"fn":"count"}],"groupBy":["stage"]}\'\n # \u2192 { "groups": [{ "key": {"stage":"proposal"}, "count": 1, "pipeline_cents": 12000000 }, \u2026], "scanned": \u2026 }\n ```\n3. **The deal timeline** \u2014 each call/email/note is one ingest to the default `timeline` feed group:\n `POST /v1/feeds/timeline/{deal_item_id}/activities` with `{ actor, verb, object }` (idempotent on\n `foreign_id` + `time`); read it back with keyset cursors, or typed via `vx.from(\'deals\').query(\u2026)`\n for the pipeline board itself.\n\n**Go deeper:** vxil.com/docs/guide/07-validation-and-hooks (hooks), vxil.com/docs/api (aggregates), vxil.com/docs/guide/06-feature-catalog (activity-feed,\nnotifications), and `examples/ecommerce/` for a bigger state machine in the wild.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n',
9372
9719
  "functions": {}
9373
9720
  },
9374
9721
  {
@@ -11006,7 +11353,7 @@ function printEnvelope({ status, body }) {
11006
11353
  async function prompt(q, opts = {}) {
11007
11354
  const stdin = process.stdin;
11008
11355
  if (opts.hidden && stdin.isTTY && stdin.setRawMode) {
11009
- process.stdout.write(q);
11356
+ process.stderr.write(q);
11010
11357
  return await new Promise((resolve8) => {
11011
11358
  stdin.setRawMode(true);
11012
11359
  stdin.resume();
@@ -11017,11 +11364,11 @@ async function prompt(q, opts = {}) {
11017
11364
  stdin.setRawMode(false);
11018
11365
  stdin.pause();
11019
11366
  stdin.removeListener("data", onData);
11020
- process.stdout.write("\n");
11367
+ process.stderr.write("\n");
11021
11368
  resolve8(val);
11022
11369
  } else if (ch === "") {
11023
11370
  stdin.setRawMode(false);
11024
- process.stdout.write("\n");
11371
+ process.stderr.write("\n");
11025
11372
  process.exit(130);
11026
11373
  } else if (ch === "\x7F" || ch === "\b") {
11027
11374
  val = val.slice(0, -1);
@@ -11032,7 +11379,7 @@ async function prompt(q, opts = {}) {
11032
11379
  stdin.on("data", onData);
11033
11380
  });
11034
11381
  }
11035
- const rl = createInterface({ input: process.stdin, output: process.stdout });
11382
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
11036
11383
  return await new Promise((res) => rl.question(q, (a) => {
11037
11384
  rl.close();
11038
11385
  res(a.trim());
@@ -11122,6 +11469,39 @@ async function resolveDashCookie(dash) {
11122
11469
  if (!cookie) fail("login succeeded but returned no session cookie");
11123
11470
  return cookie;
11124
11471
  }
11472
+ async function interactiveLogin(dash) {
11473
+ const email = flag("email") || process.env.VXIL_DEV_EMAIL || void 0;
11474
+ const password = flag("password") || process.env.VXIL_DEV_PASSWORD || void 0;
11475
+ const mustPrompt = !email || !password;
11476
+ if (mustPrompt && (!process.stdin.isTTY || !process.stdout.isTTY || jsonOut)) {
11477
+ fail("need a dashboard session \u2014 run `vxil login` first (or pass --email/--password, or set VXIL_DEV_EMAIL/VXIL_DEV_PASSWORD); a non-interactive run cannot prompt");
11478
+ }
11479
+ const e = email ?? await prompt("dashboard email: ");
11480
+ const p = password ?? await prompt("password: ", { hidden: true });
11481
+ const r = await dash("POST", "/dashboard/login", { email: e, password: p });
11482
+ if (r.status !== 200) fail(`login failed: ${r.status} ${JSON.stringify(r.json).slice(0, 160)}`);
11483
+ const cookie = r.cookie?.split(";")[0];
11484
+ if (!cookie) fail("login succeeded but returned no session cookie");
11485
+ if (mustPrompt) {
11486
+ const creds = loadCredentials();
11487
+ creds.session = cookie;
11488
+ saveCredentials(creds);
11489
+ console.error("\u2713 signed in \u2014 session stored in ~/.vxil/credentials.json");
11490
+ }
11491
+ return cookie;
11492
+ }
11493
+ async function withDashSession(dash, run) {
11494
+ const creds = loadCredentials();
11495
+ if (creds.session) {
11496
+ const r2 = await run(creds.session);
11497
+ if (!("unauthorized" in r2)) return r2;
11498
+ console.error("dashboard session expired \u2014 sign in again\u2026");
11499
+ }
11500
+ const cookie = await interactiveLogin(dash);
11501
+ const r = await run(cookie);
11502
+ if ("unauthorized" in r) fail("the dashboard rejected the fresh session \u2014 run `vxil login` and retry");
11503
+ return r;
11504
+ }
11125
11505
  function requireApi(sel = "prod", opts = {}) {
11126
11506
  const t = resolveTargetOrFail(sel, opts.exitCode ?? 1);
11127
11507
  if (!t.apiKey) fail(noKeyMessage(t), opts.exitCode ?? 1);
@@ -11211,6 +11591,22 @@ async function runApply(apply, sel = "prod", opts = {}) {
11211
11591
  process.exit(2);
11212
11592
  }
11213
11593
  const cwd = process.cwd();
11594
+ const declaredFns = Object.keys(cfg.functions ?? {});
11595
+ const skipFunctions = hasFlag("skip-functions");
11596
+ if (declaredFns.length && !skipFunctions && !gate) {
11597
+ const ent = await probeFunctionsEntitlement(api, declaredFns[0]);
11598
+ if (!ent.entitled) {
11599
+ const msg = formatFunctionsPreflight(ent, {
11600
+ declared: declaredFns.length,
11601
+ baseUrl: target.baseUrl,
11602
+ verb: apply ? "push" : "plan",
11603
+ ...target.tenantId ? { tenantId: target.tenantId } : {}
11604
+ });
11605
+ if (apply) fail(`${msg}
11606
+ (nothing was pushed)`);
11607
+ console.error(`vxil: warning \u2014 ${msg}`);
11608
+ }
11609
+ }
11214
11610
  const notCompared = (u, hint) => {
11215
11611
  console.log(formatNotCompared(u, hint));
11216
11612
  if (gate) report.errors++;
@@ -11281,6 +11677,11 @@ ${r.feature} (remote v${r.version}):`);
11281
11677
  }
11282
11678
  return;
11283
11679
  }
11680
+ if (skipFunctions) {
11681
+ console.log(`
11682
+ functions: skipped (--skip-functions) \u2014 ${declaredFns.length} declared function(s) not ${apply ? "deployed" : "compared"}`);
11683
+ return;
11684
+ }
11284
11685
  const r = await planFunctions({ api, functions: cfg.functions, cwd, apply });
11285
11686
  console.log("\nfunctions:");
11286
11687
  if (r.remoteUnavailable) {
@@ -11289,6 +11690,9 @@ ${r.feature} (remote v${r.version}):`);
11289
11690
  }
11290
11691
  console.log(formatFnChanges(r.changes));
11291
11692
  if (apply && r.applied) console.log(` \u2713 deployed ${r.applied} function(s)`);
11693
+ for (const w of r.warnings ?? []) {
11694
+ console.error(` ! ${w.fn}: ${w.message}`);
11695
+ }
11292
11696
  if (apply && r.cmsHookSubscriptions && (r.cmsHookSubscriptions.created || r.cmsHookSubscriptions.deleted)) {
11293
11697
  console.log(` \u2713 cms-hook subscription reconciled (+${r.cmsHookSubscriptions.created} / -${r.cmsHookSubscriptions.deleted})`);
11294
11698
  }
@@ -11298,6 +11702,8 @@ ${r.feature} (remote v${r.version}):`);
11298
11702
  for (const c of r.changes) if (c.kind !== "unchanged") count(`function:${c.name}`);
11299
11703
  compared.functions += Object.keys(cfg.functions).length;
11300
11704
  });
11705
+ const strictSecrets = hasFlag("strict-secrets");
11706
+ const informationalKeys = [];
11301
11707
  if (!apply && (gate || explain)) {
11302
11708
  await runSection("secrets", async () => {
11303
11709
  const refs = collectSecretRefs(cfg);
@@ -11314,9 +11720,16 @@ ${r.feature} (remote v${r.version}):`);
11314
11720
  const cmp = compareSecretNames(refs, stored);
11315
11721
  if (!cmp.missing.length && !cmp.unreferenced.length) console.log(` (in sync \u2014 ${cmp.present.length} ref(s), all stored)`);
11316
11722
  for (const m of cmp.missing) {
11317
- const ign = isIgnored(`secret:${m.name}`, ignore);
11318
- console.log(` ${ign ? "\xB7" : "-"} secret:${m.name}${m.feature ? ` (${m.feature})` : ""} referenced at ${m.via} but NOT stored on the target` + (m.stored_elsewhere?.length ? ` (stored under ${m.stored_elsewhere.join(", ")} instead)` : "") + (ign ? " \u2014 ignored" : ""));
11319
- count(`secret:${m.name}`);
11723
+ const key = `secret:${m.name}`;
11724
+ const verdict = unstoredSecretVerdict({ strict: strictSecrets, ignored: isIgnored(key, ignore) });
11725
+ const mark = verdict === "drift" ? "-" : "\xB7";
11726
+ console.log(` ${mark} ${key}${m.feature ? ` (${m.feature})` : ""} referenced at ${m.via} but NOT stored on the target` + (m.stored_elsewhere?.length ? ` (stored under ${m.stored_elsewhere.join(", ")} instead)` : "") + (verdict === "ignored" ? " \u2014 ignored" : verdict === "informational" ? " \u2014 informational (not counted; --strict-secrets counts it)" : ""));
11727
+ if (verdict === "informational") {
11728
+ report.informational = (report.informational ?? 0) + 1;
11729
+ informationalKeys.push(key);
11730
+ } else {
11731
+ count(key);
11732
+ }
11320
11733
  }
11321
11734
  for (const u of cmp.unreferenced) console.log(` \xB7 ${u.feature}/${u.secret_name} stored but not referenced by this config (informational)`);
11322
11735
  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>`");
@@ -11367,14 +11780,19 @@ api state \u2014 ${self.target.tenantSlug ?? "(env key)"} vs ${other.target.tena
11367
11780
  exit_code: code,
11368
11781
  target: target.tenantSlug ?? null,
11369
11782
  env: target.envLabel,
11370
- ...report,
11783
+ drift: report.drift,
11784
+ ignored: report.ignored,
11785
+ errors: report.errors,
11786
+ informational: report.informational ?? 0,
11371
11787
  compared,
11372
11788
  ignored_keys: ignoredKeys,
11789
+ informational_keys: informationalKeys,
11790
+ strict_secrets: strictSecrets,
11373
11791
  ...opts.apiState ? { api_state: apiStateCounts } : {}
11374
11792
  }));
11375
11793
  } else {
11376
11794
  console.log(`
11377
- vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${report.errors} error(s) \u2014 compared ${formatCompared(compared)} \u2014 exit ${code}`);
11795
+ vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${report.informational ?? 0} informational, ${report.errors} error(s) \u2014 compared ${formatCompared(compared)} \u2014 exit ${code}`);
11378
11796
  }
11379
11797
  process.exit(code);
11380
11798
  }
@@ -11382,7 +11800,7 @@ vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${rep
11382
11800
  if (apply && !hasFlag("no-gen")) await runGen(sel);
11383
11801
  }
11384
11802
  async function runServerApply(sel) {
11385
- const { api } = requireApi(sel, { write: "push --server" });
11803
+ const { api, target } = requireApi(sel, { write: "push --server" });
11386
11804
  const resume = flag("resume");
11387
11805
  if (resume) {
11388
11806
  const done2 = await serverApply({ api, resume });
@@ -11394,8 +11812,26 @@ apply ${done2.apply_id}: ${done2.status}`);
11394
11812
  if (done2.status === "succeeded" && !hasFlag("no-gen")) await runGen(sel);
11395
11813
  return;
11396
11814
  }
11397
- const cfg = await loadVxilConfig();
11815
+ const loaded = await loadVxilConfig();
11398
11816
  const cwd = process.cwd();
11817
+ const { functions: declaredFns, ...rest2 } = loaded;
11818
+ const cfg = hasFlag("skip-functions") ? rest2 : loaded;
11819
+ const fnNames = Object.keys(cfg.functions ?? {});
11820
+ if (hasFlag("skip-functions") && declaredFns && Object.keys(declaredFns).length) {
11821
+ console.log(`functions: skipped (--skip-functions) \u2014 ${Object.keys(declaredFns).length} declared function(s) not deployed`);
11822
+ }
11823
+ if (fnNames.length) {
11824
+ const ent = await probeFunctionsEntitlement(api, fnNames[0]);
11825
+ if (!ent.entitled) {
11826
+ fail(`${formatFunctionsPreflight(ent, {
11827
+ declared: fnNames.length,
11828
+ baseUrl: target.baseUrl,
11829
+ verb: "push",
11830
+ ...target.tenantId ? { tenantId: target.tenantId } : {}
11831
+ })}
11832
+ (nothing was applied)`);
11833
+ }
11834
+ }
11399
11835
  const fnSources = {};
11400
11836
  for (const [name, def] of Object.entries(cfg.functions ?? {})) {
11401
11837
  fnSources[name] = (await bundleFunction(resolve7(cwd, def.entry), { projectRoot: cwd })).source;
@@ -11968,7 +12404,7 @@ try {
11968
12404
  }
11969
12405
  case "link": {
11970
12406
  const slug = positional(0)[0];
11971
- if (!slug) fail("usage: vxil link <slug> [--key <api_key>] [--tenant <id>] [--as dev|<target-name>] [--env <label>]");
12407
+ if (!slug) fail("usage: vxil link <slug> [--key <api_key>] [--tenant <id>] [--as dev|<target-name>] [--env <label>] [--mint]");
11972
12408
  const base = process.env.VXIL_BASE_URL ?? DEFAULT_BASE;
11973
12409
  const directKey = flag("key");
11974
12410
  if (directKey) {
@@ -11977,8 +12413,19 @@ try {
11977
12413
  console.log(`\u2713 linked '${slug}' ${bound} (using provided --key)`);
11978
12414
  break;
11979
12415
  }
11980
- const dash = makeDash(dashboardBase(base));
11981
12416
  const creds = loadCredentials();
12417
+ const stored = creds.keys?.[slug];
12418
+ if (stored && !hasFlag("mint")) {
12419
+ const r = await linkViaStoredKey(makeApi({ apiKey: stored, baseUrl: base }), slug, stored);
12420
+ if ("result" in r) {
12421
+ const tenantId = flag("tenant") ?? r.result.tenant_id;
12422
+ const bound = persistLink(slug, tenantId, stored, base);
12423
+ console.log(`\u2713 linked '${slug}' ${bound} (using the key already stored for this slug in ~/.vxil/credentials.json \u2014 pass --mint for a new one)`);
12424
+ break;
12425
+ }
12426
+ console.error(`stored key for '${slug}' was refused (${r.invalid}) \u2014 minting a new one\u2026`);
12427
+ }
12428
+ const dash = makeDash(dashboardBase(base));
11982
12429
  const keyName = `cli-${hostname()}`.slice(0, 100);
11983
12430
  if (creds.session) {
11984
12431
  const r = await linkViaSession(dash, creds.session, slug, keyName);
@@ -12003,6 +12450,102 @@ try {
12003
12450
  }
12004
12451
  break;
12005
12452
  }
12453
+ case "keys": {
12454
+ const [sub, arg] = positional(0);
12455
+ const KEYS_USAGE = "usage: vxil keys mint --name <name> --scopes <a,b,c> [--env live|test] [--store] [--json] \xB7 vxil keys list [--json] \xB7 vxil keys revoke <key_id> [--yes]";
12456
+ if (sub === "help" || hasFlag("help")) {
12457
+ console.log(`${KEYS_USAGE}
12458
+ mint a NARROW scoped key for a server on the linked project \u2014 scopes are validated
12459
+ locally (admin refused); the key prints ONCE on stdout (--json \u2192 { api_key, key_id,
12460
+ scopes }) and is stored as this machine's CLI key only under --store
12461
+ list names, ids, scopes, env, created, last-used \u2014 never a secret; * marks the CLI's key
12462
+ revoke type the key id to confirm (--yes for scripts); refuses the CLI's own key unless --yes
12463
+ all three need a dashboard session: the one \`vxil login\` stored, or a sign-in at the terminal
12464
+ (--email/--password for a script; that session is not stored). A piped / --json run never prompts
12465
+ valid scopes: ${MINTABLE_SCOPES.join(", ")}`);
12466
+ break;
12467
+ }
12468
+ if (sub !== "mint" && sub !== "list" && sub !== "revoke") fail(KEYS_USAGE);
12469
+ const mintArgs = sub === "mint" ? (() => {
12470
+ const name = parseKeyName(flag("name"));
12471
+ if ("error" in name) fail(name.error);
12472
+ const scopes = parseScopes(flag("scopes"));
12473
+ if ("error" in scopes) fail(scopes.error);
12474
+ const env = parseKeyEnv(flag("env"));
12475
+ if ("error" in env) fail(env.error);
12476
+ return { name: name.name, scopes: scopes.scopes, env: env.env };
12477
+ })() : void 0;
12478
+ const t = resolveTargetOrFail(selector());
12479
+ if (!t.tenantSlug) fail("no linked project \u2014 run `vxil link <slug>` (or `vxil quickstart`) first; keys are minted on the bound project");
12480
+ const slug = t.tenantSlug;
12481
+ const dash = makeDash(dashboardBase(t.baseUrl));
12482
+ const run = (fn) => withDashSession(dash, async (cookie) => {
12483
+ let id = t.tenantId && t.tenantId !== "(unknown)" ? t.tenantId : void 0;
12484
+ if (!id) {
12485
+ const f = await findTenantViaSession(dash, cookie, slug);
12486
+ if ("unauthorized" in f) return f;
12487
+ id = f.tenant.id;
12488
+ }
12489
+ return fn(cookie, id);
12490
+ });
12491
+ if (mintArgs) {
12492
+ announceTarget(t, "keys mint");
12493
+ const { result } = await run((cookie, id) => mintKeyViaSession(dash, cookie, id, mintArgs));
12494
+ console.log(mintStdout(result, jsonOut));
12495
+ let stored = false;
12496
+ let storeError;
12497
+ if (hasFlag("store")) {
12498
+ try {
12499
+ const creds = loadCredentials();
12500
+ creds.keys = { ...creds.keys, [slug]: result.api_key };
12501
+ saveCredentials(creds);
12502
+ stored = true;
12503
+ } catch (e) {
12504
+ storeError = e instanceof Error ? e.message : String(e);
12505
+ }
12506
+ }
12507
+ for (const l of mintNotices(result, jsonOut, { stored, ...storeError !== void 0 ? { storeError } : {} })) console.error(l);
12508
+ if (result.env !== mintArgs.env) {
12509
+ console.error(`vxil: warning \u2014 the server minted a '${result.env}' key although --env ${mintArgs.env} was requested; revoke it (\`vxil keys revoke ${result.key_id}\`) if the env matters`);
12510
+ }
12511
+ break;
12512
+ }
12513
+ if (sub === "list") {
12514
+ const { keys: keys2 } = await run((cookie, id) => listKeysViaSession(dash, cookie, id));
12515
+ const current = currentKeyIdFor(keys2, t.apiKey);
12516
+ if (jsonOut) console.log(JSON.stringify({ project: slug, current_key_id: current ?? null, keys: keys2 }, null, 2));
12517
+ else for (const l of renderKeyTable(keys2, current)) console.log(l);
12518
+ break;
12519
+ }
12520
+ const keyId = arg;
12521
+ if (!keyId) fail("usage: vxil keys revoke <key_id> [--yes] \u2014 `vxil keys list` shows the ids");
12522
+ const yes = hasFlag("yes");
12523
+ const { keys } = await run((cookie, id) => listKeysViaSession(dash, cookie, id));
12524
+ const guard = revokeGuard({ keys, keyId, currentApiKey: t.apiKey, slug, yes });
12525
+ if ("refuse" in guard) fail(guard.refuse);
12526
+ if (!guard.row) fail(`no key '${keyId}' on '${slug}' \u2014 \`vxil keys list\` shows the ids`);
12527
+ if (guard.row.revoked_at) fail(`'${keyId}' is already revoked`);
12528
+ announceTarget(t, "keys revoke");
12529
+ if (!yes) {
12530
+ if (!process.stdin.isTTY) fail("keys revoke is irreversible \u2014 pass --yes to confirm in a non-interactive run");
12531
+ const ans = await prompt(`revoke key '${guard.row.name ?? keyId}' (${keyId}, scopes [${(guard.row.scopes ?? []).join(", ")}])? type the key id to confirm: `);
12532
+ if (ans !== keyId) fail("confirmation did not match \u2014 aborted.");
12533
+ }
12534
+ await run((cookie, id) => revokeKeyViaSession(dash, cookie, id, keyId));
12535
+ if (guard.isCurrent) {
12536
+ const creds = loadCredentials();
12537
+ const onDisk = creds.keys !== void 0 && creds.keys[slug] !== void 0 && creds.keys[slug] === t.apiKey;
12538
+ if (onDisk && creds.keys) {
12539
+ delete creds.keys[slug];
12540
+ saveCredentials(creds);
12541
+ }
12542
+ const where = onDisk ? "removed from ~/.vxil/credentials.json" : `it came from ${t.keySource === "credentials" ? "~/.vxil/credentials.json" : t.keySource} \u2014 unset it`;
12543
+ console.error(`this was the CLI's key for '${slug}' \u2014 ${where}; run \`vxil link ${slug} --mint\` (or \`vxil keys mint \u2026 --store\`) before the next verb`);
12544
+ }
12545
+ if (jsonOut) console.log(JSON.stringify({ key_id: keyId, revoked: true }));
12546
+ else console.log(`\u2713 revoked '${guard.row.name ?? keyId}' (${keyId}) \u2014 the edge stops honouring it within seconds`);
12547
+ break;
12548
+ }
12006
12549
  case "listen": {
12007
12550
  const rawUrl = flag("forward-to");
12008
12551
  if (!rawUrl) {
@@ -12315,9 +12858,9 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
12315
12858
  const qs = since ? `?since=${encodeURIComponent(since)}` : "";
12316
12859
  const res = await api("GET", `/v1/functions/${encodeURIComponent(name)}/logs${qs}`);
12317
12860
  if (res.body.error) printEnvelope(res);
12318
- const data2 = res.body.data;
12319
- if (data2.lines.length) console.log(formatLogLines(data2.lines));
12320
- if (data2.next_since) since = data2.next_since;
12861
+ const data3 = res.body.data;
12862
+ if (data3.lines.length) console.log(formatLogLines(data3.lines));
12863
+ if (data3.next_since) since = data3.next_since;
12321
12864
  };
12322
12865
  await fetchPage();
12323
12866
  if (hasFlag("tail")) {
@@ -12363,6 +12906,35 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
12363
12906
  }
12364
12907
  case "cms": {
12365
12908
  const [sub] = positional(0);
12909
+ if (sub === "drop") {
12910
+ const [collection] = positional(1);
12911
+ if (!collection) fail("usage: vxil cms drop <collection> [--yes]");
12912
+ const { api: api2, target } = requireApi(selector(), { write: `cms drop ${collection}` });
12913
+ const plan = await planCollectionDrop(api2, collection);
12914
+ if (plan.remoteUnavailable) {
12915
+ fail(`cannot read ${plan.remoteUnavailable.route} (${plan.remoteUnavailable.code ?? plan.remoteUnavailable.status}) \u2014 refusing to drop against an unread target`);
12916
+ }
12917
+ if (!plan.exists) fail(`collection '${collection}' does not exist on tenant '${target.tenantSlug ?? "(env key)"}' \u2014 nothing to drop`);
12918
+ let declaredLocally = false;
12919
+ try {
12920
+ const cfg = await loadVxilConfig();
12921
+ declaredLocally = Object.prototype.hasOwnProperty.call(cfg.cms?.collections ?? {}, collection);
12922
+ } catch {
12923
+ }
12924
+ console.error(formatCollectionDrop(collection, plan, declaredLocally));
12925
+ if (!hasFlag("yes")) {
12926
+ if (!process.stdin.isTTY) fail("cms drop is destructive \u2014 pass --yes to confirm in a non-interactive run");
12927
+ const ans = await prompt(`drop collection '${collection}'? type the collection name to confirm: `);
12928
+ if (ans !== collection) fail("confirmation did not match \u2014 aborted.");
12929
+ }
12930
+ await dropCollection(api2, collection);
12931
+ if (jsonOut) {
12932
+ console.log(JSON.stringify({ ok: true, collection, fields: plan.fields, rows: plan.rows, declared_locally: declaredLocally }));
12933
+ } else {
12934
+ console.log(`\u2713 dropped collection ${collection} (${plan.fields} field(s); ${plan.rows === null ? "stored rows" : `${plan.rows} stored row(s)`} retained, unreachable until re-created)`);
12935
+ }
12936
+ break;
12937
+ }
12366
12938
  const { api } = requireApi(selector());
12367
12939
  if (sub === "status") {
12368
12940
  printEnvelope(await api("GET", "/v1/cms/collections"));
@@ -12383,7 +12955,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
12383
12955
  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`);
12384
12956
  }
12385
12957
  } else {
12386
- fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]");
12958
+ fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]|drop <collection> [--yes]");
12387
12959
  }
12388
12960
  break;
12389
12961
  }
@@ -12437,14 +13009,14 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
12437
13009
  const { api } = requireApi(selector(), { write: "send" });
12438
13010
  const [userId, template] = rest;
12439
13011
  if (!userId || !template) fail("usage: vxil send <user_id> <template> --data '<json>'");
12440
- let data2 = {};
13012
+ let data3 = {};
12441
13013
  const raw = flag("data");
12442
13014
  try {
12443
- data2 = raw ? JSON.parse(raw) : {};
13015
+ data3 = raw ? JSON.parse(raw) : {};
12444
13016
  } catch {
12445
13017
  fail("--data must be valid JSON");
12446
13018
  }
12447
- printEnvelope(await api("POST", "/v1/notifications/send", { user_id: userId, template, data: data2 }));
13019
+ printEnvelope(await api("POST", "/v1/notifications/send", { user_id: userId, template, data: data3 }));
12448
13020
  break;
12449
13021
  }
12450
13022
  case "deliveries": {
@@ -12459,28 +13031,25 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
12459
13031
  break;
12460
13032
  }
12461
13033
  case "api": {
12462
- const [method, path] = rest;
12463
- if (!method || !path?.startsWith("/")) fail("usage: vxil api <GET|POST|PUT|PATCH|DELETE> </v1/...> [--data '<json>']");
12464
- const { api } = requireApi(selector(), method.toUpperCase() === "GET" ? {} : { write: `api ${method.toUpperCase()} ${path}` });
12465
- let body;
12466
- const raw = flag("data");
12467
- if (raw !== void 0) {
12468
- try {
12469
- body = JSON.parse(raw);
12470
- } catch {
12471
- fail("--data must be valid JSON");
12472
- }
13034
+ let parsed;
13035
+ try {
13036
+ parsed = parseApiArgs(positional(0), flag("data"));
13037
+ } catch (e) {
13038
+ fail(e.message);
12473
13039
  }
12474
- printEnvelope(await api(method.toUpperCase(), path, body));
13040
+ const { method, path, body } = parsed;
13041
+ const { api } = requireApi(selector(), method === "GET" ? {} : { write: `api ${method} ${path}` });
13042
+ printEnvelope(await api(method, path, body));
12475
13043
  break;
12476
13044
  }
12477
13045
  default:
12478
13046
  console.log(`vxil \u2014 a Supabase-grade code-first CLI for the whole platform
12479
- init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--dev --ttl <h>] \xB7 login \xB7 link <slug> [--as dev|<name>]
13047
+ init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--dev --ttl <h>] \xB7 login \xB7 link <slug> [--key <k>] [--as dev|<name>] [--mint]
13048
+ keys mint --name <n> --scopes <a,b,c> [--env live|test] [--store] \xB7 keys list \xB7 keys revoke <key_id> [--yes] \u2014 a NARROW scoped key for a server (shown once; needs a vxil login session)
12480
13049
  architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
12481
- 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>]
13050
+ plan [--explain] \xB7 push [--dev|--target <name>|--prod] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] \xB7 pull \xB7 gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>]
12482
13051
  push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
12483
- 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)
13052
+ diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only, unstored refs informational unless --strict-secrets)
12484
13053
  doctor [--dev|--target <name>] \u2014 + secrets referenced-vs-stored (advisory) + per-function served/published/local hash
12485
13054
  versions <feature> \xB7 rollback <feature> --to <v>
12486
13055
  secrets set|list [--all-projects]|rm \xB7 seed \xB7 export|import <feature>
@@ -12489,11 +13058,11 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
12489
13058
  migrate payments --from-provider stripe|paddle|paypal|revenuecat (--customers <f.csv> | --all) [--dry-run] \u2014 backfill subscriptions via the server sync leg (resumable)
12490
13059
  payments simulate --scenario refund-pair|cross-platform-unlock|renewal|expiry|past-due-grace|transfer --user <id> [--tier <t>] \u2014 mock/dev projects only
12491
13060
  env pull [--dev] [--file <f>] [--print] [--no-gitignore]
12492
- 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
13061
+ functions new|deploy|list|delete|invoke [--async]|logs [--tail]|dev <name> [--port <n>] \xB7 cms status|pull|reindex <collection>|drop <collection> [--yes] \xB7 dev up [--ttl <h>] [--new]|down [--yes]|seed|reset
12493
13062
  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
12494
13063
  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
12495
13064
  dev branch [<name>] [--ttl <h=24>] [--no-env] [--print] | --list | --rm <name> [--yes]
12496
- config get \xB7 users \xB7 send \xB7 deliveries \xB7 api <METHOD> <path>
13065
+ config get \xB7 users \xB7 send \xB7 deliveries \xB7 api <METHOD> <path> ['<json>' | --data '<json>']
12497
13066
  global: --json (machine-readable + exit codes)`);
12498
13067
  process.exit(cmd ? 1 : 0);
12499
13068
  }
@@ -13003,7 +13572,7 @@ async function runFunctionsDev(name) {
13003
13572
  // parity with the deploy pipeline's metadata
13004
13573
  outboundService: (req) => outbound(req)
13005
13574
  });
13006
- const first = await bundleFunction(entry, { projectRoot: cwd });
13575
+ const first = await bundleFunction(entry, { projectRoot: cwd, minify: false });
13007
13576
  const mf = new Miniflare(mfOptions(first.source));
13008
13577
  const { secrets, missing } = resolveDevSecrets(def.secrets, process.env);
13009
13578
  for (const m of missing) {
@@ -13062,7 +13631,7 @@ async function runFunctionsDev(name) {
13062
13631
  timer2 = setTimeout(() => {
13063
13632
  void (async () => {
13064
13633
  try {
13065
- const rebuilt = await bundleFunction(entry, { projectRoot: cwd });
13634
+ const rebuilt = await bundleFunction(entry, { projectRoot: cwd, minify: false });
13066
13635
  await mf.setOptions(mfOptions(rebuilt.source));
13067
13636
  console.log(` \u21BB rebuilt '${name}' (${Math.round(rebuilt.bytes / 100) / 10} KB) \u2014 hot-swapped`);
13068
13637
  } catch (e) {