@vxil/cli 0.5.1 → 0.5.2

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/README.md CHANGED
@@ -44,8 +44,12 @@ Verbs: `init templates quickstart try login link plan push pull diff gen doctor
44
44
  **Every verb names its target.** `--dev` selects the dev slot, `--target <name>` a named slot bound by `vxil link <slug> --as <name>` (which merges into the existing binding rather than replacing it). The selector is **fail-closed** — an unbound slot exits 1 telling you the fix, and never falls through to another tenant — and every write verb prints a target banner (slug + env, never a key) before it acts.
45
45
 
46
46
  - `vxil plan --explain` — the config plan with every leaf tagged *declared* or *default*.
47
- - `vxil diff [--against <target>] [--ignore <k,…>]` — the CI drift gate; secrets compared by name only.
48
- - `vxil doctor` — preflight: secrets referenced-vs-stored (advisory, names only) plus a served / published / local hash verdict per function.
47
+ - `vxil push [--skip-functions]` — apply schema → config → functions; a project below the functions plan is stopped *before anything is pushed*, and `--skip-functions` pushes the rest.
48
+ - `vxil diff [--against <target>] [--ignore <k,…>] [--strict-secrets]` — the CI drift gate; secrets compared by name only, and a referenced-but-unstored name is informational unless `--strict-secrets`.
49
+ - `vxil doctor` — preflight: secrets referenced-vs-stored (advisory, names only) plus a served / published / local hash verdict per function. Under `--json` stdout is one JSON document; notices go to stderr.
50
+ - `vxil link <slug>` — reuses the key already stored for the slug on this machine; `--mint` for a fresh one, `--key` to hand one in.
51
+ - `vxil cms drop <collection> [--yes]` — remove one collection definition through the same guarded ack `push --allow-destructive` uses (rows are retained).
52
+ - `vxil api <METHOD> <path> ['<json>' | --data '<json>']` — call any `/v1` route; bare JSON on stdout, banner on stderr.
49
53
  - `vxil secrets list --all-projects` — the names-and-metadata matrix across every bound project (values never come back).
50
54
  - `vxil migrate payments --from-provider stripe|paddle|paypal|revenuecat (--customers <f.csv> | --all) [--dry-run]` — backfill subscriptions through the server sync leg; resumable.
51
55
  - `vxil payments simulate --scenario <s> --user <id> [--tier <t>]` — mock/dev projects only: drive a refund pair, cross-platform unlock, renewal, expiry, past-due grace or transfer and check the oracle.
@@ -37,6 +37,12 @@ export declare function validateNotificationOverrides(templates: {
37
37
  text?: string;
38
38
  }>>;
39
39
  } | undefined): string[];
40
+ /** AWS region grammar for `notifications.ses.region` (`us-east-1`,
41
+ * `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
42
+ * one or more lowercase words, a single digit. Pinned as a pattern rather than
43
+ * a list so a newly launched SES region needs no vxil release; a typo still
44
+ * fails at `vxil push` (TypeBox), never as a DNS error on the first send. */
45
+ export declare const SES_REGION_PATTERN = "^[a-z]{2}(-[a-z]+)+-\\d$";
40
46
  export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObject<{
41
47
  enabled: import("@sinclair/typebox").TBoolean;
42
48
  fromEmail: import("@sinclair/typebox").TString;
@@ -44,7 +50,12 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
44
50
  replyTo: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
45
51
  resendApiKeyRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
46
52
  webhookSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
47
- provider: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"resend">, import("@sinclair/typebox").TLiteral<"mock">]>;
53
+ provider: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"resend">, import("@sinclair/typebox").TLiteral<"ses">, import("@sinclair/typebox").TLiteral<"mock">]>;
54
+ ses: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
55
+ region: import("@sinclair/typebox").TString;
56
+ accessKeyIdRef: import("@sinclair/typebox").TString;
57
+ secretAccessKeyRef: import("@sinclair/typebox").TString;
58
+ }>>;
48
59
  defaultLocale: import("@sinclair/typebox").TString;
49
60
  retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
50
61
  maxAttempts: import("@sinclair/typebox").TInteger;
@@ -53,10 +64,10 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
53
64
  suppression: import("@sinclair/typebox").TObject<{
54
65
  softBounceThreshold: import("@sinclair/typebox").TInteger;
55
66
  }>;
