@vxil/cli 0.1.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/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/_vxil-feature-configs-hooks.d.ts +167 -0
- package/dist/_vxil-feature-configs.d.ts +587 -0
- package/dist/config.d.ts +158 -0
- package/dist/config.js +22 -0
- package/dist/vxil.js +4197 -0
- package/package.json +57 -0
package/dist/vxil.js
ADDED
|
@@ -0,0 +1,4197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/vxil.ts
|
|
4
|
+
import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, existsSync as existsSync3, appendFileSync, readFileSync as readFileSync2, chmodSync as chmodSync2 } from "node:fs";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { resolve as resolve4, dirname as dirname2 } from "node:path";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { watch } from "node:fs";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { hostname } from "node:os";
|
|
11
|
+
|
|
12
|
+
// src/lib.ts
|
|
13
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
14
|
+
import { resolve, dirname, basename } from "node:path";
|
|
15
|
+
import { existsSync, writeFileSync, rmSync } from "node:fs";
|
|
16
|
+
function lowerTriggerBindings(trigger) {
|
|
17
|
+
const t = trigger?.kind ?? "http";
|
|
18
|
+
const str = (k) => typeof trigger?.[k] === "string" ? trigger[k] : void 0;
|
|
19
|
+
if (t === "cron") return [{ kind: "cron", ...str("schedule") ? { schedule: str("schedule") } : {} }];
|
|
20
|
+
if (t === "queue") return [{ kind: "queue", ...str("source") ? { source: str("source") } : {} }];
|
|
21
|
+
if (t === "webhook") return [{ kind: "webhook", ...str("source") ? { source: str("source") } : {} }];
|
|
22
|
+
if (t === "cmsHook" || t === "cms-hook") {
|
|
23
|
+
return [{
|
|
24
|
+
kind: "cmsHook",
|
|
25
|
+
...str("collection") ? { collection: str("collection") } : {},
|
|
26
|
+
...str("event") ? { event: str("event") } : {}
|
|
27
|
+
}];
|
|
28
|
+
}
|
|
29
|
+
if (t === "authHook" || t === "auth-hook") {
|
|
30
|
+
return [{ kind: "authHook", ...str("event") ? { event: str("event") } : {} }];
|
|
31
|
+
}
|
|
32
|
+
return [{ kind: "http", ...str("path") ? { path: str("path") } : {} }];
|
|
33
|
+
}
|
|
34
|
+
var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
|
|
35
|
+
function monorepoConfigAlias() {
|
|
36
|
+
try {
|
|
37
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const src = resolve(here, "..", "..", "config", "src", "index.ts");
|
|
39
|
+
if (existsSync(src)) return { "vxil/config": src, "@vxil/config": src };
|
|
40
|
+
} catch {
|
|
41
|
+
}
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
async function importConfigModule(file) {
|
|
45
|
+
if (!file.endsWith(".ts")) {
|
|
46
|
+
return await import(pathToFileURL(file).href);
|
|
47
|
+
}
|
|
48
|
+
const esbuild = await import("esbuild");
|
|
49
|
+
const result = await esbuild.build({
|
|
50
|
+
entryPoints: [file],
|
|
51
|
+
bundle: true,
|
|
52
|
+
format: "esm",
|
|
53
|
+
platform: "node",
|
|
54
|
+
target: "es2022",
|
|
55
|
+
write: false,
|
|
56
|
+
packages: "external",
|
|
57
|
+
// keep vxil/config etc. external → resolved at runtime
|
|
58
|
+
alias: monorepoConfigAlias(),
|
|
59
|
+
// …except in-monorepo, where it is inlined
|
|
60
|
+
absWorkingDir: dirname(file)
|
|
61
|
+
});
|
|
62
|
+
const out = result.outputFiles?.[0];
|
|
63
|
+
if (!out) throw new Error("esbuild produced no output for the config");
|
|
64
|
+
const tmp = resolve(dirname(file), `.${basename(file, ".ts")}.${Date.now().toString(36)}.mjs`);
|
|
65
|
+
writeFileSync(tmp, out.text);
|
|
66
|
+
try {
|
|
67
|
+
return await import(pathToFileURL(tmp).href);
|
|
68
|
+
} finally {
|
|
69
|
+
rmSync(tmp, { force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function loadVxilConfig(cwd = process.cwd()) {
|
|
73
|
+
const found = CONFIG_FILENAMES.map((n) => resolve(cwd, n)).find((f) => existsSync(f));
|
|
74
|
+
if (!found) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"no vxil.config.ts (or .mjs/.js) found in this directory \u2014 run `vxil init` to scaffold one."
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
let mod;
|
|
80
|
+
try {
|
|
81
|
+
mod = await importConfigModule(found);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
const msg = e.message;
|
|
84
|
+
let hint = "";
|
|
85
|
+
if (/Cannot find (package|module) '?(vxil\/config|@vxil\/config)/.test(msg)) {
|
|
86
|
+
hint = "\n \u2192 install the CLI, which provides `vxil/config`: `npm i -D vxil` (or, in the monorepo, ensure the workspace is linked).";
|
|
87
|
+
} else if (/Unknown file extension ".ts"|ERR_UNKNOWN_FILE_EXTENSION/.test(msg)) {
|
|
88
|
+
hint = "\n \u2192 vxil.config.ts needs the tsx runtime; run via `npx vxil` (the bin uses tsx) or rename to .mjs.";
|
|
89
|
+
} else if (/SyntaxError|is not defined|Unexpected/.test(msg)) {
|
|
90
|
+
hint = "\n \u2192 vxil.config.ts has a syntax/reference error (it is executed to produce the manifest); fix it and retry.";
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`failed to load ${found}: ${msg}${hint}`);
|
|
93
|
+
}
|
|
94
|
+
const def = mod.default;
|
|
95
|
+
if (!def || typeof def !== "object") {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`${found} has no object default export. Expected:
|
|
98
|
+
import { defineConfig } from '@vxil/config';
|
|
99
|
+
export default defineConfig({ features: { \u2026 }, cms: { \u2026 }, functions: { \u2026 } });`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const cfg = def;
|
|
103
|
+
cfg.features ??= {};
|
|
104
|
+
if (typeof cfg.features !== "object") {
|
|
105
|
+
throw new Error(`${found}: \`features\` must be an object`);
|
|
106
|
+
}
|
|
107
|
+
if (typeof cfg.apiVersion === "string") loadedConfigApiVersion = cfg.apiVersion;
|
|
108
|
+
return cfg;
|
|
109
|
+
}
|
|
110
|
+
function diffManifest(prev, next, prefix = "") {
|
|
111
|
+
const out = [];
|
|
112
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(prev ?? {}), ...Object.keys(next ?? {})]);
|
|
113
|
+
for (const k of [...keys].sort()) {
|
|
114
|
+
const p = prev?.[k];
|
|
115
|
+
const n = next?.[k];
|
|
116
|
+
const path = prefix ? `${prefix}.${k}` : k;
|
|
117
|
+
const bothObjects = p && n && typeof p === "object" && typeof n === "object" && !Array.isArray(p) && !Array.isArray(n);
|
|
118
|
+
if (bothObjects) {
|
|
119
|
+
out.push(...diffManifest(p, n, path));
|
|
120
|
+
} else if (JSON.stringify(p) !== JSON.stringify(n)) {
|
|
121
|
+
out.push({ path, from: p, to: n });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
function formatDiff(changes) {
|
|
127
|
+
if (!changes.length) return " (no changes)";
|
|
128
|
+
return changes.map(({ path, from, to }) => {
|
|
129
|
+
if (from === void 0) return ` + ${path} = ${JSON.stringify(to)}`;
|
|
130
|
+
if (to === void 0) return ` - ${path} (was ${JSON.stringify(from)})`;
|
|
131
|
+
return ` ~ ${path}: ${JSON.stringify(from)} \u2192 ${JSON.stringify(to)}`;
|
|
132
|
+
}).join("\n");
|
|
133
|
+
}
|
|
134
|
+
var loadedConfigApiVersion;
|
|
135
|
+
function resolveApiVersion(configVersion) {
|
|
136
|
+
const env = process.env.VXIL_API_VERSION?.trim();
|
|
137
|
+
return env || configVersion || loadedConfigApiVersion || "v1";
|
|
138
|
+
}
|
|
139
|
+
function applyApiVersion(path, version) {
|
|
140
|
+
if (version === "v1") return path;
|
|
141
|
+
const m = /^\/v1(\/.*)?$/.exec(path);
|
|
142
|
+
return m ? `/${version}${m[1] ?? ""}` : path;
|
|
143
|
+
}
|
|
144
|
+
function makeApi({ apiKey, baseUrl, fetchImpl = fetch, retries = 4, sleep, apiVersion }) {
|
|
145
|
+
const wait = sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
146
|
+
return async function api(method, path, body, headers = {}) {
|
|
147
|
+
const versionedPath = applyApiVersion(path, resolveApiVersion(apiVersion));
|
|
148
|
+
for (let attempt = 0; ; attempt++) {
|
|
149
|
+
const res = await fetchImpl(`${baseUrl.replace(/\/$/, "")}${versionedPath}`, {
|
|
150
|
+
method,
|
|
151
|
+
headers: {
|
|
152
|
+
authorization: `Bearer ${apiKey}`,
|
|
153
|
+
...body !== void 0 ? { "content-type": "application/json" } : {},
|
|
154
|
+
"x-vxil-surface": "cli",
|
|
155
|
+
...headers
|
|
156
|
+
},
|
|
157
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
158
|
+
});
|
|
159
|
+
if (res.status === 429 && attempt < retries) {
|
|
160
|
+
const ra = Number(res.headers.get("retry-after"));
|
|
161
|
+
await wait((Number.isFinite(ra) && ra > 0 ? ra : Math.min(2 ** attempt, 8)) * 1e3);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const text = await res.text();
|
|
165
|
+
let json2;
|
|
166
|
+
try {
|
|
167
|
+
json2 = JSON.parse(text);
|
|
168
|
+
} catch {
|
|
169
|
+
json2 = { error: { code: `http_${res.status}`, message: text.slice(0, 200) } };
|
|
170
|
+
}
|
|
171
|
+
return { status: res.status, body: json2 };
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async function mapPool(items, limit, fn) {
|
|
176
|
+
const out = new Array(items.length);
|
|
177
|
+
let next = 0;
|
|
178
|
+
const worker = async () => {
|
|
179
|
+
for (; ; ) {
|
|
180
|
+
const i = next++;
|
|
181
|
+
if (i >= items.length) return;
|
|
182
|
+
out[i] = await fn(items[i], i);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
function assembleApplyBundle(cfg, fnSources) {
|
|
189
|
+
const { functions: _fnFeature, ...configFeatures } = cfg.features;
|
|
190
|
+
void _fnFeature;
|
|
191
|
+
const functions = Object.entries(cfg.functions ?? {}).map(([name, def]) => {
|
|
192
|
+
const source = fnSources[name];
|
|
193
|
+
if (source === void 0) throw new Error(`functions: no bundled source for '${name}'`);
|
|
194
|
+
const bindings = lowerTriggerBindings(def.trigger);
|
|
195
|
+
return {
|
|
196
|
+
name,
|
|
197
|
+
source,
|
|
198
|
+
trigger: def.trigger?.kind ?? "http",
|
|
199
|
+
...bindings.length ? { bindings } : {},
|
|
200
|
+
...def.signature ? { signature: def.signature } : {},
|
|
201
|
+
...def.scopes ? { scopes: def.scopes } : {},
|
|
202
|
+
...def.egressAllow ? { egressAllow: def.egressAllow } : {},
|
|
203
|
+
...def.secrets ? { secrets: def.secrets } : {}
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
return {
|
|
207
|
+
...Object.keys(configFeatures).length ? { features: configFeatures } : {},
|
|
208
|
+
...cfg.cms?.collections && Object.keys(cfg.cms.collections).length ? { cms: { collections: cfg.cms.collections } } : {},
|
|
209
|
+
...functions.length ? { functions } : {}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
var STEP_GLYPH = {
|
|
213
|
+
pending: "\xB7",
|
|
214
|
+
done: "\u2713",
|
|
215
|
+
failed: "\u2717",
|
|
216
|
+
skipped: "=",
|
|
217
|
+
rolled_back: "\u21A9",
|
|
218
|
+
kept: "\u2248"
|
|
219
|
+
};
|
|
220
|
+
function formatApplySteps(steps) {
|
|
221
|
+
if (!steps.length) return " (no steps)";
|
|
222
|
+
return steps.map((s) => {
|
|
223
|
+
const tail = s.error ? ` \u2014 ${s.error}` : s.note ? ` \u2014 ${s.note}` : "";
|
|
224
|
+
return ` ${STEP_GLYPH[s.status] ?? "?"} [${s.status}] ${s.kind} ${s.target}${tail}`;
|
|
225
|
+
}).join("\n");
|
|
226
|
+
}
|
|
227
|
+
function applyEnvelope(r, what) {
|
|
228
|
+
if (r.body.error || r.status >= 400) {
|
|
229
|
+
const e = r.body.error ?? {};
|
|
230
|
+
throw new Error(`${what} failed: ${e.code ?? r.status} ${e.message ?? ""} ${e.hint ?? ""}`.trim());
|
|
231
|
+
}
|
|
232
|
+
return r.body.data;
|
|
233
|
+
}
|
|
234
|
+
async function serverApply({ api, bundle, resume, allowDestructive, onPlan }) {
|
|
235
|
+
if (resume) {
|
|
236
|
+
return applyEnvelope(await api("POST", "/v1/apply", { apply_id: resume, mode: "apply" }), "apply resume");
|
|
237
|
+
}
|
|
238
|
+
if (!bundle) throw new Error("serverApply needs a bundle (or a resume id)");
|
|
239
|
+
const plan = applyEnvelope(
|
|
240
|
+
await api("POST", "/v1/apply", { bundle, mode: "plan", ...allowDestructive ? { allow_destructive: true } : {} }),
|
|
241
|
+
"apply plan"
|
|
242
|
+
);
|
|
243
|
+
onPlan?.(plan);
|
|
244
|
+
return applyEnvelope(
|
|
245
|
+
await api("POST", "/v1/apply", { apply_id: plan.apply_id, mode: "apply" }),
|
|
246
|
+
`apply execute (resume with: vxil push --server --resume ${plan.apply_id})`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
async function planOrPush({ api, features, apply }) {
|
|
250
|
+
const results = [];
|
|
251
|
+
for (const [feature, manifest] of Object.entries(features)) {
|
|
252
|
+
const dry = await api("PUT", `/v1/config/${feature}?dry_run=true`, manifest);
|
|
253
|
+
if (dry.status !== 200) {
|
|
254
|
+
const e = dry.body.error ?? {};
|
|
255
|
+
throw new Error(
|
|
256
|
+
`invalid config for ${feature}: ${e.code ?? dry.status} ${e.message ?? ""} ${e.hint ?? ""}`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const effective = dry.body.data?.manifest;
|
|
260
|
+
const cur = await api("GET", `/v1/config/${feature}`);
|
|
261
|
+
const exists = cur.status === 200;
|
|
262
|
+
const remote = exists ? cur.body.data?.manifest : {};
|
|
263
|
+
const version = exists ? cur.body.data?.version ?? 0 : 0;
|
|
264
|
+
const changes = diffManifest(remote, effective);
|
|
265
|
+
let applied = null;
|
|
266
|
+
if (apply && changes.length) {
|
|
267
|
+
const put = await api(
|
|
268
|
+
"PUT",
|
|
269
|
+
`/v1/config/${feature}`,
|
|
270
|
+
manifest,
|
|
271
|
+
exists ? { "if-match": String(version) } : {}
|
|
272
|
+
);
|
|
273
|
+
if (put.status !== 200) {
|
|
274
|
+
const e = put.body.error ?? {};
|
|
275
|
+
throw new Error(`push failed for ${feature}: ${e.code ?? put.status} ${e.message ?? ""} ${e.hint ?? ""}`);
|
|
276
|
+
}
|
|
277
|
+
applied = put.body.data?.version ?? null;
|
|
278
|
+
}
|
|
279
|
+
results.push({ feature, version, changes, applied });
|
|
280
|
+
}
|
|
281
|
+
return results;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/cms.ts
|
|
285
|
+
function fieldToInput(name, def) {
|
|
286
|
+
const out = { field: name, type: def.type };
|
|
287
|
+
if (def.required !== void 0) out.required = def.required;
|
|
288
|
+
if (def.validation !== void 0) out.validation = def.validation;
|
|
289
|
+
if (def.indexSlot !== void 0) out.index_slot = def.indexSlot;
|
|
290
|
+
if (def.relationTo !== void 0) out.relation_to = def.relationTo;
|
|
291
|
+
if (def.computed !== void 0) out.computed = def.computed;
|
|
292
|
+
if (def.compute !== void 0) out.compute = def.compute;
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
async function readRemote(api) {
|
|
296
|
+
const res = await api("GET", "/v1/cms/collections");
|
|
297
|
+
const map = /* @__PURE__ */ new Map();
|
|
298
|
+
if (res.status !== 200) return map;
|
|
299
|
+
const cols = res.body.data?.collections ?? [];
|
|
300
|
+
for (const c of cols) map.set(c.collection, { collection: c.collection, fields: c.fields ?? [] });
|
|
301
|
+
return map;
|
|
302
|
+
}
|
|
303
|
+
async function planCmsSchema({ api, collections, apply, allowDestructive = false }) {
|
|
304
|
+
const remote = await readRemote(api);
|
|
305
|
+
const changes = [];
|
|
306
|
+
for (const [name, def] of Object.entries(collections)) {
|
|
307
|
+
const declaredFields = Object.entries(def.fields ?? {});
|
|
308
|
+
const rc = remote.get(name);
|
|
309
|
+
if (!rc) {
|
|
310
|
+
changes.push({
|
|
311
|
+
collection: name,
|
|
312
|
+
kind: "create-collection",
|
|
313
|
+
detail: `collection ${name} (${declaredFields.length} fields)`
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const remoteByName = new Map(rc.fields.map((f) => [f.field, f]));
|
|
318
|
+
for (const [fn, fd] of declaredFields) {
|
|
319
|
+
const existing = remoteByName.get(fn);
|
|
320
|
+
if (!existing) {
|
|
321
|
+
changes.push({ collection: name, kind: "add-field", field: fn, detail: `${name}.${fn} (${fd.type})` });
|
|
322
|
+
} else if (existing.type !== fd.type) {
|
|
323
|
+
changes.push({
|
|
324
|
+
collection: name,
|
|
325
|
+
kind: "destructive",
|
|
326
|
+
op: "alter-field",
|
|
327
|
+
field: fn,
|
|
328
|
+
detail: `${name}.${fn} type ${existing.type} \u2192 ${fd.type} (destructive alter)`
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const f of rc.fields) {
|
|
333
|
+
if (!Object.prototype.hasOwnProperty.call(def.fields ?? {}, f.field)) {
|
|
334
|
+
changes.push({
|
|
335
|
+
collection: name,
|
|
336
|
+
kind: "destructive",
|
|
337
|
+
op: "drop-field",
|
|
338
|
+
field: f.field,
|
|
339
|
+
detail: `${name}.${f.field} present remotely, absent in config (destructive drop)`
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (allowDestructive) {
|
|
345
|
+
for (const rname of remote.keys()) {
|
|
346
|
+
if (!Object.prototype.hasOwnProperty.call(collections, rname)) {
|
|
347
|
+
changes.push({
|
|
348
|
+
collection: rname,
|
|
349
|
+
kind: "destructive",
|
|
350
|
+
op: "drop-collection",
|
|
351
|
+
detail: `collection ${rname} present remotely, absent in config (destructive drop)`
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const destructive = changes.filter((c) => c.kind === "destructive");
|
|
357
|
+
if (apply && destructive.length && !allowDestructive) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`cms: ${destructive.length} destructive schema change(s) refused \u2014 re-run with --allow-destructive to apply (stored item data is retained; only the definition is dropped):
|
|
360
|
+
` + destructive.map((c) => ` ! ${c.detail}`).join("\n")
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
let applied = 0;
|
|
364
|
+
if (apply) {
|
|
365
|
+
for (const c of changes) {
|
|
366
|
+
if (c.kind === "create-collection") {
|
|
367
|
+
const def = collections[c.collection];
|
|
368
|
+
const fields = Object.entries(def.fields ?? {}).map(([fn, fd]) => fieldToInput(fn, fd));
|
|
369
|
+
const body = { collection: c.collection, fields };
|
|
370
|
+
if (def.singular) body.singular = def.singular;
|
|
371
|
+
if (def.ownerField) body.owner_field = def.ownerField;
|
|
372
|
+
const res = await api("POST", "/v1/cms/collections", body);
|
|
373
|
+
if (res.status !== 201 && res.status !== 200 && res.body.error?.code !== "collection_exists") {
|
|
374
|
+
const e = res.body.error ?? {};
|
|
375
|
+
throw new Error(`cms: create ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
376
|
+
}
|
|
377
|
+
applied++;
|
|
378
|
+
} else if (c.kind === "add-field") {
|
|
379
|
+
const fd = collections[c.collection].fields[c.field];
|
|
380
|
+
const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, fieldToInput(c.field, fd));
|
|
381
|
+
if (res.status !== 201 && res.status !== 200 && res.body.error?.code !== "already_exists") {
|
|
382
|
+
const e = res.body.error ?? {};
|
|
383
|
+
throw new Error(`cms: add ${c.collection}.${c.field} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
384
|
+
}
|
|
385
|
+
applied++;
|
|
386
|
+
} else if (c.kind === "destructive") {
|
|
387
|
+
applied += await applyDestructive(api, collections, c);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return { changes, applied };
|
|
392
|
+
}
|
|
393
|
+
async function applyDestructive(api, collections, c) {
|
|
394
|
+
const ok2xx = (status) => status >= 200 && status < 300;
|
|
395
|
+
if (c.op === "drop-collection") {
|
|
396
|
+
const res2 = await api("POST", "/v1/cms/collections", { drop_collection: c.collection, allow_destructive: true });
|
|
397
|
+
if (!ok2xx(res2.status) && res2.body.error?.code !== "not_found") {
|
|
398
|
+
const e = res2.body.error ?? {};
|
|
399
|
+
throw new Error(`cms: drop collection ${c.collection} failed: ${e.code ?? res2.status} ${e.message ?? ""}`);
|
|
400
|
+
}
|
|
401
|
+
return 1;
|
|
402
|
+
}
|
|
403
|
+
const path = `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`;
|
|
404
|
+
if (c.op === "drop-field") {
|
|
405
|
+
const res2 = await api("POST", path, { drop_field: c.field, allow_destructive: true });
|
|
406
|
+
if (!ok2xx(res2.status) && res2.body.error?.code !== "not_found") {
|
|
407
|
+
const e = res2.body.error ?? {};
|
|
408
|
+
throw new Error(`cms: drop ${c.collection}.${c.field} failed: ${e.code ?? res2.status} ${e.message ?? ""}`);
|
|
409
|
+
}
|
|
410
|
+
return 1;
|
|
411
|
+
}
|
|
412
|
+
const fd = collections[c.collection].fields[c.field];
|
|
413
|
+
const res = await api("POST", path, { ...fieldToInput(c.field, fd), allow_destructive: true });
|
|
414
|
+
if (!ok2xx(res.status)) {
|
|
415
|
+
const e = res.body.error ?? {};
|
|
416
|
+
throw new Error(`cms: alter ${c.collection}.${c.field} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
417
|
+
}
|
|
418
|
+
return 1;
|
|
419
|
+
}
|
|
420
|
+
function formatCmsChanges(changes) {
|
|
421
|
+
if (!changes.length) return " (no cms-schema changes)";
|
|
422
|
+
return changes.map((c) => {
|
|
423
|
+
if (c.kind === "destructive") return ` ! ${c.detail}`;
|
|
424
|
+
return ` + ${c.detail}`;
|
|
425
|
+
}).join("\n");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/functions.ts
|
|
429
|
+
import { resolve as resolve2, relative, isAbsolute, sep } from "node:path";
|
|
430
|
+
import { createHash } from "node:crypto";
|
|
431
|
+
var MAX_SOURCE_BYTES = 256e3;
|
|
432
|
+
function sourceSha12(source) {
|
|
433
|
+
return createHash("sha256").update(source, "utf8").digest("hex").slice(0, 12);
|
|
434
|
+
}
|
|
435
|
+
function escapesRoot(root, path) {
|
|
436
|
+
const rel = relative(root, path);
|
|
437
|
+
return rel.startsWith("..") || isAbsolute(rel);
|
|
438
|
+
}
|
|
439
|
+
function assertBundleContained(projectRoot, inputs) {
|
|
440
|
+
const root = resolve2(projectRoot);
|
|
441
|
+
for (const input of inputs) {
|
|
442
|
+
const abs = resolve2(root, input);
|
|
443
|
+
if (!escapesRoot(root, abs)) continue;
|
|
444
|
+
if (abs.split(sep).includes("node_modules")) continue;
|
|
445
|
+
throw new Error(
|
|
446
|
+
`functions: bundled import '${input}' resolves outside the project root (${root}) \u2014 function source (and everything it imports) must live inside the project or node_modules`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async function bundleFunction(entryPath, opts = {}) {
|
|
451
|
+
const root = resolve2(opts.projectRoot ?? process.cwd());
|
|
452
|
+
const entry = resolve2(entryPath);
|
|
453
|
+
if (escapesRoot(root, entry)) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
`functions: entry '${entryPath}' resolves outside the project root (${root}) \u2014 declare function entries as paths inside the project`
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
const esbuild = await import("esbuild");
|
|
459
|
+
const result = await esbuild.build({
|
|
460
|
+
entryPoints: [entry],
|
|
461
|
+
bundle: true,
|
|
462
|
+
format: "esm",
|
|
463
|
+
platform: "browser",
|
|
464
|
+
// Workers runtime (Web-standard globals; no node builtins)
|
|
465
|
+
target: "es2022",
|
|
466
|
+
write: false,
|
|
467
|
+
legalComments: "none",
|
|
468
|
+
absWorkingDir: root,
|
|
469
|
+
metafile: true,
|
|
470
|
+
// drives the import-containment check below (non-executing)
|
|
471
|
+
plugins: []
|
|
472
|
+
// explicitly none — build plugins are code execution
|
|
473
|
+
});
|
|
474
|
+
const out = result.outputFiles?.[0];
|
|
475
|
+
if (!out) throw new Error(`esbuild produced no output for ${entryPath}`);
|
|
476
|
+
assertBundleContained(root, Object.keys(result.metafile?.inputs ?? {}));
|
|
477
|
+
const source = out.text;
|
|
478
|
+
const bytes = Buffer.byteLength(source, "utf8");
|
|
479
|
+
if (bytes > MAX_SOURCE_BYTES) {
|
|
480
|
+
throw new Error(`bundled ${entryPath} is ${Math.round(bytes / 1e3)} KB \u2014 exceeds the ${MAX_SOURCE_BYTES / 1e3} KB deploy cap`);
|
|
481
|
+
}
|
|
482
|
+
return { source, bytes };
|
|
483
|
+
}
|
|
484
|
+
function stableStringify(v) {
|
|
485
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
|
486
|
+
if (v !== null && typeof v === "object") {
|
|
487
|
+
const o = v;
|
|
488
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`;
|
|
489
|
+
}
|
|
490
|
+
return JSON.stringify(v) ?? "undefined";
|
|
491
|
+
}
|
|
492
|
+
function normalizeSecretRefs(refs) {
|
|
493
|
+
const out = [];
|
|
494
|
+
for (const raw of refs ?? []) {
|
|
495
|
+
const s = String(raw);
|
|
496
|
+
const canonical = s.startsWith("secret:") ? s : `secret:${s}`;
|
|
497
|
+
if (!out.includes(canonical)) out.push(canonical);
|
|
498
|
+
}
|
|
499
|
+
return out;
|
|
500
|
+
}
|
|
501
|
+
async function readRemote2(api) {
|
|
502
|
+
const res = await api("GET", "/v1/functions");
|
|
503
|
+
const map = /* @__PURE__ */ new Map();
|
|
504
|
+
if (res.status !== 200) return map;
|
|
505
|
+
const fns = res.body.data?.functions ?? [];
|
|
506
|
+
for (const f of fns) {
|
|
507
|
+
map.set(f.name, {
|
|
508
|
+
scriptRef: f.scriptRef ?? "",
|
|
509
|
+
secrets: f.secrets ?? [],
|
|
510
|
+
bindings: f.bindings ?? [],
|
|
511
|
+
signature: f.signature ?? null
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
return map;
|
|
515
|
+
}
|
|
516
|
+
function sameSecretSet(a, b) {
|
|
517
|
+
return a.length === b.length && a.every((x) => b.includes(x));
|
|
518
|
+
}
|
|
519
|
+
async function planFunctions({ api, functions, cwd, apply }) {
|
|
520
|
+
const remote = await readRemote2(api);
|
|
521
|
+
const results = await mapPool(Object.entries(functions), 1, async ([name, def]) => {
|
|
522
|
+
if (!/^[a-z][a-z0-9-]{0,47}$/.test(name)) {
|
|
523
|
+
throw new Error(`functions: invalid name '${name}' (must match /^[a-z][a-z0-9-]{0,47}$/)`);
|
|
524
|
+
}
|
|
525
|
+
const { source, bytes } = await bundleFunction(resolve2(cwd, def.entry), { projectRoot: cwd });
|
|
526
|
+
const triggerKind = def.trigger?.kind ?? "http";
|
|
527
|
+
const secrets = normalizeSecretRefs(def.secrets);
|
|
528
|
+
const bindings = lowerTriggerBindings(def.trigger);
|
|
529
|
+
const remoteFn = remote.get(name);
|
|
530
|
+
const sha = sourceSha12(source);
|
|
531
|
+
const label = `${name} (${Math.round(bytes / 100) / 10} KB) [${triggerKind}${def.scopes?.length ? " \xB7 " + def.scopes.join(",") : ""}${secrets.length ? " \xB7 secrets:" + secrets.map((s) => s.slice("secret:".length)).join(",") : ""}]`;
|
|
532
|
+
if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null)) {
|
|
533
|
+
return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null };
|
|
534
|
+
}
|
|
535
|
+
const change = { name, kind: remoteFn === void 0 ? "deploy-new" : "deploy-update", detail: label };
|
|
536
|
+
if (apply) {
|
|
537
|
+
const body = { source, trigger: triggerKind, secrets };
|
|
538
|
+
if (bindings.length) body.bindings = bindings;
|
|
539
|
+
if (def.signature) body.signature = def.signature;
|
|
540
|
+
if (def.scopes) body.scopes = def.scopes;
|
|
541
|
+
if (def.egressAllow) body.egressAllow = def.egressAllow;
|
|
542
|
+
const res = await api("POST", `/v1/functions/${encodeURIComponent(name)}`, body);
|
|
543
|
+
if (res.status !== 200 && res.status !== 201) {
|
|
544
|
+
const e = res.body.error ?? {};
|
|
545
|
+
throw new Error(`functions: deploy ${name} failed: ${e.code ?? res.status} ${e.message ?? ""} ${e.hint ?? ""}`);
|
|
546
|
+
}
|
|
547
|
+
const cmsHooks = res.body.data?.cms_hook_subscriptions ?? null;
|
|
548
|
+
return { change, applied: 1, cmsHooks };
|
|
549
|
+
}
|
|
550
|
+
return { change, applied: 0, cmsHooks: null };
|
|
551
|
+
});
|
|
552
|
+
const changes = results.map((r) => r.change);
|
|
553
|
+
const applied = results.reduce((s, r) => s + r.applied, 0);
|
|
554
|
+
const cmsHookSubscriptions = results.reduce((acc, r) => {
|
|
555
|
+
if (!r.cmsHooks) return acc;
|
|
556
|
+
return { created: (acc?.created ?? 0) + r.cmsHooks.created, deleted: (acc?.deleted ?? 0) + r.cmsHooks.deleted };
|
|
557
|
+
}, null);
|
|
558
|
+
return { changes, applied, cmsHookSubscriptions };
|
|
559
|
+
}
|
|
560
|
+
function formatFnChanges(changes) {
|
|
561
|
+
const actionable = changes.filter((c) => c.kind !== "unchanged");
|
|
562
|
+
if (!actionable.length) return " (no function changes)";
|
|
563
|
+
return actionable.map((c) => ` ${c.kind === "deploy-new" ? "+" : "~"} ${c.detail}`).join("\n");
|
|
564
|
+
}
|
|
565
|
+
function formatLogLines(lines) {
|
|
566
|
+
const out = [];
|
|
567
|
+
for (const l of lines) {
|
|
568
|
+
const wall = typeof l.wall_ms === "number" ? ` ${l.wall_ms}ms` : "";
|
|
569
|
+
if (!l.logs?.length && !l.exceptions?.length) {
|
|
570
|
+
out.push(`${l.ts} ${l.outcome}${wall}`);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
for (const g of l.logs ?? []) out.push(`${l.ts} ${(g.level || "log").padEnd(5)} ${g.message}`);
|
|
574
|
+
for (const e of l.exceptions ?? []) out.push(`${l.ts} error ${e.name}: ${e.message}`);
|
|
575
|
+
}
|
|
576
|
+
return out.length ? out.join("\n") : " (no log lines)";
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ../runtime/src/egress.ts
|
|
580
|
+
function isPrivateIPv4(host) {
|
|
581
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
582
|
+
if (!m) return false;
|
|
583
|
+
const o = m.slice(1).map(Number);
|
|
584
|
+
if (o.some((n) => n > 255)) return false;
|
|
585
|
+
const [a, b] = o;
|
|
586
|
+
return a === 10 || // 10.0.0.0/8 RFC1918
|
|
587
|
+
a === 172 && b >= 16 && b <= 31 || // 172.16.0.0/12 RFC1918
|
|
588
|
+
a === 192 && b === 168 || // 192.168.0.0/16 RFC1918
|
|
589
|
+
a === 127 || // 127.0.0.0/8 loopback
|
|
590
|
+
a === 169 && b === 254 || // 169.254.0.0/16 link-local (incl. metadata)
|
|
591
|
+
a === 100 && b >= 64 && b <= 127 || // 100.64.0.0/10 CGNAT / shared
|
|
592
|
+
a === 0 || // 0.0.0.0/8 "this host"
|
|
593
|
+
a >= 224;
|
|
594
|
+
}
|
|
595
|
+
function expandIPv6(host) {
|
|
596
|
+
if (!/^[0-9a-f:.]+$/.test(host) || !host.includes(":")) return null;
|
|
597
|
+
let h = host;
|
|
598
|
+
let tail = [];
|
|
599
|
+
const lastColon = h.lastIndexOf(":");
|
|
600
|
+
const maybeV4 = h.slice(lastColon + 1);
|
|
601
|
+
if (maybeV4.includes(".")) {
|
|
602
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(maybeV4);
|
|
603
|
+
if (!m) return null;
|
|
604
|
+
const q = m.slice(1).map(Number);
|
|
605
|
+
if (q.some((n) => n > 255)) return null;
|
|
606
|
+
tail = [q[0] << 8 | q[1], q[2] << 8 | q[3]];
|
|
607
|
+
h = h.slice(0, lastColon + 1) + "0:0";
|
|
608
|
+
}
|
|
609
|
+
const parts = h.split("::");
|
|
610
|
+
if (parts.length > 2) return null;
|
|
611
|
+
const head = parts[0] ? parts[0].split(":") : [];
|
|
612
|
+
const rest2 = parts.length === 2 ? parts[1] ? parts[1].split(":") : [] : null;
|
|
613
|
+
let groups;
|
|
614
|
+
if (rest2 === null) {
|
|
615
|
+
groups = head;
|
|
616
|
+
} else {
|
|
617
|
+
const fill = 8 - head.length - rest2.length - tail.length;
|
|
618
|
+
if (fill < 0) return null;
|
|
619
|
+
groups = [...head, ...Array(fill).fill("0"), ...rest2];
|
|
620
|
+
}
|
|
621
|
+
const nums = groups.map((g) => g === "" ? 0 : parseInt(g, 16));
|
|
622
|
+
const all = [...nums, ...tail];
|
|
623
|
+
if (all.length !== 8 || all.some((n) => !Number.isFinite(n) || n < 0 || n > 65535)) {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
return all.map((n) => n.toString(16));
|
|
627
|
+
}
|
|
628
|
+
function isPrivateIPv6(host) {
|
|
629
|
+
const hextets = expandIPv6(host);
|
|
630
|
+
if (!hextets) return false;
|
|
631
|
+
const n = hextets.map((x) => parseInt(x, 16));
|
|
632
|
+
const [h0, h1] = n;
|
|
633
|
+
if (n.every((x) => x === 0)) return true;
|
|
634
|
+
if (n.slice(0, 7).every((x) => x === 0) && n[7] === 1) return true;
|
|
635
|
+
if ((h0 & 65024) === 64512) return true;
|
|
636
|
+
if ((h0 & 65472) === 65152) return true;
|
|
637
|
+
if (n.slice(0, 5).every((x) => x === 0) && h0 === 0 && n[5] === 65535) {
|
|
638
|
+
const a = n[6] >> 8 & 255;
|
|
639
|
+
const b = n[6] & 255;
|
|
640
|
+
const c = n[7] >> 8 & 255;
|
|
641
|
+
const d = n[7] & 255;
|
|
642
|
+
return isPrivateIPv4(`${a}.${b}.${c}.${d}`);
|
|
643
|
+
}
|
|
644
|
+
if (h0 === 100 && h1 === 65435) {
|
|
645
|
+
const a = n[6] >> 8 & 255;
|
|
646
|
+
const b = n[6] & 255;
|
|
647
|
+
const c = n[7] >> 8 & 255;
|
|
648
|
+
const d = n[7] & 255;
|
|
649
|
+
return isPrivateIPv4(`${a}.${b}.${c}.${d}`);
|
|
650
|
+
}
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
var VXIL_API_HOSTS = /* @__PURE__ */ new Set(["api.vxil.com", "api.vxil.org"]);
|
|
654
|
+
function egressDecision(targetUrl, allowHosts = []) {
|
|
655
|
+
let u;
|
|
656
|
+
try {
|
|
657
|
+
u = new URL(targetUrl);
|
|
658
|
+
} catch {
|
|
659
|
+
return { allow: false, reason: "invalid-url" };
|
|
660
|
+
}
|
|
661
|
+
if (u.protocol !== "https:") return { allow: false, reason: `non-https:${u.protocol}` };
|
|
662
|
+
if (u.username !== "" || u.password !== "") return { allow: false, reason: "embedded-credentials" };
|
|
663
|
+
const host = u.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
|
|
664
|
+
if (host === "") return { allow: false, reason: "empty-host" };
|
|
665
|
+
if (isPrivateIPv4(host) || isPrivateIPv6(host)) return { allow: false, reason: `private-ip:${host}` };
|
|
666
|
+
if (host === "localhost" || host.endsWith(".localhost") || host === "metadata.google.internal" || host.endsWith(".internal") || host.endsWith(".local")) {
|
|
667
|
+
return { allow: false, reason: `internal-host:${host}` };
|
|
668
|
+
}
|
|
669
|
+
if (host.endsWith(".workers.dev")) return { allow: false, reason: `sibling-worker:${host}` };
|
|
670
|
+
if (VXIL_API_HOSTS.has(host)) return { allow: true, reason: "vxil-api" };
|
|
671
|
+
for (const a of allowHosts) {
|
|
672
|
+
const h = a.toLowerCase().trim();
|
|
673
|
+
if (h && (host === h || host.endsWith("." + h))) return { allow: true, reason: `allowlist:${h}` };
|
|
674
|
+
}
|
|
675
|
+
return { allow: false, reason: `not-allowlisted:${host}` };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// src/fnDev.ts
|
|
679
|
+
var SCOPE_AUD = {
|
|
680
|
+
cms: "cms",
|
|
681
|
+
payments: "payments",
|
|
682
|
+
notifications: "notifications",
|
|
683
|
+
comments: "comments",
|
|
684
|
+
files: "files",
|
|
685
|
+
ai: "ai",
|
|
686
|
+
rag: "rag",
|
|
687
|
+
search: "vector-search",
|
|
688
|
+
vector: "vector-search",
|
|
689
|
+
feeds: "activity-feed",
|
|
690
|
+
orgs: "orgs",
|
|
691
|
+
auth: "auth",
|
|
692
|
+
jobs: "jobs",
|
|
693
|
+
realtime: "realtime",
|
|
694
|
+
"rate-limits": "rate-limits"
|
|
695
|
+
};
|
|
696
|
+
function audsForScopes(scopes) {
|
|
697
|
+
const auds = /* @__PURE__ */ new Set();
|
|
698
|
+
for (const s of scopes) {
|
|
699
|
+
const a = SCOPE_AUD[s.split(":")[0] ?? ""];
|
|
700
|
+
if (a) auds.add(a);
|
|
701
|
+
}
|
|
702
|
+
return auds;
|
|
703
|
+
}
|
|
704
|
+
var DEV_VXIL_BASE = "https://api.vxil.org";
|
|
705
|
+
var PLACEHOLDER_PREFIX = "vxil-dev-placeholder.";
|
|
706
|
+
function buildEnvelope(opts) {
|
|
707
|
+
const scopedJwts = {};
|
|
708
|
+
for (const aud of audsForScopes(opts.scopes)) scopedJwts[aud] = `${PLACEHOLDER_PREFIX}${aud}`;
|
|
709
|
+
return {
|
|
710
|
+
trigger: "http",
|
|
711
|
+
tenant_id: opts.tenantId,
|
|
712
|
+
request_id: opts.requestId,
|
|
713
|
+
idempotency_key: opts.idempotencyKey ?? opts.requestId,
|
|
714
|
+
vxil_base: DEV_VXIL_BASE,
|
|
715
|
+
scoped_jwts: scopedJwts,
|
|
716
|
+
secrets: opts.secrets ?? {},
|
|
717
|
+
payload: opts.payload
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
function audForPath(pathname) {
|
|
721
|
+
const seg = (p) => pathname === `/v1/${p}` || pathname.startsWith(`/v1/${p}/`);
|
|
722
|
+
if (seg("users") || seg("tenants") || seg("config") || pathname === "/v1/features" || pathname === "/v1/audit" || pathname.startsWith("/v1/audit/") || pathname.startsWith("/v1/webhooks/subscriptions") || seg("functions")) return "control-plane";
|
|
723
|
+
if (seg("fn")) return "functions";
|
|
724
|
+
if (seg("notifications")) return "notifications";
|
|
725
|
+
if (seg("jobs")) return "jobs";
|
|
726
|
+
if (seg("auth")) return "auth";
|
|
727
|
+
if (seg("rate-limits")) return "rate-limits";
|
|
728
|
+
if (seg("files")) return "files";
|
|
729
|
+
if (seg("comments")) return "comments";
|
|
730
|
+
if (seg("dm")) return "comments";
|
|
731
|
+
if (seg("cms")) return "cms";
|
|
732
|
+
if (seg("realtime")) return "realtime";
|
|
733
|
+
if (seg("orgs")) return "orgs";
|
|
734
|
+
if (seg("feeds")) return "activity-feed";
|
|
735
|
+
if (seg("search")) return "vector-search";
|
|
736
|
+
if (seg("ai")) return "ai";
|
|
737
|
+
if (seg("rag")) return "rag";
|
|
738
|
+
if (seg("payments")) return "payments";
|
|
739
|
+
if (pathname.startsWith("/v1/webhooks/sources") || pathname.startsWith("/v1/webhooks/events") || pathname.startsWith("/v1/webhooks/inbound/")) return "webhooks";
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
function resolveDevSecrets(refs, env) {
|
|
743
|
+
const secrets = {};
|
|
744
|
+
const missing = [];
|
|
745
|
+
for (const raw of refs ?? []) {
|
|
746
|
+
const name = String(raw).replace(/^secret:/, "");
|
|
747
|
+
const value = env[`VXIL_FN_SECRET_${name.toUpperCase()}`];
|
|
748
|
+
if (value === void 0) missing.push(name);
|
|
749
|
+
else secrets[name] = value;
|
|
750
|
+
}
|
|
751
|
+
return { secrets, missing };
|
|
752
|
+
}
|
|
753
|
+
function json(body, status, headers = {}) {
|
|
754
|
+
return new Response(JSON.stringify(body), {
|
|
755
|
+
status,
|
|
756
|
+
headers: { "content-type": "application/json", ...headers }
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
var EDGE_FORWARD_HEADERS = ["content-type", "accept", "if-match", "idempotency-key"];
|
|
760
|
+
function makeDevOutbound(opts) {
|
|
761
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
762
|
+
const log = opts.log ?? (() => {
|
|
763
|
+
});
|
|
764
|
+
const impliedAuds = audsForScopes(opts.scopes);
|
|
765
|
+
return async (req) => {
|
|
766
|
+
let url;
|
|
767
|
+
try {
|
|
768
|
+
url = new URL(req.url);
|
|
769
|
+
} catch {
|
|
770
|
+
return json({ error: "egress_blocked", reason: "invalid-url", host: "", fn: opts.fnName }, 403, { "x-vxil-egress": "blocked" });
|
|
771
|
+
}
|
|
772
|
+
const decision = egressDecision(req.url, opts.egressAllow);
|
|
773
|
+
if (decision.allow && decision.reason === "vxil-api") {
|
|
774
|
+
const aud = audForPath(url.pathname);
|
|
775
|
+
if (!aud) {
|
|
776
|
+
return json({ error: { code: "not_found", message: `the edge does not route ${url.pathname}.` } }, 404);
|
|
777
|
+
}
|
|
778
|
+
if (!impliedAuds.has(aud)) {
|
|
779
|
+
log(`\u2717 scope clamp: ${url.pathname} needs the '${aud}' feature, but '${opts.fnName}' is scoped [${opts.scopes.join(", ") || "none"}]`);
|
|
780
|
+
return json({
|
|
781
|
+
error: {
|
|
782
|
+
code: "scope_clamp",
|
|
783
|
+
message: `function '${opts.fnName}' is not scoped for the '${aud}' feature (path ${url.pathname}).`,
|
|
784
|
+
hint: aud === "functions" ? "fn\u2192fn calls are never allowed (no recursion), locally or deployed." : `add a '${aud}'-implying scope to functions.${opts.fnName}.scopes in vxil.config.ts and redeploy \u2014 the deployed dispatcher enforces the same clamp.`
|
|
785
|
+
}
|
|
786
|
+
}, 403);
|
|
787
|
+
}
|
|
788
|
+
const target = `${opts.edgeUrl.replace(/\/$/, "")}${url.pathname}${url.search}`;
|
|
789
|
+
const headers = new Headers();
|
|
790
|
+
for (const h of EDGE_FORWARD_HEADERS) {
|
|
791
|
+
const v = req.headers.get(h);
|
|
792
|
+
if (v) headers.set(h, v);
|
|
793
|
+
}
|
|
794
|
+
headers.set("authorization", `Bearer ${opts.apiKey}`);
|
|
795
|
+
headers.set("x-vxil-surface", "cli-fn-dev");
|
|
796
|
+
const method2 = req.method.toUpperCase();
|
|
797
|
+
const body2 = method2 === "GET" || method2 === "HEAD" ? void 0 : await req.arrayBuffer();
|
|
798
|
+
return fetchImpl(target, { method: method2, headers, ...body2 !== void 0 ? { body: body2 } : {} });
|
|
799
|
+
}
|
|
800
|
+
if (!decision.allow) {
|
|
801
|
+
log(`\u2717 egress blocked: ${url.hostname} (${decision.reason}) \u2014 add it to functions.${opts.fnName}.egressAllow if intended`);
|
|
802
|
+
return json({
|
|
803
|
+
error: "egress_blocked",
|
|
804
|
+
reason: decision.reason,
|
|
805
|
+
host: url.hostname,
|
|
806
|
+
fn: opts.fnName,
|
|
807
|
+
hint: "A vxil function may only reach api.vxil.* or a host on its declared egressAllow."
|
|
808
|
+
}, 403, { "x-vxil-egress": "blocked" });
|
|
809
|
+
}
|
|
810
|
+
const method = req.method.toUpperCase();
|
|
811
|
+
const body = method === "GET" || method === "HEAD" ? void 0 : await req.arrayBuffer();
|
|
812
|
+
return fetchImpl(req.url, { method, headers: req.headers, ...body !== void 0 ? { body } : {} });
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// src/gen.ts
|
|
817
|
+
function tsTypeFor(fieldType) {
|
|
818
|
+
switch (fieldType) {
|
|
819
|
+
case "string":
|
|
820
|
+
case "text":
|
|
821
|
+
return "string";
|
|
822
|
+
case "int":
|
|
823
|
+
case "float":
|
|
824
|
+
return "number";
|
|
825
|
+
case "bool":
|
|
826
|
+
return "boolean";
|
|
827
|
+
case "datetime":
|
|
828
|
+
return "string";
|
|
829
|
+
// ISO-8601
|
|
830
|
+
case "relation":
|
|
831
|
+
case "file":
|
|
832
|
+
return "string";
|
|
833
|
+
// related item_id / file id
|
|
834
|
+
case "json":
|
|
835
|
+
default:
|
|
836
|
+
return "unknown";
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
var META_SORTABLE = ["created_at", "updated_at", "published_at"];
|
|
840
|
+
var CMS_STATUS_VALUES = ["draft", "published", "archived"];
|
|
841
|
+
function slottedFields(c) {
|
|
842
|
+
return c.fields.filter((f) => f.index_slot);
|
|
843
|
+
}
|
|
844
|
+
function isFilterableField(f) {
|
|
845
|
+
return !(f.computed && !f.index_slot);
|
|
846
|
+
}
|
|
847
|
+
function filterTypeFor(f) {
|
|
848
|
+
const slot = f.index_slot ?? "";
|
|
849
|
+
if (slot.startsWith("s")) return "VxilFilterRangeText";
|
|
850
|
+
if (slot.startsWith("n")) return "VxilFilterRange<number>";
|
|
851
|
+
if (slot.startsWith("t")) return "VxilFilterRangeDatetime";
|
|
852
|
+
switch (tsTypeFor(f.type)) {
|
|
853
|
+
case "string":
|
|
854
|
+
return "VxilFilterEqText";
|
|
855
|
+
case "number":
|
|
856
|
+
return "VxilFilterEq<number>";
|
|
857
|
+
case "boolean":
|
|
858
|
+
return "VxilFilterEq<boolean>";
|
|
859
|
+
default:
|
|
860
|
+
return "VxilFilterJson";
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
function quoteKey(k) {
|
|
864
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : JSON.stringify(k);
|
|
865
|
+
}
|
|
866
|
+
function unionOf(values) {
|
|
867
|
+
if (!values.length) return "never";
|
|
868
|
+
return values.map((v) => JSON.stringify(v)).join(" | ");
|
|
869
|
+
}
|
|
870
|
+
function collectionType(c) {
|
|
871
|
+
const lines = [];
|
|
872
|
+
const ind = " ";
|
|
873
|
+
const rowFields = c.fields.map((f) => {
|
|
874
|
+
const t = tsTypeFor(f.type);
|
|
875
|
+
return f.required ? `${ind}${quoteKey(f.field)}: ${t};` : `${ind}${quoteKey(f.field)}: ${t} | null;`;
|
|
876
|
+
});
|
|
877
|
+
const insertFields = c.fields.filter((f) => !f.computed).map((f) => {
|
|
878
|
+
const t = tsTypeFor(f.type);
|
|
879
|
+
return f.required ? `${ind}${quoteKey(f.field)}: ${t};` : `${ind}${quoteKey(f.field)}?: ${t} | null;`;
|
|
880
|
+
});
|
|
881
|
+
const patchFields = c.fields.filter((f) => !f.computed).map((f) => `${ind}${quoteKey(f.field)}?: ${tsTypeFor(f.type)} | null;`);
|
|
882
|
+
const slotted = slottedFields(c);
|
|
883
|
+
const sortable = unionOf([...slotted.map((f) => f.field), ...META_SORTABLE]);
|
|
884
|
+
const statusUnion = unionOf([...CMS_STATUS_VALUES]);
|
|
885
|
+
const filterLines = [
|
|
886
|
+
`${ind}/** reserved lifecycle filter (always available; 'archived' reserved by the DB CHECK; object form is $in-only \u2014 the server rejects {$eq: \u2026}) */`,
|
|
887
|
+
`${ind}$status: ${statusUnion} | { $in: (${statusUnion})[] };`,
|
|
888
|
+
...c.fields.filter(isFilterableField).map((f) => `${ind}${quoteKey(f.field)}: ${filterTypeFor(f)};`)
|
|
889
|
+
];
|
|
890
|
+
const filterable = `{
|
|
891
|
+
${filterLines.join("\n")}
|
|
892
|
+
}`;
|
|
893
|
+
lines.push(` ${quoteKey(c.collection)}: {`);
|
|
894
|
+
lines.push(` Row: {
|
|
895
|
+
${rowFields.join("\n")}
|
|
896
|
+
};`);
|
|
897
|
+
lines.push(` Insert: {
|
|
898
|
+
${insertFields.join("\n") || `${ind}/* (no insertable fields) */`}
|
|
899
|
+
};`);
|
|
900
|
+
lines.push(` Patch: {
|
|
901
|
+
${patchFields.join("\n") || `${ind}/* (no patchable fields) */`}
|
|
902
|
+
};`);
|
|
903
|
+
lines.push(` Sortable: ${sortable};`);
|
|
904
|
+
lines.push(` Filterable: ${filterable};`);
|
|
905
|
+
lines.push(` };`);
|
|
906
|
+
return lines.join("\n");
|
|
907
|
+
}
|
|
908
|
+
function lowerSig(sig) {
|
|
909
|
+
if (sig == null) return "unknown";
|
|
910
|
+
if (typeof sig === "string") return sig.trim() || "unknown";
|
|
911
|
+
if (typeof sig === "object" && !Array.isArray(sig)) {
|
|
912
|
+
const fields = Object.entries(sig).map(([k, v]) => `${quoteKey(k)}: ${typeof v === "string" && v.trim() ? v.trim() : "unknown"}`);
|
|
913
|
+
return fields.length ? `{ ${fields.join("; ")} }` : "Record<string, never>";
|
|
914
|
+
}
|
|
915
|
+
return "unknown";
|
|
916
|
+
}
|
|
917
|
+
function functionType(f) {
|
|
918
|
+
return ` ${quoteKey(f.name)}: { Input: ${lowerSig(f.signature?.input)}; Output: ${lowerSig(f.signature?.output)} };`;
|
|
919
|
+
}
|
|
920
|
+
function apiVersionEntries(input) {
|
|
921
|
+
const map = input.apiVersions;
|
|
922
|
+
if (!map) return [];
|
|
923
|
+
return Object.entries(map).sort(([a], [b]) => a.localeCompare(b));
|
|
924
|
+
}
|
|
925
|
+
function generateTypes(input) {
|
|
926
|
+
const feats = [...input.features].sort();
|
|
927
|
+
const colls = [...input.collections].sort((a, b) => a.collection.localeCompare(b.collection));
|
|
928
|
+
const fns = [...input.functions].sort((a, b) => a.name.localeCompare(b.name));
|
|
929
|
+
const apiVers = apiVersionEntries(input);
|
|
930
|
+
const header = [
|
|
931
|
+
`// vxil.types.ts \u2014 GENERATED by \`vxil gen\`${input.generatedAt ? ` at ${input.generatedAt}` : ""}`,
|
|
932
|
+
`// Tenant: ${input.tenant ?? "(unknown)"}${input.env ? ` (${input.env})` : ""} \xB7 ${feats.length} features \xB7 ${colls.length} collections \xB7 ${fns.length} functions`,
|
|
933
|
+
// API-major line only when the backend reports api_versions (older backends
|
|
934
|
+
// omit it → header byte-identical). Compact `feature=major|major` list.
|
|
935
|
+
...apiVers.length ? [`// API majors (pinned): ${apiVers.map(([f, v]) => `${f}=${v.join("|")}`).join(" \xB7 ")}`] : [],
|
|
936
|
+
"// DO NOT EDIT \u2014 re-run `vxil gen`.",
|
|
937
|
+
"/* eslint-disable */"
|
|
938
|
+
].join("\n");
|
|
939
|
+
const apiVersionsBlock = apiVers.length ? `
|
|
940
|
+
|
|
941
|
+
// The API majors this client was generated against (feature-versioning.md \xA7D10).
|
|
942
|
+
// A new major on the backend surfaces here as \`vxil gen --check\` drift.
|
|
943
|
+
export const API_VERSIONS = {
|
|
944
|
+
` + apiVers.map(([f, v]) => ` ${quoteKey(f)}: [${v.map((x) => JSON.stringify(x)).join(", ")}],`).join("\n") + `
|
|
945
|
+
} as const;` : "";
|
|
946
|
+
const cmsBody = colls.length ? colls.map(collectionType).join("\n") : " // (no collections \u2014 `vx.from(...)` is empty until you declare one)";
|
|
947
|
+
const fnBody = fns.length ? fns.map(functionType).join("\n") : " // (no deployed functions)";
|
|
948
|
+
return `${header}
|
|
949
|
+
|
|
950
|
+
export interface VxilSchema {
|
|
951
|
+
// (1) enabled features \u2192 the runtime narrows its namespaces to these keys
|
|
952
|
+
features: ${unionOf(feats)};
|
|
953
|
+
|
|
954
|
+
// (2) CMS collections, derived from GET /v1/cms/collections FieldDefs
|
|
955
|
+
cms: {
|
|
956
|
+
${cmsBody}
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
// (3) deployed functions \u2192 typed invoke (Level 0: opaque until a signature is declared)
|
|
960
|
+
functions: {
|
|
961
|
+
${fnBody}
|
|
962
|
+
};
|
|
963
|
+
}${apiVersionsBlock}
|
|
964
|
+
|
|
965
|
+
// Filter-operator aliases referenced by Filterable above. Ops mirror cms-v1
|
|
966
|
+
// query.ts; multiple ops in one object AND-compose. Range ops ($ne/$gt/$gte/
|
|
967
|
+
// $lt/$lte) require an index slot \u2014 the server leniently admits unslotted
|
|
968
|
+
// ranges via unindexed JSONB scans, deliberately NOT typed here (escape hatch:
|
|
969
|
+
// the untyped \`vx.cms.items.query\`). $in is typed on every slot family
|
|
970
|
+
// (s*/n*/t*) \u2014 cms-v1 binds the bounded array with a per-family SQL cast.
|
|
971
|
+
// Runtime caps not encodable in types:\u22648 filter terms, $in 1\u201350 elements, filter \u22642KB; $contains is an
|
|
972
|
+
// ILIKE substring match. (Emitted BELOW the interface so \`vxil gen --check\`
|
|
973
|
+
// drift-compares them; TS declarations hoist, so order never matters.)
|
|
974
|
+
export type VxilFilterEq<T> = T | { $eq?: T; $in?: T[] };
|
|
975
|
+
export type VxilFilterEqText = string | { $eq?: string; $in?: string[]; $contains?: string };
|
|
976
|
+
export type VxilFilterRange<T> = T | { $eq?: T; $ne?: T; $gt?: T; $gte?: T; $lt?: T; $lte?: T; $in?: T[] };
|
|
977
|
+
export type VxilFilterRangeText = string | { $eq?: string; $ne?: string; $gt?: string; $gte?: string; $lt?: string; $lte?: string; $in?: string[]; $contains?: string };
|
|
978
|
+
export type VxilFilterRangeDatetime = string | { $eq?: string; $ne?: string; $gt?: string; $gte?: string; $lt?: string; $lte?: string; $in?: string[]; $contains?: string };
|
|
979
|
+
export type VxilFilterJson = string | number | boolean | { $eq?: string | number | boolean; $in?: (string | number | boolean)[]; $contains?: string };
|
|
980
|
+
|
|
981
|
+
// Usage \u2014 narrow the client to THIS tenant's enabled features (a disabled feature
|
|
982
|
+
// becomes a compile error):
|
|
983
|
+
// import { Vxil } from '@vxil/sdk';
|
|
984
|
+
// import type { VxilSchema } from './vxil.types';
|
|
985
|
+
// const vx = Vxil.connect<VxilSchema>({ apiKey: process.env.VXIL_API_KEY! });
|
|
986
|
+
// await vx.from('${colls[0]?.collection ?? "collection"}').create({ /* field-typed */ });
|
|
987
|
+
`;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ../../workers/mcp-v1/src/tools.ts
|
|
991
|
+
var TOOLS = [
|
|
992
|
+
{
|
|
993
|
+
name: "users_upsert",
|
|
994
|
+
description: "Create or update an end-user in the tenant_users substrate. Call this before sending notifications to a new user. The id is your own opaque user identifier.",
|
|
995
|
+
inputSchema: {
|
|
996
|
+
type: "object",
|
|
997
|
+
properties: {
|
|
998
|
+
id: { type: "string", description: "Opaque end_user_id (your identifier, \u2264256 chars)." },
|
|
999
|
+
email: { type: "string", description: "Email address (required before email sends)." },
|
|
1000
|
+
display_name: { type: "string" },
|
|
1001
|
+
attributes: { type: "object", additionalProperties: true }
|
|
1002
|
+
},
|
|
1003
|
+
required: ["id"]
|
|
1004
|
+
},
|
|
1005
|
+
method: "POST",
|
|
1006
|
+
path: "/v1/users"
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
name: "notifications_send",
|
|
1010
|
+
feature: "notifications",
|
|
1011
|
+
description: "Send a transactional email to an end-user by ID. Returns a delivery_id immediately (202); delivery is asynchronous. Templates: magic-link (data: url, expires_minutes), welcome (data: app_name, first_name?), transactional (data: subject, paragraph, cta_label?, cta_url?).",
|
|
1012
|
+
inputSchema: {
|
|
1013
|
+
type: "object",
|
|
1014
|
+
properties: {
|
|
1015
|
+
user_id: { type: "string", description: "Opaque end_user_id from tenant_users." },
|
|
1016
|
+
template: { type: "string", enum: ["magic-link", "welcome", "transactional"] },
|
|
1017
|
+
data: { type: "object", additionalProperties: true },
|
|
1018
|
+
locale: { type: "string", default: "en-US" }
|
|
1019
|
+
},
|
|
1020
|
+
required: ["user_id", "template", "data"]
|
|
1021
|
+
},
|
|
1022
|
+
method: "POST",
|
|
1023
|
+
path: "/v1/notifications/send"
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
name: "notifications_list_deliveries",
|
|
1027
|
+
feature: "notifications",
|
|
1028
|
+
description: "List recent notification deliveries with status (queued|sent|failed|suppressed). Filter by user_id or status.",
|
|
1029
|
+
inputSchema: {
|
|
1030
|
+
type: "object",
|
|
1031
|
+
properties: {
|
|
1032
|
+
user_id: { type: "string" },
|
|
1033
|
+
status: { type: "string", enum: ["queued", "sent", "failed", "suppressed"] },
|
|
1034
|
+
limit: { type: "number", default: 20 }
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
1037
|
+
method: "GET",
|
|
1038
|
+
path: "/v1/notifications/deliveries",
|
|
1039
|
+
queryArgs: ["user_id", "status", "limit"]
|
|
1040
|
+
},
|
|
1041
|
+
{
|
|
1042
|
+
name: "jobs_enqueue",
|
|
1043
|
+
feature: "jobs",
|
|
1044
|
+
description: "Enqueue a background job: Vxil POSTs a signed callback to your https target_url with retries/backoff until 2xx. Verify X-Vxil-Jobs-Signature against jobs_get_signing_secret. idempotency_key dedups for 24h. deliver_after (ISO) / delay_seconds (\u2264 30 d, at most one) defer the first delivery; > 12 h returns state 'delayed'.",
|
|
1045
|
+
inputSchema: {
|
|
1046
|
+
type: "object",
|
|
1047
|
+
properties: {
|
|
1048
|
+
job_name: { type: "string" },
|
|
1049
|
+
target_url: { type: "string", description: "https:// endpoint Vxil will call" },
|
|
1050
|
+
payload: { type: "object", additionalProperties: true },
|
|
1051
|
+
idempotency_key: { type: "string" },
|
|
1052
|
+
max_attempts: { type: "number", minimum: 1, maximum: 20 },
|
|
1053
|
+
deliver_after: { type: "string", description: "future ISO timestamp (mutually exclusive with delay_seconds)" },
|
|
1054
|
+
delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 }
|
|
1055
|
+
},
|
|
1056
|
+
required: ["job_name", "target_url"]
|
|
1057
|
+
},
|
|
1058
|
+
method: "POST",
|
|
1059
|
+
path: "/v1/jobs/enqueue"
|
|
1060
|
+
},
|
|
1061
|
+
{
|
|
1062
|
+
name: "jobs_enqueue_batch",
|
|
1063
|
+
feature: "jobs",
|
|
1064
|
+
description: "Atomically enqueue up to 100 jobs in one call (one item invalid \u2192 422, nothing inserted). Each item = the jobs_enqueue input, incl. per-item idempotency_key and deliver_after/delay_seconds. Results align with input order.",
|
|
1065
|
+
inputSchema: {
|
|
1066
|
+
type: "object",
|
|
1067
|
+
properties: {
|
|
1068
|
+
jobs: {
|
|
1069
|
+
type: "array",
|
|
1070
|
+
minItems: 1,
|
|
1071
|
+
maxItems: 100,
|
|
1072
|
+
items: {
|
|
1073
|
+
type: "object",
|
|
1074
|
+
properties: {
|
|
1075
|
+
job_name: { type: "string" },
|
|
1076
|
+
target_url: { type: "string" },
|
|
1077
|
+
payload: { type: "object", additionalProperties: true },
|
|
1078
|
+
idempotency_key: { type: "string" },
|
|
1079
|
+
max_attempts: { type: "number", minimum: 1, maximum: 20 },
|
|
1080
|
+
deliver_after: { type: "string" },
|
|
1081
|
+
delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 }
|
|
1082
|
+
},
|
|
1083
|
+
required: ["job_name", "target_url"]
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
},
|
|
1087
|
+
required: ["jobs"]
|
|
1088
|
+
},
|
|
1089
|
+
method: "POST",
|
|
1090
|
+
path: "/v1/jobs/enqueue-batch"
|
|
1091
|
+
},
|
|
1092
|
+
{
|
|
1093
|
+
name: "jobs_list_runs",
|
|
1094
|
+
feature: "jobs",
|
|
1095
|
+
description: "List job runs (state machine: queued\u2192running\u2192succeeded | retrying\u2192dead | cancelled; delayed = scheduled future delivery, waiting = suspended on an event).",
|
|
1096
|
+
inputSchema: {
|
|
1097
|
+
type: "object",
|
|
1098
|
+
properties: {
|
|
1099
|
+
job_name: { type: "string" },
|
|
1100
|
+
state: { type: "string", enum: ["queued", "running", "retrying", "waiting", "delayed", "succeeded", "failed", "dead", "cancelled"] },
|
|
1101
|
+
limit: { type: "number", default: 20 }
|
|
1102
|
+
}
|
|
1103
|
+
},
|
|
1104
|
+
method: "GET",
|
|
1105
|
+
path: "/v1/jobs/runs",
|
|
1106
|
+
queryArgs: ["job_name", "state", "limit"]
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
name: "jobs_pause_schedule",
|
|
1110
|
+
feature: "jobs",
|
|
1111
|
+
description: "Pause an active schedule (stops firing; the row survives and can be resumed).",
|
|
1112
|
+
inputSchema: {
|
|
1113
|
+
type: "object",
|
|
1114
|
+
properties: { schedule_id: { type: "string" } },
|
|
1115
|
+
required: ["schedule_id"]
|
|
1116
|
+
},
|
|
1117
|
+
method: "POST",
|
|
1118
|
+
path: (a) => `/v1/jobs/schedules/${encodeURIComponent(String(a.schedule_id))}/pause`
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
name: "jobs_resume_schedule",
|
|
1122
|
+
feature: "jobs",
|
|
1123
|
+
description: "Resume a paused schedule. Cron \u2192 next_run_at recomputes; one-shot with a lapsed run_at \u2192 409 run_at_passed. Re-checks the schedules.maxPerTenant cap.",
|
|
1124
|
+
inputSchema: {
|
|
1125
|
+
type: "object",
|
|
1126
|
+
properties: { schedule_id: { type: "string" } },
|
|
1127
|
+
required: ["schedule_id"]
|
|
1128
|
+
},
|
|
1129
|
+
method: "POST",
|
|
1130
|
+
path: (a) => `/v1/jobs/schedules/${encodeURIComponent(String(a.schedule_id))}/resume`
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
name: "jobs_create_flow_rule",
|
|
1134
|
+
feature: "jobs",
|
|
1135
|
+
description: "Create a per-endpoint/job flow-control rule: rate_per_minute and/or max_parallel (at least one). match_kind 'endpoint' matches the target_url https ORIGIN (e.g. 'https://api.example.com'); 'job_name' matches exactly. Enforced by the executor; over-limit runs defer, never drop.",
|
|
1136
|
+
inputSchema: {
|
|
1137
|
+
type: "object",
|
|
1138
|
+
properties: {
|
|
1139
|
+
match_kind: { type: "string", enum: ["endpoint", "job_name"] },
|
|
1140
|
+
match_value: { type: "string" },
|
|
1141
|
+
max_parallel: { type: "number", minimum: 1, maximum: 100 },
|
|
1142
|
+
rate_per_minute: { type: "number", minimum: 1, maximum: 1e4 }
|
|
1143
|
+
},
|
|
1144
|
+
required: ["match_kind", "match_value"]
|
|
1145
|
+
},
|
|
1146
|
+
method: "POST",
|
|
1147
|
+
path: "/v1/jobs/flow-rules"
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
name: "jobs_list_flow_rules",
|
|
1151
|
+
feature: "jobs",
|
|
1152
|
+
description: "List the tenant flow-control rules (rate + parallelism per endpoint/job).",
|
|
1153
|
+
inputSchema: { type: "object", properties: {} },
|
|
1154
|
+
method: "GET",
|
|
1155
|
+
path: "/v1/jobs/flow-rules"
|
|
1156
|
+
},
|
|
1157
|
+
{
|
|
1158
|
+
name: "jobs_delete_flow_rule",
|
|
1159
|
+
feature: "jobs",
|
|
1160
|
+
description: "Delete a flow-control rule (soft delete; enforcement stops within the executor round).",
|
|
1161
|
+
inputSchema: {
|
|
1162
|
+
type: "object",
|
|
1163
|
+
properties: { rule_id: { type: "string" } },
|
|
1164
|
+
required: ["rule_id"]
|
|
1165
|
+
},
|
|
1166
|
+
method: "DELETE",
|
|
1167
|
+
path: (a) => `/v1/jobs/flow-rules/${encodeURIComponent(String(a.rule_id))}`
|
|
1168
|
+
},
|
|
1169
|
+
{
|
|
1170
|
+
name: "jobs_create_schedule",
|
|
1171
|
+
feature: "jobs",
|
|
1172
|
+
description: "Create a recurring (5-field cron, UTC) or one-shot (run_at ISO) schedule that enqueues the job on the minute tick. Exactly one of cron/run_at.",
|
|
1173
|
+
inputSchema: {
|
|
1174
|
+
type: "object",
|
|
1175
|
+
properties: {
|
|
1176
|
+
job_name: { type: "string" },
|
|
1177
|
+
target_url: { type: "string" },
|
|
1178
|
+
payload: { type: "object", additionalProperties: true },
|
|
1179
|
+
cron: { type: "string", description: "e.g. '0 */6 * * *' (UTC)" },
|
|
1180
|
+
run_at: { type: "string", description: "future ISO timestamp" }
|
|
1181
|
+
},
|
|
1182
|
+
required: ["job_name", "target_url"]
|
|
1183
|
+
},
|
|
1184
|
+
method: "POST",
|
|
1185
|
+
path: "/v1/jobs/schedules"
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
name: "jobs_get_signing_secret",
|
|
1189
|
+
feature: "jobs",
|
|
1190
|
+
description: "Per-tenant secret your endpoints use to verify the X-Vxil-Jobs-Signature header on job callbacks.",
|
|
1191
|
+
inputSchema: { type: "object", properties: {} },
|
|
1192
|
+
method: "GET",
|
|
1193
|
+
path: "/v1/jobs/signing-secret"
|
|
1194
|
+
},
|
|
1195
|
+
{
|
|
1196
|
+
name: "rate_limits_create_policy",
|
|
1197
|
+
feature: "rate-limits",
|
|
1198
|
+
description: "Define a rate-limit policy. key_template vars like {ip} are supplied at check time; {tenant_id} is implicit. behavior 'block' \u2192 429 on exceed; 'shape' \u2192 200 with allowed=false.",
|
|
1199
|
+
inputSchema: {
|
|
1200
|
+
type: "object",
|
|
1201
|
+
properties: {
|
|
1202
|
+
name: { type: "string" },
|
|
1203
|
+
key_template: { type: "string", description: "e.g. '{tenant_id}:{ip}'" },
|
|
1204
|
+
limit: { type: "number" },
|
|
1205
|
+
window_seconds: { type: "number" },
|
|
1206
|
+
behavior: { type: "string", enum: ["block", "shape"], default: "block" }
|
|
1207
|
+
},
|
|
1208
|
+
required: ["name", "key_template", "limit", "window_seconds"]
|
|
1209
|
+
},
|
|
1210
|
+
method: "POST",
|
|
1211
|
+
path: "/v1/rate-limits/policies"
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
name: "rate_limits_list_policies",
|
|
1215
|
+
feature: "rate-limits",
|
|
1216
|
+
description: "List rate-limit policies.",
|
|
1217
|
+
inputSchema: { type: "object", properties: {} },
|
|
1218
|
+
method: "GET",
|
|
1219
|
+
path: "/v1/rate-limits/policies"
|
|
1220
|
+
},
|
|
1221
|
+
{
|
|
1222
|
+
name: "rate_limits_check",
|
|
1223
|
+
feature: "rate-limits",
|
|
1224
|
+
description: "Consume budget against a policy. Returns allowed/remaining/reset_seconds (429 on block-behavior exceed).",
|
|
1225
|
+
inputSchema: {
|
|
1226
|
+
type: "object",
|
|
1227
|
+
properties: {
|
|
1228
|
+
policy_id: { type: "string" },
|
|
1229
|
+
key_values: { type: "object", additionalProperties: { type: "string" } },
|
|
1230
|
+
cost: { type: "number", default: 1 }
|
|
1231
|
+
},
|
|
1232
|
+
required: ["policy_id"]
|
|
1233
|
+
},
|
|
1234
|
+
method: "POST",
|
|
1235
|
+
path: "/v1/rate-limits/check"
|
|
1236
|
+
},
|
|
1237
|
+
{
|
|
1238
|
+
name: "rate_limits_create_override",
|
|
1239
|
+
feature: "rate-limits",
|
|
1240
|
+
description: "Add a per-identifier override on a policy. `pattern` matches the RENDERED key: an exact literal (e.g. 'tenant:user:42') wins outright; otherwise '*'-globs compete and the longest literal prefix wins (e.g. 'ip:10.*'). Unset fields fall through to the policy; an override may raise OR lower limit/window/behavior. Propagates to checks within \u226430s.",
|
|
1241
|
+
inputSchema: {
|
|
1242
|
+
type: "object",
|
|
1243
|
+
properties: {
|
|
1244
|
+
policy_id: { type: "string" },
|
|
1245
|
+
pattern: { type: "string", description: "rendered-key literal or '*'-glob, 1-300 chars" },
|
|
1246
|
+
limit: { type: "number" },
|
|
1247
|
+
window_seconds: { type: "number" },
|
|
1248
|
+
behavior: { type: "string", enum: ["block", "shape"] },
|
|
1249
|
+
note: { type: "string", description: "operator context, \u2264200 chars" }
|
|
1250
|
+
},
|
|
1251
|
+
required: ["policy_id", "pattern"]
|
|
1252
|
+
},
|
|
1253
|
+
method: "POST",
|
|
1254
|
+
path: (a) => `/v1/rate-limits/policies/${encodeURIComponent(String(a.policy_id))}/overrides`
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
name: "rate_limits_list_overrides",
|
|
1258
|
+
feature: "rate-limits",
|
|
1259
|
+
description: "List a policy's per-identifier overrides (max 50 per policy).",
|
|
1260
|
+
inputSchema: {
|
|
1261
|
+
type: "object",
|
|
1262
|
+
properties: { policy_id: { type: "string" } },
|
|
1263
|
+
required: ["policy_id"]
|
|
1264
|
+
},
|
|
1265
|
+
method: "GET",
|
|
1266
|
+
path: (a) => `/v1/rate-limits/policies/${encodeURIComponent(String(a.policy_id))}/overrides`
|
|
1267
|
+
},
|
|
1268
|
+
{
|
|
1269
|
+
name: "files_create_upload_url",
|
|
1270
|
+
feature: "files",
|
|
1271
|
+
description: "Mint a presigned PUT URL for a new file (bytes go straight to storage, never through Vxil). After uploading, call files_complete to make the object available.",
|
|
1272
|
+
inputSchema: {
|
|
1273
|
+
type: "object",
|
|
1274
|
+
properties: {
|
|
1275
|
+
user_id: { type: "string", description: "Owning end_user_id from tenant_users." },
|
|
1276
|
+
filename: { type: "string" },
|
|
1277
|
+
content_type: { type: "string" },
|
|
1278
|
+
size_bytes: { type: "number" }
|
|
1279
|
+
},
|
|
1280
|
+
required: ["user_id", "filename", "content_type", "size_bytes"]
|
|
1281
|
+
},
|
|
1282
|
+
method: "POST",
|
|
1283
|
+
path: "/v1/files/upload-url"
|
|
1284
|
+
},
|
|
1285
|
+
{
|
|
1286
|
+
name: "files_complete",
|
|
1287
|
+
feature: "files",
|
|
1288
|
+
description: "Verify the uploaded bytes and flip the object pending\u2192available. Required before download URLs work.",
|
|
1289
|
+
inputSchema: {
|
|
1290
|
+
type: "object",
|
|
1291
|
+
properties: { object_id: { type: "string" } },
|
|
1292
|
+
required: ["object_id"]
|
|
1293
|
+
},
|
|
1294
|
+
method: "POST",
|
|
1295
|
+
path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/complete`
|
|
1296
|
+
},
|
|
1297
|
+
{
|
|
1298
|
+
name: "files_get_download_url",
|
|
1299
|
+
feature: "files",
|
|
1300
|
+
description: "Mint a short-lived presigned GET URL for an available object.",
|
|
1301
|
+
inputSchema: {
|
|
1302
|
+
type: "object",
|
|
1303
|
+
properties: { object_id: { type: "string" } },
|
|
1304
|
+
required: ["object_id"]
|
|
1305
|
+
},
|
|
1306
|
+
method: "GET",
|
|
1307
|
+
path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/download-url`
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
name: "files_list",
|
|
1311
|
+
feature: "files",
|
|
1312
|
+
description: "List file objects (newest first; filter by user_id).",
|
|
1313
|
+
inputSchema: {
|
|
1314
|
+
type: "object",
|
|
1315
|
+
properties: {
|
|
1316
|
+
user_id: { type: "string" },
|
|
1317
|
+
limit: { type: "number", default: 20 }
|
|
1318
|
+
}
|
|
1319
|
+
},
|
|
1320
|
+
method: "GET",
|
|
1321
|
+
path: "/v1/files",
|
|
1322
|
+
queryArgs: ["user_id", "limit"]
|
|
1323
|
+
},
|
|
1324
|
+
{
|
|
1325
|
+
name: "files_delete",
|
|
1326
|
+
feature: "files",
|
|
1327
|
+
description: "Soft-delete a file object; bytes are hard-deleted after 30 days.",
|
|
1328
|
+
inputSchema: {
|
|
1329
|
+
type: "object",
|
|
1330
|
+
properties: { object_id: { type: "string" } },
|
|
1331
|
+
required: ["object_id"]
|
|
1332
|
+
},
|
|
1333
|
+
method: "DELETE",
|
|
1334
|
+
path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}`
|
|
1335
|
+
},
|
|
1336
|
+
{
|
|
1337
|
+
name: "comments_create",
|
|
1338
|
+
feature: "comments",
|
|
1339
|
+
description: "Post a comment on a tenant-defined topic ('post:123'); parent_id makes it a threaded reply. author_id is the end-user posting.",
|
|
1340
|
+
inputSchema: {
|
|
1341
|
+
type: "object",
|
|
1342
|
+
properties: {
|
|
1343
|
+
topic: { type: "string" },
|
|
1344
|
+
body: { type: "string" },
|
|
1345
|
+
author_id: { type: "string" },
|
|
1346
|
+
parent_id: { type: "string" }
|
|
1347
|
+
},
|
|
1348
|
+
required: ["topic", "body", "author_id"]
|
|
1349
|
+
},
|
|
1350
|
+
method: "POST",
|
|
1351
|
+
path: "/v1/comments"
|
|
1352
|
+
},
|
|
1353
|
+
{
|
|
1354
|
+
name: "comments_list",
|
|
1355
|
+
feature: "comments",
|
|
1356
|
+
description: "List comments on a topic (newest first; deleted comments appear as tombstones).",
|
|
1357
|
+
inputSchema: {
|
|
1358
|
+
type: "object",
|
|
1359
|
+
properties: {
|
|
1360
|
+
topic: { type: "string" },
|
|
1361
|
+
cursor: { type: "string" },
|
|
1362
|
+
limit: { type: "number", default: 50 }
|
|
1363
|
+
},
|
|
1364
|
+
required: ["topic"]
|
|
1365
|
+
},
|
|
1366
|
+
method: "GET",
|
|
1367
|
+
path: "/v1/comments",
|
|
1368
|
+
queryArgs: ["topic", "cursor", "limit"]
|
|
1369
|
+
},
|
|
1370
|
+
{
|
|
1371
|
+
name: "comments_react",
|
|
1372
|
+
feature: "comments",
|
|
1373
|
+
description: "Toggle an emoji reaction on a comment for an author.",
|
|
1374
|
+
inputSchema: {
|
|
1375
|
+
type: "object",
|
|
1376
|
+
properties: {
|
|
1377
|
+
comment_id: { type: "string" },
|
|
1378
|
+
emoji: { type: "string" },
|
|
1379
|
+
author_id: { type: "string" }
|
|
1380
|
+
},
|
|
1381
|
+
required: ["comment_id", "emoji", "author_id"]
|
|
1382
|
+
},
|
|
1383
|
+
method: "POST",
|
|
1384
|
+
path: (a) => `/v1/comments/${encodeURIComponent(String(a.comment_id))}/reactions`
|
|
1385
|
+
},
|
|
1386
|
+
{
|
|
1387
|
+
name: "cms_list_collections",
|
|
1388
|
+
feature: "cms",
|
|
1389
|
+
description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against).",
|
|
1390
|
+
inputSchema: { type: "object", properties: {} },
|
|
1391
|
+
method: "GET",
|
|
1392
|
+
path: "/v1/cms/collections"
|
|
1393
|
+
},
|
|
1394
|
+
{
|
|
1395
|
+
name: "cms_create_item",
|
|
1396
|
+
feature: "cms",
|
|
1397
|
+
description: "Create a content item in a collection. data must match the collection field defs (call cms_list_collections first). status draft|published.",
|
|
1398
|
+
inputSchema: {
|
|
1399
|
+
type: "object",
|
|
1400
|
+
properties: {
|
|
1401
|
+
collection: { type: "string" },
|
|
1402
|
+
data: { type: "object", additionalProperties: true },
|
|
1403
|
+
status: { type: "string", enum: ["draft", "published"], default: "draft" }
|
|
1404
|
+
},
|
|
1405
|
+
required: ["collection", "data"]
|
|
1406
|
+
},
|
|
1407
|
+
method: "POST",
|
|
1408
|
+
path: (a) => `/v1/cms/items/${encodeURIComponent(String(a.collection))}`
|
|
1409
|
+
},
|
|
1410
|
+
{
|
|
1411
|
+
name: "cms_query_items",
|
|
1412
|
+
feature: "cms",
|
|
1413
|
+
description: 'Query content items with the bounded DSL. filter is a JSON object (\u22648 terms): {"price":{"$gte":10},"status":"published"}; ops $eq $ne $gt $gte $lt $lte $in $contains $startsWith (substring/prefix, LIKE-escaped) $arrayContains/$anyOf (array fields: all-of / any-of). sort: "-field" (slot-indexed fields + created_at/updated_at/published_at). count=true returns {count} instead of a page (filter only \u2014 no sort/limit).',
|
|
1414
|
+
inputSchema: {
|
|
1415
|
+
type: "object",
|
|
1416
|
+
properties: {
|
|
1417
|
+
collection: { type: "string" },
|
|
1418
|
+
filter: { type: "string", description: "JSON filter object as a string" },
|
|
1419
|
+
sort: { type: "string" },
|
|
1420
|
+
limit: { type: "number", default: 25 },
|
|
1421
|
+
count: { type: "boolean", description: "true \u2192 return { count } only (no items/paging)" }
|
|
1422
|
+
},
|
|
1423
|
+
required: ["collection"]
|
|
1424
|
+
},
|
|
1425
|
+
method: "GET",
|
|
1426
|
+
path: (a) => `/v1/cms/items/${encodeURIComponent(String(a.collection))}`,
|
|
1427
|
+
queryArgs: ["filter", "sort", "limit", "count"]
|
|
1428
|
+
},
|
|
1429
|
+
{
|
|
1430
|
+
name: "cms_inc_item",
|
|
1431
|
+
feature: "cms",
|
|
1432
|
+
description: 'Atomically increment numeric field(s) of a content item IN the database (one conditional UPDATE \u2014 safe under concurrency, unlike read-modify-write). $inc maps field \u2192 delta, e.g. {"views": 1} or {"stock": -2}. Refused (409 inc_out_of_bounds) if the result would leave the field outside its validation min/max \u2014 the quota/stock pattern.',
|
|
1433
|
+
inputSchema: {
|
|
1434
|
+
type: "object",
|
|
1435
|
+
properties: {
|
|
1436
|
+
collection: { type: "string" },
|
|
1437
|
+
item_id: { type: "string" },
|
|
1438
|
+
$inc: {
|
|
1439
|
+
type: "object",
|
|
1440
|
+
additionalProperties: { type: "number" },
|
|
1441
|
+
description: "field \u2192 numeric delta (1\u20134 int/float fields)"
|
|
1442
|
+
}
|
|
1443
|
+
},
|
|
1444
|
+
required: ["collection", "item_id", "$inc"]
|
|
1445
|
+
},
|
|
1446
|
+
method: "PATCH",
|
|
1447
|
+
path: (a) => `/v1/cms/items/${encodeURIComponent(String(a.collection))}/${encodeURIComponent(String(a.item_id))}`
|
|
1448
|
+
},
|
|
1449
|
+
{
|
|
1450
|
+
name: "cms_publish_item",
|
|
1451
|
+
feature: "cms",
|
|
1452
|
+
description: "Promote a draft item to published (re-validates required fields).",
|
|
1453
|
+
inputSchema: {
|
|
1454
|
+
type: "object",
|
|
1455
|
+
properties: { collection: { type: "string" }, item_id: { type: "string" } },
|
|
1456
|
+
required: ["collection", "item_id"]
|
|
1457
|
+
},
|
|
1458
|
+
method: "POST",
|
|
1459
|
+
path: (a) => `/v1/cms/items/${encodeURIComponent(String(a.collection))}/${encodeURIComponent(String(a.item_id))}/publish`
|
|
1460
|
+
},
|
|
1461
|
+
{
|
|
1462
|
+
name: "cms_aggregate_items",
|
|
1463
|
+
feature: "cms",
|
|
1464
|
+
description: 'Bounded group-by aggregate over a collection (closed fn set: count sum min max avg \u2014 sum/avg need an index-slotted numeric field). groupBy: \u22642 slot-indexed fields. filter: the query DSL plus ONE-hop dotted join terms ({"channel.visibility":"public"} \u2014 the relation field must be slot-indexed). window: {field, sinceDays\u2264366}. Scan is capped at 50000 rows \u2014 a wider match returns 422 window_too_large (narrow it, or declare a materialized read-model). Returns { groups: [{key, <aliases>}], scanned }.',
|
|
1465
|
+
inputSchema: {
|
|
1466
|
+
type: "object",
|
|
1467
|
+
properties: {
|
|
1468
|
+
collection: { type: "string" },
|
|
1469
|
+
aggregates: {
|
|
1470
|
+
type: "array",
|
|
1471
|
+
items: {
|
|
1472
|
+
type: "object",
|
|
1473
|
+
properties: {
|
|
1474
|
+
fn: { type: "string", enum: ["count", "sum", "min", "max", "avg"] },
|
|
1475
|
+
field: { type: "string" },
|
|
1476
|
+
as: { type: "string" }
|
|
1477
|
+
},
|
|
1478
|
+
required: ["fn"]
|
|
1479
|
+
},
|
|
1480
|
+
description: "1\u20134 aggregate expressions"
|
|
1481
|
+
},
|
|
1482
|
+
groupBy: { type: "array", items: { type: "string" }, description: "\u22642 slot-indexed field names" },
|
|
1483
|
+
filter: { type: "object", additionalProperties: true },
|
|
1484
|
+
window: {
|
|
1485
|
+
type: "object",
|
|
1486
|
+
properties: {
|
|
1487
|
+
field: { type: "string" },
|
|
1488
|
+
sinceDays: { type: "number" },
|
|
1489
|
+
since: { type: "string" },
|
|
1490
|
+
until: { type: "string" }
|
|
1491
|
+
},
|
|
1492
|
+
required: ["field"]
|
|
1493
|
+
},
|
|
1494
|
+
sort: { type: "string", description: 'an aggregate alias or groupBy key; "-x" = desc' },
|
|
1495
|
+
limit: { type: "number", default: 100, description: "group rows (\u2264500)" }
|
|
1496
|
+
},
|
|
1497
|
+
required: ["collection", "aggregates"]
|
|
1498
|
+
},
|
|
1499
|
+
method: "POST",
|
|
1500
|
+
path: (a) => `/v1/cms/items/${encodeURIComponent(String(a.collection))}/aggregate`
|
|
1501
|
+
},
|
|
1502
|
+
{
|
|
1503
|
+
name: "cms_rank_items",
|
|
1504
|
+
feature: "cms",
|
|
1505
|
+
description: "Window ranking over the bounded aggregate (never raw rows): rank each groupBy entity by a metric. rank \u2208 row_number|rank|percent_rank; optional partitionBy (second slot-indexed field). Same filter/window/scan-cap rules as cms_aggregate_items. Returns { entries: [{key, partition?, metric, rank}], scanned }.",
|
|
1506
|
+
inputSchema: {
|
|
1507
|
+
type: "object",
|
|
1508
|
+
properties: {
|
|
1509
|
+
collection: { type: "string" },
|
|
1510
|
+
groupBy: { type: "string", description: "the ranked entity \u2014 a slot-indexed field (often a relation)" },
|
|
1511
|
+
metric: {
|
|
1512
|
+
type: "object",
|
|
1513
|
+
properties: {
|
|
1514
|
+
fn: { type: "string", enum: ["count", "sum", "min", "max", "avg"] },
|
|
1515
|
+
field: { type: "string" }
|
|
1516
|
+
},
|
|
1517
|
+
required: ["fn"],
|
|
1518
|
+
description: 'default {fn:"count"}'
|
|
1519
|
+
},
|
|
1520
|
+
rank: { type: "string", enum: ["row_number", "rank", "percent_rank"] },
|
|
1521
|
+
partitionBy: { type: "string" },
|
|
1522
|
+
direction: { type: "string", enum: ["asc", "desc"], default: "desc" },
|
|
1523
|
+
filter: { type: "object", additionalProperties: true },
|
|
1524
|
+
window: {
|
|
1525
|
+
type: "object",
|
|
1526
|
+
properties: {
|
|
1527
|
+
field: { type: "string" },
|
|
1528
|
+
sinceDays: { type: "number" },
|
|
1529
|
+
since: { type: "string" },
|
|
1530
|
+
until: { type: "string" }
|
|
1531
|
+
},
|
|
1532
|
+
required: ["field"]
|
|
1533
|
+
},
|
|
1534
|
+
limit: { type: "number", default: 100 }
|
|
1535
|
+
},
|
|
1536
|
+
required: ["collection", "groupBy", "rank"]
|
|
1537
|
+
},
|
|
1538
|
+
method: "POST",
|
|
1539
|
+
path: (a) => `/v1/cms/items/${encodeURIComponent(String(a.collection))}/rank`
|
|
1540
|
+
},
|
|
1541
|
+
{
|
|
1542
|
+
name: "cms_run_transaction",
|
|
1543
|
+
feature: "cms",
|
|
1544
|
+
description: "Atomic multi-collection cms transaction: 1\u20135 steps over \u22643 collections, all-or-nothing. Step ops: insert {collection,data,status?}, update {collection,item_id,data,$where?}, delete {collection,item_id,$where?}. $where is a CAS precondition over the item's own fields (\u22644 terms; scalar / null / {$eq,$ne,$gt,$gte,$lt,$lte,$in}) \u2014 a non-matching $where rolls the WHOLE transaction back (409 names the step). Use for approve-and-upgrade / transfer-ownership patterns.",
|
|
1545
|
+
inputSchema: {
|
|
1546
|
+
type: "object",
|
|
1547
|
+
properties: {
|
|
1548
|
+
steps: {
|
|
1549
|
+
type: "array",
|
|
1550
|
+
items: {
|
|
1551
|
+
type: "object",
|
|
1552
|
+
properties: {
|
|
1553
|
+
op: { type: "string", enum: ["insert", "update", "delete"] },
|
|
1554
|
+
collection: { type: "string" },
|
|
1555
|
+
item_id: { type: "string" },
|
|
1556
|
+
data: { type: "object", additionalProperties: true },
|
|
1557
|
+
status: { type: "string", enum: ["draft", "published"] },
|
|
1558
|
+
$where: { type: "object", additionalProperties: true }
|
|
1559
|
+
},
|
|
1560
|
+
required: ["op", "collection"]
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
},
|
|
1564
|
+
required: ["steps"]
|
|
1565
|
+
},
|
|
1566
|
+
method: "POST",
|
|
1567
|
+
path: "/v1/cms/transactions"
|
|
1568
|
+
},
|
|
1569
|
+
{
|
|
1570
|
+
name: "cms_list_read_models",
|
|
1571
|
+
feature: "cms",
|
|
1572
|
+
description: "List the tenant's declared cms read-models (config readModels bag): name, kind (aggregate|rank), source collection, cron/target when materialized, and the last run's status.",
|
|
1573
|
+
inputSchema: { type: "object", properties: {} },
|
|
1574
|
+
method: "GET",
|
|
1575
|
+
path: "/v1/cms/read-models"
|
|
1576
|
+
},
|
|
1577
|
+
{
|
|
1578
|
+
name: "cms_materialize_read_model",
|
|
1579
|
+
feature: "cms",
|
|
1580
|
+
description: "Run a declared read-model NOW and write its group rows into the rollup collection (replaces the model's previous rollup rows). Use for backfill/testing; the cron does this on schedule.",
|
|
1581
|
+
inputSchema: {
|
|
1582
|
+
type: "object",
|
|
1583
|
+
properties: { name: { type: "string" } },
|
|
1584
|
+
required: ["name"]
|
|
1585
|
+
},
|
|
1586
|
+
method: "POST",
|
|
1587
|
+
path: (a) => `/v1/cms/read-models/${encodeURIComponent(String(a.name))}/materialize`
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
name: "orgs_create",
|
|
1591
|
+
feature: "orgs",
|
|
1592
|
+
description: "Create a workspace/org with a founding owner (end_user_id).",
|
|
1593
|
+
inputSchema: {
|
|
1594
|
+
type: "object",
|
|
1595
|
+
properties: {
|
|
1596
|
+
slug: { type: "string" },
|
|
1597
|
+
name: { type: "string" },
|
|
1598
|
+
owner_user_id: { type: "string" }
|
|
1599
|
+
},
|
|
1600
|
+
required: ["slug", "name", "owner_user_id"]
|
|
1601
|
+
},
|
|
1602
|
+
method: "POST",
|
|
1603
|
+
path: "/v1/orgs"
|
|
1604
|
+
},
|
|
1605
|
+
{
|
|
1606
|
+
name: "orgs_add_member",
|
|
1607
|
+
feature: "orgs",
|
|
1608
|
+
description: "Add a member to an org (or change their role): owner|admin|member|viewer.",
|
|
1609
|
+
inputSchema: {
|
|
1610
|
+
type: "object",
|
|
1611
|
+
properties: {
|
|
1612
|
+
org_id: { type: "string" },
|
|
1613
|
+
user_id: { type: "string" },
|
|
1614
|
+
role: { type: "string", enum: ["owner", "admin", "member", "viewer"] }
|
|
1615
|
+
},
|
|
1616
|
+
required: ["org_id", "user_id", "role"]
|
|
1617
|
+
},
|
|
1618
|
+
method: "POST",
|
|
1619
|
+
path: (a) => `/v1/orgs/${encodeURIComponent(String(a.org_id))}/members`
|
|
1620
|
+
},
|
|
1621
|
+
{
|
|
1622
|
+
name: "orgs_check_permission",
|
|
1623
|
+
feature: "orgs",
|
|
1624
|
+
description: "RBAC check: does the user hold the permission in this org? Built-in permissions map to a minimum role: org:read (viewer+), org:write (member+), members:manage (admin+), org:admin (owner); a custom role's permission-set also satisfies its keys. Pass resource to additionally consult per-resource ACL grants; the result's source marks whether a grant came from the role lattice or a resource ACL. Read-only \u2014 needs orgs:read.",
|
|
1625
|
+
inputSchema: {
|
|
1626
|
+
type: "object",
|
|
1627
|
+
properties: {
|
|
1628
|
+
org_id: { type: "string" },
|
|
1629
|
+
user_id: { type: "string" },
|
|
1630
|
+
permission: { type: "string" },
|
|
1631
|
+
resource: { type: "string", description: "Optional resource string \u2014 also consult per-resource ACL grants for it." }
|
|
1632
|
+
},
|
|
1633
|
+
required: ["org_id", "user_id", "permission"]
|
|
1634
|
+
},
|
|
1635
|
+
method: "GET",
|
|
1636
|
+
path: (a) => `/v1/orgs/${encodeURIComponent(String(a.org_id))}/check`,
|
|
1637
|
+
queryArgs: ["user_id", "permission", "resource"]
|
|
1638
|
+
},
|
|
1639
|
+
{
|
|
1640
|
+
name: "orgs_session_claims",
|
|
1641
|
+
feature: "orgs",
|
|
1642
|
+
description: "The user's ACTIVE-ORG snapshot \u2014 the { org_id, role, perms[] } auth embeds as the session-JWT `org` claim when the tenant enables auth config orgClaims. Active org = the most recent membership (by joined_at); perms = the role's effective permission set (custom catalog entry wins, capped at 32). No membership \u2192 org_id null. EVENTUAL-CONSISTENT: JWTs recompute it only at mint/refresh \u2014 use orgs_check_permission for revocation-grade authz. Read-only \u2014 needs orgs:read.",
|
|
1643
|
+
inputSchema: {
|
|
1644
|
+
type: "object",
|
|
1645
|
+
properties: { user_id: { type: "string", description: "The end_user_id to resolve." } },
|
|
1646
|
+
required: ["user_id"]
|
|
1647
|
+
},
|
|
1648
|
+
method: "GET",
|
|
1649
|
+
path: "/v1/orgs/session-claims",
|
|
1650
|
+
queryArgs: ["user_id"]
|
|
1651
|
+
},
|
|
1652
|
+
{
|
|
1653
|
+
name: "orgs_define_role",
|
|
1654
|
+
feature: "orgs",
|
|
1655
|
+
description: "Define (or upsert) a CUSTOM tenant role as a named permission-set (migration orgs/0019). role_key must not collide with a built-in (owner|admin|member|viewer); permissions is the set of permission keys the role grants (e.g. ['org:read','org:write']). Assign it to a member like any built-in via orgs_add_member. Needs orgs:write.",
|
|
1656
|
+
inputSchema: {
|
|
1657
|
+
type: "object",
|
|
1658
|
+
properties: {
|
|
1659
|
+
role_key: { type: "string", description: "Custom role key (not a built-in)." },
|
|
1660
|
+
name: { type: "string", description: "Human-readable role name." },
|
|
1661
|
+
permissions: { type: "array", items: { type: "string" }, description: "Permission keys the role grants, e.g. ['org:read','org:write']." },
|
|
1662
|
+
rank: { type: "number", description: "Optional ordering rank among roles." }
|
|
1663
|
+
},
|
|
1664
|
+
required: ["role_key", "name", "permissions"]
|
|
1665
|
+
},
|
|
1666
|
+
method: "POST",
|
|
1667
|
+
path: "/v1/orgs/roles"
|
|
1668
|
+
},
|
|
1669
|
+
{
|
|
1670
|
+
name: "orgs_list_roles",
|
|
1671
|
+
feature: "orgs",
|
|
1672
|
+
description: "List the tenant role catalog: builtin[] (the fixed owner>admin>member>viewer lattice as permission-sets) and custom[] (tenant-defined roles). Read-only \u2014 needs orgs:read.",
|
|
1673
|
+
inputSchema: { type: "object", properties: {} },
|
|
1674
|
+
method: "GET",
|
|
1675
|
+
path: "/v1/orgs/roles"
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
name: "orgs_grant_acl",
|
|
1679
|
+
feature: "orgs",
|
|
1680
|
+
description: 'Grant a per-org/user RESOURCE ACL (migration orgs/0019): give user_id the permission on a specific resource string within the org. orgs_check_permission with the same resource then consults this grant (result source: "acl"). Needs orgs:write.',
|
|
1681
|
+
inputSchema: {
|
|
1682
|
+
type: "object",
|
|
1683
|
+
properties: {
|
|
1684
|
+
org_id: { type: "string" },
|
|
1685
|
+
user_id: { type: "string", description: "End-user the grant applies to." },
|
|
1686
|
+
resource: { type: "string", description: 'Opaque resource string the grant covers, e.g. "doc:42".' },
|
|
1687
|
+
permission: { type: "string", description: "Permission key granted on the resource." }
|
|
1688
|
+
},
|
|
1689
|
+
required: ["org_id", "user_id", "resource", "permission"]
|
|
1690
|
+
},
|
|
1691
|
+
method: "POST",
|
|
1692
|
+
path: (a) => `/v1/orgs/${encodeURIComponent(String(a.org_id))}/acl`
|
|
1693
|
+
},
|
|
1694
|
+
{
|
|
1695
|
+
name: "orgs_list_acl",
|
|
1696
|
+
feature: "orgs",
|
|
1697
|
+
description: "List an org's resource ACL grants (optionally scoped to one user_id). Read-only \u2014 needs orgs:read.",
|
|
1698
|
+
inputSchema: {
|
|
1699
|
+
type: "object",
|
|
1700
|
+
properties: {
|
|
1701
|
+
org_id: { type: "string" },
|
|
1702
|
+
user_id: { type: "string", description: "Optional end_user_id filter." }
|
|
1703
|
+
},
|
|
1704
|
+
required: ["org_id"]
|
|
1705
|
+
},
|
|
1706
|
+
method: "GET",
|
|
1707
|
+
path: (a) => `/v1/orgs/${encodeURIComponent(String(a.org_id))}/acl`,
|
|
1708
|
+
queryArgs: ["user_id"]
|
|
1709
|
+
},
|
|
1710
|
+
{
|
|
1711
|
+
name: "realtime_publish",
|
|
1712
|
+
feature: "realtime",
|
|
1713
|
+
description: "Broadcast an event to every WebSocket subscriber on a channel (server-side publish; clients connect with tokens from /v1/realtime/tokens).",
|
|
1714
|
+
inputSchema: {
|
|
1715
|
+
type: "object",
|
|
1716
|
+
properties: {
|
|
1717
|
+
channel: { type: "string", description: "e.g. 'room:42'" },
|
|
1718
|
+
event: { type: "string" },
|
|
1719
|
+
data: { type: "object", additionalProperties: true }
|
|
1720
|
+
},
|
|
1721
|
+
required: ["channel", "event"]
|
|
1722
|
+
},
|
|
1723
|
+
method: "POST",
|
|
1724
|
+
path: (a) => `/v1/realtime/channels/${encodeURIComponent(String(a.channel))}/publish`
|
|
1725
|
+
},
|
|
1726
|
+
{
|
|
1727
|
+
name: "webhooks_subscribe",
|
|
1728
|
+
feature: "webhooks",
|
|
1729
|
+
description: "Subscribe an https endpoint to this tenant\u2019s event stream (audit events like user.upserted, job.succeeded). event_prefixes filters by name prefix; empty = all. Deliveries are signed jobs callbacks (verify X-Vxil-Jobs-Signature via jobs_get_signing_secret). Requires the jobs feature.",
|
|
1730
|
+
inputSchema: {
|
|
1731
|
+
type: "object",
|
|
1732
|
+
properties: {
|
|
1733
|
+
target_url: { type: "string", description: "https:// endpoint to receive events" },
|
|
1734
|
+
event_prefixes: { type: "array", items: { type: "string" }, description: "e.g. ['user.', 'job.']" }
|
|
1735
|
+
},
|
|
1736
|
+
required: ["target_url"]
|
|
1737
|
+
},
|
|
1738
|
+
method: "POST",
|
|
1739
|
+
path: "/v1/webhooks/subscriptions"
|
|
1740
|
+
},
|
|
1741
|
+
{
|
|
1742
|
+
name: "webhooks_list_subscriptions",
|
|
1743
|
+
feature: "webhooks",
|
|
1744
|
+
description: "List webhook subscriptions.",
|
|
1745
|
+
inputSchema: { type: "object", properties: {} },
|
|
1746
|
+
method: "GET",
|
|
1747
|
+
path: "/v1/webhooks/subscriptions"
|
|
1748
|
+
},
|
|
1749
|
+
{
|
|
1750
|
+
name: "webhooks_unsubscribe",
|
|
1751
|
+
feature: "webhooks",
|
|
1752
|
+
description: "Delete a webhook subscription.",
|
|
1753
|
+
inputSchema: {
|
|
1754
|
+
type: "object",
|
|
1755
|
+
properties: { sub_id: { type: "string" } },
|
|
1756
|
+
required: ["sub_id"]
|
|
1757
|
+
},
|
|
1758
|
+
method: "DELETE",
|
|
1759
|
+
path: (a) => `/v1/webhooks/subscriptions/${encodeURIComponent(String(a.sub_id))}`
|
|
1760
|
+
},
|
|
1761
|
+
{
|
|
1762
|
+
name: "webhooks_test_subscription",
|
|
1763
|
+
feature: "webhooks",
|
|
1764
|
+
description: "Send a synthetic 'webhooks.test' event to a subscription's endpoint through the REAL signing+delivery pipeline and wait inline (\u2264 ~8 s). 200 \u2192 { delivered, state, last_error_* }; 202 \u2192 still in flight ({ run_id, state } \u2014 poll jobs_list_runs). Never fans out to other subscriptions.",
|
|
1765
|
+
inputSchema: {
|
|
1766
|
+
type: "object",
|
|
1767
|
+
properties: { sub_id: { type: "string" } },
|
|
1768
|
+
required: ["sub_id"]
|
|
1769
|
+
},
|
|
1770
|
+
method: "POST",
|
|
1771
|
+
path: (a) => `/v1/webhooks/subscriptions/${encodeURIComponent(String(a.sub_id))}/test`
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
name: "webhooks_test_source",
|
|
1775
|
+
feature: "webhooks",
|
|
1776
|
+
description: "Send a sample inbound envelope to a source's forward_url through the real delivery path (jobs signing + SSRF guard). 200 \u2192 inline outcome; 202 \u2192 still in flight; 409 no_forward_url when the source has no forward target.",
|
|
1777
|
+
inputSchema: {
|
|
1778
|
+
type: "object",
|
|
1779
|
+
properties: { source_id: { type: "string" } },
|
|
1780
|
+
required: ["source_id"]
|
|
1781
|
+
},
|
|
1782
|
+
method: "POST",
|
|
1783
|
+
path: (a) => `/v1/webhooks/sources/${encodeURIComponent(String(a.source_id))}/test`
|
|
1784
|
+
},
|
|
1785
|
+
{
|
|
1786
|
+
name: "webhooks_event_runs",
|
|
1787
|
+
feature: "webhooks",
|
|
1788
|
+
description: "Delivery visibility for an inbound webhook event: the jobs runs its forwards created, with state, attempts n/max, last error and DLQ flag (the message-attempt view).",
|
|
1789
|
+
inputSchema: {
|
|
1790
|
+
type: "object",
|
|
1791
|
+
properties: { event_id: { type: "string" } },
|
|
1792
|
+
required: ["event_id"]
|
|
1793
|
+
},
|
|
1794
|
+
method: "GET",
|
|
1795
|
+
path: (a) => `/v1/webhooks/events/${encodeURIComponent(String(a.event_id))}/runs`
|
|
1796
|
+
},
|
|
1797
|
+
{
|
|
1798
|
+
name: "feeds_add_activity",
|
|
1799
|
+
feature: "activity-feed",
|
|
1800
|
+
description: "Add an activity to a flat feed (group + feed_id, e.g. group 'user', feed_id 'u_42'). The actor/verb/object triple is the activity ('u_42' 'post' 'photo:1'); foreign_id+time make the write idempotent (re-sending the same pair updates, never duplicates). to:[] CCs the activity into other feeds ('group:feed_id'). Fans out to followers' timelines and pushes a realtime new-activity event.",
|
|
1801
|
+
inputSchema: {
|
|
1802
|
+
type: "object",
|
|
1803
|
+
properties: {
|
|
1804
|
+
group: { type: "string", description: "Feed group (a feedGroups key, e.g. 'user' | 'timeline' | 'notification')." },
|
|
1805
|
+
feed_id: { type: "string", description: "Feed instance id within the group, e.g. the user id 'u_42'." },
|
|
1806
|
+
actor: { type: "string", description: "Who performed the activity (your opaque id)." },
|
|
1807
|
+
verb: { type: "string", description: "The action, e.g. 'post' | 'like' | 'follow'." },
|
|
1808
|
+
object: { type: "string", description: "What the verb acted on, e.g. 'photo:1'." },
|
|
1809
|
+
target: { type: "string", description: "Optional secondary object the verb targets." },
|
|
1810
|
+
foreign_id: { type: "string", description: "Your stable id for this activity; with time, makes the add idempotent." },
|
|
1811
|
+
time: { type: "string", description: "ISO timestamp of the activity (defaults to now)." },
|
|
1812
|
+
to: { type: "array", items: { type: "string" }, description: "Extra feeds to CC into, each 'group:feed_id'." },
|
|
1813
|
+
extra: { type: "object", additionalProperties: true, description: "Arbitrary metadata stored with the activity." }
|
|
1814
|
+
},
|
|
1815
|
+
required: ["group", "feed_id", "actor", "verb", "object"]
|
|
1816
|
+
},
|
|
1817
|
+
method: "POST",
|
|
1818
|
+
path: (a) => `/v1/feeds/${encodeURIComponent(String(a.group))}/${encodeURIComponent(String(a.feed_id))}/activities`
|
|
1819
|
+
},
|
|
1820
|
+
{
|
|
1821
|
+
name: "feeds_read",
|
|
1822
|
+
feature: "activity-feed",
|
|
1823
|
+
description: "Read a feed (group + feed_id) newest-first with a keyset cursor. Pass id_lt (the last id you saw) to page older, id_gt to fetch newer. Flat feeds return the push\u222Apull union deduped on sort order; aggregated/notification feeds return grouped entries with a badge envelope. notification_state filters to unread|unseen|all; mark_seen/mark_read flip state as you read.",
|
|
1824
|
+
inputSchema: {
|
|
1825
|
+
type: "object",
|
|
1826
|
+
properties: {
|
|
1827
|
+
group: { type: "string" },
|
|
1828
|
+
feed_id: { type: "string" },
|
|
1829
|
+
limit: { type: "number", default: 25 },
|
|
1830
|
+
id_lt: { type: "string", description: "Keyset cursor: return activities with id < this (page older)." },
|
|
1831
|
+
id_gt: { type: "string", description: "Keyset cursor: return activities with id > this (fetch newer)." },
|
|
1832
|
+
notification_state: { type: "string", enum: ["unread", "unseen", "all"] },
|
|
1833
|
+
mark_seen: { type: "boolean", description: "Mark the returned notification groups seen." },
|
|
1834
|
+
mark_read: { type: "boolean", description: "Mark the returned notification groups read." }
|
|
1835
|
+
},
|
|
1836
|
+
required: ["group", "feed_id"]
|
|
1837
|
+
},
|
|
1838
|
+
method: "GET",
|
|
1839
|
+
path: (a) => `/v1/feeds/${encodeURIComponent(String(a.group))}/${encodeURIComponent(String(a.feed_id))}`,
|
|
1840
|
+
queryArgs: ["limit", "id_lt", "id_gt", "notification_state", "mark_seen", "mark_read"]
|
|
1841
|
+
},
|
|
1842
|
+
{
|
|
1843
|
+
name: "feeds_follow",
|
|
1844
|
+
feature: "activity-feed",
|
|
1845
|
+
description: "Make a feed (group + feed_id) follow one source ({target}) or many ({targets:[...]}) in one transaction. A target is a source feed ref 'group:feed_id'. Backfills recent activities into the follower's timeline; rejected (all-or-nothing) if it would exceed the follow.maxFollowing cap.",
|
|
1846
|
+
inputSchema: {
|
|
1847
|
+
type: "object",
|
|
1848
|
+
properties: {
|
|
1849
|
+
group: { type: "string" },
|
|
1850
|
+
feed_id: { type: "string" },
|
|
1851
|
+
target: { type: "string", description: "A single source feed to follow, e.g. 'user:u_7'." },
|
|
1852
|
+
targets: { type: "array", items: { type: "string" }, description: "Follow many source feeds in one txn." }
|
|
1853
|
+
},
|
|
1854
|
+
required: ["group", "feed_id"]
|
|
1855
|
+
},
|
|
1856
|
+
method: "POST",
|
|
1857
|
+
path: (a) => `/v1/feeds/${encodeURIComponent(String(a.group))}/${encodeURIComponent(String(a.feed_id))}/follows`
|
|
1858
|
+
},
|
|
1859
|
+
{
|
|
1860
|
+
name: "feeds_mark_read",
|
|
1861
|
+
feature: "activity-feed",
|
|
1862
|
+
description: "Mark a user's notification groups read (read implies seen). Pass ids[] for specific groups, before (ISO ts) to mark everything older, or neither to mark all. Recomputes and publishes the live unread/unseen badge.",
|
|
1863
|
+
inputSchema: {
|
|
1864
|
+
type: "object",
|
|
1865
|
+
properties: {
|
|
1866
|
+
user_id: { type: "string", description: "Opaque end_user_id whose notification feed to mark." },
|
|
1867
|
+
ids: { type: "array", items: { type: "string" }, description: "Specific notification group ids to mark read." },
|
|
1868
|
+
before: { type: "string", description: "ISO timestamp: mark all groups at or before this read." }
|
|
1869
|
+
},
|
|
1870
|
+
required: ["user_id"]
|
|
1871
|
+
},
|
|
1872
|
+
method: "POST",
|
|
1873
|
+
path: (a) => `/v1/feeds/notification/${encodeURIComponent(String(a.user_id))}/read`
|
|
1874
|
+
},
|
|
1875
|
+
{
|
|
1876
|
+
name: "feeds_unread_count",
|
|
1877
|
+
feature: "activity-feed",
|
|
1878
|
+
description: "Get a user's notification badge: { unseen, unread, total }. KV-cached over the notification_state authority with recompute-on-miss.",
|
|
1879
|
+
inputSchema: {
|
|
1880
|
+
type: "object",
|
|
1881
|
+
properties: {
|
|
1882
|
+
user_id: { type: "string", description: "Opaque end_user_id to read the badge for." }
|
|
1883
|
+
},
|
|
1884
|
+
required: ["user_id"]
|
|
1885
|
+
},
|
|
1886
|
+
method: "GET",
|
|
1887
|
+
path: (a) => `/v1/feeds/notification/${encodeURIComponent(String(a.user_id))}/count`
|
|
1888
|
+
},
|
|
1889
|
+
{
|
|
1890
|
+
name: "search_query",
|
|
1891
|
+
feature: "vector-search",
|
|
1892
|
+
description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object of equality constraints over chunk metadata. Returns scored results with document/chunk attribution.",
|
|
1893
|
+
inputSchema: {
|
|
1894
|
+
type: "object",
|
|
1895
|
+
properties: {
|
|
1896
|
+
collection: { type: "string", description: "Collection (index) name to query." },
|
|
1897
|
+
query: { type: "string", description: "Natural-language query text." },
|
|
1898
|
+
top_k: { type: "number", minimum: 1, maximum: 100, default: 10 },
|
|
1899
|
+
mode: { type: "string", enum: ["hybrid", "vector", "keyword"], default: "hybrid" },
|
|
1900
|
+
filter: { type: "object", additionalProperties: true, description: 'Metadata equality filter, e.g. {"user_id":"u_42"}.' },
|
|
1901
|
+
rerank: { type: "boolean", description: "Apply the configured BYO reranker (config rerank block) to the fused results before top-k. Fail-open: a provider fault returns the RRF order with reranked:false." }
|
|
1902
|
+
},
|
|
1903
|
+
required: ["collection", "query"]
|
|
1904
|
+
},
|
|
1905
|
+
method: "POST",
|
|
1906
|
+
path: (a) => `/v1/search/${encodeURIComponent(String(a.collection))}/query`
|
|
1907
|
+
},
|
|
1908
|
+
{
|
|
1909
|
+
name: "ai_generate",
|
|
1910
|
+
feature: "ai",
|
|
1911
|
+
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).",
|
|
1912
|
+
inputSchema: {
|
|
1913
|
+
type: "object",
|
|
1914
|
+
properties: {
|
|
1915
|
+
prompt: { type: "string", description: "Raw prompt text (use this OR template)." },
|
|
1916
|
+
template: { type: "string", description: "Name of a stored template (created via POST /v1/ai/templates)." },
|
|
1917
|
+
input: { type: "object", additionalProperties: true, description: "Variables substituted into the template." },
|
|
1918
|
+
provider: { type: "string", description: "Override the default provider (mock|openai|anthropic|gemini|azure|openrouter)." },
|
|
1919
|
+
model: { type: "string" },
|
|
1920
|
+
max_tokens: { type: "number", minimum: 1, maximum: 2e5 },
|
|
1921
|
+
temperature: { type: "number", minimum: 0, maximum: 2 },
|
|
1922
|
+
json_mode: { type: "boolean", description: "Constrain the output to JSON." },
|
|
1923
|
+
images: {
|
|
1924
|
+
type: "array",
|
|
1925
|
+
items: { type: "string" },
|
|
1926
|
+
maxItems: 8,
|
|
1927
|
+
description: "Vision inputs: a public https URL, a data:image/...;base64 URL, or file:<object_id> (a files object). Provider must support the 'vision' capability."
|
|
1928
|
+
},
|
|
1929
|
+
user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." }
|
|
1930
|
+
}
|
|
1931
|
+
},
|
|
1932
|
+
method: "POST",
|
|
1933
|
+
path: "/v1/ai/generate"
|
|
1934
|
+
},
|
|
1935
|
+
{
|
|
1936
|
+
name: "rag_answer",
|
|
1937
|
+
feature: "rag",
|
|
1938
|
+
description: "Answer a question grounded in a vector-search collection: retrieve \u2192 budget context \u2192 generate \u2192 cite. Defaults the collection/template/top_k/mode from the rag config; override per call. Returns the answer plus citations to the source chunks that grounded it.",
|
|
1939
|
+
inputSchema: {
|
|
1940
|
+
type: "object",
|
|
1941
|
+
properties: {
|
|
1942
|
+
query: { type: "string", description: "The question to answer." },
|
|
1943
|
+
collection: { type: "string", description: "Collection to ground on (defaults to rag config defaultCollection)." },
|
|
1944
|
+
template: { type: "string", description: "Override the answer template (defaults to rag config defaultTemplate)." },
|
|
1945
|
+
top_k: { type: "number", minimum: 1, maximum: 100, description: "Chunks to retrieve before budgeting." },
|
|
1946
|
+
mode: { type: "string", enum: ["hybrid", "vector", "keyword"] },
|
|
1947
|
+
filter: { type: "object", additionalProperties: true, description: "Metadata equality filter applied to retrieval." },
|
|
1948
|
+
provider: { type: "string" },
|
|
1949
|
+
model: { type: "string" },
|
|
1950
|
+
input: { type: "object", additionalProperties: true, description: "Extra template vars beyond {{query}}/{{context}}." },
|
|
1951
|
+
user_id: { type: "string", description: "Opaque end_user_id for per-user usage metering." }
|
|
1952
|
+
},
|
|
1953
|
+
required: ["query"]
|
|
1954
|
+
},
|
|
1955
|
+
method: "POST",
|
|
1956
|
+
path: "/v1/rag/answer"
|
|
1957
|
+
},
|
|
1958
|
+
{
|
|
1959
|
+
name: "rag_search",
|
|
1960
|
+
feature: "rag",
|
|
1961
|
+
description: "Retrieval-only grounding preview: the exact chunks rag_answer would ground on, with rerank + metadata boosts applied \u2014 no generation, no token spend. Use it to inspect/tune retrieval (boost weights, rerank, min_score) before paying for an answer.",
|
|
1962
|
+
inputSchema: {
|
|
1963
|
+
type: "object",
|
|
1964
|
+
properties: {
|
|
1965
|
+
query: { type: "string", description: "The retrieval query." },
|
|
1966
|
+
collection: { type: "string", description: "Collection to search (defaults to rag config defaultCollection)." },
|
|
1967
|
+
top_k: { type: "number", minimum: 1, maximum: 100, description: "Chunks to retrieve." },
|
|
1968
|
+
mode: { type: "string", enum: ["hybrid", "vector", "keyword"] },
|
|
1969
|
+
filter: { type: "object", additionalProperties: true, description: "Metadata equality filter applied to retrieval." },
|
|
1970
|
+
rerank: { type: "boolean", description: "Forward rerank:true to vector-search's BYO rerank pass (inert until configured there)." },
|
|
1971
|
+
boosts: { type: "object", additionalProperties: true, description: "Per-metadata-field boost specs: { field: { kind:'recency', halfLifeDays?, weight? } | { kind:'value', weights:{\u2026} } }." },
|
|
1972
|
+
min_score: { type: "number", description: "Drop hits below this EFFECTIVE (post-boost) score." }
|
|
1973
|
+
},
|
|
1974
|
+
required: ["query"]
|
|
1975
|
+
},
|
|
1976
|
+
method: "POST",
|
|
1977
|
+
path: "/v1/rag/search"
|
|
1978
|
+
},
|
|
1979
|
+
{
|
|
1980
|
+
name: "payments_check_entitlement",
|
|
1981
|
+
feature: "payments",
|
|
1982
|
+
description: "Check whether an end-user holds a boolean entitlement (a feature flag from the tenant's tierMap, e.g. 'export', 'priority_support'). Returns granted plus the source (subscription/tier) and the resolved tier. Use this to gate access before doing privileged work. Read-only \u2014 needs payments:read.",
|
|
1983
|
+
inputSchema: {
|
|
1984
|
+
type: "object",
|
|
1985
|
+
properties: {
|
|
1986
|
+
user_id: { type: "string", description: "Opaque end_user_id from tenant_users." },
|
|
1987
|
+
entitlement: { type: "string", description: "Entitlement key to check, e.g. 'export' or 'seats'." }
|
|
1988
|
+
},
|
|
1989
|
+
required: ["user_id", "entitlement"]
|
|
1990
|
+
},
|
|
1991
|
+
method: "GET",
|
|
1992
|
+
path: "/v1/payments/entitlements/check",
|
|
1993
|
+
queryArgs: ["user_id", "entitlement"]
|
|
1994
|
+
},
|
|
1995
|
+
{
|
|
1996
|
+
name: "payments_balance",
|
|
1997
|
+
feature: "payments",
|
|
1998
|
+
description: "Read an end-user's credit balance for a credit type (e.g. 'scans', 'tokens'): balance, held (provisional/in-flight), and available (= balance \u2212 held). Check available before consuming. Read-only \u2014 needs payments:read.",
|
|
1999
|
+
inputSchema: {
|
|
2000
|
+
type: "object",
|
|
2001
|
+
properties: {
|
|
2002
|
+
user_id: { type: "string", description: "Opaque end_user_id from tenant_users." },
|
|
2003
|
+
credit_type: { type: "string", description: "Credit type to read, e.g. 'scans'." }
|
|
2004
|
+
},
|
|
2005
|
+
required: ["user_id", "credit_type"]
|
|
2006
|
+
},
|
|
2007
|
+
method: "GET",
|
|
2008
|
+
path: "/v1/payments/credits/balance",
|
|
2009
|
+
queryArgs: ["user_id", "credit_type"]
|
|
2010
|
+
},
|
|
2011
|
+
{
|
|
2012
|
+
name: "payments_consume",
|
|
2013
|
+
feature: "payments",
|
|
2014
|
+
description: "Debit credits from an end-user \u2014 idempotent (idempotency_key REQUIRED; the SAME key always returns the SAME result, never double-charges) and balance-guarded (never oversells; returns 402 INSUFFICIENT_CREDITS when available can't cover amount). Pass job_id to make the debit a PROVISIONAL hold tied to a background job: a linked jobs run that terminally fails auto-refunds the hold, a success settles it. amount must be >= 1. Needs payments:write.",
|
|
2015
|
+
inputSchema: {
|
|
2016
|
+
type: "object",
|
|
2017
|
+
properties: {
|
|
2018
|
+
user_id: { type: "string", description: "Opaque end_user_id to debit." },
|
|
2019
|
+
credit_type: { type: "string", description: "Credit type to debit, e.g. 'scans'." },
|
|
2020
|
+
amount: { type: "number", minimum: 1, description: "Credits to consume (>= 1)." },
|
|
2021
|
+
idempotency_key: { type: "string", description: "Unique key for this debit; reusing it replays the first result (no double-charge)." },
|
|
2022
|
+
job_id: { type: "string", description: "Optional jobs run_id to link: makes the debit a provisional hold that auto-reverses if the job terminally fails." },
|
|
2023
|
+
reason: { type: "string", description: "Optional human-readable reason recorded on the ledger row." }
|
|
2024
|
+
},
|
|
2025
|
+
required: ["user_id", "credit_type", "amount", "idempotency_key"]
|
|
2026
|
+
},
|
|
2027
|
+
method: "POST",
|
|
2028
|
+
path: "/v1/payments/credits/consume",
|
|
2029
|
+
idempotencyArg: "idempotency_key"
|
|
2030
|
+
},
|
|
2031
|
+
{
|
|
2032
|
+
name: "payments_list_webhook_events",
|
|
2033
|
+
feature: "payments",
|
|
2034
|
+
description: "List provider webhook deliveries from the event log, newest-first: outcome (received|processed|error|sig_failed|parse_failed|reprocessed), signature status, error, and timestamps per delivery. Use it to diagnose billing sync issues (e.g. an unmapped product, a misconfigured webhook secret producing sig_failed rows). Read-only \u2014 needs payments:read. Payloads are detail-only (console surface).",
|
|
2035
|
+
inputSchema: {
|
|
2036
|
+
type: "object",
|
|
2037
|
+
properties: {
|
|
2038
|
+
provider: { type: "string", enum: ["mock", "stripe", "paddle", "revenuecat", "paypal"] },
|
|
2039
|
+
event_type: { type: "string", description: "Exact event type filter, e.g. 'subscription.updated'." },
|
|
2040
|
+
outcome: { type: "string", enum: ["received", "processed", "error", "sig_failed", "parse_failed", "reprocessed"] },
|
|
2041
|
+
since: { type: "string", description: "ISO-8601 lower bound on received_at." },
|
|
2042
|
+
cursor: { type: "string", description: "Opaque keyset cursor from a prior page (next_cursor)." },
|
|
2043
|
+
limit: { type: "number", default: 50, description: "Page size, clamped 1..100." }
|
|
2044
|
+
}
|
|
2045
|
+
},
|
|
2046
|
+
method: "GET",
|
|
2047
|
+
path: "/v1/payments/webhook-events",
|
|
2048
|
+
queryArgs: ["provider", "event_type", "outcome", "since", "cursor", "limit"]
|
|
2049
|
+
},
|
|
2050
|
+
{
|
|
2051
|
+
name: "mcp_get_signing_secret",
|
|
2052
|
+
description: 'Per-tenant secret your endpoints use to verify the X-Vxil-Mcp-Signature header on custom MCP tool calls (config mcp.customTools). Same recipe as jobs callbacks: t=<unix>,v1=<hmac-sha256-hex over "<toolName>.<tenantId>.<t>.<sha256hex(body)>">, 300s replay window. Needs features:read.',
|
|
2053
|
+
inputSchema: { type: "object", properties: {} },
|
|
2054
|
+
method: "GET",
|
|
2055
|
+
path: "/v1/mcp/signing-secret"
|
|
2056
|
+
},
|
|
2057
|
+
{
|
|
2058
|
+
name: "copilot_send_message",
|
|
2059
|
+
feature: "copilot",
|
|
2060
|
+
description: 'Send a message to a copilot AGENT (a configured AI assistant) and get its grounded answer. The agent retrieves from its knowledge collections, cites sources, and may PROPOSE a write action (returned as `proposal` with action_status "proposed") \u2014 proposals never auto-run; confirm them with copilot_confirm_action. Omit conversation_id to start a new conversation; pass the returned conversation_id to continue one. Needs copilot:write.',
|
|
2061
|
+
inputSchema: {
|
|
2062
|
+
type: "object",
|
|
2063
|
+
properties: {
|
|
2064
|
+
agent_id: { type: "string", description: "The configured agent id (copilot.agents key)." },
|
|
2065
|
+
message: { type: "string", description: "The end-user's message (1..32000 chars)." },
|
|
2066
|
+
conversation_id: { type: "string", description: "Continue an existing conversation; omit to start a new one." },
|
|
2067
|
+
user_id: { type: "string", description: "Opaque end_user_id to attribute the turn (for usage + rate limits)." }
|
|
2068
|
+
},
|
|
2069
|
+
required: ["agent_id", "message"]
|
|
2070
|
+
},
|
|
2071
|
+
method: "POST",
|
|
2072
|
+
path: (a) => `/v1/copilot/${encodeURIComponent(String(a.agent_id))}/messages`
|
|
2073
|
+
},
|
|
2074
|
+
{
|
|
2075
|
+
name: "copilot_list_conversations",
|
|
2076
|
+
feature: "copilot",
|
|
2077
|
+
description: "List a copilot agent's conversations, newest-updated first. Filter by user_id to scope to one end user. Read-only \u2014 needs copilot:read.",
|
|
2078
|
+
inputSchema: {
|
|
2079
|
+
type: "object",
|
|
2080
|
+
properties: {
|
|
2081
|
+
agent_id: { type: "string", description: "The configured agent id." },
|
|
2082
|
+
user_id: { type: "string", description: "Optional end_user_id filter." },
|
|
2083
|
+
limit: { type: "number", description: "Max rows (1..200, default 50)." }
|
|
2084
|
+
},
|
|
2085
|
+
required: ["agent_id"]
|
|
2086
|
+
},
|
|
2087
|
+
method: "GET",
|
|
2088
|
+
path: (a) => `/v1/copilot/${encodeURIComponent(String(a.agent_id))}/conversations`,
|
|
2089
|
+
queryArgs: ["user_id", "limit"]
|
|
2090
|
+
},
|
|
2091
|
+
{
|
|
2092
|
+
name: "copilot_confirm_action",
|
|
2093
|
+
feature: "copilot",
|
|
2094
|
+
description: 'Confirm a PROPOSED copilot action so it executes for real (the only way a write runs). Pass the conversation_id and the proposal message_id from a copilot_send_message result whose action_status was "proposed". idempotency_key makes a retried confirm a downstream no-op (reusing it replays the first result, never double-executing). Needs copilot:write. The action re-validates against the agent config + your scopes + live features before running.',
|
|
2095
|
+
inputSchema: {
|
|
2096
|
+
type: "object",
|
|
2097
|
+
properties: {
|
|
2098
|
+
conversation_id: { type: "string", description: "The conversation holding the proposal." },
|
|
2099
|
+
message_id: { type: "string", description: "The assistant message_id carrying the proposal." },
|
|
2100
|
+
idempotency_key: { type: "string", description: "Unique key for this confirm; reusing it replays the first result (no double-execute)." }
|
|
2101
|
+
},
|
|
2102
|
+
required: ["conversation_id", "message_id"]
|
|
2103
|
+
},
|
|
2104
|
+
method: "POST",
|
|
2105
|
+
path: (a) => `/v1/copilot/conversations/${encodeURIComponent(String(a.conversation_id))}/actions/${encodeURIComponent(String(a.message_id))}/confirm`,
|
|
2106
|
+
idempotencyArg: "idempotency_key"
|
|
2107
|
+
},
|
|
2108
|
+
{
|
|
2109
|
+
// Substrate meta-tool (no `feature` — always-on like users_upsert). Handled
|
|
2110
|
+
// LOCALLY in rpc.ts tools/call (mcp.md §6.8) — the `local:` path marker is
|
|
2111
|
+
// never fetched and deliberately non-/v1 so the trinity-drift scanner
|
|
2112
|
+
// (extractMcpRoutes only harvests '/v1/…' literals) ignores it: there is no
|
|
2113
|
+
// REST route behind this tool.
|
|
2114
|
+
name: "vxil_tool_search",
|
|
2115
|
+
description: `Search this server's tool catalog by keyword and get back the top-ranked matching tools WITH their input schemas. Use this instead of scanning the full tools/list when you need a capability (e.g. query "publish cms item" or "send email"). Only tools your key/tenant can actually call are returned.`,
|
|
2116
|
+
inputSchema: {
|
|
2117
|
+
type: "object",
|
|
2118
|
+
properties: {
|
|
2119
|
+
query: { type: "string", description: 'Keywords: feature, verb, noun (e.g. "cms publish", "signed upload url").' },
|
|
2120
|
+
limit: { type: "number", default: 8, minimum: 1, maximum: 20 }
|
|
2121
|
+
},
|
|
2122
|
+
required: ["query"]
|
|
2123
|
+
},
|
|
2124
|
+
method: "GET",
|
|
2125
|
+
// GET-class → stays visible under exposureLevel 'read-only'
|
|
2126
|
+
path: "local:tool-search"
|
|
2127
|
+
}
|
|
2128
|
+
];
|
|
2129
|
+
|
|
2130
|
+
// src/mcp.ts
|
|
2131
|
+
function lowerPath(path, inputSchema) {
|
|
2132
|
+
if (typeof path === "string") return path;
|
|
2133
|
+
const props = Object.keys(inputSchema.properties ?? {});
|
|
2134
|
+
const sentinels = {};
|
|
2135
|
+
for (const p of props) sentinels[p] = `__${p}__`;
|
|
2136
|
+
return path(sentinels).replace(/__([a-z0-9_]+)__/g, "{$1}");
|
|
2137
|
+
}
|
|
2138
|
+
function buildMcpCatalog(input, tools = TOOLS) {
|
|
2139
|
+
const features = [...input.features].sort();
|
|
2140
|
+
const featureSet = new Set(features);
|
|
2141
|
+
const colls = [...input.collections].sort((a, b) => a.collection.localeCompare(b.collection));
|
|
2142
|
+
const collNames = colls.map((c) => c.collection);
|
|
2143
|
+
const fns = [...input.functions].sort((a, b) => a.name.localeCompare(b.name));
|
|
2144
|
+
const catalogTools = tools.filter((t) => !t.feature || featureSet.has(t.feature)).sort((a, b) => a.name.localeCompare(b.name)).map((t) => {
|
|
2145
|
+
let inputSchema = t.inputSchema;
|
|
2146
|
+
const props = inputSchema.properties;
|
|
2147
|
+
if (collNames.length && props && "collection" in props) {
|
|
2148
|
+
inputSchema = structuredClone(t.inputSchema);
|
|
2149
|
+
const cloned = inputSchema.properties;
|
|
2150
|
+
cloned.collection.enum = collNames;
|
|
2151
|
+
}
|
|
2152
|
+
return {
|
|
2153
|
+
name: t.name,
|
|
2154
|
+
feature: t.feature ?? null,
|
|
2155
|
+
method: t.method,
|
|
2156
|
+
path: lowerPath(t.path, t.inputSchema),
|
|
2157
|
+
description: t.description,
|
|
2158
|
+
inputSchema,
|
|
2159
|
+
...t.queryArgs ? { queryArgs: t.queryArgs } : {},
|
|
2160
|
+
...t.idempotencyArg ? { idempotencyArg: t.idempotencyArg } : {}
|
|
2161
|
+
};
|
|
2162
|
+
});
|
|
2163
|
+
const cmsCollections = {};
|
|
2164
|
+
for (const c of colls) {
|
|
2165
|
+
const slotted = slottedFields(c);
|
|
2166
|
+
cmsCollections[c.collection] = {
|
|
2167
|
+
fields: c.fields.map((f) => ({
|
|
2168
|
+
field: f.field,
|
|
2169
|
+
type: f.type,
|
|
2170
|
+
required: f.required,
|
|
2171
|
+
index_slot: f.index_slot
|
|
2172
|
+
})),
|
|
2173
|
+
// `$status` (draft|published|archived) is always filterable; every stored
|
|
2174
|
+
// user field filters by equality/$in (+$contains on strings) — only an
|
|
2175
|
+
// UNSLOTTED COMPUTED field is rejected. Range ops need an index slot
|
|
2176
|
+
// (range_filterable ⊆ filterable). Meta columns are sortable-only — the
|
|
2177
|
+
// exact server contract (cms-v1 query.ts), same as gen.ts's Filterable.
|
|
2178
|
+
filterable: ["$status", ...c.fields.filter(isFilterableField).map((f) => f.field)],
|
|
2179
|
+
range_filterable: slotted.map((f) => f.field),
|
|
2180
|
+
sortable: [...slotted.map((f) => f.field), ...META_SORTABLE]
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
return {
|
|
2184
|
+
catalog: "vxil.mcp/v1",
|
|
2185
|
+
...input.generatedAt ? { generated_at: input.generatedAt } : {},
|
|
2186
|
+
...input.tenant ? { tenant: input.tenant } : {},
|
|
2187
|
+
...input.env ? { env: input.env } : {},
|
|
2188
|
+
features,
|
|
2189
|
+
note: `feature-pruned gen-time snapshot; the live MCP server additionally applies the tenant exposure policy (exposureLevel/allowToolList from /v1/config/mcp) at tools/list. cms $status filter values: ${CMS_STATUS_VALUES.join("|")}. cms filter ops: equality/$eq/$in (+$contains on string fields) work on any filterable field; $ne/$gt/$gte/$lt/$lte only on range_filterable (index-slotted) fields.`,
|
|
2190
|
+
tools: catalogTools,
|
|
2191
|
+
cms_collections: cmsCollections,
|
|
2192
|
+
functions_note: "deployed tenant functions are invoked via REST (POST /v1/fn/<name>) \u2014 the MCP server exposes no function-invoke tools",
|
|
2193
|
+
functions: fns.map((f) => ({
|
|
2194
|
+
name: f.name,
|
|
2195
|
+
invoke: `POST /v1/fn/${f.name}`,
|
|
2196
|
+
signature: f.signature ?? null
|
|
2197
|
+
}))
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// src/store.ts
|
|
2202
|
+
import { homedir } from "node:os";
|
|
2203
|
+
import { join, resolve as resolve3 } from "node:path";
|
|
2204
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync as writeFileSync2, chmodSync } from "node:fs";
|
|
2205
|
+
function credDir() {
|
|
2206
|
+
return join(homedir(), ".vxil");
|
|
2207
|
+
}
|
|
2208
|
+
function credFile() {
|
|
2209
|
+
return join(credDir(), "credentials.json");
|
|
2210
|
+
}
|
|
2211
|
+
function loadCredentials() {
|
|
2212
|
+
try {
|
|
2213
|
+
return JSON.parse(readFileSync(credFile(), "utf8"));
|
|
2214
|
+
} catch {
|
|
2215
|
+
return {};
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
function saveCredentials(c) {
|
|
2219
|
+
mkdirSync(credDir(), { recursive: true });
|
|
2220
|
+
writeFileSync2(credFile(), JSON.stringify(c, null, 2));
|
|
2221
|
+
try {
|
|
2222
|
+
chmodSync(credFile(), 384);
|
|
2223
|
+
} catch {
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
function projectFile(cwd = process.cwd()) {
|
|
2227
|
+
return resolve3(cwd, ".vxil", "project.json");
|
|
2228
|
+
}
|
|
2229
|
+
function loadProject(cwd = process.cwd()) {
|
|
2230
|
+
try {
|
|
2231
|
+
return JSON.parse(readFileSync(projectFile(cwd), "utf8"));
|
|
2232
|
+
} catch {
|
|
2233
|
+
return null;
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
function saveProject(binding, cwd = process.cwd()) {
|
|
2237
|
+
const dir = resolve3(cwd, ".vxil");
|
|
2238
|
+
mkdirSync(dir, { recursive: true });
|
|
2239
|
+
writeFileSync2(projectFile(cwd), JSON.stringify(binding, null, 2));
|
|
2240
|
+
}
|
|
2241
|
+
function resolve_target(opts) {
|
|
2242
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2243
|
+
const proj = loadProject(cwd);
|
|
2244
|
+
const creds = loadCredentials();
|
|
2245
|
+
const envKey = process.env.VXIL_API_KEY;
|
|
2246
|
+
const envBase = process.env.VXIL_BASE_URL;
|
|
2247
|
+
if (opts.which === "dev" && proj?.dev) {
|
|
2248
|
+
const devKey = process.env.VXIL_DEV_KEY ?? creds.keys?.[proj.dev.slug];
|
|
2249
|
+
return { apiKey: devKey ?? envKey, baseUrl: envBase ?? proj.dev.edge_url ?? opts.defaultBase, tenantSlug: proj.dev.slug };
|
|
2250
|
+
}
|
|
2251
|
+
if (envKey) {
|
|
2252
|
+
return { apiKey: envKey, baseUrl: envBase ?? proj?.edge_url ?? opts.defaultBase, tenantSlug: proj?.slug };
|
|
2253
|
+
}
|
|
2254
|
+
if (proj) {
|
|
2255
|
+
const key = creds.keys?.[proj.slug];
|
|
2256
|
+
return { apiKey: key, baseUrl: envBase ?? proj.edge_url ?? opts.defaultBase, tenantSlug: proj.slug };
|
|
2257
|
+
}
|
|
2258
|
+
return { apiKey: void 0, baseUrl: envBase ?? opts.defaultBase, tenantSlug: void 0 };
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
// src/link.ts
|
|
2262
|
+
function data(r) {
|
|
2263
|
+
return r.json.data ?? r.json;
|
|
2264
|
+
}
|
|
2265
|
+
function errText(r) {
|
|
2266
|
+
const e = r.json.error;
|
|
2267
|
+
return e ? `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}` : `${r.status}`;
|
|
2268
|
+
}
|
|
2269
|
+
async function linkViaSession(dash, cookie, slug, keyName) {
|
|
2270
|
+
const list = await dash("GET", "/dashboard/tenants", void 0, cookie);
|
|
2271
|
+
if (list.status === 401) return { unauthorized: true };
|
|
2272
|
+
if (list.status !== 200) throw new Error(`could not list projects (${errText(list)})`);
|
|
2273
|
+
const tenants = data(list).tenants ?? [];
|
|
2274
|
+
const t = tenants.find((x) => x.slug === slug);
|
|
2275
|
+
if (!t) {
|
|
2276
|
+
throw new Error(
|
|
2277
|
+
`no project '${slug}' on this account \u2014 run \`vxil quickstart\`, or ask an admin to add you as a member`
|
|
2278
|
+
);
|
|
2279
|
+
}
|
|
2280
|
+
const mint = await dash("POST", `/dashboard/tenants/${t.id}/api-keys`, { name: keyName }, cookie);
|
|
2281
|
+
if (mint.status === 401) return { unauthorized: true };
|
|
2282
|
+
if (mint.status !== 201 && mint.status !== 200) {
|
|
2283
|
+
throw new Error(`could not mint a key for '${slug}' (${errText(mint)})`);
|
|
2284
|
+
}
|
|
2285
|
+
const apiKey = data(mint).api_key;
|
|
2286
|
+
if (!apiKey) throw new Error("mint returned no api_key");
|
|
2287
|
+
return { result: { slug, tenant_id: t.id, api_key: apiKey } };
|
|
2288
|
+
}
|
|
2289
|
+
async function linkViaDeviceFlow(dash, slug, io) {
|
|
2290
|
+
const now = io.now ?? Date.now;
|
|
2291
|
+
const start = await dash("POST", "/dashboard/cli/link", { slug });
|
|
2292
|
+
if (start.status !== 201 && start.status !== 200) {
|
|
2293
|
+
throw new Error(`could not start the link (${errText(start)})`);
|
|
2294
|
+
}
|
|
2295
|
+
const d = data(start);
|
|
2296
|
+
if (!d.device_code || !d.user_code) throw new Error("link start returned no device_code");
|
|
2297
|
+
io.log("");
|
|
2298
|
+
io.log(`To link '${slug}', approve this request in your browser:`);
|
|
2299
|
+
io.log("");
|
|
2300
|
+
io.log(` ${d.verification_uri_complete ?? d.verification_uri ?? "(open the dashboard \u2192 CLI link)"}`);
|
|
2301
|
+
io.log(` code: ${d.user_code}`);
|
|
2302
|
+
io.log("");
|
|
2303
|
+
io.log("Waiting for approval (Ctrl-C to cancel)\u2026");
|
|
2304
|
+
const intervalMs = Math.max(1, d.interval ?? 5) * 1e3;
|
|
2305
|
+
const deadline = now() + Math.max(1, d.expires_in ?? 900) * 1e3;
|
|
2306
|
+
while (now() < deadline) {
|
|
2307
|
+
await io.sleep(intervalMs);
|
|
2308
|
+
const poll = await dash("POST", "/dashboard/cli/link/token", { device_code: d.device_code });
|
|
2309
|
+
if (poll.status === 428) continue;
|
|
2310
|
+
if (poll.status === 200) {
|
|
2311
|
+
const p = data(poll);
|
|
2312
|
+
if (!p.api_key || !p.tenant_id) throw new Error("approval returned no api_key");
|
|
2313
|
+
return { slug: p.slug || slug, tenant_id: p.tenant_id, api_key: p.api_key };
|
|
2314
|
+
}
|
|
2315
|
+
throw new Error(`link failed (${errText(poll)})`);
|
|
2316
|
+
}
|
|
2317
|
+
throw new Error("link timed out waiting for browser approval \u2014 run `vxil link <slug>` again");
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// src/devLifecycle.ts
|
|
2321
|
+
async function devDown(dash, cookie, dev) {
|
|
2322
|
+
const r = await dash("DELETE", `/dashboard/tenants/${dev.tenant_id}`, { confirm: dev.slug }, cookie);
|
|
2323
|
+
if (r.status === 200) return { gone: true, alreadyGone: false };
|
|
2324
|
+
if (r.status === 404) return { gone: true, alreadyGone: true };
|
|
2325
|
+
const e = r.json.error;
|
|
2326
|
+
throw new Error(
|
|
2327
|
+
`dev down failed (${e?.code ?? r.status}${e?.message ? ` \u2014 ${e.message}` : ""})`
|
|
2328
|
+
);
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// src/envPull.ts
|
|
2332
|
+
var MANAGED_HEADER = "# vxil (managed by `vxil env pull`)";
|
|
2333
|
+
var MANAGED_LINE = /^(\s*(?:export\s+)?)(VXIL_[A-Z0-9_]+)\s*=/;
|
|
2334
|
+
function envUpdates(t) {
|
|
2335
|
+
return {
|
|
2336
|
+
VXIL_BASE_URL: t.baseUrl,
|
|
2337
|
+
VXIL_API_KEY: t.apiKey,
|
|
2338
|
+
...t.tenantId ? { VXIL_TENANT_ID: t.tenantId } : {}
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
function mergeEnvFile(existing, updates) {
|
|
2342
|
+
const pending = new Map(Object.entries(updates));
|
|
2343
|
+
const lines = existing.length ? existing.split("\n") : [];
|
|
2344
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
2345
|
+
const out = lines.map((line) => {
|
|
2346
|
+
const m = MANAGED_LINE.exec(line);
|
|
2347
|
+
if (!m) return line;
|
|
2348
|
+
const key = m[2];
|
|
2349
|
+
const value = pending.get(key);
|
|
2350
|
+
if (value === void 0) return line;
|
|
2351
|
+
pending.delete(key);
|
|
2352
|
+
return `${m[1] ?? ""}${key}=${value}`;
|
|
2353
|
+
});
|
|
2354
|
+
if (pending.size) {
|
|
2355
|
+
if (out.length && out[out.length - 1] !== "") out.push("");
|
|
2356
|
+
out.push(MANAGED_HEADER);
|
|
2357
|
+
for (const [k, v] of pending) out.push(`${k}=${v}`);
|
|
2358
|
+
}
|
|
2359
|
+
return out.length ? `${out.join("\n")}
|
|
2360
|
+
` : "";
|
|
2361
|
+
}
|
|
2362
|
+
function gitignoreCovers(gitignoreText, filename) {
|
|
2363
|
+
const base = filename.split("/").pop() ?? filename;
|
|
2364
|
+
for (const raw of gitignoreText.split("\n")) {
|
|
2365
|
+
const line = raw.trim();
|
|
2366
|
+
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
|
|
2367
|
+
const pat = line.replace(/^\//, "").replace(/\/$/, "");
|
|
2368
|
+
if (pat === filename || pat === base) return true;
|
|
2369
|
+
if (pat.includes("*")) {
|
|
2370
|
+
const rx = new RegExp(`^${pat.split("*").map(escapeRe).join("[^/]*")}$`);
|
|
2371
|
+
if (rx.test(filename) || rx.test(base)) return true;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
return false;
|
|
2375
|
+
}
|
|
2376
|
+
function escapeRe(s) {
|
|
2377
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// src/devBranch.ts
|
|
2381
|
+
function sanitizeBranchName(raw) {
|
|
2382
|
+
return raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
|
|
2383
|
+
}
|
|
2384
|
+
function pickBranchSlot(binding, name, now, hasKey) {
|
|
2385
|
+
const slot = binding?.branches?.[name];
|
|
2386
|
+
if (!slot) return { create: true };
|
|
2387
|
+
if (slot.expires_at && new Date(slot.expires_at).getTime() <= now.getTime()) return { create: true };
|
|
2388
|
+
if (!hasKey(slot.slug)) return { create: true };
|
|
2389
|
+
return { reuse: slot };
|
|
2390
|
+
}
|
|
2391
|
+
async function createBranchTenant(dash, opts) {
|
|
2392
|
+
const r = await dash("POST", "/dashboard/quickstart", {
|
|
2393
|
+
email: opts.email,
|
|
2394
|
+
password: opts.password,
|
|
2395
|
+
app_name: opts.appName,
|
|
2396
|
+
features: opts.features.length ? opts.features : ["cms"],
|
|
2397
|
+
kind: "dev",
|
|
2398
|
+
ttl_hours: opts.ttlHours
|
|
2399
|
+
});
|
|
2400
|
+
if (r.status !== 201 && r.status !== 200) {
|
|
2401
|
+
const e = r.json.error;
|
|
2402
|
+
throw new Error(
|
|
2403
|
+
`dev tenant create failed (${e?.code ?? r.status}${e?.message ? ` \u2014 ${e.message}` : ""}${e?.hint ? `; ${e.hint}` : ""})`
|
|
2404
|
+
);
|
|
2405
|
+
}
|
|
2406
|
+
const d = r.json.data ?? r.json;
|
|
2407
|
+
if (!d.api_key || !d.tenant_id || !d.app_slug) throw new Error("dev tenant create returned no api_key");
|
|
2408
|
+
return {
|
|
2409
|
+
tenant_id: d.tenant_id,
|
|
2410
|
+
slug: d.app_slug,
|
|
2411
|
+
api_key: d.api_key,
|
|
2412
|
+
...d.edge_url ? { edge_url: d.edge_url } : {},
|
|
2413
|
+
...d.expires_at ? { expires_at: d.expires_at } : {}
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
// src/try.ts
|
|
2418
|
+
async function mintTry(fetchImpl, base, opts = {}) {
|
|
2419
|
+
const url = `${base.replace(/\/$/, "")}/v1/try`;
|
|
2420
|
+
const body = opts.turnstileToken ? { turnstile_token: opts.turnstileToken } : {};
|
|
2421
|
+
const r = await fetchImpl(url, body);
|
|
2422
|
+
if (r.status === 201 || r.status === 200) {
|
|
2423
|
+
const d = r.json.data ?? r.json;
|
|
2424
|
+
if (!d.api_key || !d.tenant_id || !d.slug) {
|
|
2425
|
+
return { ok: false, status: r.status, code: "bad_response", message: "/v1/try returned no api_key/tenant." };
|
|
2426
|
+
}
|
|
2427
|
+
return {
|
|
2428
|
+
ok: true,
|
|
2429
|
+
result: {
|
|
2430
|
+
tenant_id: d.tenant_id,
|
|
2431
|
+
slug: d.slug,
|
|
2432
|
+
api_key: d.api_key,
|
|
2433
|
+
expires_at: d.expires_at ?? "",
|
|
2434
|
+
enabled_features: d.enabled_features ?? []
|
|
2435
|
+
}
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
const e = r.json.error ?? {};
|
|
2439
|
+
if (r.status === 404) {
|
|
2440
|
+
return {
|
|
2441
|
+
ok: false,
|
|
2442
|
+
status: 404,
|
|
2443
|
+
code: e.code ?? "not_found",
|
|
2444
|
+
message: "the keyless sandbox is not enabled on this backend \u2014 use `vxil quickstart` (with an email) for a persistent project."
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
if (r.status === 429) {
|
|
2448
|
+
return {
|
|
2449
|
+
ok: false,
|
|
2450
|
+
status: 429,
|
|
2451
|
+
code: e.code ?? "rate_limited",
|
|
2452
|
+
message: `${e.message ?? "the sandbox is rate-limited or at capacity"} \u2014 retry shortly, or \`vxil quickstart\` for a persistent project.`
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
return {
|
|
2456
|
+
ok: false,
|
|
2457
|
+
status: r.status,
|
|
2458
|
+
code: e.code ?? "error",
|
|
2459
|
+
message: e.message ?? `unexpected ${r.status} from /v1/try`
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
function renderTryReport(result, opts) {
|
|
2463
|
+
const genOk = opts.genOk !== false;
|
|
2464
|
+
const lines = [];
|
|
2465
|
+
lines.push(`
|
|
2466
|
+
\u2713 sandbox backend '${result.slug}' is live \u2014 no account, no signup.`);
|
|
2467
|
+
lines.push(` enabled: ${result.enabled_features.join(", ") || "(none)"}`);
|
|
2468
|
+
lines.push(` api key (shown ONCE \u2014 not recoverable): ${result.api_key}`);
|
|
2469
|
+
if (result.expires_at) {
|
|
2470
|
+
lines.push(` \u23F3 expires ${result.expires_at} (~2h) \u2014 the tenant, its data, AND this key are DELETED after. Nothing to clean up.`);
|
|
2471
|
+
} else {
|
|
2472
|
+
lines.push(" \u23F3 this sandbox self-expires in ~2h \u2014 the tenant, its data, and this key are deleted after.");
|
|
2473
|
+
}
|
|
2474
|
+
if (genOk) {
|
|
2475
|
+
lines.push("\n try it (types were generated in ./vxil.types.ts):");
|
|
2476
|
+
lines.push(" import { Vxil } from '@vxil/sdk';");
|
|
2477
|
+
lines.push(" import type { VxilSchema } from './vxil.types';");
|
|
2478
|
+
lines.push(` const vx = Vxil.connect<VxilSchema>({ apiKey: '${result.api_key}', baseUrl: '${opts.base}' });`);
|
|
2479
|
+
} else {
|
|
2480
|
+
lines.push("\n try it (typed client skipped \u2014 `npm i -D vxil && npm i @vxil/sdk`, then `npx vxil gen`):");
|
|
2481
|
+
lines.push(" import { Vxil } from '@vxil/sdk';");
|
|
2482
|
+
lines.push(` const vx = new Vxil({ apiKey: '${result.api_key}', baseUrl: '${opts.base}' });`);
|
|
2483
|
+
}
|
|
2484
|
+
lines.push(` await vx.from('${opts.collection}').create({ /* field-typed */ }, { status: 'published' });`);
|
|
2485
|
+
lines.push(` const { items } = await vx.from('${opts.collection}').query(); // read it back`);
|
|
2486
|
+
lines.push(" // (run `vxil push` first to materialize the collection server-side)");
|
|
2487
|
+
lines.push("\n cleanup: nothing to do \u2014 it self-expires (~2h) and the nightly reaper deletes it.");
|
|
2488
|
+
lines.push(" (`vxil dev down` drops a linked dev tenant, if you make one.)");
|
|
2489
|
+
return lines;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
// src/generated/templates.ts
|
|
2493
|
+
var TEMPLATE_CATALOG = [
|
|
2494
|
+
{
|
|
2495
|
+
"id": "blog",
|
|
2496
|
+
"title": "Blog / Publishing",
|
|
2497
|
+
"vertical": "content",
|
|
2498
|
+
"summary": "A publishing backend \u2014 authors, categories, and posts with a draft\u2192publish editorial lifecycle and threaded reader comments.",
|
|
2499
|
+
"collections": [
|
|
2500
|
+
"authors",
|
|
2501
|
+
"categories",
|
|
2502
|
+
"posts"
|
|
2503
|
+
],
|
|
2504
|
+
"features": [
|
|
2505
|
+
"cms",
|
|
2506
|
+
"comments",
|
|
2507
|
+
"notifications"
|
|
2508
|
+
],
|
|
2509
|
+
"hasFunctions": false,
|
|
2510
|
+
"byoKeys": [],
|
|
2511
|
+
"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 arrives with the\n// cms public-delivery lane (docs/roadmap.md \xA74.4).\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', validation: { 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', validation: { unique: true } },\n },\n },\n posts: {\n singular: 'post',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', validation: { 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",
|
|
2512
|
+
"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.\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**The public reader tier** (unauthenticated, edge-cached post reads) arrives with the `cms` public-delivery lane\n(`docs/roadmap.md` \xA74.4) \u2014 until then, serve posts through your own frontend with a read key.\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n"
|
|
2513
|
+
},
|
|
2514
|
+
{
|
|
2515
|
+
"id": "research-library",
|
|
2516
|
+
"title": "Research Library",
|
|
2517
|
+
"vertical": "content",
|
|
2518
|
+
"summary": "A knowledge base for research and reference content \u2014 topics, sources, and cross-linked notes with a bounded citation graph.",
|
|
2519
|
+
"collections": [
|
|
2520
|
+
"topics",
|
|
2521
|
+
"sources",
|
|
2522
|
+
"notes"
|
|
2523
|
+
],
|
|
2524
|
+
"features": [
|
|
2525
|
+
"cms",
|
|
2526
|
+
"notifications"
|
|
2527
|
+
],
|
|
2528
|
+
"hasFunctions": false,
|
|
2529
|
+
"byoKeys": [],
|
|
2530
|
+
"configSrc": `import { defineConfig } from '@vxil/config';
|
|
2531
|
+
|
|
2532
|
+
// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2533
|
+
// "Research Library" \u2014 a knowledge base for research/reference content, declared
|
|
2534
|
+
// end-to-end in ONE typed file. It is NOT a vxil "research feature" (there is
|
|
2535
|
+
// none): it is a BLUEPRINT that composes shipped building blocks \u2014
|
|
2536
|
+
// \u2022 cms \u2192 the content spine (topics \u2192 sources \u2192 notes, resolved by relation)
|
|
2537
|
+
// \u2022 notifications \u2192 optional digests/alerts
|
|
2538
|
+
// Everything is DATA the tenant owns and edits after \`vxil init\`. There is no
|
|
2539
|
+
// platform lock over the shape \u2014 the invariants (a required title, a sane year)
|
|
2540
|
+
// ride as tenant-owned Lane-A validate hooks. See docs/cms-content-templates-analysis.md.
|
|
2541
|
+
// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2542
|
+
export default defineConfig({
|
|
2543
|
+
env: 'staging',
|
|
2544
|
+
|
|
2545
|
+
features: {
|
|
2546
|
+
cms: {
|
|
2547
|
+
// Draft while researching; publish when a note/source is ready to share.
|
|
2548
|
+
draftPublish: true,
|
|
2549
|
+
// A Lane-A safe-expression hook \u2014 AST-validated at \`vxil push\` time, run in
|
|
2550
|
+
// the write tx. Cross-row work would live in a function; this is one row.
|
|
2551
|
+
hooks: {
|
|
2552
|
+
// A source's year (if given) must be sane \u2014 pure function of the row.
|
|
2553
|
+
source_year: {
|
|
2554
|
+
collection: 'sources',
|
|
2555
|
+
event: 'beforeWrite',
|
|
2556
|
+
kind: 'validate',
|
|
2557
|
+
expr: 'isNull(item.year) || (item.year >= 0 && item.year <= 2100)',
|
|
2558
|
+
message: 'year must be between 0 and 2100',
|
|
2559
|
+
},
|
|
2560
|
+
},
|
|
2561
|
+
},
|
|
2562
|
+
notifications: { provider: 'mock', fromEmail: 'library@example.app' },
|
|
2563
|
+
},
|
|
2564
|
+
|
|
2565
|
+
// \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500
|
|
2566
|
+
cms: {
|
|
2567
|
+
collections: {
|
|
2568
|
+
topics: {
|
|
2569
|
+
singular: 'topic',
|
|
2570
|
+
fields: {
|
|
2571
|
+
name: { type: 'string', required: true, indexSlot: 's1' },
|
|
2572
|
+
slug: { type: 'string', indexSlot: 's2', validation: { unique: true } },
|
|
2573
|
+
description: { type: 'text' },
|
|
2574
|
+
},
|
|
2575
|
+
},
|
|
2576
|
+
sources: {
|
|
2577
|
+
singular: 'source',
|
|
2578
|
+
fields: {
|
|
2579
|
+
title: { type: 'string', required: true, indexSlot: 's1' },
|
|
2580
|
+
url: { type: 'string', indexSlot: 's2' },
|
|
2581
|
+
author: { type: 'string', indexSlot: 's3' },
|
|
2582
|
+
kind: { type: 'string', indexSlot: 's4' }, // book | article | web | dataset
|
|
2583
|
+
year: { type: 'int', indexSlot: 'n1' },
|
|
2584
|
+
topic: { type: 'relation', relationTo: 'topics' },
|
|
2585
|
+
added_at: { type: 'datetime', indexSlot: 't1' },
|
|
2586
|
+
},
|
|
2587
|
+
},
|
|
2588
|
+
notes: {
|
|
2589
|
+
singular: 'note',
|
|
2590
|
+
fields: {
|
|
2591
|
+
title: { type: 'string', required: true, indexSlot: 's1' },
|
|
2592
|
+
body: { type: 'text' },
|
|
2593
|
+
// Slot the relations so notes can be filtered by their source/topic
|
|
2594
|
+
// (the \xA712.1 single-hop dotted-key join over a slot-bound relation).
|
|
2595
|
+
source: { type: 'relation', relationTo: 'sources', indexSlot: 's2' },
|
|
2596
|
+
topic: { type: 'relation', relationTo: 'topics', indexSlot: 's3' },
|
|
2597
|
+
tags: { type: 'json' }, // ["method","open-question"] \u2014 $arrayContains-searchable
|
|
2598
|
+
},
|
|
2599
|
+
},
|
|
2600
|
+
},
|
|
2601
|
+
},
|
|
2602
|
+
|
|
2603
|
+
seed: {
|
|
2604
|
+
cms: [
|
|
2605
|
+
{
|
|
2606
|
+
collection: 'topics',
|
|
2607
|
+
items: [
|
|
2608
|
+
{ name: 'Distributed Systems', slug: 'distributed-systems', description: 'Consensus, replication, consistency.' },
|
|
2609
|
+
{ name: 'Databases', slug: 'databases', description: 'Storage engines, query planning, isolation.' },
|
|
2610
|
+
],
|
|
2611
|
+
},
|
|
2612
|
+
],
|
|
2613
|
+
},
|
|
2614
|
+
});
|
|
2615
|
+
`,
|
|
2616
|
+
"readme": '# Research Library template\n\nA knowledge base for research/reference content \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions (all `cms` collections you own and can edit):**\n- `topics` \u2014 subject areas (name, slug, description).\n- `sources` \u2014 cited works (title, url, author, kind, year \u2192 `topics`); a Lane-A hook keeps the year sane.\n- `notes` \u2014 your writing, cross-linked to a `source` and a `topic` (slot-bound so you can filter notes by either \u2014 the single-hop join in `docs/features/cms.md` \xA712.1). `tags` is a JSON array (`$arrayContains`-searchable).\n\n**Use it:**\n\n```bash\nvxil init --template research-library\nvxil quickstart # a new backend (or `vxil link <slug>` for an existing one)\nvxil push # apply the collections + hooks\nvxil gen # typed SDK + MCP catalog\n```\n\n**Own the shape.** After `init` the config is yours \u2014 add fields, edit the hook, add collections. Nothing is\nlocked; the "required title / sane year" invariants ride as tenant-owned Lane-A `validate` hooks you can see and\nchange. To make more content publicly readable later, enable the `cms` public-delivery lane when it ships\n(`docs/roadmap.md` \xA74.4).\n'
|
|
2617
|
+
},
|
|
2618
|
+
{
|
|
2619
|
+
"id": "gallery",
|
|
2620
|
+
"title": "Gallery",
|
|
2621
|
+
"vertical": "content",
|
|
2622
|
+
"summary": "An image / media gallery \u2014 albums of photos with file-backed images, dimensions, and tags.",
|
|
2623
|
+
"collections": [
|
|
2624
|
+
"albums",
|
|
2625
|
+
"photos"
|
|
2626
|
+
],
|
|
2627
|
+
"features": [
|
|
2628
|
+
"cms",
|
|
2629
|
+
"files"
|
|
2630
|
+
],
|
|
2631
|
+
"hasFunctions": false,
|
|
2632
|
+
"byoKeys": [],
|
|
2633
|
+
"configSrc": `import { defineConfig } from '@vxil/config';
|
|
2634
|
+
|
|
2635
|
+
// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2636
|
+
// "Gallery" \u2014 an image / media gallery (albums of photos), declared end-to-end in
|
|
2637
|
+
// ONE typed file. A BLUEPRINT composing shipped building blocks \u2014
|
|
2638
|
+
// \u2022 cms \u2192 albums \u2192 photos (resolved by relation)
|
|
2639
|
+
// \u2022 files \u2192 the actual image bytes (R2 + signed shared links)
|
|
2640
|
+
// The \`file\`-typed fields store a files-feature object ref; the item stays a
|
|
2641
|
+
// bounded JSONB document. Everything here is DATA the tenant owns and edits.
|
|
2642
|
+
// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2643
|
+
export default defineConfig({
|
|
2644
|
+
env: 'staging',
|
|
2645
|
+
|
|
2646
|
+
features: {
|
|
2647
|
+
cms: { draftPublish: true },
|
|
2648
|
+
files: { enabled: true }, // R2-backed object store + shared links for the images
|
|
2649
|
+
},
|
|
2650
|
+
|
|
2651
|
+
// \u2500\u2500 Schema-as-code: the cms collections (\u22648 index slots each: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500
|
|
2652
|
+
cms: {
|
|
2653
|
+
collections: {
|
|
2654
|
+
albums: {
|
|
2655
|
+
singular: 'album',
|
|
2656
|
+
fields: {
|
|
2657
|
+
title: { type: 'string', required: true, indexSlot: 's1' },
|
|
2658
|
+
slug: { type: 'string', indexSlot: 's2', validation: { unique: true } },
|
|
2659
|
+
cover: { type: 'file' }, // a files-feature object ref (the album cover)
|
|
2660
|
+
description: { type: 'text' },
|
|
2661
|
+
},
|
|
2662
|
+
},
|
|
2663
|
+
photos: {
|
|
2664
|
+
singular: 'photo',
|
|
2665
|
+
fields: {
|
|
2666
|
+
title: { type: 'string', indexSlot: 's1' },
|
|
2667
|
+
image: { type: 'file', required: true }, // the photo bytes (via \`files\`)
|
|
2668
|
+
album: { type: 'relation', relationTo: 'albums', indexSlot: 's2' },
|
|
2669
|
+
taken_at: { type: 'datetime', indexSlot: 't1' },
|
|
2670
|
+
width: { type: 'int', indexSlot: 'n1' },
|
|
2671
|
+
height: { type: 'int', indexSlot: 'n2' },
|
|
2672
|
+
tags: { type: 'json' }, // ["landscape","2024"] \u2014 $arrayContains-searchable
|
|
2673
|
+
},
|
|
2674
|
+
},
|
|
2675
|
+
},
|
|
2676
|
+
},
|
|
2677
|
+
|
|
2678
|
+
seed: {
|
|
2679
|
+
cms: [
|
|
2680
|
+
{
|
|
2681
|
+
collection: 'albums',
|
|
2682
|
+
items: [
|
|
2683
|
+
{ title: 'Travels', slug: 'travels', description: 'Photos from the road.' },
|
|
2684
|
+
],
|
|
2685
|
+
},
|
|
2686
|
+
],
|
|
2687
|
+
},
|
|
2688
|
+
});
|
|
2689
|
+
`,
|
|
2690
|
+
"readme": "# Gallery template\n\nAn image / media gallery \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `albums` \u2014 a titled, slugged collection with a `cover` image (a `files` object ref) and description.\n- `photos` \u2014 a `file`-typed `image` (the bytes live in `files` / R2), linked to an `album`, with `taken_at`,\n `width`/`height`, and JSON `tags`. The `album` relation is slot-bound so you can list a single album's photos.\n\nThe `file`-typed fields hold a `files`-feature object ref; upload bytes through `files` (R2 + signed shared links)\nand store the returned `obj_\u2026` id on the item.\n\n**Use it:**\n\n```bash\nvxil init --template gallery\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**Own the shape.** The config is yours after `init` \u2014 add EXIF fields, a `photographer` relation, more albums.\nNothing is locked.\n"
|
|
2691
|
+
},
|
|
2692
|
+
{
|
|
2693
|
+
"id": "directory",
|
|
2694
|
+
"title": "Listings Directory",
|
|
2695
|
+
"vertical": "content",
|
|
2696
|
+
"summary": "A categorized listings directory with user-submitted, owner-scoped entries and an admin publish step.",
|
|
2697
|
+
"collections": [
|
|
2698
|
+
"categories",
|
|
2699
|
+
"listings"
|
|
2700
|
+
],
|
|
2701
|
+
"features": [
|
|
2702
|
+
"cms",
|
|
2703
|
+
"auth"
|
|
2704
|
+
],
|
|
2705
|
+
"hasFunctions": false,
|
|
2706
|
+
"byoKeys": [],
|
|
2707
|
+
"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// \"Directory\" \u2014 a listings directory (categorized listings people can submit),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 categories \u2192 listings (resolved by relation)\n// \u2022 auth \u2192 accounts, so a submitter is a verified end-user\n// `listings.submitted_by` is the end-user OWNER field: in end-user mode a\n// submitter can only edit their OWN listing (a no-op for server callers). This\n// is one declarative flag, not a per-user policy. Everything here is DATA the\n// 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, // submissions land as drafts; an admin publishes them\n hooks: {\n // Every listing needs a name \u2014 a pure function of the row (Lane-A validate).\n listing_name: {\n collection: 'listings',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.name) > 0',\n message: 'a listing needs a name',\n },\n },\n },\n auth: { methods: { emailPassword: true } }, // submitters sign in as end-users\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 categories: {\n singular: 'category',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', validation: { unique: true } },\n },\n },\n listings: {\n singular: 'listing',\n // End-user owner-scope (docs/features/cms.md \xA715): a verified submitter may\n // only read/edit their OWN listing. Server callers are unaffected.\n ownerField: 'submitted_by',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', validation: { unique: true } },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's3' },\n submitted_by: { type: 'string', indexSlot: 's4' }, // the owner (end-user) id\n description: { type: 'text' },\n website: { type: 'string' }, // stored, not indexed\n location: { type: 'string' }, // stored, not indexed\n featured: { type: 'bool' },\n rating: { type: 'float', indexSlot: 'n1' },\n submitted_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'categories',\n items: [\n { name: 'Restaurants', slug: 'restaurants' },\n { name: 'Services', slug: 'services' },\n ],\n },\n ],\n },\n});\n",
|
|
2708
|
+
"readme": "# Listings Directory template\n\nA categorized listings directory (people submit entries) \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name, unique slug.\n- `listings` \u2014 name, unique slug, `category` relation, `submitted_by` (the **owner field**), description,\n website, location, `featured`, `rating`, `submitted_at`. A Lane-A hook requires a name; `draftPublish` lets\n submissions land as drafts an admin publishes.\n- `auth` (email/password) \u2014 so a submitter is a **verified end-user**.\n\n**Owner-scoping.** `listings.ownerField = 'submitted_by'` means that in end-user mode a submitter can only\nread/edit their **own** listing (`docs/features/cms.md` \xA715) \u2014 one declarative flag, not a per-user policy, and a\nno-op for server callers.\n\n**Use it:**\n\n```bash\nvxil init --template directory\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n"
|
|
2709
|
+
},
|
|
2710
|
+
{
|
|
2711
|
+
"id": "catalog",
|
|
2712
|
+
"title": "Product Catalog",
|
|
2713
|
+
"vertical": "commerce",
|
|
2714
|
+
"summary": "A content-only product catalog \u2014 browse/list products by category, with price, rating, and stock. (Checkout is the full examples/ecommerce blueprint.)",
|
|
2715
|
+
"collections": [
|
|
2716
|
+
"categories",
|
|
2717
|
+
"products"
|
|
2718
|
+
],
|
|
2719
|
+
"features": [
|
|
2720
|
+
"cms"
|
|
2721
|
+
],
|
|
2722
|
+
"hasFunctions": false,
|
|
2723
|
+
"byoKeys": [],
|
|
2724
|
+
"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// \"Catalog\" \u2014 a CONTENT-ONLY product catalog (browse/list products by category),\n// declared end-to-end in ONE typed file. A BLUEPRINT composing shipped blocks \u2014\n// \u2022 cms \u2192 categories \u2192 products (resolved by relation)\n// This is the catalog CONTENT SHELL, not a storefront: checkout/inventory/orders\n// are the full `examples/ecommerce` blueprint (a reserve\u2192settle\u2192reverse saga in\n// tenant functions). Keep those in a function; a catalog is pure content.\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, // draft products while staging; publish to list them\n hooks: {\n // Price (if given) can't be negative \u2014 a pure function of the row.\n product_price: {\n collection: 'products',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'isNull(item.price_cents) || item.price_cents >= 0',\n message: 'price_cents must be >= 0',\n },\n },\n },\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 categories: {\n singular: 'category',\n fields: {\n name: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', indexSlot: 's2', validation: { unique: true } },\n },\n },\n products: {\n singular: 'product',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n slug: { type: 'string', required: true, indexSlot: 's2', validation: { unique: true } },\n category: { type: 'relation', relationTo: 'categories', indexSlot: 's3' },\n brand: { type: 'string', indexSlot: 's4' },\n price_cents: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n rating: { type: 'float', indexSlot: 'n2' },\n description: { type: 'text' },\n images: { type: 'json' }, // [\"obj_\u2026\", \u2026] file refs or urls\n in_stock: { type: 'bool' },\n },\n },\n },\n },\n\n seed: {\n cms: [\n {\n collection: 'products',\n items: [\n { title: 'Aeron Chair', slug: 'aeron-chair', brand: 'Herman Miller', price_cents: 149900, rating: 4.8, in_stock: true },\n { title: 'Standing Desk', slug: 'standing-desk', brand: 'Uplift', price_cents: 59900, rating: 4.6, in_stock: true },\n ],\n },\n ],\n },\n});\n",
|
|
2725
|
+
"readme": "# Product Catalog template\n\nA **content-only** product catalog \u2014 declared end-to-end in one typed `vxil.config.ts`.\n\n**Provisions:**\n- `categories` \u2014 name + unique slug.\n- `products` \u2014 title, unique slug, `category` relation (slot-bound for filtering), brand, `price_cents`\n (`min: 0`), rating, JSON `images`, `in_stock`. A Lane-A hook rejects a negative price.\n\n**This is the catalog shell, not a storefront.** Checkout, inventory, orders, and coupons \u2014 the transactional spine\n\u2014 are the full **`examples/ecommerce`** blueprint (a reserve\u2192settle\u2192reverse saga in tenant `functions`, plus the\noversell-safe `$inc` stock pattern and `guards[]` coupon caps). A catalog is pure content; add the commerce logic\nfrom that example when you need it.\n\n**Use it:**\n\n```bash\nvxil init --template catalog\nvxil quickstart\nvxil push\nvxil gen\n```\n\n**Own the shape.** The config is yours after `init` \u2014 nothing is locked.\n"
|
|
2726
|
+
},
|
|
2727
|
+
{
|
|
2728
|
+
"id": "tasks",
|
|
2729
|
+
"title": "Tasks (starter)",
|
|
2730
|
+
"vertical": "starter",
|
|
2731
|
+
"summary": "The minimal starter \u2014 one cms collection plus notifications. The default `vxil init` scaffold.",
|
|
2732
|
+
"collections": [
|
|
2733
|
+
"tasks"
|
|
2734
|
+
],
|
|
2735
|
+
"features": [
|
|
2736
|
+
"cms",
|
|
2737
|
+
"notifications"
|
|
2738
|
+
],
|
|
2739
|
+
"hasFunctions": false,
|
|
2740
|
+
"byoKeys": [],
|
|
2741
|
+
"configSrc": "import { defineConfig } from '@vxil/config';\n\n// \"Tasks\" \u2014 the minimal starter: one cms collection + notifications, declared\n// end-to-end in one typed file. The default `vxil init` scaffold. Everything\n// here is DATA you own and edit.\nexport default defineConfig({\n env: 'staging',\n\n // (a) per-feature config \u2014 the same TypeBox manifests, validated server-side.\n features: {\n cms: { draftPublish: true },\n notifications: { provider: 'mock', fromEmail: 'noreply@example.app' },\n },\n\n // (b) CMS schema-as-code \u2014 collections + fields (reconciled by `vxil push`).\n cms: {\n collections: {\n tasks: {\n singular: 'task',\n fields: {\n title: { type: 'string', required: true, indexSlot: 's1' },\n done: { type: 'bool' },\n due: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n // (c) functions \u2014 tenant code (\xA77.3, paid/opt-in). Uncomment to deploy.\n // functions: {\n // hello: { entry: './functions/hello.ts', trigger: { kind: 'http' } },\n // },\n});\n",
|
|
2742
|
+
"readme": "# Tasks template (starter)\n\nThe minimal starter and the default `vxil init` scaffold \u2014 one `cms` collection (`tasks`) plus `notifications`.\n\n```bash\nvxil init # scaffolds this template by default\nvxil quickstart\nvxil push\n```\n\nOwn the shape \u2014 add fields, add collections, uncomment the starter function. Nothing is locked.\n"
|
|
2743
|
+
},
|
|
2744
|
+
{
|
|
2745
|
+
"id": "notes",
|
|
2746
|
+
"title": "Notes (starter)",
|
|
2747
|
+
"vertical": "starter",
|
|
2748
|
+
"summary": "A single-collection notes app with no draft/publish lifecycle \u2014 the simplest possible backend.",
|
|
2749
|
+
"collections": [
|
|
2750
|
+
"notes"
|
|
2751
|
+
],
|
|
2752
|
+
"features": [
|
|
2753
|
+
"cms"
|
|
2754
|
+
],
|
|
2755
|
+
"hasFunctions": false,
|
|
2756
|
+
"byoKeys": [],
|
|
2757
|
+
"configSrc": `import { defineConfig } from '@vxil/config';
|
|
2758
|
+
|
|
2759
|
+
// "Notes" \u2014 a single-collection notes app (no draft/publish), declared end-to-end
|
|
2760
|
+
// in one typed file. A minimal starter. Everything here is DATA you own and edit.
|
|
2761
|
+
export default defineConfig({
|
|
2762
|
+
env: 'staging',
|
|
2763
|
+
features: { cms: { draftPublish: false } },
|
|
2764
|
+
cms: {
|
|
2765
|
+
collections: {
|
|
2766
|
+
notes: {
|
|
2767
|
+
singular: 'note',
|
|
2768
|
+
fields: {
|
|
2769
|
+
title: { type: 'string', required: true, indexSlot: 's1' },
|
|
2770
|
+
body: { type: 'text' },
|
|
2771
|
+
pinned: { type: 'bool' },
|
|
2772
|
+
},
|
|
2773
|
+
},
|
|
2774
|
+
},
|
|
2775
|
+
},
|
|
2776
|
+
});
|
|
2777
|
+
`,
|
|
2778
|
+
"readme": "# Notes template (starter)\n\nThe simplest possible backend \u2014 one `cms` collection (`notes`), no draft/publish.\n\n```bash\nvxil init --template notes\nvxil quickstart\nvxil push\n```\n\nOwn the shape \u2014 nothing is locked.\n"
|
|
2779
|
+
}
|
|
2780
|
+
];
|
|
2781
|
+
|
|
2782
|
+
// bin/vxil.ts
|
|
2783
|
+
var DEFAULT_BASE = "https://api.vxil.com";
|
|
2784
|
+
var [, , cmd, ...rest] = process.argv;
|
|
2785
|
+
function flag(name) {
|
|
2786
|
+
const i = rest.indexOf(`--${name}`);
|
|
2787
|
+
return i >= 0 ? rest[i + 1] : void 0;
|
|
2788
|
+
}
|
|
2789
|
+
function hasFlag(name) {
|
|
2790
|
+
return rest.includes(`--${name}`);
|
|
2791
|
+
}
|
|
2792
|
+
var jsonOut = rest.includes("--json");
|
|
2793
|
+
function fail(msg) {
|
|
2794
|
+
console.error(`vxil: ${msg}`);
|
|
2795
|
+
process.exit(1);
|
|
2796
|
+
}
|
|
2797
|
+
function positional(skip = 0) {
|
|
2798
|
+
const out = [];
|
|
2799
|
+
for (let i = 0; i < rest.length; i++) {
|
|
2800
|
+
const t = rest[i];
|
|
2801
|
+
if (t.startsWith("--")) {
|
|
2802
|
+
if (rest[i + 1] && !rest[i + 1].startsWith("--")) i++;
|
|
2803
|
+
continue;
|
|
2804
|
+
}
|
|
2805
|
+
out.push(t);
|
|
2806
|
+
}
|
|
2807
|
+
return out.slice(skip);
|
|
2808
|
+
}
|
|
2809
|
+
function printEnvelope({ status, body }) {
|
|
2810
|
+
if (body.error) {
|
|
2811
|
+
console.error(`error ${status}: ${body.error.code} \u2014 ${body.error.message}`);
|
|
2812
|
+
if (body.error.hint) console.error(`hint: ${body.error.hint}`);
|
|
2813
|
+
process.exit(1);
|
|
2814
|
+
}
|
|
2815
|
+
console.log(JSON.stringify(body.data, null, 2));
|
|
2816
|
+
}
|
|
2817
|
+
async function prompt(q, opts = {}) {
|
|
2818
|
+
const stdin = process.stdin;
|
|
2819
|
+
if (opts.hidden && stdin.isTTY && stdin.setRawMode) {
|
|
2820
|
+
process.stdout.write(q);
|
|
2821
|
+
return await new Promise((resolve5) => {
|
|
2822
|
+
stdin.setRawMode(true);
|
|
2823
|
+
stdin.resume();
|
|
2824
|
+
let val = "";
|
|
2825
|
+
const onData = (buf) => {
|
|
2826
|
+
const ch = buf.toString("utf8");
|
|
2827
|
+
if (ch === "\n" || ch === "\r" || ch === "") {
|
|
2828
|
+
stdin.setRawMode(false);
|
|
2829
|
+
stdin.pause();
|
|
2830
|
+
stdin.removeListener("data", onData);
|
|
2831
|
+
process.stdout.write("\n");
|
|
2832
|
+
resolve5(val);
|
|
2833
|
+
} else if (ch === "") {
|
|
2834
|
+
stdin.setRawMode(false);
|
|
2835
|
+
process.stdout.write("\n");
|
|
2836
|
+
process.exit(130);
|
|
2837
|
+
} else if (ch === "\x7F" || ch === "\b") {
|
|
2838
|
+
val = val.slice(0, -1);
|
|
2839
|
+
} else {
|
|
2840
|
+
val += ch;
|
|
2841
|
+
}
|
|
2842
|
+
};
|
|
2843
|
+
stdin.on("data", onData);
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2847
|
+
return await new Promise((res) => rl.question(q, (a) => {
|
|
2848
|
+
rl.close();
|
|
2849
|
+
res(a.trim());
|
|
2850
|
+
}));
|
|
2851
|
+
}
|
|
2852
|
+
function dashboardBase(edge) {
|
|
2853
|
+
return process.env.VXIL_DASHBOARD_URL ?? edge.replace(/^https?:\/\/api\./, (m) => m.replace("api.", "app."));
|
|
2854
|
+
}
|
|
2855
|
+
async function dashFetch(base, method, path, body, cookie) {
|
|
2856
|
+
const res = await fetch(`${base.replace(/\/$/, "")}${path}`, {
|
|
2857
|
+
method,
|
|
2858
|
+
headers: {
|
|
2859
|
+
...body !== void 0 ? { "content-type": "application/json" } : {},
|
|
2860
|
+
...cookie ? { cookie } : {},
|
|
2861
|
+
"x-vxil-surface": "cli"
|
|
2862
|
+
},
|
|
2863
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
2864
|
+
});
|
|
2865
|
+
const text = await res.text();
|
|
2866
|
+
let json2 = {};
|
|
2867
|
+
try {
|
|
2868
|
+
json2 = JSON.parse(text);
|
|
2869
|
+
} catch {
|
|
2870
|
+
json2 = { raw: text.slice(0, 300) };
|
|
2871
|
+
}
|
|
2872
|
+
const setCookie = res.headers.get("set-cookie") ?? void 0;
|
|
2873
|
+
return { status: res.status, json: json2, cookie: setCookie };
|
|
2874
|
+
}
|
|
2875
|
+
function makeDash(base) {
|
|
2876
|
+
return (method, path, body, cookie) => dashFetch(base, method, path, body, cookie);
|
|
2877
|
+
}
|
|
2878
|
+
function persistLink(slug, tenantId, apiKey, base) {
|
|
2879
|
+
const creds = loadCredentials();
|
|
2880
|
+
creds.keys = { ...creds.keys, [slug]: apiKey };
|
|
2881
|
+
saveCredentials(creds);
|
|
2882
|
+
saveProject({ tenant_id: tenantId, slug, edge_url: base, env: flag("env") ?? "staging" });
|
|
2883
|
+
}
|
|
2884
|
+
async function resolveDashCookie(dash) {
|
|
2885
|
+
const creds = loadCredentials();
|
|
2886
|
+
if (creds.session) return creds.session;
|
|
2887
|
+
const email = flag("email") ?? process.env.VXIL_DEV_EMAIL;
|
|
2888
|
+
const password = flag("password") ?? process.env.VXIL_DEV_PASSWORD;
|
|
2889
|
+
if (!email || !password) {
|
|
2890
|
+
fail("need a dashboard session \u2014 run `vxil login`, or pass --email/--password (or set VXIL_DEV_EMAIL/VXIL_DEV_PASSWORD)");
|
|
2891
|
+
}
|
|
2892
|
+
const r = await dash("POST", "/dashboard/login", { email, password });
|
|
2893
|
+
if (r.status !== 200) fail(`login failed: ${r.status} ${JSON.stringify(r.json).slice(0, 160)}`);
|
|
2894
|
+
const cookie = r.cookie?.split(";")[0];
|
|
2895
|
+
if (!cookie) fail("login succeeded but returned no session cookie");
|
|
2896
|
+
return cookie;
|
|
2897
|
+
}
|
|
2898
|
+
function requireApi(which = "prod") {
|
|
2899
|
+
const t = resolve_target({ which, defaultBase: DEFAULT_BASE });
|
|
2900
|
+
if (!t.apiKey) {
|
|
2901
|
+
fail("no API key \u2014 run `vxil quickstart` or `vxil link <slug>`, or set VXIL_API_KEY");
|
|
2902
|
+
}
|
|
2903
|
+
return { api: makeApi({ apiKey: t.apiKey, baseUrl: t.baseUrl }), baseUrl: t.baseUrl, slug: t.tenantSlug };
|
|
2904
|
+
}
|
|
2905
|
+
async function exportNdjson(feature) {
|
|
2906
|
+
const t = resolve_target({ which: "prod", defaultBase: DEFAULT_BASE });
|
|
2907
|
+
if (!t.apiKey) {
|
|
2908
|
+
fail("no API key \u2014 run `vxil quickstart` or `vxil link <slug>`, or set VXIL_API_KEY");
|
|
2909
|
+
}
|
|
2910
|
+
const base = t.baseUrl.replace(/\/$/, "");
|
|
2911
|
+
const get = async (path) => {
|
|
2912
|
+
const res = await fetch(`${base}${path}`, {
|
|
2913
|
+
headers: { authorization: `Bearer ${t.apiKey}`, "x-vxil-surface": "cli" }
|
|
2914
|
+
});
|
|
2915
|
+
const text = await res.text();
|
|
2916
|
+
if (!res.ok) fail(`export failed (${res.status}): ${text.slice(0, 200)}`);
|
|
2917
|
+
return {
|
|
2918
|
+
lines: text.split("\n").filter((l) => l.length > 0),
|
|
2919
|
+
nextAfter: res.headers.get("x-vxil-next-after")
|
|
2920
|
+
};
|
|
2921
|
+
};
|
|
2922
|
+
const first = await get(`/v1/export/${feature}?format=ndjson`);
|
|
2923
|
+
const lines = [...first.lines];
|
|
2924
|
+
let truncated = [];
|
|
2925
|
+
const last = lines.at(-1);
|
|
2926
|
+
if (last) {
|
|
2927
|
+
try {
|
|
2928
|
+
const parsed = JSON.parse(last);
|
|
2929
|
+
if (Array.isArray(parsed.truncated)) {
|
|
2930
|
+
truncated = parsed.truncated;
|
|
2931
|
+
lines.pop();
|
|
2932
|
+
}
|
|
2933
|
+
} catch {
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
for (const tr of truncated) {
|
|
2937
|
+
if (!tr.after) {
|
|
2938
|
+
console.error(`vxil: note \u2014 table '${tr.table}' hit the server row cap and has no resumable primary key; remaining rows not included`);
|
|
2939
|
+
continue;
|
|
2940
|
+
}
|
|
2941
|
+
let after = tr.after;
|
|
2942
|
+
while (after) {
|
|
2943
|
+
const page = await get(
|
|
2944
|
+
`/v1/export/${feature}?format=ndjson&table=${encodeURIComponent(tr.table)}&after=${encodeURIComponent(after)}`
|
|
2945
|
+
);
|
|
2946
|
+
lines.push(...page.lines.slice(1));
|
|
2947
|
+
after = page.nextAfter;
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
const body = lines.join("\n") + "\n";
|
|
2951
|
+
const out = flag("out");
|
|
2952
|
+
if (out) {
|
|
2953
|
+
writeFileSync3(resolve4(process.cwd(), out), body);
|
|
2954
|
+
console.log(`exported ${feature} \u2192 ${out} (${lines.length} lines)`);
|
|
2955
|
+
} else {
|
|
2956
|
+
process.stdout.write(body);
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
async function runApply(apply, which = "prod") {
|
|
2960
|
+
const { api } = requireApi(which);
|
|
2961
|
+
const cfg = await loadVxilConfig();
|
|
2962
|
+
const cwd = process.cwd();
|
|
2963
|
+
if (cfg.cms?.collections && Object.keys(cfg.cms.collections).length) {
|
|
2964
|
+
const r = await planCmsSchema({ api, collections: cfg.cms.collections, apply, allowDestructive: hasFlag("allow-destructive") });
|
|
2965
|
+
console.log("\ncms.schema:");
|
|
2966
|
+
console.log(formatCmsChanges(r.changes));
|
|
2967
|
+
if (apply && r.applied) console.log(` \u2713 applied ${r.applied} schema change(s)`);
|
|
2968
|
+
}
|
|
2969
|
+
const { functions: _fnFeature, ...configFeatures } = cfg.features;
|
|
2970
|
+
void _fnFeature;
|
|
2971
|
+
if (Object.keys(configFeatures).length) {
|
|
2972
|
+
const results = await planOrPush({ api, features: configFeatures, apply });
|
|
2973
|
+
for (const r of results) {
|
|
2974
|
+
console.log(`
|
|
2975
|
+
${r.feature} (remote v${r.version}):`);
|
|
2976
|
+
console.log(formatDiff(r.changes));
|
|
2977
|
+
if (r.applied) console.log(` \u2713 applied as v${r.applied}`);
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
if (cfg.functions && Object.keys(cfg.functions).length) {
|
|
2981
|
+
const r = await planFunctions({ api, functions: cfg.functions, cwd, apply });
|
|
2982
|
+
console.log("\nfunctions:");
|
|
2983
|
+
console.log(formatFnChanges(r.changes));
|
|
2984
|
+
if (apply && r.applied) console.log(` \u2713 deployed ${r.applied} function(s)`);
|
|
2985
|
+
if (apply && r.cmsHookSubscriptions && (r.cmsHookSubscriptions.created || r.cmsHookSubscriptions.deleted)) {
|
|
2986
|
+
console.log(` \u2713 cms-hook subscription reconciled (+${r.cmsHookSubscriptions.created} / -${r.cmsHookSubscriptions.deleted})`);
|
|
2987
|
+
}
|
|
2988
|
+
} else if (cfg.features.functions) {
|
|
2989
|
+
console.log("\nfunctions: features.functions is declared but no functions/ are defined to deploy \u2014 the feature enables per-function on deploy (no feature-level config namespace).");
|
|
2990
|
+
}
|
|
2991
|
+
if (!apply) console.log("\n(run `vxil push` to apply)");
|
|
2992
|
+
if (apply && !hasFlag("no-gen")) await runGen(which);
|
|
2993
|
+
}
|
|
2994
|
+
async function runServerApply(which) {
|
|
2995
|
+
const { api } = requireApi(which);
|
|
2996
|
+
const resume = flag("resume");
|
|
2997
|
+
if (resume) {
|
|
2998
|
+
const done2 = await serverApply({ api, resume });
|
|
2999
|
+
console.log(formatApplySteps(done2.steps));
|
|
3000
|
+
console.log(`
|
|
3001
|
+
apply ${done2.apply_id}: ${done2.status}`);
|
|
3002
|
+
if (done2.status === "running") console.log(`resume with: vxil push --server --resume ${done2.apply_id}`);
|
|
3003
|
+
if (done2.status !== "succeeded" && done2.status !== "running") process.exit(1);
|
|
3004
|
+
if (done2.status === "succeeded" && !hasFlag("no-gen")) await runGen(which);
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
const cfg = await loadVxilConfig();
|
|
3008
|
+
const cwd = process.cwd();
|
|
3009
|
+
const fnSources = {};
|
|
3010
|
+
for (const [name, def] of Object.entries(cfg.functions ?? {})) {
|
|
3011
|
+
fnSources[name] = (await bundleFunction(resolve4(cwd, def.entry), { projectRoot: cwd })).source;
|
|
3012
|
+
}
|
|
3013
|
+
const bundle = assembleApplyBundle(cfg, fnSources);
|
|
3014
|
+
if (!Object.keys(bundle).length) fail("nothing to apply \u2014 vxil.config declares no features/cms/functions");
|
|
3015
|
+
const done = await serverApply({
|
|
3016
|
+
api,
|
|
3017
|
+
bundle,
|
|
3018
|
+
allowDestructive: hasFlag("allow-destructive"),
|
|
3019
|
+
onPlan: (plan) => {
|
|
3020
|
+
console.log(`plan (apply ${plan.apply_id}):`);
|
|
3021
|
+
console.log(formatApplySteps(plan.steps));
|
|
3022
|
+
for (const w of plan.warnings ?? []) console.log(` ! ${w}`);
|
|
3023
|
+
console.log("\napplying server-side\u2026");
|
|
3024
|
+
}
|
|
3025
|
+
});
|
|
3026
|
+
console.log(formatApplySteps(done.steps));
|
|
3027
|
+
console.log(`
|
|
3028
|
+
apply ${done.apply_id}: ${done.status}`);
|
|
3029
|
+
if (done.status === "running") {
|
|
3030
|
+
fail(`a transient step failure interrupted the apply \u2014 resume with: vxil push --server --resume ${done.apply_id}`);
|
|
3031
|
+
}
|
|
3032
|
+
if (done.status !== "succeeded") {
|
|
3033
|
+
const f = done.failed_step;
|
|
3034
|
+
fail(`apply ${done.status}${f ? ` \u2014 step ${f.seq} (${f.kind} ${f.target}): ${f.error}` : ""} (config/functions were rolled back to their pre-apply versions; cms schema additions were kept)`);
|
|
3035
|
+
}
|
|
3036
|
+
if (!hasFlag("no-gen")) await runGen(which);
|
|
3037
|
+
}
|
|
3038
|
+
async function runSeed(which, opts = {}) {
|
|
3039
|
+
const { api } = requireApi(which);
|
|
3040
|
+
const cfg = await loadVxilConfig();
|
|
3041
|
+
const seed = cfg.seed;
|
|
3042
|
+
if (!seed) {
|
|
3043
|
+
if (opts.optional) return;
|
|
3044
|
+
fail("no `seed` block in vxil.config");
|
|
3045
|
+
}
|
|
3046
|
+
let total = 0;
|
|
3047
|
+
if (seed.users?.length) {
|
|
3048
|
+
const res = await api("POST", "/v1/users/bulk", { users: seed.users });
|
|
3049
|
+
if (res.status !== 200) printEnvelope(res);
|
|
3050
|
+
total += seed.users.length;
|
|
3051
|
+
console.log(` \u2713 ${seed.users.length} users`);
|
|
3052
|
+
}
|
|
3053
|
+
for (const block of seed.cms ?? []) {
|
|
3054
|
+
for (const item of block.items) {
|
|
3055
|
+
const res = await api("POST", `/v1/cms/items/${encodeURIComponent(block.collection)}`, { data: item });
|
|
3056
|
+
if (res.status !== 201 && res.status !== 200) printEnvelope(res);
|
|
3057
|
+
total++;
|
|
3058
|
+
}
|
|
3059
|
+
console.log(` \u2713 ${block.items.length} ${block.collection} items`);
|
|
3060
|
+
}
|
|
3061
|
+
console.log(`seeded ${total} record(s)`);
|
|
3062
|
+
}
|
|
3063
|
+
async function runGen(which = "prod", opts = {}) {
|
|
3064
|
+
const offline = opts.offline ?? hasFlag("offline");
|
|
3065
|
+
const out = flag("out") ?? "vxil.types.ts";
|
|
3066
|
+
let features;
|
|
3067
|
+
let collections;
|
|
3068
|
+
let functions;
|
|
3069
|
+
let tenant;
|
|
3070
|
+
let env;
|
|
3071
|
+
let apiVersions;
|
|
3072
|
+
if (offline) {
|
|
3073
|
+
const cfg = await loadVxilConfig();
|
|
3074
|
+
env = cfg.env;
|
|
3075
|
+
features = Object.keys(cfg.features);
|
|
3076
|
+
collections = Object.entries(cfg.cms?.collections ?? {}).map(([collection, def]) => ({
|
|
3077
|
+
collection,
|
|
3078
|
+
...def.singular ? { singular: def.singular } : {},
|
|
3079
|
+
fields: Object.entries(def.fields).map(([field, f]) => ({
|
|
3080
|
+
field,
|
|
3081
|
+
type: f.type,
|
|
3082
|
+
required: !!f.required,
|
|
3083
|
+
index_slot: f.indexSlot ?? null,
|
|
3084
|
+
...f.relationTo ? { relation_to: f.relationTo } : {},
|
|
3085
|
+
...f.computed ? { computed: f.computed } : {}
|
|
3086
|
+
}))
|
|
3087
|
+
}));
|
|
3088
|
+
functions = Object.entries(cfg.functions ?? {}).map(([name, def]) => ({
|
|
3089
|
+
name,
|
|
3090
|
+
...def.signature ? { signature: def.signature } : {}
|
|
3091
|
+
}));
|
|
3092
|
+
} else {
|
|
3093
|
+
const { api, slug } = requireApi(which);
|
|
3094
|
+
tenant = slug;
|
|
3095
|
+
const fres = await api("GET", "/v1/features");
|
|
3096
|
+
features = fres.body.data?.features ?? [];
|
|
3097
|
+
apiVersions = fres.body.data?.api_versions;
|
|
3098
|
+
const cres = await api("GET", "/v1/cms/collections");
|
|
3099
|
+
collections = cres.body.data?.collections ?? [];
|
|
3100
|
+
const fnres = await api("GET", "/v1/functions");
|
|
3101
|
+
functions = (fnres.body.data?.functions ?? []).map((f) => ({
|
|
3102
|
+
name: f.name,
|
|
3103
|
+
...f.signature ? { signature: f.signature } : {},
|
|
3104
|
+
...f.trigger ? { trigger: f.trigger } : {}
|
|
3105
|
+
}));
|
|
3106
|
+
}
|
|
3107
|
+
const genInput = {
|
|
3108
|
+
...tenant ? { tenant } : {},
|
|
3109
|
+
...env ? { env } : {},
|
|
3110
|
+
features,
|
|
3111
|
+
collections,
|
|
3112
|
+
functions,
|
|
3113
|
+
...apiVersions ? { apiVersions } : {},
|
|
3114
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3115
|
+
};
|
|
3116
|
+
const src = generateTypes(genInput);
|
|
3117
|
+
const emitMcp = !hasFlag("no-mcp");
|
|
3118
|
+
const mcpOut = flag("mcp-out") ?? "vxil.mcp.json";
|
|
3119
|
+
const catalog = emitMcp ? buildMcpCatalog(genInput) : void 0;
|
|
3120
|
+
const mcpPath = resolve4(process.cwd(), mcpOut);
|
|
3121
|
+
const outPath = resolve4(process.cwd(), out);
|
|
3122
|
+
const body = (s) => {
|
|
3123
|
+
const i = s.indexOf("export interface VxilSchema");
|
|
3124
|
+
return i >= 0 ? s.slice(i) : s;
|
|
3125
|
+
};
|
|
3126
|
+
const counts = {
|
|
3127
|
+
features: features.length,
|
|
3128
|
+
collections: collections.length,
|
|
3129
|
+
functions: functions.length,
|
|
3130
|
+
...catalog ? { tools: catalog.tools.length } : {}
|
|
3131
|
+
};
|
|
3132
|
+
if (hasFlag("check")) {
|
|
3133
|
+
const existing = existsSync3(outPath) ? readFileSync2(outPath, "utf8") : "";
|
|
3134
|
+
if (body(existing) !== body(src)) {
|
|
3135
|
+
fail(`${out} is out of date (drift) \u2014 run \`vxil gen\` and commit. A collection/feature/function changed since it was generated.`);
|
|
3136
|
+
}
|
|
3137
|
+
if (catalog) {
|
|
3138
|
+
let upToDate = false;
|
|
3139
|
+
if (existsSync3(mcpPath)) {
|
|
3140
|
+
try {
|
|
3141
|
+
const cur = JSON.parse(readFileSync2(mcpPath, "utf8"));
|
|
3142
|
+
const want = JSON.parse(JSON.stringify(catalog));
|
|
3143
|
+
delete cur.generated_at;
|
|
3144
|
+
delete want.generated_at;
|
|
3145
|
+
upToDate = JSON.stringify(cur) === JSON.stringify(want);
|
|
3146
|
+
} catch {
|
|
3147
|
+
upToDate = false;
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
if (!upToDate) {
|
|
3151
|
+
fail(`${mcpOut} is out of date (drift) \u2014 run \`vxil gen\` and commit. A collection/feature/function changed since it was generated.`);
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
if (jsonOut) console.log(JSON.stringify({ ok: true, check: "up-to-date", ...counts }));
|
|
3155
|
+
else console.log(`vxil gen --check: ${out}${catalog ? ` + ${mcpOut}` : ""} is up to date.`);
|
|
3156
|
+
return;
|
|
3157
|
+
}
|
|
3158
|
+
writeFileSync3(outPath, src);
|
|
3159
|
+
if (catalog) writeFileSync3(mcpPath, `${JSON.stringify(catalog, null, 2)}
|
|
3160
|
+
`);
|
|
3161
|
+
if (jsonOut) console.log(JSON.stringify({ ok: true, out, ...catalog ? { mcp_out: mcpOut } : {}, ...counts }));
|
|
3162
|
+
else console.log(`vxil gen \u2192 ${out}${catalog ? ` + ${mcpOut}` : ""} (${features.length} features \xB7 ${collections.length} collections \xB7 ${functions.length} functions${catalog ? ` \xB7 ${catalog.tools.length} mcp tools` : ""})`);
|
|
3163
|
+
}
|
|
3164
|
+
async function runWatch(which) {
|
|
3165
|
+
const cwd = process.cwd();
|
|
3166
|
+
console.log("vxil dev --watch: applying on save (Ctrl-C to stop)\u2026");
|
|
3167
|
+
const apply = async () => {
|
|
3168
|
+
try {
|
|
3169
|
+
await runApply(true, which);
|
|
3170
|
+
} catch (e) {
|
|
3171
|
+
console.error(" apply error:", e.message.split("\n")[0]);
|
|
3172
|
+
}
|
|
3173
|
+
};
|
|
3174
|
+
await apply();
|
|
3175
|
+
let timer = null;
|
|
3176
|
+
const trigger = () => {
|
|
3177
|
+
if (timer) clearTimeout(timer);
|
|
3178
|
+
timer = setTimeout(() => {
|
|
3179
|
+
const now = (/* @__PURE__ */ new Date()).toTimeString().slice(0, 8);
|
|
3180
|
+
console.log(`
|
|
3181
|
+
[${now}] change detected \u2014 applying\u2026`);
|
|
3182
|
+
void apply();
|
|
3183
|
+
}, 300);
|
|
3184
|
+
};
|
|
3185
|
+
const watchers = [];
|
|
3186
|
+
const cfgFile = CONFIG_FILENAMES.map((n) => resolve4(cwd, n)).find((f) => existsSync3(f));
|
|
3187
|
+
if (cfgFile) watchers.push(watch(cfgFile, trigger));
|
|
3188
|
+
for (const dir of ["functions", "cms"]) {
|
|
3189
|
+
const d = resolve4(cwd, dir);
|
|
3190
|
+
if (existsSync3(d)) watchers.push(watch(d, trigger));
|
|
3191
|
+
}
|
|
3192
|
+
process.on("SIGINT", () => {
|
|
3193
|
+
for (const w of watchers) w.close();
|
|
3194
|
+
console.log("\nvxil dev --watch stopped.");
|
|
3195
|
+
process.exit(0);
|
|
3196
|
+
});
|
|
3197
|
+
await new Promise(() => {
|
|
3198
|
+
});
|
|
3199
|
+
}
|
|
3200
|
+
function mockFeatures(cfg) {
|
|
3201
|
+
return Object.entries(cfg.features).filter(([, conf]) => {
|
|
3202
|
+
const c = conf;
|
|
3203
|
+
return c.provider === "mock" || c.defaultProvider === "mock" || c.embed?.provider === "mock";
|
|
3204
|
+
}).map(([f]) => f);
|
|
3205
|
+
}
|
|
3206
|
+
async function prodPromotionGate() {
|
|
3207
|
+
const cfg = await loadVxilConfig();
|
|
3208
|
+
const mocks = mockFeatures(cfg);
|
|
3209
|
+
if (mocks.length && !hasFlag("allow-mock-in-prod")) {
|
|
3210
|
+
fail(`refusing to push to PROD: mock providers on [${mocks.join(", ")}] would ship to production (mock email/payments silently no-op). Set a real provider, or override with --allow-mock-in-prod.`);
|
|
3211
|
+
}
|
|
3212
|
+
if (!hasFlag("yes")) {
|
|
3213
|
+
const proj = loadProject();
|
|
3214
|
+
const ans = await prompt(`push to PROD tenant '${proj?.slug ?? "(linked)"}'? type the slug (or "yes") to confirm: `);
|
|
3215
|
+
if (ans !== proj?.slug && ans !== "yes") fail("confirmation did not match \u2014 aborted.");
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
async function runDoctor() {
|
|
3219
|
+
const checks = [];
|
|
3220
|
+
let cfg = null;
|
|
3221
|
+
try {
|
|
3222
|
+
cfg = await loadVxilConfig();
|
|
3223
|
+
checks.push({ name: "config loads", ok: true, detail: `${Object.keys(cfg.features).length} features \xB7 ${Object.keys(cfg.cms?.collections ?? {}).length} collections \xB7 ${Object.keys(cfg.functions ?? {}).length} functions` });
|
|
3224
|
+
} catch (e) {
|
|
3225
|
+
checks.push({ name: "config loads", ok: false, detail: e.message.split("\n")[0] ?? "load failed" });
|
|
3226
|
+
}
|
|
3227
|
+
const t = resolve_target({ defaultBase: DEFAULT_BASE });
|
|
3228
|
+
checks.push({ name: "tenant binding", ok: !!t.apiKey, detail: t.apiKey ? `key resolved (${t.tenantSlug ?? "env"}) \xB7 edge ${t.baseUrl}` : "no key \u2014 run `vxil quickstart` or `vxil link <slug> --key <k>`" });
|
|
3229
|
+
if (t.apiKey) {
|
|
3230
|
+
const api = makeApi({ apiKey: t.apiKey, baseUrl: t.baseUrl });
|
|
3231
|
+
const fres = await api("GET", "/v1/features");
|
|
3232
|
+
if (fres.status === 200) {
|
|
3233
|
+
const live = new Set(fres.body.data?.features ?? []);
|
|
3234
|
+
checks.push({ name: "edge reachable", ok: true, detail: `${live.size} features enabled live` });
|
|
3235
|
+
if (cfg) {
|
|
3236
|
+
const declared = Object.keys(cfg.features).filter((f) => f !== "functions");
|
|
3237
|
+
const missing = declared.filter((f) => !live.has(f));
|
|
3238
|
+
checks.push({ name: "config\u2194live parity", ok: missing.length === 0, detail: missing.length ? `declared but not live: ${missing.join(", ")} (run \`vxil push\`)` : "in sync" });
|
|
3239
|
+
}
|
|
3240
|
+
} else {
|
|
3241
|
+
checks.push({ name: "edge reachable", ok: false, detail: `GET /v1/features \u2192 ${fres.status}` });
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
if (cfg?.functions) {
|
|
3245
|
+
for (const [n, def] of Object.entries(cfg.functions)) {
|
|
3246
|
+
const ok = existsSync3(resolve4(process.cwd(), def.entry));
|
|
3247
|
+
checks.push({ name: `function '${n}' entry`, ok, detail: ok ? def.entry : `missing file: ${def.entry}` });
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
if (cfg) {
|
|
3251
|
+
const mocks = mockFeatures(cfg);
|
|
3252
|
+
if (mocks.length) checks.push({ name: "mock providers", ok: true, detail: `mock on [${mocks.join(", ")}] \u2014 fine for dev; \`push --prod\` blocks these` });
|
|
3253
|
+
}
|
|
3254
|
+
if (jsonOut) {
|
|
3255
|
+
console.log(JSON.stringify({ ok: checks.every((c) => c.ok), checks }));
|
|
3256
|
+
} else {
|
|
3257
|
+
for (const c of checks) console.log(` ${c.ok ? "\u2713" : "\u2717"} ${c.name}: ${c.detail}`);
|
|
3258
|
+
}
|
|
3259
|
+
if (!checks.every((c) => c.ok)) process.exit(2);
|
|
3260
|
+
}
|
|
3261
|
+
var STARTER_FN = `// A vxil function \u2014 a Web-standard fetch handler. It receives the invocation
|
|
3262
|
+
// envelope { tenant_id, vxil_base, scoped_jwts, secrets, payload } and may call your
|
|
3263
|
+
// enabled features with the per-feature scoped tokens.
|
|
3264
|
+
export default {
|
|
3265
|
+
async fetch(req: Request): Promise<Response> {
|
|
3266
|
+
const env = await req.json().catch(() => ({})) as { payload?: { name?: string } };
|
|
3267
|
+
return Response.json({ ok: true, hello: env.payload?.name ?? 'world' });
|
|
3268
|
+
},
|
|
3269
|
+
};
|
|
3270
|
+
`;
|
|
3271
|
+
var TRY_CONFIG = `import { defineConfig } from '@vxil/config';
|
|
3272
|
+
|
|
3273
|
+
// Scaffolded by \`vxil try\` \u2014 the keyless sandbox starter (cms + comments +
|
|
3274
|
+
// rate-limits, zero BYO keys). This backend self-expires in ~2h; run
|
|
3275
|
+
// \`vxil push\` to materialize the collections server-side.
|
|
3276
|
+
export default defineConfig({
|
|
3277
|
+
env: 'staging',
|
|
3278
|
+
features: {
|
|
3279
|
+
cms: { draftPublish: true },
|
|
3280
|
+
comments: {},
|
|
3281
|
+
'rate-limits': {},
|
|
3282
|
+
},
|
|
3283
|
+
cms: {
|
|
3284
|
+
collections: {
|
|
3285
|
+
tasks: {
|
|
3286
|
+
singular: 'task',
|
|
3287
|
+
fields: {
|
|
3288
|
+
title: { type: 'string', required: true, indexSlot: 's1' },
|
|
3289
|
+
done: { type: 'bool' },
|
|
3290
|
+
},
|
|
3291
|
+
},
|
|
3292
|
+
},
|
|
3293
|
+
},
|
|
3294
|
+
});
|
|
3295
|
+
`;
|
|
3296
|
+
function scaffoldProject(configSrc) {
|
|
3297
|
+
const cwd = process.cwd();
|
|
3298
|
+
writeFileSync3(resolve4(cwd, "vxil.config.ts"), configSrc);
|
|
3299
|
+
mkdirSync2(resolve4(cwd, "functions"), { recursive: true });
|
|
3300
|
+
if (!existsSync3(resolve4(cwd, "functions/hello.ts"))) writeFileSync3(resolve4(cwd, "functions/hello.ts"), STARTER_FN);
|
|
3301
|
+
mkdirSync2(resolve4(cwd, ".vxil"), { recursive: true });
|
|
3302
|
+
const gi = resolve4(cwd, ".gitignore");
|
|
3303
|
+
const ignore = ["", "# vxil", ".vxil/", ".env.local", ""].join("\n");
|
|
3304
|
+
if (!existsSync3(gi) || !readFileSync2(gi, "utf8").includes(".vxil/")) appendFileSync(gi, ignore);
|
|
3305
|
+
}
|
|
3306
|
+
function initProject() {
|
|
3307
|
+
const cwd = process.cwd();
|
|
3308
|
+
const cfgPath = resolve4(cwd, "vxil.config.ts");
|
|
3309
|
+
if (existsSync3(cfgPath) && !hasFlag("force")) fail("vxil.config.ts already exists (use --force to overwrite)");
|
|
3310
|
+
const template = flag("template") ?? "tasks";
|
|
3311
|
+
const tpl = TEMPLATE_CATALOG.find((t) => t.id === template);
|
|
3312
|
+
if (!tpl) fail(`unknown template '${template}' \u2014 run \`vxil templates\` to list the gallery, or choose: ${TEMPLATE_CATALOG.map((t) => t.id).join(", ")}`);
|
|
3313
|
+
scaffoldProject(tpl.configSrc);
|
|
3314
|
+
console.log(`scaffolded from template '${tpl.id}' (${tpl.title}):`);
|
|
3315
|
+
console.log(` vxil.config.ts # ${tpl.summary}`);
|
|
3316
|
+
console.log(` # collections: ${tpl.collections.join(", ")} \xB7 features: ${tpl.features.join(", ")}`);
|
|
3317
|
+
console.log(" functions/hello.ts # a starter function (\xA77.3)");
|
|
3318
|
+
console.log(" .vxil/ # project binding (gitignored)");
|
|
3319
|
+
console.log("\nnext: `vxil quickstart` (new tenant) or `vxil link <slug>` (existing), then `vxil push`.");
|
|
3320
|
+
}
|
|
3321
|
+
function listTemplates() {
|
|
3322
|
+
console.log("vxil templates \u2014 the Blueprint Gallery. Scaffold one with `vxil init --template <id>`.\n");
|
|
3323
|
+
const pad = Math.max(...TEMPLATE_CATALOG.map((t) => t.id.length));
|
|
3324
|
+
for (const t of TEMPLATE_CATALOG) {
|
|
3325
|
+
const fns = t.hasFunctions ? " \xB7 +functions" : "";
|
|
3326
|
+
console.log(` ${t.id.padEnd(pad)} ${t.title} [${t.vertical}${fns}]`);
|
|
3327
|
+
console.log(` ${" ".repeat(pad)} ${t.summary}`);
|
|
3328
|
+
}
|
|
3329
|
+
console.log("\nEach template is a typed vxil.config.ts you clone and OWN \u2014 no platform lock over the shape.");
|
|
3330
|
+
}
|
|
3331
|
+
try {
|
|
3332
|
+
switch (cmd) {
|
|
3333
|
+
case "init": {
|
|
3334
|
+
initProject();
|
|
3335
|
+
break;
|
|
3336
|
+
}
|
|
3337
|
+
case "templates": {
|
|
3338
|
+
listTemplates();
|
|
3339
|
+
break;
|
|
3340
|
+
}
|
|
3341
|
+
case "quickstart": {
|
|
3342
|
+
const base = process.env.VXIL_BASE_URL ?? DEFAULT_BASE;
|
|
3343
|
+
const dash = dashboardBase(base);
|
|
3344
|
+
const email = flag("email") ?? await prompt("email: ");
|
|
3345
|
+
const password = flag("password") ?? await prompt("password (>=10 chars): ", { hidden: true });
|
|
3346
|
+
const featuresArg = flag("features") ?? "cms,notifications";
|
|
3347
|
+
const appName = flag("name") ?? "vxil-app";
|
|
3348
|
+
const body = {
|
|
3349
|
+
email,
|
|
3350
|
+
password,
|
|
3351
|
+
app_name: appName,
|
|
3352
|
+
features: featuresArg.split(",").map((s) => s.trim()).filter(Boolean),
|
|
3353
|
+
// --dev mints an ephemeral preview tenant the server-side TTL reaper
|
|
3354
|
+
// tears down after --ttl hours (default 72) — the per-PR CI backend.
|
|
3355
|
+
...hasFlag("dev") ? { kind: "dev", ttl_hours: Number(flag("ttl") ?? 72) } : {},
|
|
3356
|
+
// --invite: required for ACCOUNT CREATION while the backend runs the
|
|
3357
|
+
// invite-only launch gate (existing accounts sign in ungated).
|
|
3358
|
+
...flag("invite") ? { invite_code: flag("invite") } : {}
|
|
3359
|
+
};
|
|
3360
|
+
const r = await dashFetch(dash, "POST", "/dashboard/quickstart", body);
|
|
3361
|
+
if (r.status !== 201 && r.status !== 200) {
|
|
3362
|
+
const e = r.json.error ?? {};
|
|
3363
|
+
if (e.code === "invite_required") {
|
|
3364
|
+
fail("quickstart: this backend is invite-only for NEW accounts \u2014 re-run with `--invite <code>` (existing accounts pass with just email+password).");
|
|
3365
|
+
}
|
|
3366
|
+
if (e.code === "invite_invalid") {
|
|
3367
|
+
fail("quickstart: that invite code is invalid or already used \u2014 each code works once; ask for a fresh one.");
|
|
3368
|
+
}
|
|
3369
|
+
fail(`quickstart failed: ${e.code ?? r.status} ${e.message ?? JSON.stringify(r.json).slice(0, 200)}`);
|
|
3370
|
+
}
|
|
3371
|
+
const d = r.json.data ?? r.json;
|
|
3372
|
+
if (!d.api_key || !d.tenant_id || !d.app_slug) fail("quickstart returned no api_key/tenant");
|
|
3373
|
+
const creds = loadCredentials();
|
|
3374
|
+
creds.keys = { ...creds.keys, [d.app_slug]: d.api_key };
|
|
3375
|
+
saveCredentials(creds);
|
|
3376
|
+
saveProject({ tenant_id: d.tenant_id, slug: d.app_slug, edge_url: d.edge_url ?? base, env: flag("env") ?? "staging" });
|
|
3377
|
+
if (!existsSync3(resolve4(process.cwd(), "vxil.config.ts"))) initProject();
|
|
3378
|
+
console.log(`
|
|
3379
|
+
\u2713 tenant '${d.app_slug}' created \xB7 enabled: ${(d.enabled ?? []).join(", ")}`);
|
|
3380
|
+
if (d.expires_at) console.log(` dev tenant \u2014 expires ${d.expires_at} (the nightly reaper tears it down; extend by recreating)`);
|
|
3381
|
+
if (!hasFlag("no-push")) {
|
|
3382
|
+
console.log("\napplying your config (cms schema \u2192 config \u2192 functions) + generating types\u2026");
|
|
3383
|
+
await runApply(true);
|
|
3384
|
+
}
|
|
3385
|
+
let firstCollection = "your-collection";
|
|
3386
|
+
try {
|
|
3387
|
+
const c = await loadVxilConfig();
|
|
3388
|
+
firstCollection = Object.keys(c.cms?.collections ?? {})[0] ?? firstCollection;
|
|
3389
|
+
} catch {
|
|
3390
|
+
}
|
|
3391
|
+
const spaOrigin = (() => {
|
|
3392
|
+
try {
|
|
3393
|
+
return d.sdk_url ? new URL(d.sdk_url).origin : base;
|
|
3394
|
+
} catch {
|
|
3395
|
+
return base;
|
|
3396
|
+
}
|
|
3397
|
+
})();
|
|
3398
|
+
console.log(`
|
|
3399
|
+
\u2728 your backend is live`);
|
|
3400
|
+
console.log(` dashboard: ${spaOrigin}/dashboard`);
|
|
3401
|
+
console.log(` api: ${d.edge_url ?? base}`);
|
|
3402
|
+
console.log(` types: ./vxil.types.ts (generated \u2014 re-run \`vxil gen\` after a schema change)`);
|
|
3403
|
+
console.log(` try it:`);
|
|
3404
|
+
console.log(` import { Vxil } from '@vxil/sdk';`);
|
|
3405
|
+
console.log(` import type { VxilSchema } from './vxil.types';`);
|
|
3406
|
+
console.log(` const vx = Vxil.connect<VxilSchema>({ apiKey }); // narrows to YOUR enabled features`);
|
|
3407
|
+
console.log(` await vx.from('${firstCollection}').create({ /* field-typed */ });`);
|
|
3408
|
+
console.log(`
|
|
3409
|
+
key stored in ~/.vxil/credentials.json \xB7 project bound in .vxil/project.json`);
|
|
3410
|
+
break;
|
|
3411
|
+
}
|
|
3412
|
+
case "try": {
|
|
3413
|
+
const base = process.env.VXIL_BASE_URL ?? DEFAULT_BASE;
|
|
3414
|
+
const tfetch = async (url, reqBody) => {
|
|
3415
|
+
const res = await fetch(url, {
|
|
3416
|
+
method: "POST",
|
|
3417
|
+
headers: { "content-type": "application/json", "x-vxil-surface": "cli" },
|
|
3418
|
+
body: JSON.stringify(reqBody)
|
|
3419
|
+
});
|
|
3420
|
+
const text = await res.text();
|
|
3421
|
+
let json2 = {};
|
|
3422
|
+
try {
|
|
3423
|
+
json2 = JSON.parse(text);
|
|
3424
|
+
} catch {
|
|
3425
|
+
json2 = { raw: text.slice(0, 300) };
|
|
3426
|
+
}
|
|
3427
|
+
return { status: res.status, json: json2 };
|
|
3428
|
+
};
|
|
3429
|
+
const turnstile = flag("turnstile");
|
|
3430
|
+
const outcome = await mintTry(tfetch, base, turnstile ? { turnstileToken: turnstile } : {});
|
|
3431
|
+
if (!outcome.ok) fail(`vxil try: ${outcome.message}`);
|
|
3432
|
+
const { result } = outcome;
|
|
3433
|
+
const creds = loadCredentials();
|
|
3434
|
+
creds.keys = { ...creds.keys, [result.slug]: result.api_key };
|
|
3435
|
+
saveCredentials(creds);
|
|
3436
|
+
saveProject({
|
|
3437
|
+
tenant_id: result.tenant_id,
|
|
3438
|
+
slug: result.slug,
|
|
3439
|
+
edge_url: base,
|
|
3440
|
+
env: flag("env") ?? "staging",
|
|
3441
|
+
ephemeral: true,
|
|
3442
|
+
...result.expires_at ? { expires_at: result.expires_at } : {}
|
|
3443
|
+
});
|
|
3444
|
+
if (!existsSync3(resolve4(process.cwd(), "vxil.config.ts"))) scaffoldProject(TRY_CONFIG);
|
|
3445
|
+
let genOk = true;
|
|
3446
|
+
try {
|
|
3447
|
+
await runGen("prod", { offline: true });
|
|
3448
|
+
} catch (e) {
|
|
3449
|
+
genOk = false;
|
|
3450
|
+
console.log(` (typed-client generation skipped: ${String(e.message).split("\n")[0]})`);
|
|
3451
|
+
}
|
|
3452
|
+
let firstCollection = "your-collection";
|
|
3453
|
+
try {
|
|
3454
|
+
const c = await loadVxilConfig();
|
|
3455
|
+
firstCollection = Object.keys(c.cms?.collections ?? {})[0] ?? firstCollection;
|
|
3456
|
+
} catch {
|
|
3457
|
+
}
|
|
3458
|
+
for (const l of renderTryReport(result, { base, collection: firstCollection, genOk })) console.log(l);
|
|
3459
|
+
break;
|
|
3460
|
+
}
|
|
3461
|
+
case "login": {
|
|
3462
|
+
const base = process.env.VXIL_BASE_URL ?? DEFAULT_BASE;
|
|
3463
|
+
const dash = dashboardBase(base);
|
|
3464
|
+
const email = flag("email") ?? await prompt("email: ");
|
|
3465
|
+
const password = flag("password") ?? await prompt("password: ", { hidden: true });
|
|
3466
|
+
const r = await dashFetch(dash, "POST", "/dashboard/login", { email, password });
|
|
3467
|
+
if (r.status !== 200) fail(`login failed: ${r.status} ${JSON.stringify(r.json).slice(0, 160)}`);
|
|
3468
|
+
const cookie = r.cookie?.split(";")[0];
|
|
3469
|
+
if (!cookie) fail("login succeeded but returned no session cookie");
|
|
3470
|
+
const creds = loadCredentials();
|
|
3471
|
+
creds.session = cookie;
|
|
3472
|
+
saveCredentials(creds);
|
|
3473
|
+
console.log("\u2713 logged in \u2014 session stored in ~/.vxil/credentials.json");
|
|
3474
|
+
break;
|
|
3475
|
+
}
|
|
3476
|
+
case "link": {
|
|
3477
|
+
const slug = positional(0)[0];
|
|
3478
|
+
if (!slug) fail("usage: vxil link <slug> [--key <api_key>] [--tenant <id>]");
|
|
3479
|
+
const base = process.env.VXIL_BASE_URL ?? DEFAULT_BASE;
|
|
3480
|
+
const directKey = flag("key");
|
|
3481
|
+
if (directKey) {
|
|
3482
|
+
const tenantId = flag("tenant") ?? "(unknown)";
|
|
3483
|
+
const creds2 = loadCredentials();
|
|
3484
|
+
creds2.keys = { ...creds2.keys, [slug]: directKey };
|
|
3485
|
+
saveCredentials(creds2);
|
|
3486
|
+
saveProject({ tenant_id: tenantId, slug, edge_url: base, env: flag("env") ?? "staging" });
|
|
3487
|
+
console.log(`\u2713 linked '${slug}' (using provided --key)`);
|
|
3488
|
+
break;
|
|
3489
|
+
}
|
|
3490
|
+
const dash = makeDash(dashboardBase(base));
|
|
3491
|
+
const creds = loadCredentials();
|
|
3492
|
+
const keyName = `cli-${hostname()}`.slice(0, 100);
|
|
3493
|
+
if (creds.session) {
|
|
3494
|
+
const r = await linkViaSession(dash, creds.session, slug, keyName);
|
|
3495
|
+
if ("result" in r) {
|
|
3496
|
+
persistLink(r.result.slug, r.result.tenant_id, r.result.api_key, base);
|
|
3497
|
+
console.log(`\u2713 linked '${slug}' (minted a key via your dashboard session)`);
|
|
3498
|
+
break;
|
|
3499
|
+
}
|
|
3500
|
+
console.error("session expired \u2014 falling back to the browser link flow\u2026");
|
|
3501
|
+
}
|
|
3502
|
+
if (!process.stdout.isTTY && !process.stdin.isTTY) {
|
|
3503
|
+
fail("link without --key needs an interactive terminal (browser approval) or a `vxil login` session; in CI pass --key <api_key> (or set VXIL_API_KEY)");
|
|
3504
|
+
}
|
|
3505
|
+
{
|
|
3506
|
+
const linked = await linkViaDeviceFlow(dash, slug, {
|
|
3507
|
+
log: (l) => console.log(l),
|
|
3508
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms))
|
|
3509
|
+
});
|
|
3510
|
+
persistLink(linked.slug, linked.tenant_id, linked.api_key, base);
|
|
3511
|
+
console.log(`
|
|
3512
|
+
\u2713 linked '${linked.slug}' (approved in the browser)`);
|
|
3513
|
+
}
|
|
3514
|
+
break;
|
|
3515
|
+
}
|
|
3516
|
+
case "plan":
|
|
3517
|
+
case "diff": {
|
|
3518
|
+
await runApply(false);
|
|
3519
|
+
break;
|
|
3520
|
+
}
|
|
3521
|
+
case "push": {
|
|
3522
|
+
const dev = hasFlag("dev");
|
|
3523
|
+
if (hasFlag("prod") && !dev) await prodPromotionGate();
|
|
3524
|
+
if (hasFlag("server") || flag("resume") !== void 0) {
|
|
3525
|
+
await runServerApply(dev ? "dev" : "prod");
|
|
3526
|
+
break;
|
|
3527
|
+
}
|
|
3528
|
+
await runApply(true, dev ? "dev" : "prod");
|
|
3529
|
+
break;
|
|
3530
|
+
}
|
|
3531
|
+
case "gen":
|
|
3532
|
+
case "types": {
|
|
3533
|
+
await runGen();
|
|
3534
|
+
break;
|
|
3535
|
+
}
|
|
3536
|
+
case "doctor": {
|
|
3537
|
+
await runDoctor();
|
|
3538
|
+
break;
|
|
3539
|
+
}
|
|
3540
|
+
case "versions": {
|
|
3541
|
+
const feature = positional(0)[0];
|
|
3542
|
+
if (!feature) fail("usage: vxil versions <feature>");
|
|
3543
|
+
const { api } = requireApi();
|
|
3544
|
+
printEnvelope(await api("GET", `/v1/config/${feature}/versions`));
|
|
3545
|
+
break;
|
|
3546
|
+
}
|
|
3547
|
+
case "rollback": {
|
|
3548
|
+
const feature = positional(0)[0];
|
|
3549
|
+
const to = flag("to");
|
|
3550
|
+
if (!feature || !to) fail("usage: vxil rollback <feature> --to <version>");
|
|
3551
|
+
const { api } = requireApi();
|
|
3552
|
+
const res = await api("POST", `/v1/config/${feature}/versions/${to}/rollback`);
|
|
3553
|
+
if (res.body.error) printEnvelope(res);
|
|
3554
|
+
else console.log(`\u2713 rolled ${feature} back to v${to} (now v${res.body.data?.version ?? "?"}). Re-run \`vxil gen\` if the schema changed.`);
|
|
3555
|
+
break;
|
|
3556
|
+
}
|
|
3557
|
+
case "pull": {
|
|
3558
|
+
const { api } = requireApi();
|
|
3559
|
+
const fres = await api("GET", "/v1/features");
|
|
3560
|
+
const features = fres.body.data?.features ?? [];
|
|
3561
|
+
const featBlocks = {};
|
|
3562
|
+
for (const f of features) {
|
|
3563
|
+
const c = await api("GET", `/v1/config/${f}`);
|
|
3564
|
+
if (c.status === 200) featBlocks[f] = c.body.data?.manifest ?? {};
|
|
3565
|
+
}
|
|
3566
|
+
const cres = await api("GET", "/v1/cms/collections");
|
|
3567
|
+
const collections = cres.body.data?.collections ?? [];
|
|
3568
|
+
const cmsBlock = {};
|
|
3569
|
+
for (const col of collections) {
|
|
3570
|
+
const fields = {};
|
|
3571
|
+
for (const fd of col.fields) {
|
|
3572
|
+
const d = { type: fd.type };
|
|
3573
|
+
if (fd.required) d.required = true;
|
|
3574
|
+
if (fd.index_slot) d.indexSlot = fd.index_slot;
|
|
3575
|
+
if (fd.relation_to) d.relationTo = fd.relation_to;
|
|
3576
|
+
fields[fd.field] = d;
|
|
3577
|
+
}
|
|
3578
|
+
cmsBlock[col.collection] = { ...col.singular ? { singular: col.singular } : {}, fields };
|
|
3579
|
+
}
|
|
3580
|
+
const src = `import { defineConfig } from '@vxil/config';
|
|
3581
|
+
|
|
3582
|
+
// Materialized from the live tenant by \`vxil pull\` on ${(/* @__PURE__ */ new Date()).toISOString()}.
|
|
3583
|
+
export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { collections: cmsBlock } }, null, 2)});
|
|
3584
|
+
`;
|
|
3585
|
+
const out = flag("out") ?? "vxil.config.ts";
|
|
3586
|
+
if (existsSync3(resolve4(process.cwd(), out)) && !hasFlag("force")) fail(`${out} exists (use --force or --out <file>)`);
|
|
3587
|
+
writeFileSync3(resolve4(process.cwd(), out), src);
|
|
3588
|
+
console.log(`vxil pull \u2192 ${out} (${features.length} features \xB7 ${collections.length} collections)`);
|
|
3589
|
+
break;
|
|
3590
|
+
}
|
|
3591
|
+
case "env": {
|
|
3592
|
+
const [sub] = positional(0);
|
|
3593
|
+
if (sub !== "pull") fail("usage: vxil env pull [--dev] [--file <path=.env.local>] [--print] [--no-gitignore]");
|
|
3594
|
+
runEnvPull(hasFlag("dev") ? "dev" : "prod");
|
|
3595
|
+
break;
|
|
3596
|
+
}
|
|
3597
|
+
case "secrets": {
|
|
3598
|
+
const [sub] = positional(0);
|
|
3599
|
+
const { api } = requireApi();
|
|
3600
|
+
if (sub === "set") {
|
|
3601
|
+
const ref = positional(1)[0];
|
|
3602
|
+
if (!ref) fail("usage: vxil secrets set <feature>/<name> [--value <v>] (or set from vxil.config secrets)");
|
|
3603
|
+
const [feature, name] = ref.includes("/") ? ref.split("/") : [void 0, ref];
|
|
3604
|
+
let f = feature;
|
|
3605
|
+
let n = name;
|
|
3606
|
+
if (!feature) {
|
|
3607
|
+
const cfg = await loadVxilConfig();
|
|
3608
|
+
const decl = cfg.secrets?.[ref];
|
|
3609
|
+
if (!decl) fail(`secret '${ref}' not declared in vxil.config (use <feature>/<name>)`);
|
|
3610
|
+
f = decl.feature;
|
|
3611
|
+
n = ref;
|
|
3612
|
+
}
|
|
3613
|
+
const value = flag("value") ?? await prompt(`value for ${f}/${n}: `, { hidden: true });
|
|
3614
|
+
const res = await api("PUT", `/v1/secrets/${f}/${n}`, { value });
|
|
3615
|
+
if (res.status !== 200) printEnvelope(res);
|
|
3616
|
+
else {
|
|
3617
|
+
console.log(`\u2713 secret ${f}/${n} set (ref: secret:${n})`);
|
|
3618
|
+
const co = res.body.data?.co_rotate;
|
|
3619
|
+
if (co?.message) console.log(`note: ${co.message}`);
|
|
3620
|
+
}
|
|
3621
|
+
} else if (sub === "list") {
|
|
3622
|
+
const feature = flag("feature");
|
|
3623
|
+
const res = await api("GET", `/v1/secrets${feature ? `?feature=${encodeURIComponent(feature)}` : ""}`);
|
|
3624
|
+
if (res.body.error) printEnvelope(res);
|
|
3625
|
+
else if (jsonOut) console.log(JSON.stringify(res.body.data, null, 2));
|
|
3626
|
+
else {
|
|
3627
|
+
const rows = res.body.data?.secrets ?? [];
|
|
3628
|
+
if (!rows.length) console.log("(no secrets stored)");
|
|
3629
|
+
for (const r of rows) {
|
|
3630
|
+
console.log(` ${r.feature}/${r.secret_name} (ref secret:${r.secret_name} \xB7 rotated ${r.rotated_at})`);
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
} else if (sub === "rm") {
|
|
3634
|
+
const ref = positional(1)[0];
|
|
3635
|
+
if (!ref?.includes("/")) fail("usage: vxil secrets rm <feature>/<name> [--yes]");
|
|
3636
|
+
const [feature, name] = ref.split("/");
|
|
3637
|
+
if (!hasFlag("yes")) {
|
|
3638
|
+
const ans = await prompt(`delete secret ${ref}? type the name to confirm: `);
|
|
3639
|
+
if (ans !== name) fail("confirmation did not match \u2014 aborted.");
|
|
3640
|
+
}
|
|
3641
|
+
const res = await api("DELETE", `/v1/secrets/${encodeURIComponent(feature)}/${encodeURIComponent(name)}`);
|
|
3642
|
+
if (res.body.error) printEnvelope(res);
|
|
3643
|
+
else console.log(`\u2713 secret ${ref} deleted \u2014 configs still holding ref secret:${name} will fail until re-set`);
|
|
3644
|
+
} else {
|
|
3645
|
+
fail("usage: vxil secrets set|list|rm");
|
|
3646
|
+
}
|
|
3647
|
+
break;
|
|
3648
|
+
}
|
|
3649
|
+
case "seed": {
|
|
3650
|
+
await runSeed(hasFlag("dev") ? "dev" : "prod");
|
|
3651
|
+
break;
|
|
3652
|
+
}
|
|
3653
|
+
case "export": {
|
|
3654
|
+
const feature = positional(0)[0];
|
|
3655
|
+
if (!feature) fail("usage: vxil export <feature> [--out <file>] [--ndjson]");
|
|
3656
|
+
if (hasFlag("ndjson")) {
|
|
3657
|
+
await exportNdjson(feature);
|
|
3658
|
+
break;
|
|
3659
|
+
}
|
|
3660
|
+
const { api } = requireApi();
|
|
3661
|
+
const res = await api("GET", `/v1/export/${feature}`);
|
|
3662
|
+
if (res.body.error) printEnvelope(res);
|
|
3663
|
+
const out = flag("out");
|
|
3664
|
+
const json2 = JSON.stringify(res.body.data ?? res.body, null, 2);
|
|
3665
|
+
if (out) {
|
|
3666
|
+
writeFileSync3(resolve4(process.cwd(), out), json2);
|
|
3667
|
+
console.log(`exported ${feature} \u2192 ${out}`);
|
|
3668
|
+
} else console.log(json2);
|
|
3669
|
+
break;
|
|
3670
|
+
}
|
|
3671
|
+
case "import": {
|
|
3672
|
+
const feature = positional(0)[0];
|
|
3673
|
+
const file = flag("file");
|
|
3674
|
+
if (!feature || !file) fail("usage: vxil import <feature> --file <bundle.json> [--mode merge|replace]");
|
|
3675
|
+
const { api } = requireApi(hasFlag("dev") ? "dev" : "prod");
|
|
3676
|
+
const bundle = JSON.parse(readFileSync2(resolve4(process.cwd(), file), "utf8"));
|
|
3677
|
+
printEnvelope(await api("POST", `/v1/import/${feature}`, { bundle, mode: flag("mode") ?? "merge" }));
|
|
3678
|
+
break;
|
|
3679
|
+
}
|
|
3680
|
+
case "functions": {
|
|
3681
|
+
const [sub, name] = positional(0);
|
|
3682
|
+
if (sub === "new") {
|
|
3683
|
+
if (!name) fail("usage: vxil functions new <name>");
|
|
3684
|
+
mkdirSync2(resolve4(process.cwd(), "functions"), { recursive: true });
|
|
3685
|
+
const p = resolve4(process.cwd(), `functions/${name}.ts`);
|
|
3686
|
+
if (existsSync3(p) && !hasFlag("force")) fail(`functions/${name}.ts exists`);
|
|
3687
|
+
writeFileSync3(p, STARTER_FN);
|
|
3688
|
+
console.log(`scaffolded functions/${name}.ts \u2014 declare it in vxil.config.ts \`functions\` and \`vxil push\``);
|
|
3689
|
+
} else if (sub === "list") {
|
|
3690
|
+
const { api } = requireApi();
|
|
3691
|
+
printEnvelope(await api("GET", "/v1/functions"));
|
|
3692
|
+
} else if (sub === "delete") {
|
|
3693
|
+
if (!name) fail("usage: vxil functions delete <name>");
|
|
3694
|
+
const { api } = requireApi();
|
|
3695
|
+
printEnvelope(await api("DELETE", `/v1/functions/${encodeURIComponent(name)}`));
|
|
3696
|
+
} else if (sub === "deploy") {
|
|
3697
|
+
const { api } = requireApi();
|
|
3698
|
+
const cfg = await loadVxilConfig();
|
|
3699
|
+
const fns = cfg.functions ?? {};
|
|
3700
|
+
const subset = name ? { [name]: fns[name] } : fns;
|
|
3701
|
+
if (name && !fns[name]) fail(`function '${name}' not declared in vxil.config`);
|
|
3702
|
+
const r = await planFunctions({ api, functions: subset, cwd: process.cwd(), apply: true });
|
|
3703
|
+
console.log(formatFnChanges(r.changes));
|
|
3704
|
+
console.log(`\u2713 deployed ${r.applied} function(s)`);
|
|
3705
|
+
} else if (sub === "logs") {
|
|
3706
|
+
if (!name) fail("usage: vxil functions logs <name> [--since <iso>] [--tail]");
|
|
3707
|
+
const { api } = requireApi(hasFlag("dev") ? "dev" : "prod");
|
|
3708
|
+
let since = flag("since");
|
|
3709
|
+
const fetchPage = async () => {
|
|
3710
|
+
const qs = since ? `?since=${encodeURIComponent(since)}` : "";
|
|
3711
|
+
const res = await api("GET", `/v1/functions/${encodeURIComponent(name)}/logs${qs}`);
|
|
3712
|
+
if (res.body.error) printEnvelope(res);
|
|
3713
|
+
const data2 = res.body.data;
|
|
3714
|
+
if (data2.lines.length) console.log(formatLogLines(data2.lines));
|
|
3715
|
+
if (data2.next_since) since = data2.next_since;
|
|
3716
|
+
};
|
|
3717
|
+
await fetchPage();
|
|
3718
|
+
if (hasFlag("tail")) {
|
|
3719
|
+
console.error(`tailing '${name}' (Ctrl-C to stop)\u2026`);
|
|
3720
|
+
while (true) {
|
|
3721
|
+
await new Promise((r) => setTimeout(r, 3e3));
|
|
3722
|
+
await fetchPage();
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
} else if (sub === "invoke") {
|
|
3726
|
+
if (!name) fail("usage: vxil functions invoke <name> [--data '<json>'] [--async]");
|
|
3727
|
+
const which = hasFlag("dev") ? "dev" : "prod";
|
|
3728
|
+
const { api, baseUrl } = requireApi(which);
|
|
3729
|
+
let payload = {};
|
|
3730
|
+
const raw = flag("data");
|
|
3731
|
+
if (raw !== void 0) {
|
|
3732
|
+
try {
|
|
3733
|
+
payload = JSON.parse(raw);
|
|
3734
|
+
} catch {
|
|
3735
|
+
fail("--data must be valid JSON");
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
if (hasFlag("async")) {
|
|
3739
|
+
const proj = loadProject();
|
|
3740
|
+
const tenantId = which === "dev" ? proj?.dev?.tenant_id : proj?.tenant_id;
|
|
3741
|
+
if (!tenantId) fail("`--async` needs a linked tenant (run `vxil link <slug>` or `vxil quickstart`)");
|
|
3742
|
+
printEnvelope(await api("POST", "/v1/jobs/enqueue", {
|
|
3743
|
+
job_name: `fn-queue:${name}`,
|
|
3744
|
+
target_url: `${baseUrl.replace(/\/$/, "")}/v1/internal/fn/trigger/${encodeURIComponent(name)}?tenant=${tenantId}`,
|
|
3745
|
+
payload: { trigger: "queue", payload },
|
|
3746
|
+
idempotency_key: `fnq:${name}:${Date.now()}`
|
|
3747
|
+
}));
|
|
3748
|
+
} else {
|
|
3749
|
+
const res = await api("POST", `/v1/fn/${encodeURIComponent(name)}`, payload);
|
|
3750
|
+
if (res.body.error) printEnvelope(res);
|
|
3751
|
+
else console.log(JSON.stringify(res.body.data ?? res.body, null, 2));
|
|
3752
|
+
}
|
|
3753
|
+
} else if (sub === "dev") {
|
|
3754
|
+
if (!name) fail("usage: vxil functions dev <name> [--port <n>]");
|
|
3755
|
+
await runFunctionsDev(name);
|
|
3756
|
+
} else {
|
|
3757
|
+
fail("usage: vxil functions new|deploy|list|delete|invoke|logs|dev <name>");
|
|
3758
|
+
}
|
|
3759
|
+
break;
|
|
3760
|
+
}
|
|
3761
|
+
case "cms": {
|
|
3762
|
+
const [sub] = positional(0);
|
|
3763
|
+
const { api } = requireApi();
|
|
3764
|
+
if (sub === "status") {
|
|
3765
|
+
printEnvelope(await api("GET", "/v1/cms/collections"));
|
|
3766
|
+
} else if (sub === "pull") {
|
|
3767
|
+
printEnvelope(await api("GET", "/v1/cms/collections"));
|
|
3768
|
+
} else {
|
|
3769
|
+
fail("usage: vxil cms status|pull");
|
|
3770
|
+
}
|
|
3771
|
+
break;
|
|
3772
|
+
}
|
|
3773
|
+
case "dev": {
|
|
3774
|
+
const [sub] = positional(0);
|
|
3775
|
+
if (hasFlag("watch") || sub === "watch") {
|
|
3776
|
+
await runWatch("dev");
|
|
3777
|
+
break;
|
|
3778
|
+
}
|
|
3779
|
+
await runDev(sub);
|
|
3780
|
+
break;
|
|
3781
|
+
}
|
|
3782
|
+
// ── existing day-to-day helpers (unchanged) ──────────────────────────────
|
|
3783
|
+
case "config": {
|
|
3784
|
+
const [sub, feature] = rest;
|
|
3785
|
+
if (sub !== "get" || !feature) fail("usage: vxil config get <feature>");
|
|
3786
|
+
const { api } = requireApi();
|
|
3787
|
+
printEnvelope(await api("GET", `/v1/config/${feature}`));
|
|
3788
|
+
break;
|
|
3789
|
+
}
|
|
3790
|
+
case "users": {
|
|
3791
|
+
const { api } = requireApi();
|
|
3792
|
+
const [sub, id] = rest;
|
|
3793
|
+
if (sub === "add") {
|
|
3794
|
+
if (!id) fail("usage: vxil users add <id> --email <email>");
|
|
3795
|
+
printEnvelope(await api("POST", "/v1/users", { id, email: flag("email") }));
|
|
3796
|
+
} else if (sub === "list") {
|
|
3797
|
+
const email = flag("email");
|
|
3798
|
+
printEnvelope(await api("GET", `/v1/users${email ? `?email=${encodeURIComponent(email)}` : ""}`));
|
|
3799
|
+
} else fail("usage: vxil users add|list");
|
|
3800
|
+
break;
|
|
3801
|
+
}
|
|
3802
|
+
case "send": {
|
|
3803
|
+
const { api } = requireApi();
|
|
3804
|
+
const [userId, template] = rest;
|
|
3805
|
+
if (!userId || !template) fail("usage: vxil send <user_id> <template> --data '<json>'");
|
|
3806
|
+
let data2 = {};
|
|
3807
|
+
const raw = flag("data");
|
|
3808
|
+
try {
|
|
3809
|
+
data2 = raw ? JSON.parse(raw) : {};
|
|
3810
|
+
} catch {
|
|
3811
|
+
fail("--data must be valid JSON");
|
|
3812
|
+
}
|
|
3813
|
+
printEnvelope(await api("POST", "/v1/notifications/send", { user_id: userId, template, data: data2 }));
|
|
3814
|
+
break;
|
|
3815
|
+
}
|
|
3816
|
+
case "deliveries": {
|
|
3817
|
+
const { api } = requireApi();
|
|
3818
|
+
const qs = new URLSearchParams();
|
|
3819
|
+
const user = flag("user");
|
|
3820
|
+
if (user) qs.set("user_id", user);
|
|
3821
|
+
const status = flag("status");
|
|
3822
|
+
if (status) qs.set("status", status);
|
|
3823
|
+
const s = qs.toString();
|
|
3824
|
+
printEnvelope(await api("GET", `/v1/notifications/deliveries${s ? `?${s}` : ""}`));
|
|
3825
|
+
break;
|
|
3826
|
+
}
|
|
3827
|
+
case "api": {
|
|
3828
|
+
const { api } = requireApi();
|
|
3829
|
+
const [method, path] = rest;
|
|
3830
|
+
if (!method || !path?.startsWith("/")) fail("usage: vxil api <GET|POST|PUT|PATCH|DELETE> </v1/...> [--data '<json>']");
|
|
3831
|
+
let body;
|
|
3832
|
+
const raw = flag("data");
|
|
3833
|
+
if (raw !== void 0) {
|
|
3834
|
+
try {
|
|
3835
|
+
body = JSON.parse(raw);
|
|
3836
|
+
} catch {
|
|
3837
|
+
fail("--data must be valid JSON");
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
printEnvelope(await api(method.toUpperCase(), path, body));
|
|
3841
|
+
break;
|
|
3842
|
+
}
|
|
3843
|
+
default:
|
|
3844
|
+
console.log(`vxil \u2014 a Supabase-grade code-first CLI for the whole platform
|
|
3845
|
+
init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--dev --ttl <h>] \xB7 login \xB7 link <slug>
|
|
3846
|
+
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>]
|
|
3847
|
+
doctor \xB7 versions <feature> \xB7 rollback <feature> --to <v>
|
|
3848
|
+
secrets set|list|rm \xB7 seed \xB7 export|import <feature>
|
|
3849
|
+
env pull [--dev] [--file <f>] [--print] [--no-gitignore]
|
|
3850
|
+
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
|
|
3851
|
+
dev branch [<name>] [--ttl <h=24>] [--no-env] [--print] | --list | --rm <name> [--yes]
|
|
3852
|
+
config get \xB7 users \xB7 send \xB7 deliveries \xB7 api <METHOD> <path>
|
|
3853
|
+
global: --json (machine-readable + exit codes)`);
|
|
3854
|
+
process.exit(cmd ? 1 : 0);
|
|
3855
|
+
}
|
|
3856
|
+
} catch (e) {
|
|
3857
|
+
fail(e.message);
|
|
3858
|
+
}
|
|
3859
|
+
async function devUpCreate(dashBase, appName, ttlHours) {
|
|
3860
|
+
const cfg = await loadVxilConfig();
|
|
3861
|
+
const feats = Object.keys(cfg.features);
|
|
3862
|
+
const email = flag("email") ?? process.env.VXIL_DEV_EMAIL;
|
|
3863
|
+
const password = flag("password") ?? process.env.VXIL_DEV_PASSWORD;
|
|
3864
|
+
if (!email || !password) fail("creating a dev tenant needs --email/--password (or VXIL_DEV_EMAIL/VXIL_DEV_PASSWORD)");
|
|
3865
|
+
const d = await createBranchTenant(makeDash(dashBase), {
|
|
3866
|
+
email,
|
|
3867
|
+
password,
|
|
3868
|
+
appName,
|
|
3869
|
+
features: feats,
|
|
3870
|
+
ttlHours
|
|
3871
|
+
});
|
|
3872
|
+
const creds = loadCredentials();
|
|
3873
|
+
creds.keys = { ...creds.keys, [d.slug]: d.api_key };
|
|
3874
|
+
saveCredentials(creds);
|
|
3875
|
+
return d;
|
|
3876
|
+
}
|
|
3877
|
+
async function runDev(sub) {
|
|
3878
|
+
const base = process.env.VXIL_BASE_URL ?? loadProject()?.edge_url ?? DEFAULT_BASE;
|
|
3879
|
+
const dash = dashboardBase(base);
|
|
3880
|
+
const proj = loadProject();
|
|
3881
|
+
if (sub === "up") {
|
|
3882
|
+
const name = flag("name") ?? `${proj?.slug ?? "app"}-dev`;
|
|
3883
|
+
const d = await devUpCreate(dash, name, Number(flag("ttl") ?? 72));
|
|
3884
|
+
const binding = proj ?? { tenant_id: d.tenant_id, slug: d.slug, edge_url: d.edge_url ?? base };
|
|
3885
|
+
binding.dev = { tenant_id: d.tenant_id, slug: d.slug, edge_url: d.edge_url ?? base };
|
|
3886
|
+
saveProject(binding);
|
|
3887
|
+
console.log(`\u2713 dev tenant '${d.slug}' ready (keyless mock providers). Use \`vxil push --dev\` / \`vxil gen\` against it.`);
|
|
3888
|
+
if (d.expires_at) console.log(` expires ${d.expires_at} \u2014 the nightly reaper deletes it then (re-run \`vxil dev up\` to extend).`);
|
|
3889
|
+
} else if (sub === "branch") {
|
|
3890
|
+
await runDevBranch(dash, base);
|
|
3891
|
+
} else if (sub === "seed") {
|
|
3892
|
+
fail("run `vxil seed --dev` to seed the dev tenant from the config `seed` block.");
|
|
3893
|
+
} else if (sub === "down") {
|
|
3894
|
+
await devTeardown(dash, { confirm: true });
|
|
3895
|
+
} else if (sub === "reset") {
|
|
3896
|
+
await devTeardown(dash, { confirm: false }).catch((e) => {
|
|
3897
|
+
if (!/no dev tenant bound/.test(e.message)) throw e;
|
|
3898
|
+
});
|
|
3899
|
+
await runDev("up");
|
|
3900
|
+
await runApply(true, "dev");
|
|
3901
|
+
await runSeed("dev", { optional: true });
|
|
3902
|
+
console.log("\u2713 dev reset complete (torn down \u2192 recreated \u2192 pushed \u2192 seeded)");
|
|
3903
|
+
} else {
|
|
3904
|
+
fail("usage: vxil dev up|down|seed|reset|branch");
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
async function runDevBranch(dash, base) {
|
|
3908
|
+
const proj = loadProject();
|
|
3909
|
+
if (hasFlag("list")) {
|
|
3910
|
+
const branches = proj?.branches ?? {};
|
|
3911
|
+
const names = Object.keys(branches);
|
|
3912
|
+
if (jsonOut) {
|
|
3913
|
+
console.log(JSON.stringify({ branches, selected: proj?.dev?.slug ?? null }, null, 2));
|
|
3914
|
+
return;
|
|
3915
|
+
}
|
|
3916
|
+
if (!names.length) {
|
|
3917
|
+
console.log("(no dev branches \u2014 create one with `vxil dev branch <name>`)");
|
|
3918
|
+
return;
|
|
3919
|
+
}
|
|
3920
|
+
for (const n of names.sort()) {
|
|
3921
|
+
const s = branches[n];
|
|
3922
|
+
const sel = proj?.dev?.tenant_id === s.tenant_id ? " \u2190 selected" : "";
|
|
3923
|
+
console.log(` ${n} \xB7 ${s.slug} \xB7 expires ${s.expires_at ?? "(none)"}${sel}`);
|
|
3924
|
+
}
|
|
3925
|
+
return;
|
|
3926
|
+
}
|
|
3927
|
+
if (hasFlag("rm")) {
|
|
3928
|
+
const name2 = flag("rm") ?? positional(1)[0];
|
|
3929
|
+
if (!name2) fail("usage: vxil dev branch --rm <name> [--yes]");
|
|
3930
|
+
await devTeardown(dash, { confirm: true, branch: name2 });
|
|
3931
|
+
return;
|
|
3932
|
+
}
|
|
3933
|
+
const raw = positional(1)[0] ?? currentGitBranch();
|
|
3934
|
+
if (!raw) fail("not on a git branch \u2014 pass a name: vxil dev branch <name>");
|
|
3935
|
+
const name = sanitizeBranchName(raw);
|
|
3936
|
+
if (!name) fail(`branch name '${raw}' sanitizes to empty \u2014 pass an explicit name`);
|
|
3937
|
+
const creds = loadCredentials();
|
|
3938
|
+
const decision = pickBranchSlot(proj, name, /* @__PURE__ */ new Date(), (slug) => Boolean(creds.keys?.[slug]));
|
|
3939
|
+
let slot;
|
|
3940
|
+
if ("reuse" in decision) {
|
|
3941
|
+
slot = decision.reuse;
|
|
3942
|
+
console.log(`\u2713 reusing dev branch '${name}' (tenant '${slot.slug}'${slot.expires_at ? ` \xB7 expires ${slot.expires_at}` : ""})`);
|
|
3943
|
+
} else {
|
|
3944
|
+
const d = await devUpCreate(dash, `${proj?.slug ?? "app"}-${name}`, Number(flag("ttl") ?? 24));
|
|
3945
|
+
slot = {
|
|
3946
|
+
tenant_id: d.tenant_id,
|
|
3947
|
+
slug: d.slug,
|
|
3948
|
+
edge_url: d.edge_url ?? base,
|
|
3949
|
+
...d.expires_at ? { expires_at: d.expires_at } : {}
|
|
3950
|
+
};
|
|
3951
|
+
console.log(`\u2713 dev branch '${name}' created (tenant '${slot.slug}'${slot.expires_at ? ` \xB7 expires ${slot.expires_at}` : ""})`);
|
|
3952
|
+
}
|
|
3953
|
+
const binding = loadProject() ?? { tenant_id: slot.tenant_id, slug: slot.slug, edge_url: slot.edge_url };
|
|
3954
|
+
binding.branches = { ...binding.branches, [name]: slot };
|
|
3955
|
+
binding.dev = { tenant_id: slot.tenant_id, slug: slot.slug, edge_url: slot.edge_url };
|
|
3956
|
+
saveProject(binding);
|
|
3957
|
+
console.log(` selected \u2014 \`vxil push --dev\` / \`vxil gen\` / \`vxil functions dev\` now target '${slot.slug}'`);
|
|
3958
|
+
if (hasFlag("print")) {
|
|
3959
|
+
const key = loadCredentials().keys?.[slot.slug];
|
|
3960
|
+
if (key) {
|
|
3961
|
+
console.log(`export VXIL_API_KEY=${key}`);
|
|
3962
|
+
console.log(`export VXIL_BASE_URL=${slot.edge_url}`);
|
|
3963
|
+
console.log(`export VXIL_TENANT_ID=${slot.tenant_id}`);
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
if (!hasFlag("no-env") && !hasFlag("print")) runEnvPull("dev");
|
|
3967
|
+
console.log(" note: re-run `vxil gen` \u2014 generated types drift across branch schemas.");
|
|
3968
|
+
}
|
|
3969
|
+
function currentGitBranch() {
|
|
3970
|
+
const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
3971
|
+
if (r.status !== 0 || r.error) return null;
|
|
3972
|
+
const name = r.stdout.trim();
|
|
3973
|
+
return name && name !== "HEAD" ? name : null;
|
|
3974
|
+
}
|
|
3975
|
+
async function devTeardown(dashBase, opts) {
|
|
3976
|
+
const proj = loadProject();
|
|
3977
|
+
let slot;
|
|
3978
|
+
if (opts.branch !== void 0) {
|
|
3979
|
+
const s = proj?.branches?.[opts.branch];
|
|
3980
|
+
if (!s) throw new Error(`no dev branch '${opts.branch}' bound \u2014 see \`vxil dev branch --list\``);
|
|
3981
|
+
slot = s;
|
|
3982
|
+
} else {
|
|
3983
|
+
if (!proj?.dev) throw new Error("no dev tenant bound \u2014 run `vxil dev up` first");
|
|
3984
|
+
slot = proj.dev;
|
|
3985
|
+
}
|
|
3986
|
+
if (opts.confirm && !hasFlag("yes")) {
|
|
3987
|
+
const ans = await prompt(`tear down dev tenant '${slot.slug}'? type the slug to confirm: `);
|
|
3988
|
+
if (ans !== slot.slug) fail("confirmation did not match \u2014 aborted.");
|
|
3989
|
+
}
|
|
3990
|
+
const dash = makeDash(dashBase);
|
|
3991
|
+
const cookie = await resolveDashCookie(dash);
|
|
3992
|
+
const { alreadyGone } = await devDown(dash, cookie, { tenant_id: slot.tenant_id, slug: slot.slug });
|
|
3993
|
+
const creds = loadCredentials();
|
|
3994
|
+
if (creds.keys?.[slot.slug]) {
|
|
3995
|
+
delete creds.keys[slot.slug];
|
|
3996
|
+
saveCredentials(creds);
|
|
3997
|
+
}
|
|
3998
|
+
const cur = loadProject();
|
|
3999
|
+
if (cur) {
|
|
4000
|
+
let changed = false;
|
|
4001
|
+
if (opts.branch !== void 0 && cur.branches?.[opts.branch]) {
|
|
4002
|
+
delete cur.branches[opts.branch];
|
|
4003
|
+
if (!Object.keys(cur.branches).length) delete cur.branches;
|
|
4004
|
+
changed = true;
|
|
4005
|
+
}
|
|
4006
|
+
if (cur.dev && cur.dev.tenant_id === slot.tenant_id) {
|
|
4007
|
+
delete cur.dev;
|
|
4008
|
+
changed = true;
|
|
4009
|
+
}
|
|
4010
|
+
if (changed) saveProject(cur);
|
|
4011
|
+
}
|
|
4012
|
+
const what = opts.branch !== void 0 ? `dev branch '${opts.branch}' (tenant '${slot.slug}')` : `dev tenant '${slot.slug}'`;
|
|
4013
|
+
console.log(alreadyGone ? `\u2713 ${what} was already gone \u2014 cleared the local binding` : `\u2713 ${what} torn down`);
|
|
4014
|
+
}
|
|
4015
|
+
function runEnvPull(which) {
|
|
4016
|
+
const t = resolve_target({ which, defaultBase: DEFAULT_BASE });
|
|
4017
|
+
if (!t.apiKey) {
|
|
4018
|
+
fail(which === "dev" ? "no dev API key \u2014 run `vxil dev up` or `vxil dev branch <name>` first (or set VXIL_DEV_KEY)" : "no API key \u2014 run `vxil quickstart` or `vxil link <slug>`, or set VXIL_API_KEY");
|
|
4019
|
+
}
|
|
4020
|
+
const proj = loadProject();
|
|
4021
|
+
const tenantId = which === "dev" ? proj?.dev?.tenant_id : proj?.tenant_id;
|
|
4022
|
+
const updates = envUpdates({
|
|
4023
|
+
apiKey: t.apiKey,
|
|
4024
|
+
baseUrl: t.baseUrl,
|
|
4025
|
+
...tenantId && tenantId !== "(unknown)" ? { tenantId } : {}
|
|
4026
|
+
});
|
|
4027
|
+
if (hasFlag("print")) {
|
|
4028
|
+
for (const [k, v] of Object.entries(updates)) console.log(`${k}=${v}`);
|
|
4029
|
+
return;
|
|
4030
|
+
}
|
|
4031
|
+
const fileArg = flag("file") ?? ".env.local";
|
|
4032
|
+
const file = resolve4(process.cwd(), fileArg);
|
|
4033
|
+
const note = guardEnvFile(fileArg);
|
|
4034
|
+
const existing = existsSync3(file) ? readFileSync2(file, "utf8") : "";
|
|
4035
|
+
writeFileSync3(file, mergeEnvFile(existing, updates));
|
|
4036
|
+
try {
|
|
4037
|
+
chmodSync2(file, 384);
|
|
4038
|
+
} catch {
|
|
4039
|
+
}
|
|
4040
|
+
console.log(`\u2713 wrote ${fileArg} (${Object.keys(updates).join(", ")})${note ? `
|
|
4041
|
+
${note}` : ""}`);
|
|
4042
|
+
}
|
|
4043
|
+
function guardEnvFile(fileArg) {
|
|
4044
|
+
const cwd = process.cwd();
|
|
4045
|
+
const git = (args) => {
|
|
4046
|
+
const r = spawnSync("git", args, { cwd, stdio: "ignore" });
|
|
4047
|
+
if (r.error && r.error.code === "ENOENT") return "no-git";
|
|
4048
|
+
if (r.error) return "error";
|
|
4049
|
+
return r.status ?? 1;
|
|
4050
|
+
};
|
|
4051
|
+
const appendOrWarn = (covered) => {
|
|
4052
|
+
if (covered) return void 0;
|
|
4053
|
+
if (hasFlag("no-gitignore")) {
|
|
4054
|
+
return `! ${fileArg} is NOT gitignored and holds your API key \u2014 add it to .gitignore before committing`;
|
|
4055
|
+
}
|
|
4056
|
+
const gi = resolve4(cwd, ".gitignore");
|
|
4057
|
+
appendFileSync(gi, ["", "# vxil", fileArg, ""].join("\n"));
|
|
4058
|
+
return `note: appended '${fileArg}' to .gitignore (it holds your API key)`;
|
|
4059
|
+
};
|
|
4060
|
+
const inRepo = git(["rev-parse", "--is-inside-work-tree"]);
|
|
4061
|
+
if (inRepo === "no-git") {
|
|
4062
|
+
const gi = resolve4(cwd, ".gitignore");
|
|
4063
|
+
const text = existsSync3(gi) ? readFileSync2(gi, "utf8") : "";
|
|
4064
|
+
return appendOrWarn(gitignoreCovers(text, fileArg));
|
|
4065
|
+
}
|
|
4066
|
+
if (inRepo !== 0) return void 0;
|
|
4067
|
+
if (git(["ls-files", "--error-unmatch", "--", fileArg]) === 0) {
|
|
4068
|
+
fail(`${fileArg} is TRACKED by git \u2014 writing your API key into it would commit a secret. Run \`git rm --cached ${fileArg}\` (and gitignore it) first.`);
|
|
4069
|
+
}
|
|
4070
|
+
return appendOrWarn(git(["check-ignore", "-q", "--", fileArg]) === 0);
|
|
4071
|
+
}
|
|
4072
|
+
async function runFunctionsDev(name) {
|
|
4073
|
+
const cfg = await loadVxilConfig();
|
|
4074
|
+
const def = cfg.functions?.[name];
|
|
4075
|
+
if (!def) fail(`function '${name}' not declared in vxil.config`);
|
|
4076
|
+
const t = resolve_target({ which: "dev", defaultBase: DEFAULT_BASE });
|
|
4077
|
+
if (!t.apiKey) fail("no API key \u2014 run `vxil quickstart`, `vxil link <slug>`, or `vxil dev up` (or set VXIL_API_KEY)");
|
|
4078
|
+
const proj = loadProject();
|
|
4079
|
+
const tenantId = (proj?.dev && proj.dev.slug === t.tenantSlug ? proj.dev.tenant_id : proj?.tenant_id) ?? "dev-tenant";
|
|
4080
|
+
let Miniflare;
|
|
4081
|
+
try {
|
|
4082
|
+
const mod = await import("miniflare");
|
|
4083
|
+
Miniflare = mod.Miniflare;
|
|
4084
|
+
} catch {
|
|
4085
|
+
fail("`vxil functions dev` runs your function under miniflare (the local Workers runtime), an optional dependency of the CLI \u2014 install it with `npm i -D miniflare` (or pnpm/yarn) and re-run.");
|
|
4086
|
+
}
|
|
4087
|
+
const cwd = process.cwd();
|
|
4088
|
+
const entry = resolve4(cwd, def.entry);
|
|
4089
|
+
const scopes = def.scopes ?? [];
|
|
4090
|
+
const egressAllow = def.egressAllow ?? [];
|
|
4091
|
+
const outbound = makeDevOutbound({
|
|
4092
|
+
fnName: name,
|
|
4093
|
+
scopes,
|
|
4094
|
+
egressAllow,
|
|
4095
|
+
edgeUrl: t.baseUrl,
|
|
4096
|
+
apiKey: t.apiKey,
|
|
4097
|
+
log: (line) => console.log(` ${line}`)
|
|
4098
|
+
});
|
|
4099
|
+
const mfOptions = (source) => ({
|
|
4100
|
+
modules: [{ type: "ESModule", path: "fn.mjs", contents: source }],
|
|
4101
|
+
compatibilityDate: "2025-09-01",
|
|
4102
|
+
// parity with the deploy pipeline's metadata
|
|
4103
|
+
outboundService: (req) => outbound(req)
|
|
4104
|
+
});
|
|
4105
|
+
const first = await bundleFunction(entry, { projectRoot: cwd });
|
|
4106
|
+
const mf = new Miniflare(mfOptions(first.source));
|
|
4107
|
+
const { secrets, missing } = resolveDevSecrets(def.secrets, process.env);
|
|
4108
|
+
for (const m of missing) {
|
|
4109
|
+
console.log(` ! secret '${m}' not resolved locally \u2014 export VXIL_FN_SECRET_${m.toUpperCase()}=\u2026 (deployed invokes read tenant_secrets)`);
|
|
4110
|
+
}
|
|
4111
|
+
const port = Number(flag("port") ?? 8788);
|
|
4112
|
+
let seq = 0;
|
|
4113
|
+
const server = createServer((req, res) => {
|
|
4114
|
+
const chunks = [];
|
|
4115
|
+
req.on("data", (c) => chunks.push(c));
|
|
4116
|
+
req.on("end", () => {
|
|
4117
|
+
void (async () => {
|
|
4118
|
+
seq += 1;
|
|
4119
|
+
const requestId = `dev-${seq}`;
|
|
4120
|
+
const reqUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
4121
|
+
let payload = Object.fromEntries(reqUrl.searchParams);
|
|
4122
|
+
if ((req.method ?? "GET") !== "GET") {
|
|
4123
|
+
try {
|
|
4124
|
+
payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
4125
|
+
} catch {
|
|
4126
|
+
payload = {};
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
const idem = req.headers["idempotency-key"];
|
|
4130
|
+
const envelope = buildEnvelope({
|
|
4131
|
+
fnName: name,
|
|
4132
|
+
scopes,
|
|
4133
|
+
payload,
|
|
4134
|
+
tenantId,
|
|
4135
|
+
requestId,
|
|
4136
|
+
...typeof idem === "string" ? { idempotencyKey: idem } : {},
|
|
4137
|
+
secrets
|
|
4138
|
+
});
|
|
4139
|
+
const t0 = Date.now();
|
|
4140
|
+
try {
|
|
4141
|
+
const out = await mf.dispatchFetch("https://fn.vxil.internal/invoke", {
|
|
4142
|
+
method: "POST",
|
|
4143
|
+
headers: { "content-type": "application/json" },
|
|
4144
|
+
body: JSON.stringify(envelope)
|
|
4145
|
+
});
|
|
4146
|
+
const text = await out.text();
|
|
4147
|
+
console.log(` [${requestId}] ${req.method} ${reqUrl.pathname} \u2192 ${out.status} (${Date.now() - t0} ms)`);
|
|
4148
|
+
res.writeHead(out.status, { "content-type": out.headers.get("content-type") ?? "application/json" });
|
|
4149
|
+
res.end(text);
|
|
4150
|
+
} catch (e) {
|
|
4151
|
+
console.error(` [${requestId}] isolate error: ${String(e.message).split("\n")[0]}`);
|
|
4152
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
4153
|
+
res.end(JSON.stringify({ error: { code: "function_unavailable", message: String(e.message).slice(0, 200) } }));
|
|
4154
|
+
}
|
|
4155
|
+
})();
|
|
4156
|
+
});
|
|
4157
|
+
});
|
|
4158
|
+
let timer = null;
|
|
4159
|
+
const watcher = watch(dirname2(entry), () => {
|
|
4160
|
+
if (timer) clearTimeout(timer);
|
|
4161
|
+
timer = setTimeout(() => {
|
|
4162
|
+
void (async () => {
|
|
4163
|
+
try {
|
|
4164
|
+
const rebuilt = await bundleFunction(entry, { projectRoot: cwd });
|
|
4165
|
+
await mf.setOptions(mfOptions(rebuilt.source));
|
|
4166
|
+
console.log(` \u21BB rebuilt '${name}' (${Math.round(rebuilt.bytes / 100) / 10} KB) \u2014 hot-swapped`);
|
|
4167
|
+
} catch (e) {
|
|
4168
|
+
console.error(` \u2717 rebuild failed: ${String(e.message).split("\n")[0]}`);
|
|
4169
|
+
}
|
|
4170
|
+
})();
|
|
4171
|
+
}, 300);
|
|
4172
|
+
});
|
|
4173
|
+
server.listen(port, () => {
|
|
4174
|
+
const auds = [...audsForScopes(scopes)];
|
|
4175
|
+
console.log(`vxil functions dev '${name}' \u2014 local isolate, remote edge (design \xA75.1-C)`);
|
|
4176
|
+
console.log(` tenant: ${t.tenantSlug ?? "(env key)"} \xB7 edge ${t.baseUrl}`);
|
|
4177
|
+
console.log(` bundle: ${def.entry} (${Math.round(first.bytes / 100) / 10} KB) \u2014 rebuilds on save`);
|
|
4178
|
+
console.log(` invoke: curl -X POST http://localhost:${port}/ -d '{"name":"world"}'`);
|
|
4179
|
+
console.log(` scopes: [${scopes.join(", ") || "none"}] \u2192 features [${auds.join(", ") || "none"}] (clamp reproduced locally)`);
|
|
4180
|
+
console.log(` egress: deny-by-default, same egressDecision as deployed \xB7 allowed: ${egressAllow.join(", ") || "(none beyond api.vxil.*)"}`);
|
|
4181
|
+
console.log(" caveat: local edge calls use YOUR API key, not per-invoke scoped JWTs \u2014 the aud-level");
|
|
4182
|
+
console.log(" feature clamp is reproduced, but per-scope (read/write) enforcement, breaker,");
|
|
4183
|
+
console.log(" metering, CPU/mem limits, and tenant_secrets only exist on deploy.");
|
|
4184
|
+
console.log(" Deploy is the contract; this loop is for iteration. Ctrl-C to stop.");
|
|
4185
|
+
});
|
|
4186
|
+
process.on("SIGINT", () => {
|
|
4187
|
+
watcher.close();
|
|
4188
|
+
server.close();
|
|
4189
|
+
void mf.dispose().finally(() => {
|
|
4190
|
+
console.log(`
|
|
4191
|
+
vxil functions dev '${name}' stopped.`);
|
|
4192
|
+
process.exit(0);
|
|
4193
|
+
});
|
|
4194
|
+
});
|
|
4195
|
+
await new Promise(() => {
|
|
4196
|
+
});
|
|
4197
|
+
}
|