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