56
- rateLimit: import("@sinclair/typebox").TObject<{
67
+ rateLimit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
57
68
  perDay: import("@sinclair/typebox").TInteger;
58
69
  perTenantSec: import("@sinclair/typebox").TInteger;
59
- }>;
70
+ }>>;
60
71
  templates: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
61
72
  allowOverride: import("@sinclair/typebox").TBoolean;
62
73
  overrides: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
@@ -150,6 +161,17 @@ declare const OidcProviderSchema: import("@sinclair/typebox").TObject<{
150
161
  allowedDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
151
162
  autoLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
152
163
  }>;
164
+ export declare const REDIRECT_ORIGIN_PATTERN = "^(https://[^/?#\\s]+|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$";
165
+ /** THE scheme rule for a redirect/return URL: https anywhere, http on loopback
166
+ * only. Takes the parsed URL (or any {protocol, hostname} pair) so the config
167
+ * validator and the worker's runtime guard call the identical predicate. */
168
+ export declare function redirectSchemeAllowed(u: {
169
+ protocol: string;
170
+ hostname: string;
171
+ }): boolean;
172
+ /** An allow-list ENTRY is a bare origin that parses and passes the scheme
173
+ * rule. Used by validateFeatureConfig (auth) on top of the coarse pattern. */
174
+ export declare function isAllowedRedirectOriginEntry(entry: string): boolean;
153
175
  export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
154
176
  enabled: import("@sinclair/typebox").TBoolean;
155
177
  methods: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
package/dist/config.d.ts CHANGED
@@ -221,6 +221,15 @@ export type ApiVersion = 'v1';
221
221
  * only by a real v2. */
222
222
  export declare const RELEASED_API_VERSIONS: readonly ApiVersion[];
223
223
  /** The whole vxil backend, declared in one typed, version-controlled file. */
