@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 +6 -2
- package/dist/_vxil-feature-configs.d.ts +25 -3
- package/dist/config.d.ts +28 -19
- package/dist/vxil.js +274 -52
- package/package.json +2 -2
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
|
|
48
|
-
- `vxil
|
|
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?:
|
|
237
|
-
jobs?:
|
|
238
|
-
auth?:
|
|
239
|
-
'rate-limits'?:
|
|
240
|
-
files?:
|
|
241
|
-
webhooks?:
|
|
242
|
-
comments?:
|
|
243
|
-
cms?:
|
|
244
|
-
mcp?:
|
|
245
|
-
realtime?:
|
|
246
|
-
presence?:
|
|
247
|
-
orgs?:
|
|
248
|
-
'activity-feed'?:
|
|
249
|
-
'vector-search'?:
|
|
250
|
-
ai?:
|
|
251
|
-
rag?:
|
|
252
|
-
payments?:
|
|
253
|
-
functions?:
|
|
254
|
-
copilot?:
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 ?? {}),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
11318
|
-
|
|
11319
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
12463
|
-
|
|
12464
|
-
|
|
12465
|
-
|
|
12466
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
49
|
+
"@vxil/config": "0.4.1",
|
|
50
50
|
"@vxil/runtime": "0.0.1",
|
|
51
51
|
"@vxil/types": "0.0.1"
|
|
52
52
|
},
|