@vxil/cli 0.2.0 → 0.3.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/README.md +5 -1
- package/dist/vxil.js +589 -15
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ npx @vxil/cli quickstart # create a tenant + scaffold vxil.config.ts
|
|
|
7
7
|
npx @vxil/cli push # apply your config (features, cms collections, functions)
|
|
8
8
|
npx @vxil/cli gen # generate a typed client + MCP manifest for YOUR backend
|
|
9
9
|
npx @vxil/cli functions dev fn # run a tenant function locally against the remote edge
|
|
10
|
+
npx @vxil/cli listen --forward-to localhost:3000/webhook # forward inbound webhook events to your local server
|
|
11
|
+
npx @vxil/cli mcp install --client cursor # wire your backend's MCP server into your editor's agent
|
|
10
12
|
npx @vxil/cli doctor # sanity-check keys, URLs, reachability
|
|
11
13
|
```
|
|
12
14
|
|
|
@@ -24,7 +26,9 @@ export default defineConfig({
|
|
|
24
26
|
});
|
|
25
27
|
```
|
|
26
28
|
|
|
27
|
-
Verbs: `init quickstart push pull diff gen secrets env seed export import functions cms dev doctor versions rollback link apply migrate try`.
|
|
29
|
+
Verbs: `init quickstart push pull diff gen secrets env seed export import functions listen mcp cms dev doctor versions rollback link apply migrate try`.
|
|
30
|
+
|
|
31
|
+
`vxil listen --forward-to <url>` polls your tenant's inbound webhook events and re-POSTs each new one to your local server under the exact delivery contract production uses — same body shape, same signed `X-Vxil-Jobs-Signature` header (your real signing secret) — so the handler you test locally is the handler you ship. `--replay-last <n>` re-forwards recent events; `--raw` posts the stored provider payload alone.
|
|
28
32
|
|
|
29
33
|
Docs: [vxil.com](https://vxil.com) · Dashboard: [vxil.com/dashboard](https://vxil.com/dashboard)
|
|
30
34
|
|
package/dist/vxil.js
CHANGED
|
@@ -7,7 +7,7 @@ import { resolve as resolve6, dirname as dirname3 } 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 { hostname } from "node:os";
|
|
10
|
+
import { homedir as homedir2, hostname } from "node:os";
|
|
11
11
|
|
|
12
12
|
// src/lib.ts
|
|
13
13
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
@@ -753,6 +753,44 @@ function formatCmsChanges(changes) {
|
|
|
753
753
|
}).join("\n");
|
|
754
754
|
}
|
|
755
755
|
|
|
756
|
+
// ../types/src/ulid.ts
|
|
757
|
+
var ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
758
|
+
function ulid(now = Date.now()) {
|
|
759
|
+
let time = "";
|
|
760
|
+
let t = now;
|
|
761
|
+
for (let i = 0; i < 10; i++) {
|
|
762
|
+
time = ALPHABET[t % 32] + time;
|
|
763
|
+
t = Math.floor(t / 32);
|
|
764
|
+
}
|
|
765
|
+
const rand = new Uint8Array(16);
|
|
766
|
+
crypto.getRandomValues(rand);
|
|
767
|
+
let out = time;
|
|
768
|
+
for (let i = 0; i < 16; i++) {
|
|
769
|
+
out += ALPHABET[rand[i] % 32];
|
|
770
|
+
}
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
function newId(prefix) {
|
|
774
|
+
return `${prefix}_${ulid()}`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ../runtime/src/hash.ts
|
|
778
|
+
async function sha256Hex(input) {
|
|
779
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
780
|
+
return [...new Uint8Array(digest)].map((b2) => b2.toString(16).padStart(2, "0")).join("");
|
|
781
|
+
}
|
|
782
|
+
async function hmacSha256Hex(secret, data2) {
|
|
783
|
+
const key = await crypto.subtle.importKey(
|
|
784
|
+
"raw",
|
|
785
|
+
new TextEncoder().encode(secret),
|
|
786
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
787
|
+
false,
|
|
788
|
+
["sign"]
|
|
789
|
+
);
|
|
790
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data2));
|
|
791
|
+
return [...new Uint8Array(sig)].map((b2) => b2.toString(16).padStart(2, "0")).join("");
|
|
792
|
+
}
|
|
793
|
+
|
|
756
794
|
// ../runtime/src/egress.ts
|
|
757
795
|
function isPrivateIPv4(host) {
|
|
758
796
|
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
@@ -852,6 +890,21 @@ function egressDecision(targetUrl, allowHosts = []) {
|
|
|
852
890
|
return { allow: false, reason: `not-allowlisted:${host}` };
|
|
853
891
|
}
|
|
854
892
|
|
|
893
|
+
// ../runtime/src/jobsCallbackSig.ts
|
|
894
|
+
var JOBS_SIG_HEADER = "X-Vxil-Jobs-Signature";
|
|
895
|
+
function signedString(runId, tenantId, issuedAt, bodyHashHex) {
|
|
896
|
+
return `${runId}.${tenantId}.${issuedAt}.${bodyHashHex}`;
|
|
897
|
+
}
|
|
898
|
+
async function signJobsCallbackWithTenantSecret(tenantSecret, params) {
|
|
899
|
+
const t = params.issuedAt ?? Math.floor(Date.now() / 1e3);
|
|
900
|
+
const bodyHash = await sha256Hex(params.body);
|
|
901
|
+
const v1 = await hmacSha256Hex(
|
|
902
|
+
tenantSecret,
|
|
903
|
+
signedString(params.runId, params.tenantId, t, bodyHash)
|
|
904
|
+
);
|
|
905
|
+
return `t=${t},v1=${v1}`;
|
|
906
|
+
}
|
|
907
|
+
|
|
855
908
|
// ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/index.js
|
|
856
909
|
import os from "os";
|
|
857
910
|
import fs from "fs";
|
|
@@ -1332,7 +1385,7 @@ kebab.column.to = fromKebab;
|
|
|
1332
1385
|
// ../../node_modules/.pnpm/postgres@3.4.9/node_modules/postgres/src/connection.js
|
|
1333
1386
|
import net from "net";
|
|
1334
1387
|
import tls from "tls";
|
|
1335
|
-
import
|
|
1388
|
+
import crypto2 from "crypto";
|
|
1336
1389
|
import Stream from "stream";
|
|
1337
1390
|
import { performance } from "perf_hooks";
|
|
1338
1391
|
|
|
@@ -2009,14 +2062,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
|
|
|
2009
2062
|
);
|
|
2010
2063
|
}
|
|
2011
2064
|
async function SASL() {
|
|
2012
|
-
nonce = (await
|
|
2065
|
+
nonce = (await crypto2.randomBytes(18)).toString("base64");
|
|
2013
2066
|
bytes_default().p().str("SCRAM-SHA-256" + bytes_default.N);
|
|
2014
2067
|
const i = bytes_default.i;
|
|
2015
2068
|
write(bytes_default.inc(4).str("n,,n=*,r=" + nonce).i32(bytes_default.i - i - 4, i).end());
|
|
2016
2069
|
}
|
|
2017
2070
|
async function SASLContinue(x) {
|
|
2018
2071
|
const res = x.toString("utf8", 9).split(",").reduce((acc, x2) => (acc[x2[0]] = x2.slice(2), acc), {});
|
|
2019
|
-
const saltedPassword = await
|
|
2072
|
+
const saltedPassword = await crypto2.pbkdf2Sync(
|
|
2020
2073
|
await Pass(),
|
|
2021
2074
|
Buffer.from(res.s, "base64"),
|
|
2022
2075
|
parseInt(res.i),
|
|
@@ -2258,13 +2311,13 @@ function parseError(x) {
|
|
|
2258
2311
|
return error;
|
|
2259
2312
|
}
|
|
2260
2313
|
function md5(x) {
|
|
2261
|
-
return
|
|
2314
|
+
return crypto2.createHash("md5").update(x).digest("hex");
|
|
2262
2315
|
}
|
|
2263
2316
|
function hmac(key, x) {
|
|
2264
|
-
return
|
|
2317
|
+
return crypto2.createHmac("sha256", key).update(x).digest();
|
|
2265
2318
|
}
|
|
2266
2319
|
function sha256(x) {
|
|
2267
|
-
return
|
|
2320
|
+
return crypto2.createHash("sha256").update(x).digest();
|
|
2268
2321
|
}
|
|
2269
2322
|
function xor(a, b2) {
|
|
2270
2323
|
const length = Math.max(a.length, b2.length);
|
|
@@ -3312,7 +3365,7 @@ export type VxilFilterJson = string | number | boolean | { $eq?: string | number
|
|
|
3312
3365
|
var TOOLS = [
|
|
3313
3366
|
{
|
|
3314
3367
|
name: "users_upsert",
|
|
3315
|
-
description: "Create or update an end-user in the
|
|
3368
|
+
description: "Create or update an end-user in the shared users substrate. Call this before sending notifications to a new user. The id is your own opaque user identifier.",
|
|
3316
3369
|
inputSchema: {
|
|
3317
3370
|
type: "object",
|
|
3318
3371
|
properties: {
|
|
@@ -4245,7 +4298,7 @@ var TOOLS = [
|
|
|
4245
4298
|
{
|
|
4246
4299
|
name: "ai_generate",
|
|
4247
4300
|
feature: "ai",
|
|
4248
|
-
description: "Generate a completion from a prompt or a named tenant template. Pass either prompt (raw text) or template + input (vars for a stored template). Defaults to the tenant-configured provider/model; override with provider/model. Returns the synchronous completion (set stream only via the REST API).",
|
|
4301
|
+
description: "Generate a completion from a prompt or a named tenant template. Pass either prompt (raw text) or template + input (vars for a stored template). Defaults to the tenant-configured provider/model; override with provider/model. response_schema makes the output schema-guaranteed JSON (parsed into data.output). Returns the synchronous completion (set stream only via the REST API).",
|
|
4249
4302
|
inputSchema: {
|
|
4250
4303
|
type: "object",
|
|
4251
4304
|
properties: {
|
|
@@ -4256,7 +4309,12 @@ var TOOLS = [
|
|
|
4256
4309
|
model: { type: "string" },
|
|
4257
4310
|
max_tokens: { type: "number", minimum: 1, maximum: 2e5 },
|
|
4258
4311
|
temperature: { type: "number", minimum: 0, maximum: 2 },
|
|
4259
|
-
json_mode: { type: "boolean", description: "Constrain the output to JSON." },
|
|
4312
|
+
json_mode: { type: "boolean", description: "Constrain the output to JSON (no schema \u2014 use response_schema for a guaranteed shape)." },
|
|
4313
|
+
response_schema: {
|
|
4314
|
+
type: "object",
|
|
4315
|
+
additionalProperties: true,
|
|
4316
|
+
description: "JSON Schema the reply must satisfy; enforced natively where the provider supports strict structured output, else via one validate-and-repair pass; 422 output_schema_mismatch if still invalid. The parsed object is returned as output (+ repaired:true when the repair pass fired)."
|
|
4317
|
+
},
|
|
4260
4318
|
images: {
|
|
4261
4319
|
type: "array",
|
|
4262
4320
|
items: { type: "string" },
|
|
@@ -4391,6 +4449,15 @@ var TOOLS = [
|
|
|
4391
4449
|
method: "GET",
|
|
4392
4450
|
path: "/v1/mcp/signing-secret"
|
|
4393
4451
|
},
|
|
4452
|
+
{
|
|
4453
|
+
// Substrate tool (no `feature` — always-on like users_upsert): usage is a
|
|
4454
|
+
// platform property, not a feature you enable.
|
|
4455
|
+
name: "usage_current",
|
|
4456
|
+
description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), and month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically). 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.",
|
|
4457
|
+
inputSchema: { type: "object", properties: {} },
|
|
4458
|
+
method: "GET",
|
|
4459
|
+
path: "/v1/usage"
|
|
4460
|
+
},
|
|
4394
4461
|
{
|
|
4395
4462
|
name: "copilot_send_message",
|
|
4396
4463
|
feature: "copilot",
|
|
@@ -4860,6 +4927,313 @@ function renderTryReport(result, opts) {
|
|
|
4860
4927
|
return lines;
|
|
4861
4928
|
}
|
|
4862
4929
|
|
|
4930
|
+
// src/listen.ts
|
|
4931
|
+
var SEEN_CAP = 2e3;
|
|
4932
|
+
function newListenState() {
|
|
4933
|
+
return { seen: /* @__PURE__ */ new Set(), forwarded: 0, failed: 0 };
|
|
4934
|
+
}
|
|
4935
|
+
function markSeen(state, id, cap = SEEN_CAP) {
|
|
4936
|
+
if (state.seen.has(id)) return;
|
|
4937
|
+
state.seen.add(id);
|
|
4938
|
+
while (state.seen.size > cap) {
|
|
4939
|
+
const oldest = state.seen.values().next().value;
|
|
4940
|
+
if (oldest === void 0) break;
|
|
4941
|
+
state.seen.delete(oldest);
|
|
4942
|
+
}
|
|
4943
|
+
}
|
|
4944
|
+
var ListenApiError = class extends Error {
|
|
4945
|
+
constructor(message, code, fatal) {
|
|
4946
|
+
super(message);
|
|
4947
|
+
this.code = code;
|
|
4948
|
+
this.fatal = fatal;
|
|
4949
|
+
this.name = "ListenApiError";
|
|
4950
|
+
}
|
|
4951
|
+
code;
|
|
4952
|
+
fatal;
|
|
4953
|
+
};
|
|
4954
|
+
function envelopeError(res, what) {
|
|
4955
|
+
const e = res.body.error ?? { code: `http_${res.status}`, message: void 0 };
|
|
4956
|
+
let hint = "";
|
|
4957
|
+
let fatal = res.status === 401;
|
|
4958
|
+
if (e.code === "capability_disabled") {
|
|
4959
|
+
fatal = true;
|
|
4960
|
+
hint = " \u2014 enable it: add `webhooks: {}` under features in vxil.config.ts, then `vxil push`";
|
|
4961
|
+
} else if (e.code === "insufficient_scope") {
|
|
4962
|
+
fatal = true;
|
|
4963
|
+
hint = " \u2014 this API key lacks webhooks:read; mint a key with it (dashboard \u2192 API keys) or re-link";
|
|
4964
|
+
}
|
|
4965
|
+
return new ListenApiError(
|
|
4966
|
+
`${what}: ${e.code}${e.message ? ` \u2014 ${e.message}` : ""}${hint}`,
|
|
4967
|
+
e.code,
|
|
4968
|
+
fatal
|
|
4969
|
+
);
|
|
4970
|
+
}
|
|
4971
|
+
async function pollNewEvents(api, opts = {}) {
|
|
4972
|
+
const qs = new URLSearchParams({ limit: "200" });
|
|
4973
|
+
if (opts.sourceId) qs.set("source_id", opts.sourceId);
|
|
4974
|
+
const res = await api("GET", `/v1/webhooks/events?${qs.toString()}`);
|
|
4975
|
+
if (res.body.error || res.status >= 400) throw envelopeError(res, "list webhook events");
|
|
4976
|
+
const events = res.body.data?.events ?? [];
|
|
4977
|
+
return [...events].sort((a, b2) => a.event_id < b2.event_id ? -1 : a.event_id > b2.event_id ? 1 : 0);
|
|
4978
|
+
}
|
|
4979
|
+
function selectUnseen(state, events, opts = {}) {
|
|
4980
|
+
const out = [];
|
|
4981
|
+
for (const ev of events) {
|
|
4982
|
+
if (state.seen.has(ev.event_id)) continue;
|
|
4983
|
+
markSeen(state, ev.event_id, opts.seenCap ?? SEEN_CAP);
|
|
4984
|
+
if (opts.eventPrefixes?.length) {
|
|
4985
|
+
const t = ev.event_type ?? "";
|
|
4986
|
+
if (!opts.eventPrefixes.some((p) => t.startsWith(p))) continue;
|
|
4987
|
+
}
|
|
4988
|
+
out.push(ev);
|
|
4989
|
+
}
|
|
4990
|
+
return out;
|
|
4991
|
+
}
|
|
4992
|
+
function buildDelivery(event, runId) {
|
|
4993
|
+
return {
|
|
4994
|
+
run_id: runId,
|
|
4995
|
+
job_name: "webhooks.inbound.deliver",
|
|
4996
|
+
attempt: 1,
|
|
4997
|
+
payload: {
|
|
4998
|
+
vxil_event_id: event.event_id,
|
|
4999
|
+
source_id: event.source_id,
|
|
5000
|
+
provider: event.provider,
|
|
5001
|
+
provider_event_id: event.provider_event_id,
|
|
5002
|
+
event_type: event.event_type,
|
|
5003
|
+
sig_verified: event.sig_verified,
|
|
5004
|
+
payload: event.payload
|
|
5005
|
+
}
|
|
5006
|
+
};
|
|
5007
|
+
}
|
|
5008
|
+
async function forwardOne(deps, forwardTo, event, opts) {
|
|
5009
|
+
const runId = newId("run_listen");
|
|
5010
|
+
const headers = { "content-type": "application/json" };
|
|
5011
|
+
let body;
|
|
5012
|
+
if (opts.raw) {
|
|
5013
|
+
body = JSON.stringify(event.payload ?? null);
|
|
5014
|
+
headers["x-vxil-listen"] = "raw";
|
|
5015
|
+
} else {
|
|
5016
|
+
body = JSON.stringify(buildDelivery(event, runId));
|
|
5017
|
+
headers["x-vxil-run-id"] = runId;
|
|
5018
|
+
if (opts.signingSecret && opts.tenantId) {
|
|
5019
|
+
headers[JOBS_SIG_HEADER] = await signJobsCallbackWithTenantSecret(opts.signingSecret, {
|
|
5020
|
+
runId,
|
|
5021
|
+
tenantId: opts.tenantId,
|
|
5022
|
+
body
|
|
5023
|
+
});
|
|
5024
|
+
}
|
|
5025
|
+
}
|
|
5026
|
+
try {
|
|
5027
|
+
const res = await deps.fetchImpl(forwardTo, {
|
|
5028
|
+
method: "POST",
|
|
5029
|
+
headers,
|
|
5030
|
+
body,
|
|
5031
|
+
signal: AbortSignal.timeout(2e4)
|
|
5032
|
+
});
|
|
5033
|
+
return { status: res.status, runId };
|
|
5034
|
+
} catch (e) {
|
|
5035
|
+
return { networkError: String(e.message).split("\n")[0] ?? "fetch failed", runId };
|
|
5036
|
+
}
|
|
5037
|
+
}
|
|
5038
|
+
function normalizeForwardTo(raw) {
|
|
5039
|
+
return /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
|
|
5040
|
+
}
|
|
5041
|
+
function formatSummary(state) {
|
|
5042
|
+
return `vxil listen stopped \u2014 ${state.forwarded} delivered, ${state.failed} failed`;
|
|
5043
|
+
}
|
|
5044
|
+
async function runListen(deps, opts) {
|
|
5045
|
+
const state = opts.state ?? newListenState();
|
|
5046
|
+
const intervalMs = Math.max(1, opts.intervalSeconds ?? 2) * 1e3;
|
|
5047
|
+
const sres = await deps.api("GET", "/v1/webhooks/sources");
|
|
5048
|
+
if (sres.body.error || sres.status >= 400) throw envelopeError(sres, "list webhook sources");
|
|
5049
|
+
let tenantId = sres.body.meta?.tenant_id;
|
|
5050
|
+
const sources = sres.body.data?.sources ?? [];
|
|
5051
|
+
if (sources.length === 0) {
|
|
5052
|
+
deps.err("no webhook sources yet \u2014 create one (dashboard \u2192 Webhooks, or POST /v1/webhooks/sources) and point the provider at its receiver URL; waiting for events anyway\u2026");
|
|
5053
|
+
} else {
|
|
5054
|
+
deps.err(`sources (${sources.length}):`);
|
|
5055
|
+
for (const s of sources) {
|
|
5056
|
+
deps.err(` ${s.source_id} ${s.provider} ${s.name}${s.forward_url ? "" : " (no forward_url \u2014 this listener is the only delivery)"}`);
|
|
5057
|
+
}
|
|
5058
|
+
}
|
|
5059
|
+
let signingSecret;
|
|
5060
|
+
if (!opts.raw) {
|
|
5061
|
+
const kres = await deps.api("GET", "/v1/jobs/signing-secret");
|
|
5062
|
+
if (!kres.body.error && kres.status === 200) {
|
|
5063
|
+
signingSecret = kres.body.data?.signing_secret;
|
|
5064
|
+
tenantId = kres.body.meta?.tenant_id ?? tenantId;
|
|
5065
|
+
}
|
|
5066
|
+
if (!signingSecret) {
|
|
5067
|
+
const code = kres.body.error?.code ?? `http_${kres.status}`;
|
|
5068
|
+
deps.err(`! could not fetch the jobs signing secret (${code}) \u2014 forwarding UNSIGNED. Grant jobs:read to this key for production-parity X-Vxil-Jobs-Signature headers.`);
|
|
5069
|
+
} else if (!tenantId) {
|
|
5070
|
+
signingSecret = void 0;
|
|
5071
|
+
deps.err("! no tenant_id in the response envelopes \u2014 cannot bind the signature; forwarding UNSIGNED.");
|
|
5072
|
+
}
|
|
5073
|
+
}
|
|
5074
|
+
const deliver = async (ev) => {
|
|
5075
|
+
const outcome = await forwardOne(deps, opts.forwardTo, ev, {
|
|
5076
|
+
...tenantId ? { tenantId } : {},
|
|
5077
|
+
...signingSecret ? { signingSecret } : {},
|
|
5078
|
+
...opts.raw ? { raw: true } : {}
|
|
5079
|
+
});
|
|
5080
|
+
const path = (() => {
|
|
5081
|
+
try {
|
|
5082
|
+
return new URL(opts.forwardTo).pathname || "/";
|
|
5083
|
+
} catch {
|
|
5084
|
+
return opts.forwardTo;
|
|
5085
|
+
}
|
|
5086
|
+
})();
|
|
5087
|
+
if ("status" in outcome) {
|
|
5088
|
+
if (outcome.status >= 200 && outcome.status < 300) state.forwarded += 1;
|
|
5089
|
+
else state.failed += 1;
|
|
5090
|
+
deps.log(opts.json ? JSON.stringify({ event_id: ev.event_id, event_type: ev.event_type, provider: ev.provider, status: outcome.status, run_id: outcome.runId }) : `\u2192 ${outcome.status} POST ${path} ${ev.provider} ${ev.event_type ?? "(no type)"} ${ev.event_id}`);
|
|
5091
|
+
} else {
|
|
5092
|
+
state.failed += 1;
|
|
5093
|
+
deps.log(opts.json ? JSON.stringify({ event_id: ev.event_id, event_type: ev.event_type, provider: ev.provider, network_error: outcome.networkError, run_id: outcome.runId }) : `\u2192 ERR POST ${path} ${ev.provider} ${ev.event_type ?? "(no type)"} ${ev.event_id} (${outcome.networkError})`);
|
|
5094
|
+
}
|
|
5095
|
+
if (opts.showRuns) {
|
|
5096
|
+
const rr = await deps.api("GET", `/v1/webhooks/events/${encodeURIComponent(ev.event_id)}/runs`);
|
|
5097
|
+
if (rr.body.error || rr.status >= 400) {
|
|
5098
|
+
deps.err(` runs: unavailable (${rr.body.error?.code ?? rr.status})`);
|
|
5099
|
+
} else {
|
|
5100
|
+
const runs = rr.body.data?.runs ?? [];
|
|
5101
|
+
if (runs.length === 0) {
|
|
5102
|
+
deps.err(" runs: none (the source has no forward_url \u2014 this listener is the only delivery)");
|
|
5103
|
+
} else {
|
|
5104
|
+
for (const r of runs) {
|
|
5105
|
+
deps.err(` run ${r.run_id}: ${r.state} (attempt ${r.attempt_number ?? 0}/${r.max_attempts ?? "?"}${r.dead_lettered ? ", DLQ" : ""})`);
|
|
5106
|
+
}
|
|
5107
|
+
}
|
|
5108
|
+
}
|
|
5109
|
+
}
|
|
5110
|
+
};
|
|
5111
|
+
const initial = await pollNewEvents(deps.api, opts.sourceId ? { sourceId: opts.sourceId } : {});
|
|
5112
|
+
const preexisting = selectUnseen(state, initial, opts.eventPrefixes?.length ? { eventPrefixes: opts.eventPrefixes } : {});
|
|
5113
|
+
if (opts.replayLast && opts.replayLast > 0) {
|
|
5114
|
+
const replay = preexisting.slice(-opts.replayLast);
|
|
5115
|
+
if (replay.length) deps.err(`replaying the last ${replay.length} stored event(s)\u2026`);
|
|
5116
|
+
for (const ev of replay) await deliver(ev);
|
|
5117
|
+
}
|
|
5118
|
+
deps.err(`listening \u2014 forwarding new inbound webhook events to ${opts.forwardTo} every ${intervalMs / 1e3}s${signingSecret ? " (signed: X-Vxil-Jobs-Signature)" : ""} \u2014 Ctrl-C to stop\u2026`);
|
|
5119
|
+
for (let polls = 0; opts.maxPolls === void 0 || polls < opts.maxPolls; polls++) {
|
|
5120
|
+
await deps.sleep(intervalMs);
|
|
5121
|
+
let events;
|
|
5122
|
+
try {
|
|
5123
|
+
events = await pollNewEvents(deps.api, opts.sourceId ? { sourceId: opts.sourceId } : {});
|
|
5124
|
+
} catch (e) {
|
|
5125
|
+
if (e instanceof ListenApiError && e.fatal) throw e;
|
|
5126
|
+
deps.err(`! poll failed: ${String(e.message).split("\n")[0]} \u2014 retrying`);
|
|
5127
|
+
continue;
|
|
5128
|
+
}
|
|
5129
|
+
const fresh = selectUnseen(state, events, opts.eventPrefixes?.length ? { eventPrefixes: opts.eventPrefixes } : {});
|
|
5130
|
+
for (const ev of fresh) await deliver(ev);
|
|
5131
|
+
}
|
|
5132
|
+
return state;
|
|
5133
|
+
}
|
|
5134
|
+
|
|
5135
|
+
// src/mcpInstall.ts
|
|
5136
|
+
var MCP_CLIENTS = ["cursor", "claude", "vscode"];
|
|
5137
|
+
function isMcpClient(s) {
|
|
5138
|
+
return MCP_CLIENTS.includes(s);
|
|
5139
|
+
}
|
|
5140
|
+
var KEY_PLACEHOLDER = "vxil_live_REPLACE_WITH_YOUR_KEY";
|
|
5141
|
+
function mcpUrlFor(edgeBase, envOverride) {
|
|
5142
|
+
if (envOverride) return envOverride;
|
|
5143
|
+
const base = edgeBase.replace(/\/+$/, "");
|
|
5144
|
+
return `${base.replace(/^https?:\/\/api\./, "https://mcp.")}/mcp`;
|
|
5145
|
+
}
|
|
5146
|
+
function configTarget(client, scope, io) {
|
|
5147
|
+
if (client === "cursor") {
|
|
5148
|
+
return { path: `${scope === "global" ? io.home : io.cwd}/.cursor/mcp.json`, format: "mcpServers" };
|
|
5149
|
+
}
|
|
5150
|
+
if (client === "vscode") {
|
|
5151
|
+
if (scope === "project") return { path: `${io.cwd}/.vscode/mcp.json`, format: "vscodeServers" };
|
|
5152
|
+
const userDir = io.platform === "darwin" ? `${io.home}/Library/Application Support/Code/User` : io.platform === "win32" ? `${io.home}/AppData/Roaming/Code/User` : `${io.home}/.config/Code/User`;
|
|
5153
|
+
return { path: `${userDir}/mcp.json`, format: "vscodeServers" };
|
|
5154
|
+
}
|
|
5155
|
+
if (scope === "project") return { path: `${io.cwd}/.mcp.json`, format: "mcpServers" };
|
|
5156
|
+
return { path: "", format: "claudeCli" };
|
|
5157
|
+
}
|
|
5158
|
+
var VSCODE_KEY_INPUT = {
|
|
5159
|
+
id: "vxil-api-key",
|
|
5160
|
+
type: "promptString",
|
|
5161
|
+
description: "Vxil API key",
|
|
5162
|
+
password: true
|
|
5163
|
+
};
|
|
5164
|
+
function needsInlineKey(client, scope) {
|
|
5165
|
+
return !(scope === "project" && (client === "claude" || client === "vscode"));
|
|
5166
|
+
}
|
|
5167
|
+
function keyRef(client, scope, explicitKey) {
|
|
5168
|
+
if (scope === "project" && client === "claude") {
|
|
5169
|
+
return {
|
|
5170
|
+
header: "Bearer ${VXIL_API_KEY}",
|
|
5171
|
+
note: "the entry references ${VXIL_API_KEY} \u2014 export it in your shell (e.g. from `vxil env pull`'s .env.local) so no key lands in the shareable .mcp.json"
|
|
5172
|
+
};
|
|
5173
|
+
}
|
|
5174
|
+
if (scope === "project" && client === "vscode") {
|
|
5175
|
+
return {
|
|
5176
|
+
header: `Bearer \${input:${VSCODE_KEY_INPUT.id}}`,
|
|
5177
|
+
note: "VS Code prompts for the key on first connect (held by VS Code, never written to .vscode/mcp.json)",
|
|
5178
|
+
inputs: [VSCODE_KEY_INPUT]
|
|
5179
|
+
};
|
|
5180
|
+
}
|
|
5181
|
+
return { header: `Bearer ${explicitKey ?? KEY_PLACEHOLDER}` };
|
|
5182
|
+
}
|
|
5183
|
+
var McpConfigParseError = class extends Error {
|
|
5184
|
+
constructor() {
|
|
5185
|
+
super("existing content is not valid JSON \u2014 fix it by hand, or run with --print and paste the entry yourself (refusing to overwrite)");
|
|
5186
|
+
this.name = "McpConfigParseError";
|
|
5187
|
+
}
|
|
5188
|
+
};
|
|
5189
|
+
function mergeMcpConfig(existingText, entry, format, serverName = "vxil") {
|
|
5190
|
+
let root = {};
|
|
5191
|
+
const text = (existingText ?? "").trim();
|
|
5192
|
+
if (text.length) {
|
|
5193
|
+
let parsed;
|
|
5194
|
+
try {
|
|
5195
|
+
parsed = JSON.parse(text);
|
|
5196
|
+
} catch {
|
|
5197
|
+
throw new McpConfigParseError();
|
|
5198
|
+
}
|
|
5199
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
5200
|
+
throw new McpConfigParseError();
|
|
5201
|
+
}
|
|
5202
|
+
root = parsed;
|
|
5203
|
+
}
|
|
5204
|
+
const serversKey = format === "vscodeServers" ? "servers" : "mcpServers";
|
|
5205
|
+
const cur = root[serversKey];
|
|
5206
|
+
const servers = cur && typeof cur === "object" && !Array.isArray(cur) ? cur : {};
|
|
5207
|
+
servers[serverName] = { type: "http", url: entry.url, headers: { Authorization: entry.header } };
|
|
5208
|
+
root[serversKey] = servers;
|
|
5209
|
+
if (format === "vscodeServers" && entry.inputs?.length) {
|
|
5210
|
+
const existing = Array.isArray(root.inputs) ? root.inputs : [];
|
|
5211
|
+
const merged = [...existing];
|
|
5212
|
+
for (const inp of entry.inputs) {
|
|
5213
|
+
if (!merged.some((e) => e && typeof e === "object" && e.id === inp.id)) {
|
|
5214
|
+
merged.push({ ...inp });
|
|
5215
|
+
}
|
|
5216
|
+
}
|
|
5217
|
+
root.inputs = merged;
|
|
5218
|
+
}
|
|
5219
|
+
return `${JSON.stringify(root, null, 2)}
|
|
5220
|
+
`;
|
|
5221
|
+
}
|
|
5222
|
+
function claudeAddArgv(url, keyOrPlaceholder, serverName = "vxil") {
|
|
5223
|
+
return [
|
|
5224
|
+
"mcp",
|
|
5225
|
+
"add",
|
|
5226
|
+
"--transport",
|
|
5227
|
+
"http",
|
|
5228
|
+
"--scope",
|
|
5229
|
+
"user",
|
|
5230
|
+
"--header",
|
|
5231
|
+
`Authorization: Bearer ${keyOrPlaceholder}`,
|
|
5232
|
+
serverName,
|
|
5233
|
+
url
|
|
5234
|
+
];
|
|
5235
|
+
}
|
|
5236
|
+
|
|
4863
5237
|
// src/generated/templates.ts
|
|
4864
5238
|
var TEMPLATE_CATALOG = [
|
|
4865
5239
|
{
|
|
@@ -4880,7 +5254,7 @@ var TEMPLATE_CATALOG = [
|
|
|
4880
5254
|
"hasFunctions": false,
|
|
4881
5255
|
"byoKeys": [],
|
|
4882
5256
|
"configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Blog\" \u2014 a publishing / headless-CMS backend (authors, categories, posts with\n// a draft\u2192publish lifecycle), declared end-to-end in ONE typed file. A BLUEPRINT\n// composing shipped building blocks \u2014\n// \u2022 cms \u2192 authors \u2192 categories \u2192 posts (resolved by relation)\n// \u2022 comments \u2192 threaded reader comments on posts\n// Everything here is DATA the tenant owns and edits after `vxil init`. The\n// editorial workflow migrates ~100%; the public reader tier is SHIPPED \u2014 `posts`\n// is `public: true`, served keyless over the cms public-delivery lane (cms.md \xA716).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // the editorial lifecycle: write as draft, publish live\n hooks: {\n // Every post needs a title \u2014 a pure function of the row (Lane-A validate).\n post_title: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.title) > 0',\n message: 'a post needs a title',\n },\n },\n },\n comments: {}, // threaded reader comments on published posts\n notifications: { provider: 'mock', fromEmail: 'noreply@blog.app' },\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n authors: {\n singular: 'author',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n bio: { type: 'text' },\n },\n },\n categories: {\n singular: 'category',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', unique: true },\n },\n },\n posts: {\n singular: 'post',\n // PUBLIC DELIVERY (cms.md \xA716): published posts are readable with NO API\n // key over GET /v1/cms/public/:tenantId/posts \u2014 edge-cached, drafts never\n // served. This is the reader tier of a blog: a static/JAMstack front-end\n // (or the served `listCmsPublic()` SDK helper) fetches the feed anonymously.\n public: true,\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', unique: true },\n excerpt: { type: 'text' },\n body: { type: 'text' },\n author: { type: 'relation', relationTo: 'authors', indexSlot: 's3' },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's4' },\n published_at: { type: 'datetime', indexSlot: 't1' },\n reading_minutes: { type: 'int', indexSlot: 'n1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'authors',\n items: [{ name: 'Ada Lovelace', slug: 'ada', bio: 'Writes about computing.' }],\n },\n ],\n },\n});\n",
|
|
4883
|
-
"readme": "# Blog / Publishing template\n\nA publishing / headless-CMS backend \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `authors` \u2014 name, unique slug, bio.\n- `categories` \u2014 name, unique slug.\n- `posts` \u2014 title, unique slug, excerpt, body, `author` + `category` relations (slot-bound for filtering),\n `published_at`, `reading_minutes`. A Lane-A hook requires a title, and `draftPublish` gives you the\n write-as-draft \u2192 publish-live editorial lifecycle. **`public: true`** \u2014 published posts are served over\n the keyless public-delivery lane (see below).\n- `comments` \u2014 threaded reader comments on published posts.\n\n**Use it:**\n\n```bash\nvxil init --template blog\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The editorial lifecycle is config** \u2014 `draftPublish: true` gives write-as-draft \u2192 `publish`; readers filter\n with the reserved `$status` key (`docs/features/cms.md` \xA73).\n- **Slot-bound relations are the join declaration** \u2014 `posts.author`/`posts.category` ride `s3`/`s4`, so a feed\n filters by author directly, or reaches one hop into the target: `?filter={\"category.slug\":\"news\"}` is the\n \xA712.1 single-hop dotted-key join.\n- **Invariants ride as tenant-owned Lane-A hooks** \u2014 the \"posts need a title\" rule is data in YOUR config, not\n platform code (\xA77).\n\n```ts\n// the reader feed with a server key: published posts, newest first\nconst { items, next_cursor } = await vx.from('posts').query({\n filter: { $status: 'published' }, sort: '-published_at', limit: 25,\n});\n```\n\n**The public reader tier \u2014 keyless** (`docs/features/cms.md` \xA716). Because `posts` is `public: true`, a\nstatic/JAMstack front-end reads the feed with **no API key**: the edge mints a restricted read-only token,\nforces `status = 'published'` (drafts are never served), and edge-caches the response\n(`s-maxage=60`, `stale-while-revalidate`).\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public feed \u2014 NO api key, no Vxil client, no auth\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-published_at', limit: 25 });\n// one post by slug (a draft slug 404s to the reader)\nconst { items: [post] } = await listCmsPublic('ten_your_tenant_id', 'posts', { filter: { slug: 'hello' }, limit: 1 });\n```\n\n**Reader comments UI (optional):** drop [`@vxil/comments
|
|
5257
|
+
"readme": "# Blog / Publishing template\n\nA publishing / headless-CMS backend \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `authors` \u2014 name, unique slug, bio.\n- `categories` \u2014 name, unique slug.\n- `posts` \u2014 title, unique slug, excerpt, body, `author` + `category` relations (slot-bound for filtering),\n `published_at`, `reading_minutes`. A Lane-A hook requires a title, and `draftPublish` gives you the\n write-as-draft \u2192 publish-live editorial lifecycle. **`public: true`** \u2014 published posts are served over\n the keyless public-delivery lane (see below).\n- `comments` \u2014 threaded reader comments on published posts.\n\n**Use it:**\n\n```bash\nvxil init --template blog\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n- **The editorial lifecycle is config** \u2014 `draftPublish: true` gives write-as-draft \u2192 `publish`; readers filter\n with the reserved `$status` key (`docs/features/cms.md` \xA73).\n- **Slot-bound relations are the join declaration** \u2014 `posts.author`/`posts.category` ride `s3`/`s4`, so a feed\n filters by author directly, or reaches one hop into the target: `?filter={\"category.slug\":\"news\"}` is the\n \xA712.1 single-hop dotted-key join.\n- **Invariants ride as tenant-owned Lane-A hooks** \u2014 the \"posts need a title\" rule is data in YOUR config, not\n platform code (\xA77).\n\n```ts\n// the reader feed with a server key: published posts, newest first\nconst { items, next_cursor } = await vx.from('posts').query({\n filter: { $status: 'published' }, sort: '-published_at', limit: 25,\n});\n```\n\n**The public reader tier \u2014 keyless** (`docs/features/cms.md` \xA716). Because `posts` is `public: true`, a\nstatic/JAMstack front-end reads the feed with **no API key**: the edge mints a restricted read-only token,\nforces `status = 'published'` (drafts are never served), and edge-caches the response\n(`s-maxage=60`, `stale-while-revalidate`).\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public feed \u2014 NO api key, no Vxil client, no auth\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-published_at', limit: 25 });\n// one post by slug (a draft slug 404s to the reader)\nconst { items: [post] } = await listCmsPublic('ten_your_tenant_id', 'posts', { filter: { slug: 'hello' }, limit: 1 });\n```\n\n**Reader comments UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a post\npage for the threaded `comments` feature \u2014 a pure client-side component over the shipped comments API.\n\n**Go deeper:** `docs/features/cms.md` (\xA73 query DSL \xB7 \xA77 hooks \xB7 \xA712.1 joins \xB7 **\xA716 public delivery**) \xB7\n`docs/features/comments.md` \xB7 `templates/docs-site/` (a pure public-content site) \xB7\n`templates/catalog/` (the same shape for products) \xB7 `examples/feedback-board/`.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
|
|
4884
5258
|
"functions": {}
|
|
4885
5259
|
},
|
|
4886
5260
|
{
|
|
@@ -4902,7 +5276,7 @@ var TEMPLATE_CATALOG = [
|
|
|
4902
5276
|
"hasFunctions": false,
|
|
4903
5277
|
"byoKeys": [],
|
|
4904
5278
|
"configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Microblog\" \u2014 a Twitter-style micro-blogging backend (profiles, 280-char\n// posts, follows, likes, a realtime live feed), declared end-to-end in ONE\n// typed file. A BLUEPRINT composing shipped building blocks \u2014\n// \u2022 cms \u2192 profiles \u2192 posts (by relation) + follows/likes edge rows\n// \u2022 auth \u2192 accounts, so a poster is a VERIFIED end-user\n// \u2022 realtime \u2192 the live feed channel (posts fan out via the cms `cdc` bridge)\n// Every collection declares an end-user OWNER field, so from a thin client a\n// signed-in user can only write their OWN rows; tenant-wide reads (the global\n// timeline, follower counts) are served by YOUR backend with a server key \u2014\n// owner-scoping is a no-op for server callers (docs/features/cms.md \xA715).\n// Everything here is DATA the tenant owns and edits after `vxil init`.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n draftPublish: true, // create posts with `status: 'published'` \u2014 no editorial step\n // Fail-safe (cms.md \xA715.1): a verified end-user key may only touch\n // collections that declare an ownerField. Every collection below does;\n // any collection you ADD later without one is denied to end-user keys\n // instead of silently shared tenant-wide. Server keys are unaffected.\n strictEndUserScope: true,\n // Lane-A safe-expression hooks \u2014 AST-validated at push time, run inside\n // the write transaction (cms.md \xA77). NOTE: hooks do NOT run on `$inc`\n // (\xA79.3) \u2014 likes_count is guarded by its own validation.min instead.\n hooks: {\n post_body_nonempty: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(trim(item.body)) > 0',\n message: 'a post cannot be empty',\n },\n post_body_280: {\n collection: 'posts',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.body) <= 280',\n message: 'a post is at most 280 characters',\n },\n // Field-vs-field comparison is grammar-legal (\xA77.2 operators over item.*).\n follow_not_self: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'item.follower != item.followee',\n message: 'you cannot follow yourself',\n },\n // COMPOSED-KEY integrity: `pair` is written by the client/SDK as\n // follower + ':' + followee (see the field comment). This hook makes\n // that convention server-enforced, so the `unique: true` claim on\n // `pair` really means \"at most one follow edge per (follower,\n // followee)\" \u2014 a double-follow is a clean 409 unique_violation.\n follow_pair: {\n collection: 'follows',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.follower, ':', item.followee)\",\n message: \"pair must be follower + ':' + followee\",\n },\n like_pair: {\n collection: 'likes',\n event: 'beforeWrite',\n kind: 'validate',\n expr: \"item.pair == concat(item.post, ':', item.user_id)\",\n message: \"pair must be post + ':' + user_id\",\n },\n },\n // Realtime CDC bridge (cms.md \xA714): every NEW post auto-publishes a\n // `cms.item.created` / `.published` frame (this rule's two events \u2014\n // updates/deletes don't fire it) \u2014 full item data, \u226432KB \u2014 onto the\n // realtime channel below. The config-only live feed: at-most-once,\n // fire-and-forget (guaranteed delivery would use webhooks or functions).\n cdc: {\n feed_live: {\n collection: 'posts',\n channel: 'feed:global',\n events: ['created', 'published'],\n payload: 'full',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // posters sign in as end-users\n realtime: {}, // defaults are fine; a channel exists as soon as someone uses it\n },\n\n // \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\n cms: {\n collections: {\n profiles: {\n singular: 'profile',\n // End-user owner-scope (cms.md \xA715): a signed-in user edits only their\n // OWN profile. `user_id` must be a real string field (declared below).\n ownerField: 'user_id',\n // PUBLIC DELIVERY (cms.md \xA716): public profile pages read with NO API key\n // over GET /v1/cms/public/:tenantId/profiles \u2014 edge-cached, and the\n // `user_id` owner field is STRIPPED from every served row (an anonymous\n // reader never sees the end-user id). Owner-scoping (above) still governs\n // the authed WRITE lane; public delivery is a read-only, owner-unscoped tier.\n public: true,\n fields: {\n // Declarative uniqueness (cms.md \xA79.3), carried by `vxil push`: N\n // racing claims of a handle yield exactly one 201, the rest a clean\n // 409 unique_violation \u2014 the insert IS the claim. (Flipping the flag\n // on an ALREADY-pushed field is a dashboard/REST in-place alter \u2014\n // push's diff compares field name+type only.)\n handle: { type: 'string', required: true, indexSlot: 's1', unique: true },\n display_name: { type: 'string', indexSlot: 's2' },\n bio: { type: 'text' },\n user_id: { type: 'string', indexSlot: 's3' }, // the owner (end-user) id\n },\n },\n posts: {\n singular: 'post',\n ownerField: 'user_id',\n // PUBLIC DELIVERY (cms.md \xA716): the GLOBAL TIMELINE served with NO API key\n // over GET /v1/cms/public/:tenantId/posts?sort=-posted_at \u2014 edge-cached,\n // published-only, `user_id` stripped from every row. This is exactly the\n // \"tenant-wide reads served by YOUR backend\" note above, but now keyless:\n // an anonymous visitor reads the public feed without your server key.\n public: true,\n fields: {\n body: { type: 'text', required: true }, // \u2264280 chars \u2014 enforced by the hooks above\n author: { type: 'relation', relationTo: 'profiles', indexSlot: 's1' },\n user_id: { type: 'string', indexSlot: 's2' }, // the owner (end-user) id\n posted_at: { type: 'datetime', indexSlot: 't1' }, // slot t1 \u21D2 sort=-posted_at is index-served\n // Bumped atomically via PATCH {\"$inc\":{\"likes_count\":1}} (cms.md \xA79.3);\n // min:0 turns a decrement below zero into a clean 409, never a race.\n likes_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n },\n },\n follows: {\n singular: 'follow',\n // Owner = the follower: an end-user creates/removes only their OWN edges.\n ownerField: 'follower',\n fields: {\n follower: { type: 'string', required: true, indexSlot: 's1' }, // end-user id\n followee: { type: 'string', required: true, indexSlot: 's2' }, // end-user id\n // COMPOSED KEY \u2014 cms `unique` is single-field, so composite uniqueness\n // is modeled by having the client/SDK write follower + ':' + followee\n // here; the `follow_pair` hook rejects a mismatched composition, and\n // `unique: true` (cms.md \xA79.3) makes a double-follow a clean 409\n // unique_violation on a plain create \u2014 no lock/guard needed for\n // pair dedup. (lock+guard, \xA710, stays the tool for count-invariants\n // BEYOND uniqueness \u2014 e.g. \"at most N\".)\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n likes: {\n singular: 'like',\n ownerField: 'user_id',\n fields: {\n post: { type: 'relation', relationTo: 'posts', required: true, indexSlot: 's1' },\n user_id: { type: 'string', required: true, indexSlot: 's2' }, // the owner (end-user) id\n // COMPOSED KEY \u2014 post + ':' + user_id, declared unique: one like per\n // user per post; a double-like is a clean 409 unique_violation.\n pair: { type: 'string', required: true, indexSlot: 's3', unique: true },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'profiles',\n items: [\n { handle: 'ada', display_name: 'Ada Lovelace', bio: 'Notes on engines, in 280 chars.', user_id: 'usr_demo_ada' },\n { handle: 'grace', display_name: 'Grace Hopper', bio: 'Compilers, ships, short posts.', user_id: 'usr_demo_grace' },\n ],\n },\n {\n // The `author` relation is omitted here: seed items are plain creates and\n // cannot reference the server-generated item_id of the profiles above \u2014\n // set it on posts your app creates at runtime. Seeded items land as\n // drafts; publish them from the dashboard, or create real posts with\n // `status: 'published'` (see README).\n collection: 'posts',\n items: [\n { body: 'Hello, world \u2014 first post on my own backend.', user_id: 'usr_demo_ada', posted_at: '2026-07-01T09:00:00Z', likes_count: 0 },\n { body: 'A microblog is just cms + auth + realtime in one config file.', user_id: 'usr_demo_grace', posted_at: '2026-07-01T09:05:00Z', likes_count: 0 },\n ],\n },\n ],\n },\n});\n",
|
|
4905
|
-
"readme": "# Microblog / Social Feed template\n\nA Twitter-style micro-blogging backend \u2014 profiles, 280-character posts, follows, likes, and a realtime\nlive feed \u2014 declared end-to-end in one typed `vxil.config.ts`. Backend building blocks you enable in one\nline: `cms` holds the data (Lane-A hooks enforce the 280-char rule inside the write transaction), `auth`\nmakes every poster a verified end-user, and `realtime` streams new posts to every open client.\n\n**What it provisions:**\n- `profiles` \u2014 unique `handle`, display name, bio; owner-scoped by `user_id`. **`public: true`** \u2014 public\n profile pages read keyless (the `user_id` owner field is stripped from every served row).\n- `posts` \u2014 `body` (two validate hooks: non-empty, \u2264280), `author` relation \u2192 profiles, `posted_at`,\n atomic `likes_count`; owner-scoped by `user_id`. **`public: true`** \u2014 the global timeline reads keyless.\n- `follows` \u2014 `follower`/`followee` + a composed unique `pair` key; hooks block self-follows and a malformed pair.\n- `likes` \u2014 `post` relation + `user_id` + a composed unique `pair` key (one like per user per post).\n- Features: `cms` (with `strictEndUserScope` + a `cdc` live-feed rule), `auth` (email/password), `realtime`.\n\n**Apply it:**\n\n```bash\nvxil init --template microblog\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n1. **End-user owner scoping** (`docs/features/cms.md` \xA715) \u2014 every collection names an `ownerField`, so a\n signed-in user can only write their OWN rows from a thin client; `strictEndUserScope: true` fail-closes\n any collection you later forget to scope. Server keys are unaffected \u2014 the global timeline and\n follower counts are served by your backend, where owner-scoping is a no-op.\n2. **Composed-key uniqueness** \u2014 cms `unique` is single-field, so \"unique (follower, followee)\" is modeled\n as a `pair` field the client writes as `follower + ':' + followee`; a validate hook enforces the\n composition, and the declarative `unique: true` on `pair` (in the config, carried by `vxil push` \u2014\n `cms.md` \xA79.3) turns a double-follow/double-like into a clean `409 unique_violation` on a plain\n create \u2014 no lock needed. (`lock` + `guard` (`cms.md` \xA710) remains the general tool for\n count-invariants BEYOND uniqueness, e.g. \"at most N seats/redemptions\".)\n3. **Atomic counters** (`cms.md` \xA79.3) \u2014 `likes_count` bumps via `$inc`: ONE conditional UPDATE, no\n read-modify-write race; `validation.min: 0` refuses a decrement below zero.\n4. **Realtime live feed** (`cms.md` \xA714) \u2014 the `cdc` rule auto-publishes every new post (its\n `created`/`published` events) onto the `feed:global` channel; browsers subscribe with\n `@vxil/realtime` (`docs/features/realtime.md` \xA75).\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nimport type { VxilSchema } from './vxil.types';\nconst vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! });\n\n// post \u2014 the hooks reject empty or >280 bodies inside the write tx\nconst { item_id: post } = await vx.from('posts').create(\n { body: 'hello from my own backend', user_id: 'usr_demo_ada',\n posted_at: new Date().toISOString(), likes_count: 0 },\n { status: 'published' }, // also fires the cdc frame onto feed:global\n);\n// like it \u2014 a plain create: the unique claim on `pair` makes a double-like a clean 409 unique_violation \u2026\nconst pair = `${post}:usr_demo_grace`;\nawait vx.from('likes').create({ post, user_id: 'usr_demo_grace', pair });\n// \u2026 and the counter bumps atomically, no read-modify-write (cms.md \xA79.3)\nawait vx.from('posts').inc(post, { likes_count: 1 });\n```\n\n**The public timeline \u2014 keyless** (`docs/features/cms.md` \xA716). Because `posts` and `profiles` are\n`public: true`, an anonymous visitor reads the global feed and public profile pages with **no API key** \u2014\nthe edge forces `status = 'published'`, edge-caches the page, and **strips the `user_id` owner field** from\nevery row (an anonymous reader never sees an end-user id). Owner-scoping still governs every authed WRITE:\nthe two lanes are independent. (`follows`/`likes` stay private \u2014 no `public` flag.)\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public global timeline \u2014 NO api key, user_id stripped from every row\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-posted_at', limit: 25 });\n// a public profile by handle\nconst { items: [p] } = await listCmsPublic('ten_your_tenant_id', 'profiles', { filter: { handle: 'ada' }, limit: 1 });\n```\n\n**Drop-in UI (optional):** [`@vxil/realtime`](../../packages/realtime) is the browser client for the live\n`feed:global` channel; [`@vxil/
|
|
5279
|
+
"readme": "# Microblog / Social Feed template\n\nA Twitter-style micro-blogging backend \u2014 profiles, 280-character posts, follows, likes, and a realtime\nlive feed \u2014 declared end-to-end in one typed `vxil.config.ts`. Backend building blocks you enable in one\nline: `cms` holds the data (Lane-A hooks enforce the 280-char rule inside the write transaction), `auth`\nmakes every poster a verified end-user, and `realtime` streams new posts to every open client.\n\n**What it provisions:**\n- `profiles` \u2014 unique `handle`, display name, bio; owner-scoped by `user_id`. **`public: true`** \u2014 public\n profile pages read keyless (the `user_id` owner field is stripped from every served row).\n- `posts` \u2014 `body` (two validate hooks: non-empty, \u2264280), `author` relation \u2192 profiles, `posted_at`,\n atomic `likes_count`; owner-scoped by `user_id`. **`public: true`** \u2014 the global timeline reads keyless.\n- `follows` \u2014 `follower`/`followee` + a composed unique `pair` key; hooks block self-follows and a malformed pair.\n- `likes` \u2014 `post` relation + `user_id` + a composed unique `pair` key (one like per user per post).\n- Features: `cms` (with `strictEndUserScope` + a `cdc` live-feed rule), `auth` (email/password), `realtime`.\n\n**Apply it:**\n\n```bash\nvxil init --template microblog\nvxil quickstart # or `vxil link` to an existing tenant\nvxil push\nvxil gen\n```\n\n**What to learn from this:**\n1. **End-user owner scoping** (`docs/features/cms.md` \xA715) \u2014 every collection names an `ownerField`, so a\n signed-in user can only write their OWN rows from a thin client; `strictEndUserScope: true` fail-closes\n any collection you later forget to scope. Server keys are unaffected \u2014 the global timeline and\n follower counts are served by your backend, where owner-scoping is a no-op.\n2. **Composed-key uniqueness** \u2014 cms `unique` is single-field, so \"unique (follower, followee)\" is modeled\n as a `pair` field the client writes as `follower + ':' + followee`; a validate hook enforces the\n composition, and the declarative `unique: true` on `pair` (in the config, carried by `vxil push` \u2014\n `cms.md` \xA79.3) turns a double-follow/double-like into a clean `409 unique_violation` on a plain\n create \u2014 no lock needed. (`lock` + `guard` (`cms.md` \xA710) remains the general tool for\n count-invariants BEYOND uniqueness, e.g. \"at most N seats/redemptions\".)\n3. **Atomic counters** (`cms.md` \xA79.3) \u2014 `likes_count` bumps via `$inc`: ONE conditional UPDATE, no\n read-modify-write race; `validation.min: 0` refuses a decrement below zero.\n4. **Realtime live feed** (`cms.md` \xA714) \u2014 the `cdc` rule auto-publishes every new post (its\n `created`/`published` events) onto the `feed:global` channel; browsers subscribe with\n `@vxil/realtime` (`docs/features/realtime.md` \xA75).\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nimport type { VxilSchema } from './vxil.types';\nconst vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! });\n\n// post \u2014 the hooks reject empty or >280 bodies inside the write tx\nconst { item_id: post } = await vx.from('posts').create(\n { body: 'hello from my own backend', user_id: 'usr_demo_ada',\n posted_at: new Date().toISOString(), likes_count: 0 },\n { status: 'published' }, // also fires the cdc frame onto feed:global\n);\n// like it \u2014 a plain create: the unique claim on `pair` makes a double-like a clean 409 unique_violation \u2026\nconst pair = `${post}:usr_demo_grace`;\nawait vx.from('likes').create({ post, user_id: 'usr_demo_grace', pair });\n// \u2026 and the counter bumps atomically, no read-modify-write (cms.md \xA79.3)\nawait vx.from('posts').inc(post, { likes_count: 1 });\n```\n\n**The public timeline \u2014 keyless** (`docs/features/cms.md` \xA716). Because `posts` and `profiles` are\n`public: true`, an anonymous visitor reads the global feed and public profile pages with **no API key** \u2014\nthe edge forces `status = 'published'`, edge-caches the page, and **strips the `user_id` owner field** from\nevery row (an anonymous reader never sees an end-user id). Owner-scoping still governs every authed WRITE:\nthe two lanes are independent. (`follows`/`likes` stay private \u2014 no `public` flag.)\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public global timeline \u2014 NO api key, user_id stripped from every row\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'posts', { sort: '-posted_at', limit: 25 });\n// a public profile by handle\nconst { items: [p] } = await listCmsPublic('ten_your_tenant_id', 'profiles', { filter: { handle: 'ada' }, limit: 1 });\n```\n\n**Drop-in UI (optional):** [`@vxil/realtime`](../../packages/realtime) is the browser client for the live\n`feed:global` channel; [`@vxil/react/feed`](../../packages/react) is a React timeline +\nnotification-bell surface over the shipped `activity-feed` API for a follow-graph home feed. (The React\ncomponents ship via npm into a bundled app \u2014 there is no served `.mjs` for them, unlike `@vxil/realtime`.)\n\n**Go deeper:** `docs/features/cms.md` (\xA77 hooks, \xA79 concurrency, \xA710 lock/guard, \xA714 CDC, \xA715 owner-scoping,\n**\xA716 public delivery**), `docs/features/realtime.md` (+ the `@vxil/realtime` browser client),\n`docs/features/auth.md`, and `examples/ecommerce/` for the same patterns composed with functions.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n",
|
|
4906
5280
|
"functions": {}
|
|
4907
5281
|
},
|
|
4908
5282
|
{
|
|
@@ -4953,7 +5327,7 @@ var TEMPLATE_CATALOG = [
|
|
|
4953
5327
|
// \u2022 cms \u2192 sections \u2192 pages (draft\u2192publish) + changelog entries
|
|
4954
5328
|
// Authors write drafts behind an API key; readers see only PUBLISHED rows, served
|
|
4955
5329
|
// from the edge cache. Everything here is DATA the tenant owns and edits after
|
|
4956
|
-
// \`vxil init\`. Drop the @vxil/comments
|
|
5330
|
+
// \`vxil init\`. Drop the @vxil/react/comments widget onto a page for reader
|
|
4957
5331
|
// comments (add the \`comments\` feature first).
|
|
4958
5332
|
// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4959
5333
|
export default defineConfig({
|
|
@@ -5070,7 +5444,7 @@ export default defineConfig({
|
|
|
5070
5444
|
},
|
|
5071
5445
|
});
|
|
5072
5446
|
`,
|
|
5073
|
-
"readme": "# Docs Site / Changelog template\n\nA **public content site** \u2014 documentation sections and pages plus a changelog feed \u2014 declared end-to-end\nin one typed `vxil.config.ts`. This is the canonical showcase of **cms public delivery**\n(`docs/features/cms.md` \xA716): every reader-facing collection is `public: true`, so a static/JAMstack\nfront-end serves the whole site over the **keyless, edge-cached** `GET /v1/cms/public/:tenantId/:collection`\nlane \u2014 **no API key on the read path at all**. Authors write drafts behind an API key; readers see only\n**published** rows, served from the edge cache.\n\n**What it provisions (all `cms` collections you own and can edit):**\n- `sections` \u2014 the doc nav tree (title, unique `slug`, `order` for the sidebar, summary). **Public.**\n- `pages` \u2014 the documentation pages (title, unique `slug`, a `section` relation slot-bound for the\n single-hop join, `order`, markdown `body`, `updated_at`, JSON `tags`). **Public.** A Lane-A hook\n requires a title and a lowercase slug.\n- `changelog` \u2014 a release feed (`version`, `title`, `kind`, `released_at`, markdown `body`). **Public.**\n A Lane-A hook requires a version.\n- Feature: `cms` with `draftPublish` \u2014 write as draft, publish live.\n\n**Apply it:**\n\n```bash\nvxil init --template docs-site\nvxil quickstart # a fresh backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks (the `public: true` flags ride push \u2014 cms.md \xA716)\nvxil gen # typed SDK + per-tenant MCP catalog\n```\n\n## The whole point: a keyless public read path\n\nBecause `sections`, `pages`, and `changelog` are `public: true`, your front-end reads them with **no API\nkey** \u2014 the edge mints a restricted read-only token bound to your tenant, forces `status = 'published'`,\nand edge-caches the response (`s-maxage=60`, `stale-while-revalidate`). Drafts are never served.\n\n```ts\n// Reader front-end \u2014 NO api key, no Vxil client, no auth. The served SDK's\n// keyless helper (docs/features/cms.md \xA716); or hit the URL with plain fetch.\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n\nconst TENANT = 'ten_your_tenant_id';\n\n// the sidebar: published sections, in order\nconst { items: sections } = await listCmsPublic(TENANT, 'sections', { sort: 'order', limit: 100 });\n\n// one page by slug (published only \u2014 a draft slug 404s to the reader)\nconst { items: [page] } = await listCmsPublic(TENANT, 'pages', { filter: { slug: 'introduction' }, limit: 1 });\n\n// every page in the \"guides\" section \u2014 the \xA712.1 single-hop dotted-key join\nconst guides = await listCmsPublic(TENANT, 'pages', { filter: { 'section.slug': 'guides' }, sort: 'order' });\n\n// the changelog, newest first (released_at is slot-bound \u21D2 index-served)\nconst { items: releases } = await listCmsPublic(TENANT, 'changelog', { sort: '-released_at', limit: 25 });\n```\n\nOr with plain `fetch` (any language, any runtime):\n\n```bash\ncurl \"https://api.vxil.com/v1/cms/public/$TENANT/pages?sort=order&limit=100\"\n```\n\n## What to learn from this\n\n1. **Public delivery is one collection flag** (`docs/features/cms.md` \xA716) \u2014 `public: true` opts a\n collection into the keyless lane; it is layered **on top of** the tenant isolation boundary, never replacing it.\n Carried by **both** push paths (the `vxil push` reconciler AND control-plane `/v1/apply`), so the flag\n is not read-only inert.\n2. **Published-only, default-deny** \u2014 the lane FORCES `status = 'published'`; a `$status` filter is\n rejected, so no query param can ever surface a draft. Keep a page draft while writing and it stays\n private until you publish it \u2014 you can stage a whole release behind an API key, then publish atomically.\n3. **Owner ids never leak** \u2014 this lane is owner-**unscoped** by design (public content is not per-user);\n if a public collection also declares an `ownerField`, it is stripped from every served row. (The doc\n collections here declare none \u2014 they are shared content.)\n4. **Editorial invariants ride as tenant-owned Lane-A hooks** \u2014 \"a page needs a title\", \"slug must be\n lowercase\", \"a release needs a version\" are AST-checked safe expressions in **your** config, run inside\n the write transaction (\xA77), not platform code.\n5. **Honest limits** (\xA716) \u2014 the public lane is **read-only** (no keyless writes), serves the safe query\n subset (`filter`/`sort`/`limit`/`cursor`) only \u2014 not the \xA712 relational read-models \u2014 and never more\n than **100 rows/page**. Freshness is eventually-consistent within `s-maxage=60` of a change.\n\n## Add reader comments (optional)\n\nTo let readers comment on a page, enable the `comments` feature and drop the\n[`@vxil/comments
|
|
5447
|
+
"readme": "# Docs Site / Changelog template\n\nA **public content site** \u2014 documentation sections and pages plus a changelog feed \u2014 declared end-to-end\nin one typed `vxil.config.ts`. This is the canonical showcase of **cms public delivery**\n(`docs/features/cms.md` \xA716): every reader-facing collection is `public: true`, so a static/JAMstack\nfront-end serves the whole site over the **keyless, edge-cached** `GET /v1/cms/public/:tenantId/:collection`\nlane \u2014 **no API key on the read path at all**. Authors write drafts behind an API key; readers see only\n**published** rows, served from the edge cache.\n\n**What it provisions (all `cms` collections you own and can edit):**\n- `sections` \u2014 the doc nav tree (title, unique `slug`, `order` for the sidebar, summary). **Public.**\n- `pages` \u2014 the documentation pages (title, unique `slug`, a `section` relation slot-bound for the\n single-hop join, `order`, markdown `body`, `updated_at`, JSON `tags`). **Public.** A Lane-A hook\n requires a title and a lowercase slug.\n- `changelog` \u2014 a release feed (`version`, `title`, `kind`, `released_at`, markdown `body`). **Public.**\n A Lane-A hook requires a version.\n- Feature: `cms` with `draftPublish` \u2014 write as draft, publish live.\n\n**Apply it:**\n\n```bash\nvxil init --template docs-site\nvxil quickstart # a fresh backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks (the `public: true` flags ride push \u2014 cms.md \xA716)\nvxil gen # typed SDK + per-tenant MCP catalog\n```\n\n## The whole point: a keyless public read path\n\nBecause `sections`, `pages`, and `changelog` are `public: true`, your front-end reads them with **no API\nkey** \u2014 the edge mints a restricted read-only token bound to your tenant, forces `status = 'published'`,\nand edge-caches the response (`s-maxage=60`, `stale-while-revalidate`). Drafts are never served.\n\n```ts\n// Reader front-end \u2014 NO api key, no Vxil client, no auth. The served SDK's\n// keyless helper (docs/features/cms.md \xA716); or hit the URL with plain fetch.\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n\nconst TENANT = 'ten_your_tenant_id';\n\n// the sidebar: published sections, in order\nconst { items: sections } = await listCmsPublic(TENANT, 'sections', { sort: 'order', limit: 100 });\n\n// one page by slug (published only \u2014 a draft slug 404s to the reader)\nconst { items: [page] } = await listCmsPublic(TENANT, 'pages', { filter: { slug: 'introduction' }, limit: 1 });\n\n// every page in the \"guides\" section \u2014 the \xA712.1 single-hop dotted-key join\nconst guides = await listCmsPublic(TENANT, 'pages', { filter: { 'section.slug': 'guides' }, sort: 'order' });\n\n// the changelog, newest first (released_at is slot-bound \u21D2 index-served)\nconst { items: releases } = await listCmsPublic(TENANT, 'changelog', { sort: '-released_at', limit: 25 });\n```\n\nOr with plain `fetch` (any language, any runtime):\n\n```bash\ncurl \"https://api.vxil.com/v1/cms/public/$TENANT/pages?sort=order&limit=100\"\n```\n\n## What to learn from this\n\n1. **Public delivery is one collection flag** (`docs/features/cms.md` \xA716) \u2014 `public: true` opts a\n collection into the keyless lane; it is layered **on top of** the tenant isolation boundary, never replacing it.\n Carried by **both** push paths (the `vxil push` reconciler AND control-plane `/v1/apply`), so the flag\n is not read-only inert.\n2. **Published-only, default-deny** \u2014 the lane FORCES `status = 'published'`; a `$status` filter is\n rejected, so no query param can ever surface a draft. Keep a page draft while writing and it stays\n private until you publish it \u2014 you can stage a whole release behind an API key, then publish atomically.\n3. **Owner ids never leak** \u2014 this lane is owner-**unscoped** by design (public content is not per-user);\n if a public collection also declares an `ownerField`, it is stripped from every served row. (The doc\n collections here declare none \u2014 they are shared content.)\n4. **Editorial invariants ride as tenant-owned Lane-A hooks** \u2014 \"a page needs a title\", \"slug must be\n lowercase\", \"a release needs a version\" are AST-checked safe expressions in **your** config, run inside\n the write transaction (\xA77), not platform code.\n5. **Honest limits** (\xA716) \u2014 the public lane is **read-only** (no keyless writes), serves the safe query\n subset (`filter`/`sort`/`limit`/`cursor`) only \u2014 not the \xA712 relational read-models \u2014 and never more\n than **100 rows/page**. Freshness is eventually-consistent within `s-maxage=60` of a change.\n\n## Add reader comments (optional)\n\nTo let readers comment on a page, enable the `comments` feature and drop the\n[`@vxil/react/comments`](../../packages/react) widget onto your page (a pure client-side component\nover the shipped comments API). The public docs stay keyless; comments authenticate as end-users.\n\n**Go deeper:** `docs/features/cms.md` (\xA73 query DSL \xB7 \xA77 hooks \xB7 \xA712.1 joins \xB7 **\xA716 public delivery**) \xB7\n`templates/blog/` (an editorial variant with reader comments) \xB7 `templates/catalog/` (a public product grid) \xB7\n`docs/cms-content-templates-analysis.md`.\n\n**Own the shape.** The config is yours after `init` \u2014 add a `docs`-vs-`api` section type, an `authors`\nrelation, a search-index collection. Nothing is locked.\n",
|
|
5074
5448
|
"functions": {}
|
|
5075
5449
|
},
|
|
5076
5450
|
{
|
|
@@ -5365,7 +5739,7 @@ export default defineConfig({
|
|
|
5365
5739
|
},
|
|
5366
5740
|
});
|
|
5367
5741
|
`,
|
|
5368
|
-
"readme": "# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the cms \xA710 pattern \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge).\n\n**Apply it:**\n\n```bash\nvxil init --template storefront\nvxil quickstart\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook\nvxil push\nvxil seed # demo products + the WELCOME10 coupon\nvxil gen\n```\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, `cms.md` \xA79.3), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n\n```typescript\n// browse the live catalog with a server key (typed SDK)\nconst { items } = await vx.from('products').query({\n filter: { $status: 'published' },\n sort: '-price_cents',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (`docs/features/cms.md` \xA716). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = 'published'` and edge-caches the response. Only the write path (cart,\n checkout, admin) needs a key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-price_cents', limit: 24 });\n// a product's sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic('ten_your_tenant_id', 'variants', { filter: { product: productId } });\n```\n\n**Reader UI (optional):** drop [`@vxil/comments
|
|
5742
|
+
"readme": "# Storefront / E-commerce template\n\nA single-seller e-commerce backend \u2014 catalog, variants, owner-scoped carts, coupons, moderated\nreviews, and an exactly-once checkout \u2014 declared in one typed `vxil.config.ts` plus four functions.\nPayments run as a payments integration with **your own Stripe account**: vxil is never in the flow of funds.\n\n**What it provisions:**\n- `products` / `variants` \u2014 the catalog; `variants.stock` is the oversell-safe inventory counter;\n `variants.price_ref` holds the provider price id (e.g. a Stripe Price) the hosted checkout charges by.\n Both are **`public: true`** \u2014 the shopfront (grid + product-detail variants) reads keyless (see below);\n the transactional collections (`carts`/`orders`/`coupons`) are **not** public.\n- `carts` / `cart_items` \u2014 owner-scoped carts per shopper (guest checkout via anonymous auth);\n each line snapshots `unit_price_cents` + `price_ref` at add-to-cart.\n- `orders` \u2014 unique `number` + unique `cart_ref`, with a state-machine hook guarding status transitions.\n- `coupons` / `coupon_redemptions` \u2014 codes plus one row per redemption; cap redemption writes\n with a write-body `guards[]` under one `lock` (the cms \xA710 pattern \u2014 shown in the config, not wired into `checkout.ts`).\n- `reviews` \u2014 owner-scoped, 1\u20135 rating enforced by a hook, moderated via draft/publish.\n- Features: `cms`, `auth`, `payments` (Stripe, BYO keys), `notifications`, `functions`; four functions \u2014\n `checkout` (the saga), `price-cart` (pricing engine), `on-order-paid` (receipt + fulfillment webhook),\n `abandoned-cart` (hourly cron nudge).\n\n**Apply it:**\n\n```bash\nvxil init --template storefront\nvxil quickstart\nvxil secrets set stripe_secret && vxil secrets set stripe_webhook\nvxil push\nvxil seed # demo products + the WELCOME10 coupon\nvxil gen\n```\n\n**What to learn from this:**\n1. **The checkout saga** (`functions/checkout.ts`) \u2014 reserve inventory \u2192 create the order \u2192 open the\n payment, compensating on any failure. All-or-nothing across features, no cross-feature ACID needed.\n Invoke `checkout`/`price-cart` from YOUR backend (server key): with `strictEndUserScope` on, an\n end-user-mode invocation is correctly denied on the shared collections they touch\n (`cart_items`/`variants`/`coupons`) \u2014 inventory is tenant-wide by design.\n2. **Oversell-safety** \u2014 `variants.stock` with `validation: { min: 0 }` makes `PATCH { $inc: { stock: -qty } }`\n a single-statement conditional decrement: exactly one winner under N concurrent buyers.\n3. **Exactly-once placement** \u2014 `orders.cart_ref` is declared `unique: true` in the config (carried by\n `vxil push`, `cms.md` \xA79.3), so N racing checkouts of one cart yield exactly one order \u2014 the rest see\n the `409 unique_violation` and `checkout.ts` answers `already_placed` (idempotent).\n\n```typescript\n// browse the live catalog with a server key (typed SDK)\nconst { items } = await vx.from('products').query({\n filter: { $status: 'published' },\n sort: '-price_cents',\n limit: 25,\n});\n```\n\n4. **The public shopfront \u2014 keyless** (`docs/features/cms.md` \xA716). `products` and `variants` are\n `public: true`, so a static/JAMstack storefront renders the grid and product-detail pages with **no API\n key** \u2014 the edge forces `status = 'published'` and edge-caches the response. Only the write path (cart,\n checkout, admin) needs a key; the transactional collections are never public.\n\n```ts\nimport { listCmsPublic } from 'https://vxil.com/sdk.mjs';\n// the public grid \u2014 NO api key\nconst { items } = await listCmsPublic('ten_your_tenant_id', 'products', { sort: '-price_cents', limit: 24 });\n// a product's sellable variants (price, availability, provider price_ref)\nconst variants = await listCmsPublic('ten_your_tenant_id', 'variants', { filter: { product: productId } });\n```\n\n**Reader UI (optional):** drop [`@vxil/react/comments`](../../packages/react) onto a product page\nfor the `reviews` thread + a file uploader (enable the `comments` feature) \u2014 a pure client-side component\nover the shipped API. It installs via npm into a bundled app (no served `.mjs`).\n\n**Go deeper:** [`examples/ecommerce`](../../examples/ecommerce) is the fully-annotated deep version of this\nblueprint; [`docs/features/cms.md`](../../docs/features/cms.md) (\xA77 hooks, \xA79.3 `$inc`, \xA710 `lock`/`guards[]`,\n**\xA716 public delivery**), [`docs/features/payments.md`](../../docs/features/payments.md),\n[`docs/features/functions.md`](../../docs/features/functions.md), and `templates/catalog/` (a content-only\npublic product grid).\n",
|
|
5369
5743
|
"functions": {
|
|
5370
5744
|
"abandoned-cart.ts": "// abandoned-cart.ts \u2014 RETENTION CRON (a vxil function, \xA77.3).\n//\n// Trigger: cron `0 * * * *` (hourly). Sweep open carts that went stale (last_activity\n// older than 1h) using the slot-indexed range filter, and nudge the shopper. This is the\n// jobs-cron pattern \u2014 no new primitive, just a scheduled function.\n\ninterface Env { vxil_base?: string; scoped_jwts?: Record<string, string> }\ninterface Cart { status: string; last_activity: string; end_user?: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const cms = env.scoped_jwts?.cms;\n const notif = env.scoped_jwts?.notifications;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n\n // carts still `open` whose last_activity is > 1h ago (t1 range filter, index-served)\n const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();\n const filter = enc({ status: 'open', last_activity: { $lt: cutoff } });\n const res = await fetch(`${base}/v1/cms/items/carts?filter=${filter}&limit=100`, {\n headers: { authorization: `Bearer ${cms}` },\n });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: Cart }[] } };\n const carts = body.data?.items ?? [];\n\n // POST /v1/notifications/send is { user_id, template, data } \u2014 `transactional` is the\n // shipped generic template (requires data.subject + data.paragraph, notifications.md \xA77).\n let nudged = 0;\n for (const c of carts) {\n if (!notif || !c.data.end_user) continue;\n await fetch(`${base}/v1/notifications/send`, {\n method: 'POST',\n headers: { authorization: `Bearer ${notif}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_id: c.data.end_user,\n template: 'transactional',\n data: {\n subject: 'You left items in your cart',\n paragraph: `Your cart (${c.item_id}) is still waiting \u2014 come back and finish checkout any time.`,\n },\n }),\n });\n nudged++;\n }\n return Response.json({ scanned: carts.length, nudged });\n },\n};\n\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\n",
|
|
5371
5745
|
"checkout.ts": "// checkout.ts \u2014 THE CHECKOUT SAGA (a vxil function, \xA77.3).\n//\n// The hard part of e-commerce: \"reserve N SKUs + capture payment + create the order,\n// all-or-nothing\" \u2014 which is the deliberately-REJECTED cross-feature-ACID case. The\n// doctrinal (and incumbent-identical) answer is a reserve\u2192settle\u2192reverse SAGA, and it\n// is exactly-once under any concurrency. Shopify+Stripe do the same thing (Stripe is a\n// physically separate system reconciled by webhook); nothing here is a platform gap.\n//\n// Invoke it SERVER-SIDE (your backend POSTs /v1/fn/checkout with a server key):\n// under cms.strictEndUserScope an end-user-mode invocation is correctly denied on\n// the shared collections this saga touches (cart_items/variants) \u2014 inventory is a\n// tenant-wide surface, so the reserve step is server work by design.\n//\n// Steps:\n// 1. read the cart (owner + currency) + its lines (cms:read)\n// 2. RESERVE each line: PATCH variant {$inc:{stock:-qty}} \u2014 validation.min:0 makes it a\n// single-statement oversell-safe decrement (409 inc_out_of_bounds if insufficient).\n// On any failure \u2192 COMPENSATE (re-$inc the ones already reserved) \u2192 409 out_of_stock.\n// 3. create the ORDER with a unique cart_ref \u2192 EXACTLY-ONCE (409 on a racing duplicate).\n// 4. open a payments checkout-session (mode:payment, Idempotency-Key = order number).\n// 5. return { order_id, checkout_url }. Capture completes async \u2192 functions/on-order-paid.ts.\n// (Not shipped here: if payment never completes, schedule a jobs `deliver_after`\n// release that re-$inc's the reserve.)\n\ninterface Env {\n vxil_base?: string;\n scoped_jwts?: Record<string, string>;\n end_user?: { id: string };\n // the caller's HTTP body rides the invocation envelope under `payload` (functions.md \xA72)\n payload?: { cart_id?: string; success_url?: string; cancel_url?: string };\n}\ninterface Line { variant: string; qty: number; unit_price_cents: number; price_ref: string }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.org';\n const cms = env.scoped_jwts?.cms;\n const pay = env.scoped_jwts?.payments;\n if (!cms || !pay) return json({ error: 'missing cms/payments scope' }, 403);\n const { cart_id, success_url, cancel_url } = env.payload ?? {};\n if (!cart_id) return json({ error: 'cart_id required' }, 400);\n\n const H = (jwt: string) => ({ authorization: `Bearer ${jwt}`, 'content-type': 'application/json' });\n\n // 1. read the cart (its end_user owner + currency), then its lines\n const cartRes = await fetch(`${base}/v1/cms/items/carts/${cart_id}`, { headers: { authorization: `Bearer ${cms}` } });\n if (!cartRes.ok) return json({ error: 'cart_not_found' }, 404);\n const cart = ((await cartRes.json()) as { data?: { data?: { end_user?: string; currency?: string } } }).data?.data ?? {};\n const shopper = env.end_user?.id ?? cart.end_user;\n if (!shopper) return json({ error: 'cart has no owner (end_user)' }, 400);\n const lines = await get<Line>(`${base}/v1/cms/items/cart_items?filter=${enc({ cart: cart_id })}&limit=100`, cms);\n if (lines.length === 0) return json({ error: 'empty cart' }, 400);\n\n // 2. RESERVE inventory line-by-line (oversell-safe $inc). Track for compensation.\n const reserved: Line[] = [];\n for (const ln of lines) {\n const r = await fetch(`${base}/v1/cms/items/variants/${ln.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: -ln.qty } }),\n });\n if (!r.ok) {\n // compensate everything reserved so far, then fail cleanly\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'out_of_stock', variant: ln.variant }, 409);\n }\n reserved.push(ln);\n }\n\n // 3. create the ORDER \u2014 unique cart_ref makes placement exactly-once under concurrency.\n const total = lines.reduce((s, l) => s + l.unit_price_cents * l.qty, 0);\n const number = `ORD-${cart_id.slice(0, 8)}`;\n const orderRes = await fetch(`${base}/v1/cms/items/orders`, {\n method: 'POST', headers: H(cms),\n body: JSON.stringify({\n data: {\n number, cart_ref: cart_id, status: 'pending', end_user: shopper,\n total_cents: total, placed_at: new Date().toISOString(), lines,\n },\n }),\n });\n if (orderRes.status === 409) {\n // a concurrent checkout already placed this cart \u2192 idempotent: report it placed\n return json({ status: 'already_placed', number }, 200);\n }\n if (!orderRes.ok) {\n await Promise.all(reserved.map((p) =>\n fetch(`${base}/v1/cms/items/variants/${p.variant}`, {\n method: 'PATCH', headers: H(cms), body: JSON.stringify({ $inc: { stock: p.qty } }),\n })));\n return json({ error: 'order_create_failed' }, 502);\n }\n const order = (await orderRes.json()) as { data?: { item_id?: string } };\n\n // 4. open the hosted payment (one-time). Idempotency-Key = order number \u21D2 safe to retry.\n // The documented checkout-sessions contract (payments.md \xA73): user_id + line_items\n // [{ price_ref, quantity, amount_cents?, currency? }] + mode + success/cancel URLs.\n // price_ref is the PROVIDER's price id (a Stripe Price) snapshot on the cart line \u2014\n // Stripe's adapter charges by price id; amount_cents/currency serve amount-based\n // providers (PayPal payment mode). NOTE: the shipped Stripe adapter charges\n // line_items[0] only \u2014 for multi-line carts on Stripe, collapse to one provider\n // line (or one order-total price) before opening the session.\n const currency = cart.currency ?? 'usd';\n const sess = await fetch(`${base}/v1/payments/checkout-sessions`, {\n method: 'POST',\n headers: { ...H(pay), 'idempotency-key': number },\n body: JSON.stringify({\n user_id: shopper,\n mode: 'payment',\n line_items: lines.map((l) => ({ price_ref: l.price_ref, quantity: l.qty, amount_cents: l.unit_price_cents, currency })),\n success_url: success_url ?? 'https://storefront.example/checkout/success',\n cancel_url: cancel_url ?? 'https://storefront.example/checkout/cancel',\n }),\n });\n if (!sess.ok) {\n // the order stays placed (pending) \u2014 surface the payment error so the caller can\n // retry the session (same Idempotency-Key) after fixing price_refs / provider keys.\n return json({ order_id: order.data?.item_id, number, error: 'payment_session_failed' }, 502);\n }\n const s = (await sess.json()) as { data?: { url?: string } };\n\n return json({ order_id: order.data?.item_id, number, checkout_url: s.data?.url }, 201);\n },\n};\n\n// \u2500\u2500 tiny helpers (the vxil REST envelope is { data: { items }, meta }; items carry item_id) \u2500\u2500\nasync function get<T>(url: string, jwt: string): Promise<T[]> {\n const res = await fetch(url, { headers: { authorization: `Bearer ${jwt}` } });\n const body = (await res.json()) as { data?: { items?: { item_id: string; data: T }[] } };\n return (body.data?.items ?? []).map((i) => ({ id: i.item_id, ...i.data } as T));\n}\nconst enc = (o: unknown) => encodeURIComponent(JSON.stringify(o));\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
|
|
@@ -9381,6 +9755,54 @@ try {
|
|
|
9381
9755
|
}
|
|
9382
9756
|
break;
|
|
9383
9757
|
}
|
|
9758
|
+
case "listen": {
|
|
9759
|
+
const rawUrl = flag("forward-to");
|
|
9760
|
+
if (!rawUrl) {
|
|
9761
|
+
fail("usage: vxil listen --forward-to <url> [--dev] [--source <source_id>] [--events <type-prefix,\u2026>] [--replay-last <n>] [--interval <seconds=2>] [--show-runs] [--raw]");
|
|
9762
|
+
}
|
|
9763
|
+
const replayLast = flag("replay-last") !== void 0 ? Number(flag("replay-last")) : void 0;
|
|
9764
|
+
if (replayLast !== void 0 && (!Number.isInteger(replayLast) || replayLast < 0)) {
|
|
9765
|
+
fail("--replay-last must be a non-negative integer");
|
|
9766
|
+
}
|
|
9767
|
+
const interval = flag("interval") !== void 0 ? Number(flag("interval")) : void 0;
|
|
9768
|
+
if (interval !== void 0 && !(Number.isFinite(interval) && interval > 0)) {
|
|
9769
|
+
fail("--interval must be a positive number of seconds");
|
|
9770
|
+
}
|
|
9771
|
+
const { api } = requireApi(hasFlag("dev") ? "dev" : "prod");
|
|
9772
|
+
const state = newListenState();
|
|
9773
|
+
process.on("SIGINT", () => {
|
|
9774
|
+
console.error(`
|
|
9775
|
+
${formatSummary(state)}`);
|
|
9776
|
+
process.exit(0);
|
|
9777
|
+
});
|
|
9778
|
+
const prefixes = flag("events")?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
9779
|
+
await runListen({
|
|
9780
|
+
api,
|
|
9781
|
+
fetchImpl: fetch,
|
|
9782
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
9783
|
+
log: (l) => console.log(l),
|
|
9784
|
+
err: (l) => console.error(l)
|
|
9785
|
+
}, {
|
|
9786
|
+
forwardTo: normalizeForwardTo(rawUrl),
|
|
9787
|
+
state,
|
|
9788
|
+
...flag("source") ? { sourceId: flag("source") } : {},
|
|
9789
|
+
...prefixes?.length ? { eventPrefixes: prefixes } : {},
|
|
9790
|
+
...replayLast !== void 0 ? { replayLast } : {},
|
|
9791
|
+
...interval !== void 0 ? { intervalSeconds: interval } : {},
|
|
9792
|
+
...hasFlag("show-runs") ? { showRuns: true } : {},
|
|
9793
|
+
...hasFlag("raw") ? { raw: true } : {},
|
|
9794
|
+
...jsonOut ? { json: true } : {}
|
|
9795
|
+
});
|
|
9796
|
+
break;
|
|
9797
|
+
}
|
|
9798
|
+
case "mcp": {
|
|
9799
|
+
const [sub] = positional(0);
|
|
9800
|
+
if (sub !== "install") {
|
|
9801
|
+
fail("usage: vxil mcp install [--client cursor|claude|vscode] [--project] [--print] [--key <api_key>] [--scopes a,b] [--name <server>]");
|
|
9802
|
+
}
|
|
9803
|
+
await runMcpInstall();
|
|
9804
|
+
break;
|
|
9805
|
+
}
|
|
9384
9806
|
case "plan":
|
|
9385
9807
|
case "diff": {
|
|
9386
9808
|
await runApply(false);
|
|
@@ -9764,12 +10186,15 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
9764
10186
|
default:
|
|
9765
10187
|
console.log(`vxil \u2014 a Supabase-grade code-first CLI for the whole platform
|
|
9766
10188
|
init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--dev --ttl <h>] \xB7 login \xB7 link <slug>
|
|
10189
|
+
architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
|
|
9767
10190
|
plan \xB7 push [--dev|--prod] [--server [--resume <apply_id>]] [--no-gen] \xB7 pull \xB7 diff \xB7 gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>]
|
|
9768
10191
|
doctor \xB7 versions <feature> \xB7 rollback <feature> --to <v>
|
|
9769
10192
|
secrets set|list|rm \xB7 seed \xB7 export|import <feature>
|
|
9770
10193
|
migrate plan|schema [--apply]|data [--execute] [--only <steps>] [--resume]|verify --from supabase|generic-pg|csv-json [--url <pg>|--dir <d>]
|
|
9771
10194
|
env pull [--dev] [--file <f>] [--print] [--no-gitignore]
|
|
9772
10195
|
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
|
|
10196
|
+
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
|
|
10197
|
+
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
|
|
9773
10198
|
dev branch [<name>] [--ttl <h=24>] [--no-env] [--print] | --list | --rm <name> [--yes]
|
|
9774
10199
|
config get \xB7 users \xB7 send \xB7 deliveries \xB7 api <METHOD> <path>
|
|
9775
10200
|
global: --json (machine-readable + exit codes)`);
|
|
@@ -9991,6 +10416,155 @@ function guardEnvFile(fileArg) {
|
|
|
9991
10416
|
}
|
|
9992
10417
|
return appendOrWarn(git(["check-ignore", "-q", "--", fileArg]) === 0);
|
|
9993
10418
|
}
|
|
10419
|
+
function shellQuote(s) {
|
|
10420
|
+
return /[^A-Za-z0-9_@%+=:,./-]/.test(s) ? `'${s.replace(/'/g, "'\\''")}'` : s;
|
|
10421
|
+
}
|
|
10422
|
+
async function runMcpInstall() {
|
|
10423
|
+
const cwd = process.cwd();
|
|
10424
|
+
const clientFlag = flag("client");
|
|
10425
|
+
let client;
|
|
10426
|
+
if (clientFlag !== void 0) {
|
|
10427
|
+
if (!isMcpClient(clientFlag)) fail(`unknown --client '${clientFlag}' \u2014 choose cursor, claude, or vscode`);
|
|
10428
|
+
client = clientFlag;
|
|
10429
|
+
} else {
|
|
10430
|
+
const hits = [];
|
|
10431
|
+
if (existsSync6(resolve6(cwd, ".cursor"))) hits.push("cursor");
|
|
10432
|
+
if (existsSync6(resolve6(cwd, ".vscode"))) hits.push("vscode");
|
|
10433
|
+
if (existsSync6(resolve6(cwd, ".claude")) || existsSync6(resolve6(cwd, ".mcp.json"))) hits.push("claude");
|
|
10434
|
+
if (hits.length !== 1) {
|
|
10435
|
+
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`);
|
|
10436
|
+
}
|
|
10437
|
+
client = hits[0];
|
|
10438
|
+
}
|
|
10439
|
+
const scope = hasFlag("project") ? "project" : "global";
|
|
10440
|
+
const serverName = flag("name") ?? "vxil";
|
|
10441
|
+
const t = resolve_target({ which: "prod", defaultBase: DEFAULT_BASE });
|
|
10442
|
+
const url = mcpUrlFor(t.baseUrl, process.env.VXIL_MCP_URL);
|
|
10443
|
+
const target = configTarget(client, scope, { home: homedir2(), cwd, platform: process.platform });
|
|
10444
|
+
const explicitKey = flag("key");
|
|
10445
|
+
const inline = needsInlineKey(client, scope);
|
|
10446
|
+
if (hasFlag("print")) {
|
|
10447
|
+
const ref2 = keyRef(client, scope, explicitKey);
|
|
10448
|
+
if (target.format === "claudeCli") {
|
|
10449
|
+
console.log("# vxil mcp install --print (client=claude scope=global \u2014 runs the claude CLI)");
|
|
10450
|
+
console.log(`claude ${claudeAddArgv(url, explicitKey ?? KEY_PLACEHOLDER, serverName).map(shellQuote).join(" ")}`);
|
|
10451
|
+
} else {
|
|
10452
|
+
console.log(`# vxil mcp install --print \u2192 ${target.path} (client=${client} scope=${scope})`);
|
|
10453
|
+
console.log(mergeMcpConfig(null, { url, header: ref2.header, ...ref2.inputs ? { inputs: ref2.inputs } : {} }, target.format, serverName).trimEnd());
|
|
10454
|
+
}
|
|
10455
|
+
if (ref2.note) console.log(`# note: ${ref2.note}`);
|
|
10456
|
+
return;
|
|
10457
|
+
}
|
|
10458
|
+
let key = explicitKey;
|
|
10459
|
+
let provenance = explicitKey !== void 0 ? "flag" : void 0;
|
|
10460
|
+
let mintedName;
|
|
10461
|
+
if (inline && key === void 0) {
|
|
10462
|
+
const creds = loadCredentials();
|
|
10463
|
+
const proj = loadProject();
|
|
10464
|
+
if (creds.session && proj?.tenant_id && proj.tenant_id !== "(unknown)") {
|
|
10465
|
+
const name = `mcp-${client}`;
|
|
10466
|
+
const scopesFlag = flag("scopes");
|
|
10467
|
+
const mint = await dashFetch(dashboardBase(t.baseUrl), "POST", `/dashboard/tenants/${proj.tenant_id}/api-keys`, {
|
|
10468
|
+
name,
|
|
10469
|
+
agent: client,
|
|
10470
|
+
...scopesFlag ? { scopes: scopesFlag.split(",").map((s) => s.trim()).filter(Boolean) } : {}
|
|
10471
|
+
}, creds.session);
|
|
10472
|
+
if (mint.status === 201 || mint.status === 200) {
|
|
10473
|
+
const d = mint.json.data ?? mint.json;
|
|
10474
|
+
if (d.api_key) {
|
|
10475
|
+
key = d.api_key;
|
|
10476
|
+
provenance = "minted";
|
|
10477
|
+
mintedName = name;
|
|
10478
|
+
}
|
|
10479
|
+
} else if (mint.status !== 401) {
|
|
10480
|
+
const e = mint.json.error;
|
|
10481
|
+
fail(`could not mint an MCP key: ${e?.code ?? mint.status}${e?.message ? ` \u2014 ${e.message}` : ""}${e?.hint ? `
|
|
10482
|
+
hint: ${e.hint}` : ""}`);
|
|
10483
|
+
}
|
|
10484
|
+
}
|
|
10485
|
+
if (key === void 0) {
|
|
10486
|
+
const linked = proj ? creds.keys?.[proj.slug] : void 0;
|
|
10487
|
+
if (linked) {
|
|
10488
|
+
key = linked;
|
|
10489
|
+
provenance = "linked";
|
|
10490
|
+
console.error("note: reusing your CLI key for this agent \u2014 run `vxil login` then re-run to mint a dedicated revocable key instead");
|
|
10491
|
+
} else if (process.env.VXIL_API_KEY) {
|
|
10492
|
+
key = process.env.VXIL_API_KEY;
|
|
10493
|
+
provenance = "env";
|
|
10494
|
+
} else {
|
|
10495
|
+
fail("no API key \u2014 run `vxil login` (mints a dedicated agent key), `vxil quickstart` or `vxil link <slug>`, set VXIL_API_KEY, or pass --key <api_key>");
|
|
10496
|
+
}
|
|
10497
|
+
}
|
|
10498
|
+
}
|
|
10499
|
+
if (flag("scopes") !== void 0 && provenance !== "minted") {
|
|
10500
|
+
console.error("note: --scopes only narrows a freshly-minted key \u2014 it did not apply here" + (inline ? " (run `vxil login` first to mint one)" : " (this target references a key you supply, nothing is minted)"));
|
|
10501
|
+
}
|
|
10502
|
+
if (target.format === "claudeCli") {
|
|
10503
|
+
const argv = claudeAddArgv(url, key, serverName);
|
|
10504
|
+
const r = spawnSync("claude", argv, { stdio: "inherit" });
|
|
10505
|
+
if (r.error || r.status !== 0) {
|
|
10506
|
+
const printable = provenance === "minted" ? key : KEY_PLACEHOLDER;
|
|
10507
|
+
const why = r.error ? r.error.code ?? r.error.message : `exit ${r.status}`;
|
|
10508
|
+
console.error(`vxil: could not run the claude CLI (${why}) \u2014 run this yourself:`);
|
|
10509
|
+
console.error(` claude ${claudeAddArgv(url, printable, serverName).map(shellQuote).join(" ")}`);
|
|
10510
|
+
console.error(provenance === "minted" ? ` (the key above was minted just now as '${mintedName}' \u2014 revoke it any time in the dashboard's API-keys list)` : ` (replace ${KEY_PLACEHOLDER} with your API key)`);
|
|
10511
|
+
process.exit(r.error ? 1 : r.status ?? 1);
|
|
10512
|
+
}
|
|
10513
|
+
if (jsonOut) {
|
|
10514
|
+
console.log(JSON.stringify({
|
|
10515
|
+
ok: true,
|
|
10516
|
+
client,
|
|
10517
|
+
scope,
|
|
10518
|
+
command: `claude ${claudeAddArgv(url, KEY_PLACEHOLDER, serverName).map(shellQuote).join(" ")}`,
|
|
10519
|
+
url,
|
|
10520
|
+
minted: provenance === "minted",
|
|
10521
|
+
...mintedName ? { key_name: mintedName } : {},
|
|
10522
|
+
server_name: serverName
|
|
10523
|
+
}));
|
|
10524
|
+
} else {
|
|
10525
|
+
console.log(`vxil mcp install \u2192 claude mcp add (client=claude scope=global, key: ${provenance === "minted" ? `minted '${mintedName}'` : provenance ?? "supplied"})`);
|
|
10526
|
+
console.log(` restart the Claude Code session to pick up the '${serverName}' MCP server (${url})`);
|
|
10527
|
+
}
|
|
10528
|
+
return;
|
|
10529
|
+
}
|
|
10530
|
+
const ref = keyRef(client, scope, key);
|
|
10531
|
+
const existing = existsSync6(target.path) ? readFileSync6(target.path, "utf8") : null;
|
|
10532
|
+
let merged;
|
|
10533
|
+
try {
|
|
10534
|
+
merged = mergeMcpConfig(existing, { url, header: ref.header, ...ref.inputs ? { inputs: ref.inputs } : {} }, target.format, serverName);
|
|
10535
|
+
} catch (e) {
|
|
10536
|
+
if (e instanceof McpConfigParseError) fail(`${target.path}: ${e.message}`);
|
|
10537
|
+
throw e;
|
|
10538
|
+
}
|
|
10539
|
+
let guardNote;
|
|
10540
|
+
if (client === "cursor" && scope === "project") guardNote = guardEnvFile(".cursor/mcp.json");
|
|
10541
|
+
mkdirSync4(dirname3(target.path), { recursive: true });
|
|
10542
|
+
writeFileSync5(target.path, merged);
|
|
10543
|
+
if (inline) {
|
|
10544
|
+
try {
|
|
10545
|
+
chmodSync2(target.path, 384);
|
|
10546
|
+
} catch {
|
|
10547
|
+
}
|
|
10548
|
+
}
|
|
10549
|
+
const keyDesc = !inline ? client === "vscode" ? "prompted by VS Code on first connect" : "references ${VXIL_API_KEY}" : provenance === "minted" ? `minted '${mintedName}'` : provenance === "flag" ? "from --key" : provenance === "linked" ? "reused linked CLI key" : "from VXIL_API_KEY";
|
|
10550
|
+
if (jsonOut) {
|
|
10551
|
+
console.log(JSON.stringify({
|
|
10552
|
+
ok: true,
|
|
10553
|
+
client,
|
|
10554
|
+
scope,
|
|
10555
|
+
path: target.path,
|
|
10556
|
+
url,
|
|
10557
|
+
minted: provenance === "minted",
|
|
10558
|
+
...mintedName ? { key_name: mintedName } : {},
|
|
10559
|
+
server_name: serverName
|
|
10560
|
+
}));
|
|
10561
|
+
} else {
|
|
10562
|
+
console.log(`vxil mcp install \u2192 ${target.path} (client=${client} scope=${scope}, key: ${keyDesc})`);
|
|
10563
|
+
if (ref.note) console.log(` note: ${ref.note}`);
|
|
10564
|
+
if (guardNote) console.log(` ${guardNote}`);
|
|
10565
|
+
console.log(` restart/reload ${client === "cursor" ? "Cursor" : client === "vscode" ? "VS Code" : "the Claude Code session"} to pick up the '${serverName}' MCP server (${url})`);
|
|
10566
|
+
}
|
|
10567
|
+
}
|
|
9994
10568
|
async function runFunctionsDev(name) {
|
|
9995
10569
|
const cfg = await loadVxilConfig();
|
|
9996
10570
|
const def = cfg.functions?.[name];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The vxil CLI (`npx vxil`) — config-as-code (`vxil/config`), typed-client generation, functions deploy, for the whole vxil backend.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"miniflare": "^4.20260611.0",
|
|
50
50
|
"@vxil/config": "0.2.0",
|
|
51
|
-
"@vxil/runtime": "0.0.1"
|
|
51
|
+
"@vxil/runtime": "0.0.1",
|
|
52
|
+
"@vxil/types": "0.0.1"
|
|
52
53
|
},
|
|
53
54
|
"scripts": {
|
|
54
55
|
"build": "pnpm --filter @vxil/feature-configs run build && pnpm --filter @vxil/config run build && esbuild bin/vxil.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/vxil.js --banner:js='#!/usr/bin/env node' --external:esbuild --external:miniflare && esbuild src/config-entry.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/config.js && node scripts/build-config-dts.mjs",
|