224
+ /** Recursive partial: a `vxil.config.ts` may write any SUBSET of a nested bag
225
+ * (`auth: { otp: { enabled: true } }`) — the server applies the defaults for
226
+ * the rest (Value.Default). A shallow `Partial` made `tsc` reject every
227
+ * partially-written nested bag, including the guide's own skeleton
228
+ * (found by the 2026-09-19 cvskit dry-run). Arrays and primitives are kept
229
+ * as-is; only plain object bags recurse. */
230
+ export type DeepPartial<T> = T extends readonly unknown[] ? T : T extends object ? {
231
+ [K in keyof T]?: DeepPartial<T[K]>;
232
+ } : T;
224
233
  export interface VxilConfig {
225
234
  /** target environment label; resolved to a baseUrl by `vxil link` / `--env`. */
226
235
  env?: string;
@@ -233,25 +242,25 @@ export interface VxilConfig {
233
242
  * same Value.Default/Clean pipeline the server uses. Keys match FEATURE_SCHEMAS
234
243
  * exactly (note the hyphenated ids). */
235
244
  features?: {
236
- notifications?: Partial<NotificationsConfig>;
237
- jobs?: Partial<JobsConfig>;
238
- auth?: Partial<AuthConfig>;
239
- 'rate-limits'?: Partial<RateLimitsConfig>;
240
- files?: Partial<FilesConfig>;
241
- webhooks?: Partial<WebhooksConfig>;
242
- comments?: Partial<CommentsConfig>;
243
- cms?: Partial<CmsConfig>;
244
- mcp?: Partial<McpConfig>;
245
- realtime?: Partial<RealtimeConfig>;
246
- presence?: Partial<PresenceConfig>;
247
- orgs?: Partial<OrgsConfig>;
248
- 'activity-feed'?: Partial<ActivityFeedConfig>;
249
- 'vector-search'?: Partial<VectorSearchConfig>;
250
- ai?: Partial<AiConfig>;
251
- rag?: Partial<RagConfig>;
252
- payments?: Partial<PaymentsConfig>;
253
- functions?: Partial<FunctionsConfig>;
254
- copilot?: Partial<CopilotConfig>;
245
+ notifications?: DeepPartial<NotificationsConfig>;
246
+ jobs?: DeepPartial<JobsConfig>;
247
+ auth?: DeepPartial<AuthConfig>;
248
+ 'rate-limits'?: DeepPartial<RateLimitsConfig>;
249
+ files?: DeepPartial<FilesConfig>;
250
+ webhooks?: DeepPartial<WebhooksConfig>;
251
+ comments?: DeepPartial<CommentsConfig>;
252
+ cms?: DeepPartial<CmsConfig>;
253
+ mcp?: DeepPartial<McpConfig>;
254
+ realtime?: DeepPartial<RealtimeConfig>;
255
+ presence?: DeepPartial<PresenceConfig>;
256
+ orgs?: DeepPartial<OrgsConfig>;
257
+ 'activity-feed'?: DeepPartial<ActivityFeedConfig>;
258
+ 'vector-search'?: DeepPartial<VectorSearchConfig>;
259
+ ai?: DeepPartial<AiConfig>;
260
+ rag?: DeepPartial<RagConfig>;
261
+ payments?: DeepPartial<PaymentsConfig>;
262
+ functions?: DeepPartial<FunctionsConfig>;
263
+ copilot?: DeepPartial<CopilotConfig>;
255
264
  };
256
265
  /** (b) CMS SCHEMA-AS-CODE — collections + fields. `push` reconciles them
257
266
  * additively against GET /v1/cms/collections (no destructive drops without
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);
@@ -758,9 +783,10 @@ async function planFunctions({ api, functions, cwd, apply }) {
758
783
  const data2 = res.body.data;
759
784
  const cmsHooks = data2?.cms_hook_subscriptions ?? null;
760
785
  const webhooks = data2?.webhook_subscriptions ?? null;
761
- return { change, applied: 1, cmsHooks, webhooks };
786
+ const warnings2 = (data2?.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";
@@ -5074,7 +5177,13 @@ var TOOLS = [
5074
5177
  },
5075
5178
  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
5179
  },
5077
- user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." }
5180
+ user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." },
5181
+ correlation_id: {
5182
+ type: "string",
5183
+ minLength: 1,
5184
+ maxLength: 128,
5185
+ 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."
5186
+ }
5078
5187
  }
5079
5188
  },
5080
5189
  method: "POST",
@@ -5247,6 +5356,8 @@ var TOOLS = [
5247
5356
  event_type: { type: "string", description: "Exact event type filter, e.g. 'subscription.updated'." },
5248
5357
  outcome: { type: "string", enum: ["received", "processed", "error", "sig_failed", "parse_failed", "reprocessed", "ignored", "unowned", "rejected_environment"] },
5249
5358
  environment: { type: "string", enum: ["production", "sandbox"], description: "The provider-reported environment of the delivery (a separate axis from the API key label)." },
5359
+ 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." },
5360
+ 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
5361
  since: { type: "string", description: "ISO-8601 lower bound on received_at." },
5251
5362
  cursor: { type: "string", description: "Opaque keyset cursor from a prior page (next_cursor)." },
5252
5363
  limit: { type: "number", default: 50, description: "Page size, clamped 1..100." }
@@ -5254,7 +5365,7 @@ var TOOLS = [
5254
5365
  },
5255
5366
  method: "GET",
5256
5367
  path: "/v1/payments/webhook-events",
5257
- queryArgs: ["provider", "event_type", "outcome", "environment", "since", "cursor", "limit"]
5368
+ queryArgs: ["provider", "event_type", "outcome", "environment", "provider_charge_id", "provider_evt_id", "since", "cursor", "limit"]
5258
5369
  },
5259
5370
  {
5260
5371
  name: "mcp_get_signing_secret",
@@ -5769,6 +5880,10 @@ function partitionConfigChanges(feature, changes, patterns) {
5769
5880
  for (const c of changes) (isIgnored(`config:${feature}.${c.path}`, patterns) ? ignored : kept).push(c);
5770
5881
  return { kept, ignored };
5771
5882
  }
5883
+ function unstoredSecretVerdict(opts) {
5884
+ if (opts.ignored) return "ignored";
5885
+ return opts.strict ? "drift" : "informational";
5886
+ }
5772
5887
  function driftExitCode(r) {
5773
5888
  if (r.errors > 0) return 2;
5774
5889
  return r.drift > 0 ? 1 : 0;
@@ -5825,14 +5940,15 @@ function secretsChecks(refs, stored) {
5825
5940
  }
5826
5941
  return out;
5827
5942
  }
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";
5943
+ 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
5944
  function notificationsProviderChecks(manifest) {
5830
5945
  if (!manifest || manifest.enabled === false) return [];
5831
5946
  if (manifest.provider !== "mock") {
5947
+ const region = manifest.provider === "ses" && manifest.ses?.region ? ` (${manifest.ses.region})` : "";
5832
5948
  return [{
5833
5949
  name: "notifications provider",
5834
5950
  ok: true,
5835
- detail: `${String(manifest.provider ?? "resend")}${manifest.fromEmail ? ` \xB7 from ${manifest.fromEmail}` : ""}`
5951
+ detail: `${String(manifest.provider ?? "resend")}${region}${manifest.fromEmail ? ` \xB7 from ${manifest.fromEmail}` : ""}`
5836
5952
  }];
5837
5953
  }
5838
5954
  return [{ name: "notifications provider", ok: true, warn: true, detail: MOCK_PROVIDER_NOTE }];
@@ -5983,6 +6099,20 @@ async function linkViaDeviceFlow(dash, slug, io) {
5983
6099
  }
5984
6100
  throw new Error("link timed out waiting for browser approval \u2014 run `vxil link <slug>` again");
5985
6101
  }
6102
+ async function linkViaStoredKey(api, slug, storedKey) {
6103
+ let res;
6104
+ try {
6105
+ res = await api("GET", "/v1/features");
6106
+ } catch (e2) {
6107
+ return { invalid: `could not reach the edge (${e2.message})` };
6108
+ }
6109
+ if (res.status === 200) {
6110
+ const tenantId = res.body.meta?.tenant_id;
6111
+ return { result: { slug, tenant_id: tenantId && tenantId.length ? tenantId : "(unknown)", api_key: storedKey } };
6112
+ }
6113
+ const e = res.body.error;
6114
+ return { invalid: `${e?.code ?? res.status}${e?.message ? ` \u2014 ${e.message}` : ""}` };
6115
+ }
5986
6116
 
5987
6117
  // src/secretsValue.ts
5988
6118
  var ARGV_SECRET_WARNING = `WARNING: --value <v> puts the secret on the command line \u2014 it lands in shell history and is
@@ -9367,8 +9497,8 @@ export default defineConfig({
9367
9497
  ],
9368
9498
  "hasFunctions": false,
9369
9499
  "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',
9500
+ "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",
9501
+ "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
9502
  "functions": {}
9373
9503
  },
9374
9504
  {
@@ -11211,6 +11341,22 @@ async function runApply(apply, sel = "prod", opts = {}) {
11211
11341
  process.exit(2);
11212
11342
  }
11213
11343
  const cwd = process.cwd();
11344
+ const declaredFns = Object.keys(cfg.functions ?? {});
11345
+ const skipFunctions = hasFlag("skip-functions");
11346
+ if (declaredFns.length && !skipFunctions && !gate) {
11347
+ const ent = await probeFunctionsEntitlement(api, declaredFns[0]);
11348
+ if (!ent.entitled) {
11349
+ const msg = formatFunctionsPreflight(ent, {
11350
+ declared: declaredFns.length,
11351
+ baseUrl: target.baseUrl,
11352
+ verb: apply ? "push" : "plan",
11353
+ ...target.tenantId ? { tenantId: target.tenantId } : {}
11354
+ });
11355
+ if (apply) fail(`${msg}
11356
+ (nothing was pushed)`);
11357
+ console.error(`vxil: warning \u2014 ${msg}`);
11358
+ }
11359
+ }
11214
11360
  const notCompared = (u, hint) => {
11215
11361
  console.log(formatNotCompared(u, hint));
11216
11362
  if (gate) report.errors++;
@@ -11281,6 +11427,11 @@ ${r.feature} (remote v${r.version}):`);
11281
11427
  }
11282
11428
  return;
11283
11429
  }
11430
+ if (skipFunctions) {
11431
+ console.log(`
11432
+ functions: skipped (--skip-functions) \u2014 ${declaredFns.length} declared function(s) not ${apply ? "deployed" : "compared"}`);
11433
+ return;
11434
+ }
11284
11435
  const r = await planFunctions({ api, functions: cfg.functions, cwd, apply });
11285
11436
  console.log("\nfunctions:");
11286
11437
  if (r.remoteUnavailable) {
@@ -11289,6 +11440,9 @@ ${r.feature} (remote v${r.version}):`);
11289
11440
  }
11290
11441
  console.log(formatFnChanges(r.changes));
11291
11442
  if (apply && r.applied) console.log(` \u2713 deployed ${r.applied} function(s)`);
11443
+ for (const w of r.warnings ?? []) {
11444
+ console.error(` ! ${w.fn}: ${w.message}`);
11445
+ }
11292
11446
  if (apply && r.cmsHookSubscriptions && (r.cmsHookSubscriptions.created || r.cmsHookSubscriptions.deleted)) {
11293
11447
  console.log(` \u2713 cms-hook subscription reconciled (+${r.cmsHookSubscriptions.created} / -${r.cmsHookSubscriptions.deleted})`);
11294
11448
  }
@@ -11298,6 +11452,8 @@ ${r.feature} (remote v${r.version}):`);
11298
11452
  for (const c of r.changes) if (c.kind !== "unchanged") count(`function:${c.name}`);
11299
11453
  compared.functions += Object.keys(cfg.functions).length;
11300
11454
  });
11455
+ const strictSecrets = hasFlag("strict-secrets");
11456
+ const informationalKeys = [];
11301
11457
  if (!apply && (gate || explain)) {
11302
11458
  await runSection("secrets", async () => {
11303
11459
  const refs = collectSecretRefs(cfg);
@@ -11314,9 +11470,16 @@ ${r.feature} (remote v${r.version}):`);
11314
11470
  const cmp = compareSecretNames(refs, stored);
11315
11471
  if (!cmp.missing.length && !cmp.unreferenced.length) console.log(` (in sync \u2014 ${cmp.present.length} ref(s), all stored)`);
11316
11472
  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}`);
11473
+ const key = `secret:${m.name}`;
11474
+ const verdict = unstoredSecretVerdict({ strict: strictSecrets, ignored: isIgnored(key, ignore) });
11475
+ const mark = verdict === "drift" ? "-" : "\xB7";
11476
+ 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)" : ""));
11477
+ if (verdict === "informational") {
11478
+ report.informational = (report.informational ?? 0) + 1;
11479
+ informationalKeys.push(key);
11480
+ } else {
11481
+ count(key);
11482
+ }
11320
11483
  }
11321
11484
  for (const u of cmp.unreferenced) console.log(` \xB7 ${u.feature}/${u.secret_name} stored but not referenced by this config (informational)`);
11322
11485
  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 +11530,19 @@ api state \u2014 ${self.target.tenantSlug ?? "(env key)"} vs ${other.target.tena
11367
11530
  exit_code: code,
11368
11531
  target: target.tenantSlug ?? null,
11369
11532
  env: target.envLabel,
11370
- ...report,
11533
+ drift: report.drift,
11534
+ ignored: report.ignored,
11535
+ errors: report.errors,
11536
+ informational: report.informational ?? 0,
11371
11537
  compared,
11372
11538
  ignored_keys: ignoredKeys,
11539
+ informational_keys: informationalKeys,
11540
+ strict_secrets: strictSecrets,
11373
11541
  ...opts.apiState ? { api_state: apiStateCounts } : {}
11374
11542
  }));
11375
11543
  } else {
11376
11544
  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}`);
11545
+ 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
11546
  }
11379
11547
  process.exit(code);
11380
11548
  }
@@ -11382,7 +11550,7 @@ vxil diff \u2192 ${report.drift} drift item(s), ${report.ignored} ignored, ${rep
11382
11550
  if (apply && !hasFlag("no-gen")) await runGen(sel);
11383
11551
  }
11384
11552
  async function runServerApply(sel) {
11385
- const { api } = requireApi(sel, { write: "push --server" });
11553
+ const { api, target } = requireApi(sel, { write: "push --server" });
11386
11554
  const resume = flag("resume");
11387
11555
  if (resume) {
11388
11556
  const done2 = await serverApply({ api, resume });
@@ -11394,8 +11562,26 @@ apply ${done2.apply_id}: ${done2.status}`);
11394
11562
  if (done2.status === "succeeded" && !hasFlag("no-gen")) await runGen(sel);
11395
11563
  return;
11396
11564
  }
11397
- const cfg = await loadVxilConfig();
11565
+ const loaded = await loadVxilConfig();
11398
11566
  const cwd = process.cwd();
11567
+ const { functions: declaredFns, ...rest2 } = loaded;
11568
+ const cfg = hasFlag("skip-functions") ? rest2 : loaded;
11569
+ const fnNames = Object.keys(cfg.functions ?? {});
11570
+ if (hasFlag("skip-functions") && declaredFns && Object.keys(declaredFns).length) {
11571
+ console.log(`functions: skipped (--skip-functions) \u2014 ${Object.keys(declaredFns).length} declared function(s) not deployed`);
11572
+ }
11573
+ if (fnNames.length) {
11574
+ const ent = await probeFunctionsEntitlement(api, fnNames[0]);
11575
+ if (!ent.entitled) {
11576
+ fail(`${formatFunctionsPreflight(ent, {
11577
+ declared: fnNames.length,
11578
+ baseUrl: target.baseUrl,
11579
+ verb: "push",
11580
+ ...target.tenantId ? { tenantId: target.tenantId } : {}
11581
+ })}
11582
+ (nothing was applied)`);
11583
+ }
11584
+ }
11399
11585
  const fnSources = {};
11400
11586
  for (const [name, def] of Object.entries(cfg.functions ?? {})) {
11401
11587
  fnSources[name] = (await bundleFunction(resolve7(cwd, def.entry), { projectRoot: cwd })).source;
@@ -11968,7 +12154,7 @@ try {
11968
12154
  }
11969
12155
  case "link": {
11970
12156
  const slug = positional(0)[0];
11971
- if (!slug) fail("usage: vxil link <slug> [--key <api_key>] [--tenant <id>] [--as dev|<target-name>] [--env <label>]");
12157
+ if (!slug) fail("usage: vxil link <slug> [--key <api_key>] [--tenant <id>] [--as dev|<target-name>] [--env <label>] [--mint]");
11972
12158
  const base = process.env.VXIL_BASE_URL ?? DEFAULT_BASE;
11973
12159
  const directKey = flag("key");
11974
12160
  if (directKey) {
@@ -11977,8 +12163,19 @@ try {
11977
12163
  console.log(`\u2713 linked '${slug}' ${bound} (using provided --key)`);
11978
12164
  break;
11979
12165
  }
11980
- const dash = makeDash(dashboardBase(base));
11981
12166
  const creds = loadCredentials();
12167
+ const stored = creds.keys?.[slug];
12168
+ if (stored && !hasFlag("mint")) {
12169
+ const r = await linkViaStoredKey(makeApi({ apiKey: stored, baseUrl: base }), slug, stored);
12170
+ if ("result" in r) {
12171
+ const tenantId = flag("tenant") ?? r.result.tenant_id;
12172
+ const bound = persistLink(slug, tenantId, stored, base);
12173
+ 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)`);
12174
+ break;
12175
+ }
12176
+ console.error(`stored key for '${slug}' was refused (${r.invalid}) \u2014 minting a new one\u2026`);
12177
+ }
12178
+ const dash = makeDash(dashboardBase(base));
11982
12179
  const keyName = `cli-${hostname()}`.slice(0, 100);
11983
12180
  if (creds.session) {
11984
12181
  const r = await linkViaSession(dash, creds.session, slug, keyName);
@@ -12363,6 +12560,35 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
12363
12560
  }
12364
12561
  case "cms": {
12365
12562
  const [sub] = positional(0);
12563
+ if (sub === "drop") {
12564
+ const [collection] = positional(1);
12565
+ if (!collection) fail("usage: vxil cms drop <collection> [--yes]");
12566
+ const { api: api2, target } = requireApi(selector(), { write: `cms drop ${collection}` });
12567
+ const plan = await planCollectionDrop(api2, collection);
12568
+ if (plan.remoteUnavailable) {
12569
+ fail(`cannot read ${plan.remoteUnavailable.route} (${plan.remoteUnavailable.code ?? plan.remoteUnavailable.status}) \u2014 refusing to drop against an unread target`);
12570
+ }
12571
+ if (!plan.exists) fail(`collection '${collection}' does not exist on tenant '${target.tenantSlug ?? "(env key)"}' \u2014 nothing to drop`);
12572
+ let declaredLocally = false;
12573
+ try {
12574
+ const cfg = await loadVxilConfig();
12575
+ declaredLocally = Object.prototype.hasOwnProperty.call(cfg.cms?.collections ?? {}, collection);
12576
+ } catch {
12577
+ }
12578
+ console.error(formatCollectionDrop(collection, plan, declaredLocally));
12579
+ if (!hasFlag("yes")) {
12580
+ if (!process.stdin.isTTY) fail("cms drop is destructive \u2014 pass --yes to confirm in a non-interactive run");
12581
+ const ans = await prompt(`drop collection '${collection}'? type the collection name to confirm: `);
12582
+ if (ans !== collection) fail("confirmation did not match \u2014 aborted.");
12583
+ }
12584
+ await dropCollection(api2, collection);
12585
+ if (jsonOut) {
12586
+ console.log(JSON.stringify({ ok: true, collection, fields: plan.fields, rows: plan.rows, declared_locally: declaredLocally }));
12587
+ } else {
12588
+ 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)`);
12589
+ }
12590
+ break;
12591
+ }
12366
12592
  const { api } = requireApi(selector());
12367
12593
  if (sub === "status") {
12368
12594
  printEnvelope(await api("GET", "/v1/cms/collections"));
@@ -12383,7 +12609,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
12383
12609
  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
12610
  }
12385
12611
  } else {
12386
- fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]");
12612
+ fail("usage: vxil cms status|pull|reindex <collection> [--field <field>]|drop <collection> [--yes]");
12387
12613
  }
12388
12614
  break;
12389
12615
  }
@@ -12459,28 +12685,24 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
12459
12685
  break;
12460
12686
  }
12461
12687
  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
- }
12688
+ let parsed;
12689
+ try {
12690
+ parsed = parseApiArgs(positional(0), flag("data"));
12691
+ } catch (e) {
12692
+ fail(e.message);
12473
12693
  }
12474
- printEnvelope(await api(method.toUpperCase(), path, body));
12694
+ const { method, path, body } = parsed;
12695
+ const { api } = requireApi(selector(), method === "GET" ? {} : { write: `api ${method} ${path}` });
12696
+ printEnvelope(await api(method, path, body));
12475
12697
  break;
12476
12698
  }
12477
12699
  default:
12478
12700
  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>]
12701
+ 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]
12480
12702
  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>]
12703
+ 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
12704
  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)
12705
+ 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
12706
  doctor [--dev|--target <name>] \u2014 + secrets referenced-vs-stored (advisory) + per-function served/published/local hash
12485
12707
  versions <feature> \xB7 rollback <feature> --to <v>
12486
12708
  secrets set|list [--all-projects]|rm \xB7 seed \xB7 export|import <feature>
@@ -12489,11 +12711,11 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
12489
12711
  migrate payments --from-provider stripe|paddle|paypal|revenuecat (--customers <f.csv> | --all) [--dry-run] \u2014 backfill subscriptions via the server sync leg (resumable)
12490
12712
  payments simulate --scenario refund-pair|cross-platform-unlock|renewal|expiry|past-due-grace|transfer --user <id> [--tier <t>] \u2014 mock/dev projects only
12491
12713
  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
12714
+ 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
12715
  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
12716
  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
12717
  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>
12718
+ config get \xB7 users \xB7 send \xB7 deliveries \xB7 api <METHOD> <path> ['<json>' | --data '<json>']
12497
12719
  global: --json (machine-readable + exit codes)`);
12498
12720
  process.exit(cmd ? 1 : 0);
12499
12721
  }
@@ -13003,7 +13225,7 @@ async function runFunctionsDev(name) {
13003
13225
  // parity with the deploy pipeline's metadata
13004
13226
  outboundService: (req) => outbound(req)
13005
13227
  });
13006
- const first = await bundleFunction(entry, { projectRoot: cwd });
13228
+ const first = await bundleFunction(entry, { projectRoot: cwd, minify: false });
13007
13229
  const mf = new Miniflare(mfOptions(first.source));
13008
13230
  const { secrets, missing } = resolveDevSecrets(def.secrets, process.env);
13009
13231
  for (const m of missing) {
@@ -13062,7 +13284,7 @@ async function runFunctionsDev(name) {
13062
13284
  timer2 = setTimeout(() => {
13063
13285
  void (async () => {
13064
13286
  try {
13065
- const rebuilt = await bundleFunction(entry, { projectRoot: cwd });
13287
+ const rebuilt = await bundleFunction(entry, { projectRoot: cwd, minify: false });
13066
13288
  await mf.setOptions(mfOptions(rebuilt.source));
13067
13289
  console.log(` \u21BB rebuilt '${name}' (${Math.round(rebuilt.bytes / 100) / 10} KB) \u2014 hot-swapped`);
13068
13290
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "private": false,
5
5
  "description": "The vxil CLI — init, quickstart, push, gen, secrets, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
6
6
  "license": "MIT",
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "miniflare": "^4.20260611.0",
49
- "@vxil/config": "0.4.0",
49
+ "@vxil/config": "0.4.1",
50
50
  "@vxil/runtime": "0.0.1",
51
51
  "@vxil/types": "0.0.1"
52
52
  },