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