@vxil/cli 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/vxil.js +380 -33
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ export default defineConfig({
|
|
|
39
39
|
});
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
Verbs: `init templates quickstart try login link plan push pull diff gen doctor architect versions rollback secrets env seed export import functions listen mcp cms dev payments migrate config users send deliveries api`.
|
|
42
|
+
Verbs: `init templates quickstart try login link keys plan push pull diff gen doctor architect versions rollback secrets env seed export import functions listen mcp cms dev payments migrate config users send deliveries api`.
|
|
43
43
|
|
|
44
44
|
**Every verb names its target.** `--dev` selects the dev slot, `--target <name>` a named slot bound by `vxil link <slug> --as <name>` (which merges into the existing binding rather than replacing it). The selector is **fail-closed** — an unbound slot exits 1 telling you the fix, and never falls through to another tenant — and every write verb prints a target banner (slug + env, never a key) before it acts.
|
|
45
45
|
|
|
@@ -48,6 +48,7 @@ Verbs: `init templates quickstart try login link plan push pull diff gen doctor
|
|
|
48
48
|
- `vxil diff [--against <target>] [--ignore <k,…>] [--strict-secrets]` — the CI drift gate; secrets compared by name only, and a referenced-but-unstored name is informational unless `--strict-secrets`.
|
|
49
49
|
- `vxil doctor` — preflight: secrets referenced-vs-stored (advisory, names only) plus a served / published / local hash verdict per function. Under `--json` stdout is one JSON document; notices go to stderr.
|
|
50
50
|
- `vxil link <slug>` — reuses the key already stored for the slug on this machine; `--mint` for a fresh one, `--key` to hand one in.
|
|
51
|
+
- `vxil keys mint --name <name> --scopes <a,b,c> [--env live|test] [--store]` — mint a **narrow** key for a server (scopes validated locally; `admin` refused); the key prints **once** to stdout (`--json` → `{ api_key, key_id, scopes }`) and is never stored unless `--store`. `vxil keys list` shows names, ids, scopes, created and last-used (never secrets); `vxil keys revoke <key_id> [--yes]` asks you to type the id, and refuses the key the CLI itself is using unless `--yes`. Needs a dashboard session (`vxil login`, or it prompts at a terminal; a piped / `--json` run fails with that remedy instead — pass `--email`/`--password` to script it, which never stores the session).
|
|
51
52
|
- `vxil cms drop <collection> [--yes]` — remove one collection definition through the same guarded ack `push --allow-destructive` uses (rows are retained).
|
|
52
53
|
- `vxil api <METHOD> <path> ['<json>' | --data '<json>']` — call any `/v1` route; bare JSON on stdout, banner on stderr.
|
|
53
54
|
- `vxil secrets list --all-projects` — the names-and-metadata matrix across every bound project (values never come back).
|
package/dist/vxil.js
CHANGED
|
@@ -780,10 +780,10 @@ async function planFunctions({ api, functions, cwd, apply, onNote }) {
|
|
|
780
780
|
const e = res.body.error ?? {};
|
|
781
781
|
throw new Error(`functions: deploy ${name} failed: ${e.code ?? res.status} ${e.message ?? ""} ${e.hint ?? ""}`);
|
|
782
782
|
}
|
|
783
|
-
const
|
|
784
|
-
const cmsHooks =
|
|
785
|
-
const webhooks =
|
|
786
|
-
const warnings2 = (
|
|
783
|
+
const data3 = res.body.data;
|
|
784
|
+
const cmsHooks = data3?.cms_hook_subscriptions ?? null;
|
|
785
|
+
const webhooks = data3?.webhook_subscriptions ?? null;
|
|
786
|
+
const warnings2 = (data3?.warnings ?? []).map((message) => ({ fn: name, message: String(message) }));
|
|
787
787
|
return { change, applied: 1, cmsHooks, webhooks, warnings: warnings2 };
|
|
788
788
|
}
|
|
789
789
|
return { change, applied: 0, cmsHooks: null, webhooks: null, warnings: [] };
|
|
@@ -1356,12 +1356,79 @@ function newId(prefix) {
|
|
|
1356
1356
|
return `${prefix}_${ulid()}`;
|
|
1357
1357
|
}
|
|
1358
1358
|
|
|
1359
|
+
// ../types/src/index.ts
|
|
1360
|
+
var KNOWN_SCOPES = [
|
|
1361
|
+
"features:read",
|
|
1362
|
+
"features:write",
|
|
1363
|
+
// Dedicated write scope for the BYO-secret path. PUT /v1/secrets/:feature/:name
|
|
1364
|
+
// requires THIS (or 'admin') as of 2026-07-17 (audit H2 — 'features:write' is no
|
|
1365
|
+
// longer secret-write-equivalent). GET/DELETE /v1/secrets still also accept
|
|
1366
|
+
// 'features:write'.
|
|
1367
|
+
"secrets:write",
|
|
1368
|
+
"users:read",
|
|
1369
|
+
"users:write",
|
|
1370
|
+
"notifications:send",
|
|
1371
|
+
"notifications:read",
|
|
1372
|
+
"jobs:read",
|
|
1373
|
+
"jobs:write",
|
|
1374
|
+
"auth:read",
|
|
1375
|
+
"auth:write",
|
|
1376
|
+
// Narrow least-privilege scope for the SIGN-IN surface only (2026-09-10,
|
|
1377
|
+
// F4-25/F8-56): every auth route an anonymous or self-authenticating client
|
|
1378
|
+
// must call to obtain, renew, verify or end ITS OWN session (sign-up/sign-in,
|
|
1379
|
+
// magic-link, OTP, anonymous, step-up, OAuth, refresh, by-token revoke,
|
|
1380
|
+
// sessions/verify, password reset). It does NOT reach the administrative
|
|
1381
|
+
// routes — GET /v1/auth/sessions (auth:read), POST /v1/auth/sessions/:id/
|
|
1382
|
+
// revoke and POST /v1/auth/users/:id/erase (auth:write). This is the scope a
|
|
1383
|
+
// key baked into a browser/mobile bundle carries for sign-in; 'auth:write'
|
|
1384
|
+
// still satisfies every route (any-of, the usage:read idiom) so existing keys
|
|
1385
|
+
// keep working.
|
|
1386
|
+
"auth:signin",
|
|
1387
|
+
"ratelimits:read",
|
|
1388
|
+
"ratelimits:write",
|
|
1389
|
+
"ratelimits:check",
|
|
1390
|
+
"files:read",
|
|
1391
|
+
"files:write",
|
|
1392
|
+
"webhooks:read",
|
|
1393
|
+
"webhooks:write",
|
|
1394
|
+
"comments:read",
|
|
1395
|
+
"comments:write",
|
|
1396
|
+
"cms:read",
|
|
1397
|
+
"cms:write",
|
|
1398
|
+
"realtime:read",
|
|
1399
|
+
"realtime:write",
|
|
1400
|
+
"presence:read",
|
|
1401
|
+
"orgs:read",
|
|
1402
|
+
"orgs:write",
|
|
1403
|
+
"payments:read",
|
|
1404
|
+
"payments:write",
|
|
1405
|
+
"vector-search:read",
|
|
1406
|
+
"vector-search:write",
|
|
1407
|
+
"ai:read",
|
|
1408
|
+
"ai:write",
|
|
1409
|
+
"rag:read",
|
|
1410
|
+
"rag:write",
|
|
1411
|
+
"activity-feed:read",
|
|
1412
|
+
"activity-feed:write",
|
|
1413
|
+
"functions:invoke",
|
|
1414
|
+
"functions:read",
|
|
1415
|
+
"functions:write",
|
|
1416
|
+
"copilot:read",
|
|
1417
|
+
"copilot:write",
|
|
1418
|
+
// Narrow least-privilege read for GET /v1/usage (2026-08-05, parity P0#4).
|
|
1419
|
+
// The route ALSO accepts 'features:read' (any-of, the secrets:write idiom)
|
|
1420
|
+
// so pre-existing keys keep working; mint this when a key should see usage
|
|
1421
|
+
// and nothing else.
|
|
1422
|
+
"usage:read",
|
|
1423
|
+
"admin"
|
|
1424
|
+
];
|
|
1425
|
+
|
|
1359
1426
|
// ../runtime/src/hash.ts
|
|
1360
1427
|
async function sha256Hex(input) {
|
|
1361
1428
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
1362
1429
|
return [...new Uint8Array(digest)].map((b2) => b2.toString(16).padStart(2, "0")).join("");
|
|
1363
1430
|
}
|
|
1364
|
-
async function hmacSha256Hex(secret,
|
|
1431
|
+
async function hmacSha256Hex(secret, data3) {
|
|
1365
1432
|
const key = await crypto.subtle.importKey(
|
|
1366
1433
|
"raw",
|
|
1367
1434
|
new TextEncoder().encode(secret),
|
|
@@ -1369,7 +1436,7 @@ async function hmacSha256Hex(secret, data2) {
|
|
|
1369
1436
|
false,
|
|
1370
1437
|
["sign"]
|
|
1371
1438
|
);
|
|
1372
|
-
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(
|
|
1439
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data3));
|
|
1373
1440
|
return [...new Uint8Array(sig)].map((b2) => b2.toString(16).padStart(2, "0")).join("");
|
|
1374
1441
|
}
|
|
1375
1442
|
|
|
@@ -2301,7 +2368,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
|
|
|
2301
2368
|
function drain() {
|
|
2302
2369
|
!query && onopen(connection2);
|
|
2303
2370
|
}
|
|
2304
|
-
function
|
|
2371
|
+
function data3(x) {
|
|
2305
2372
|
if (incomings) {
|
|
2306
2373
|
incomings.push(x);
|
|
2307
2374
|
remaining -= x.length;
|
|
@@ -2355,7 +2422,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
|
|
|
2355
2422
|
statementId = Math.random().toString(36).slice(2);
|
|
2356
2423
|
statementCount = 1;
|
|
2357
2424
|
lifeTimer.start();
|
|
2358
|
-
socket.on("data",
|
|
2425
|
+
socket.on("data", data3);
|
|
2359
2426
|
keep_alive && socket.setKeepAlive && socket.setKeepAlive(true, 1e3 * keep_alive);
|
|
2360
2427
|
const s = StartupMessage();
|
|
2361
2428
|
write(s);
|
|
@@ -2398,7 +2465,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
|
|
|
2398
2465
|
error(Errors.connection("CONNECTION_DESTROYED", options));
|
|
2399
2466
|
clearImmediate(nextWriteTimer);
|
|
2400
2467
|
if (socket) {
|
|
2401
|
-
socket.removeListener("data",
|
|
2468
|
+
socket.removeListener("data", data3);
|
|
2402
2469
|
socket.removeListener("connect", connected);
|
|
2403
2470
|
socket.readyState === "open" && socket.end(bytes_default().X().end());
|
|
2404
2471
|
}
|
|
@@ -2409,7 +2476,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
|
|
|
2409
2476
|
remaining = 0;
|
|
2410
2477
|
incomings = null;
|
|
2411
2478
|
clearImmediate(nextWriteTimer);
|
|
2412
|
-
socket.removeListener("data",
|
|
2479
|
+
socket.removeListener("data", data3);
|
|
2413
2480
|
socket.removeListener("connect", connected);
|
|
2414
2481
|
idleTimer.cancel();
|
|
2415
2482
|
lifeTimer.cancel();
|
|
@@ -3001,14 +3068,14 @@ function Subscribe(postgres2, options) {
|
|
|
3001
3068
|
const state2 = {
|
|
3002
3069
|
lsn: Buffer.concat(x.consistent_point.split("/").map((x2) => Buffer.from(("00000000" + x2).slice(-8), "hex")))
|
|
3003
3070
|
};
|
|
3004
|
-
stream2.on("data",
|
|
3071
|
+
stream2.on("data", data3);
|
|
3005
3072
|
stream2.on("error", error);
|
|
3006
3073
|
stream2.on("close", sql2.close);
|
|
3007
3074
|
return { stream: stream2, state: xs.state };
|
|
3008
3075
|
function error(e) {
|
|
3009
3076
|
console.error("Unexpected error during logical streaming - reconnecting", e);
|
|
3010
3077
|
}
|
|
3011
|
-
function
|
|
3078
|
+
function data3(x2) {
|
|
3012
3079
|
if (x2[0] === 119) {
|
|
3013
3080
|
parse(x2.subarray(25), state2, sql2.options.parsers, handle, options.transform);
|
|
3014
3081
|
} else if (x2[0] === 107 && x2[17]) {
|
|
@@ -3183,9 +3250,9 @@ function largeObject(sql, oid, mode = 131072 | 262144) {
|
|
|
3183
3250
|
async read(size2) {
|
|
3184
3251
|
const l = size2 > max ? size2 - max : size2;
|
|
3185
3252
|
max -= size2;
|
|
3186
|
-
const [{ data:
|
|
3187
|
-
this.push(
|
|
3188
|
-
if (
|
|
3253
|
+
const [{ data: data3 }] = await lo.read(l);
|
|
3254
|
+
this.push(data3);
|
|
3255
|
+
if (data3.length < size2)
|
|
3189
3256
|
this.push(null);
|
|
3190
3257
|
}
|
|
3191
3258
|
});
|
|
@@ -6049,7 +6116,7 @@ function errText(r) {
|
|
|
6049
6116
|
const e = r.json.error;
|
|
6050
6117
|
return e ? `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}` : `${r.status}`;
|
|
6051
6118
|
}
|
|
6052
|
-
async function
|
|
6119
|
+
async function findTenantViaSession(dash, cookie, slug) {
|
|
6053
6120
|
const list = await dash("GET", "/dashboard/tenants", void 0, cookie);
|
|
6054
6121
|
if (list.status === 401) return { unauthorized: true };
|
|
6055
6122
|
if (list.status !== 200) throw new Error(`could not list projects (${errText(list)})`);
|
|
@@ -6060,6 +6127,12 @@ async function linkViaSession(dash, cookie, slug, keyName) {
|
|
|
6060
6127
|
`no project '${slug}' on this account \u2014 run \`vxil quickstart\`, or ask an admin to add you as a member`
|
|
6061
6128
|
);
|
|
6062
6129
|
}
|
|
6130
|
+
return { tenant: t };
|
|
6131
|
+
}
|
|
6132
|
+
async function linkViaSession(dash, cookie, slug, keyName) {
|
|
6133
|
+
const found = await findTenantViaSession(dash, cookie, slug);
|
|
6134
|
+
if ("unauthorized" in found) return found;
|
|
6135
|
+
const t = found.tenant;
|
|
6063
6136
|
const mint = await dash("POST", `/dashboard/tenants/${t.id}/api-keys`, { name: keyName }, cookie);
|
|
6064
6137
|
if (mint.status === 401) return { unauthorized: true };
|
|
6065
6138
|
if (mint.status !== 201 && mint.status !== 200) {
|
|
@@ -6114,6 +6187,150 @@ async function linkViaStoredKey(api, slug, storedKey) {
|
|
|
6114
6187
|
return { invalid: `${e?.code ?? res.status}${e?.message ? ` \u2014 ${e.message}` : ""}` };
|
|
6115
6188
|
}
|
|
6116
6189
|
|
|
6190
|
+
// src/keys.ts
|
|
6191
|
+
var MINTABLE_SCOPES = KNOWN_SCOPES.filter((s) => s !== "admin");
|
|
6192
|
+
function data2(r) {
|
|
6193
|
+
return r.json.data ?? r.json;
|
|
6194
|
+
}
|
|
6195
|
+
function errText2(r) {
|
|
6196
|
+
const e = r.json.error;
|
|
6197
|
+
if (!e) return `${r.status}`;
|
|
6198
|
+
return `${e.code ?? r.status}${e.message ? ` \u2014 ${e.message}` : ""}${e.hint ? `
|
|
6199
|
+
hint: ${e.hint}` : ""}`;
|
|
6200
|
+
}
|
|
6201
|
+
function parseScopes(raw) {
|
|
6202
|
+
const list = (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
6203
|
+
if (!list.length) {
|
|
6204
|
+
return { error: `--scopes is required \u2014 a comma-separated list of scopes, e.g. --scopes cms:read,cms:write
|
|
6205
|
+
valid scopes: ${MINTABLE_SCOPES.join(", ")}` };
|
|
6206
|
+
}
|
|
6207
|
+
const scopes = [...new Set(list)];
|
|
6208
|
+
const god = scopes.filter((s) => s === "admin" || s === "*");
|
|
6209
|
+
if (god.length) {
|
|
6210
|
+
return { error: `'${god.join("', '")}' cannot be minted here (admin-class keys are operator-provisioned only) \u2014 name the feature scopes this key needs, e.g. --scopes cms:read,files:write` };
|
|
6211
|
+
}
|
|
6212
|
+
const unknown = scopes.filter((s) => !MINTABLE_SCOPES.includes(s));
|
|
6213
|
+
if (unknown.length) {
|
|
6214
|
+
return { error: `unknown scope${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}
|
|
6215
|
+
valid scopes: ${MINTABLE_SCOPES.join(", ")}` };
|
|
6216
|
+
}
|
|
6217
|
+
return { scopes };
|
|
6218
|
+
}
|
|
6219
|
+
function parseKeyEnv(raw) {
|
|
6220
|
+
if (raw === void 0) return { env: "live" };
|
|
6221
|
+
if (raw === "live" || raw === "test") return { env: raw };
|
|
6222
|
+
return { error: `--env must be 'live' or 'test' (got '${raw}') \u2014 this is the key's env prefix, not the project's environment label` };
|
|
6223
|
+
}
|
|
6224
|
+
function parseKeyName(raw) {
|
|
6225
|
+
const name = (raw ?? "").trim();
|
|
6226
|
+
if (!name) return { error: "--name is required \u2014 a label for this key, e.g. --name web-worker" };
|
|
6227
|
+
if (name.length > 100) return { error: `--name is at most 100 characters (got ${name.length})` };
|
|
6228
|
+
return { name };
|
|
6229
|
+
}
|
|
6230
|
+
async function mintKeyViaSession(dash, cookie, tenantId, req) {
|
|
6231
|
+
const r = await dash(
|
|
6232
|
+
"POST",
|
|
6233
|
+
`/dashboard/tenants/${tenantId}/api-keys`,
|
|
6234
|
+
{ name: req.name, scopes: req.scopes, env: req.env },
|
|
6235
|
+
cookie
|
|
6236
|
+
);
|
|
6237
|
+
if (r.status === 401) return { unauthorized: true };
|
|
6238
|
+
if (r.status !== 201 && r.status !== 200) {
|
|
6239
|
+
throw new Error(`could not mint the key (${errText2(r)})`);
|
|
6240
|
+
}
|
|
6241
|
+
const d = data2(r);
|
|
6242
|
+
if (!d.api_key || !d.key_id) throw new Error("mint returned no api_key");
|
|
6243
|
+
return {
|
|
6244
|
+
result: {
|
|
6245
|
+
key_id: d.key_id,
|
|
6246
|
+
api_key: d.api_key,
|
|
6247
|
+
scopes: d.scopes ?? req.scopes,
|
|
6248
|
+
env: d.env ?? req.env,
|
|
6249
|
+
name: req.name,
|
|
6250
|
+
...d.prefix ? { prefix: d.prefix } : {}
|
|
6251
|
+
}
|
|
6252
|
+
};
|
|
6253
|
+
}
|
|
6254
|
+
async function listKeysViaSession(dash, cookie, tenantId) {
|
|
6255
|
+
const r = await dash("GET", `/dashboard/tenants/${tenantId}/api-keys`, void 0, cookie);
|
|
6256
|
+
if (r.status === 401) return { unauthorized: true };
|
|
6257
|
+
if (r.status !== 200) throw new Error(`could not list keys (${errText2(r)})`);
|
|
6258
|
+
return { keys: data2(r).keys ?? [] };
|
|
6259
|
+
}
|
|
6260
|
+
async function revokeKeyViaSession(dash, cookie, tenantId, keyId) {
|
|
6261
|
+
const r = await dash("DELETE", `/dashboard/tenants/${tenantId}/api-keys/${encodeURIComponent(keyId)}`, void 0, cookie);
|
|
6262
|
+
if (r.status === 401) return { unauthorized: true };
|
|
6263
|
+
if (r.status === 404) throw new Error(`no active key '${keyId}' on this project (already revoked, or not this project's) \u2014 \`vxil keys list\` shows the ids`);
|
|
6264
|
+
if (r.status !== 200) throw new Error(`could not revoke the key (${errText2(r)})`);
|
|
6265
|
+
return { revoked: true };
|
|
6266
|
+
}
|
|
6267
|
+
function currentKeyIdFor(keys, apiKey) {
|
|
6268
|
+
if (!apiKey) return void 0;
|
|
6269
|
+
const hit = keys.find((k) => typeof k.prefix === "string" && k.prefix.length >= 12 && apiKey.startsWith(k.prefix));
|
|
6270
|
+
return hit?.key_id;
|
|
6271
|
+
}
|
|
6272
|
+
function revokeGuard(args) {
|
|
6273
|
+
const row2 = args.keys.find((k) => k.key_id === args.keyId);
|
|
6274
|
+
const isCurrent = currentKeyIdFor(args.keys, args.currentApiKey) === args.keyId;
|
|
6275
|
+
if (isCurrent && !args.yes) {
|
|
6276
|
+
return {
|
|
6277
|
+
refuse: `'${args.keyId}' is the key this CLI is using for '${args.slug}' (the one in ~/.vxil/credentials.json / VXIL_API_KEY) \u2014 revoking it breaks every vxil verb on this project until you re-link. Pass --yes to revoke it anyway, then run \`vxil link ${args.slug} --mint\` (or \`vxil keys mint \u2026 --store\`) for a replacement.`
|
|
6278
|
+
};
|
|
6279
|
+
}
|
|
6280
|
+
return { ok: true, row: row2, isCurrent };
|
|
6281
|
+
}
|
|
6282
|
+
function mintStdout(r, json2) {
|
|
6283
|
+
return json2 ? JSON.stringify({ api_key: r.api_key, key_id: r.key_id, scopes: r.scopes }) : r.api_key;
|
|
6284
|
+
}
|
|
6285
|
+
function mintNotices(r, json2, opts = {}) {
|
|
6286
|
+
const storage = opts.stored ? "It was stored as this project's CLI key (~/.vxil/credentials.json), replacing the previous one." : "It was NOT stored on this machine (pass --store to make it this project's CLI key).";
|
|
6287
|
+
const storeWarn = opts.storeError !== void 0 ? [`vxil: warning \u2014 --store failed: could not write ~/.vxil/credentials.json (${opts.storeError}). The key (${r.key_id}) was minted and is printed above \u2014 copy it from there now; this machine's CLI key is unchanged. To discard it instead: \`vxil keys revoke ${r.key_id}\`.`] : [];
|
|
6288
|
+
if (json2) {
|
|
6289
|
+
return [
|
|
6290
|
+
`minted '${r.name}' (${r.key_id}, env ${r.env}) \u2014 shown ONCE; it is not retrievable again. ${storage} Revoke with \`vxil keys revoke ${r.key_id}\`.`,
|
|
6291
|
+
...storeWarn
|
|
6292
|
+
];
|
|
6293
|
+
}
|
|
6294
|
+
return [
|
|
6295
|
+
`\u2713 minted '${r.name}' (${r.key_id}, env ${r.env}) with scopes [${r.scopes.join(", ")}]`,
|
|
6296
|
+
`This key is shown ONCE and is not retrievable again \u2014 copy it now. ${storage} Revoke with \`vxil keys revoke ${r.key_id}\`.`,
|
|
6297
|
+
...storeWarn
|
|
6298
|
+
];
|
|
6299
|
+
}
|
|
6300
|
+
function when(iso) {
|
|
6301
|
+
if (!iso) return "\u2014";
|
|
6302
|
+
const d = new Date(iso);
|
|
6303
|
+
return Number.isNaN(d.getTime()) ? String(iso) : d.toISOString().slice(0, 16).replace("T", " ");
|
|
6304
|
+
}
|
|
6305
|
+
function renderKeyTable(keys, currentKeyId) {
|
|
6306
|
+
if (!keys.length) return ["(no keys on this project)"];
|
|
6307
|
+
const rows = keys.map((k) => ({
|
|
6308
|
+
mark: k.key_id === currentKeyId ? "*" : " ",
|
|
6309
|
+
id: k.key_id,
|
|
6310
|
+
name: k.name ?? "",
|
|
6311
|
+
env: k.env ?? "",
|
|
6312
|
+
status: k.revoked_at ? "revoked" : k.expires_at && new Date(k.expires_at).getTime() < Date.now() ? "expired" : "active",
|
|
6313
|
+
created: when(k.created_at),
|
|
6314
|
+
used: when(k.last_used_at),
|
|
6315
|
+
scopes: (k.scopes ?? []).join(",")
|
|
6316
|
+
}));
|
|
6317
|
+
const w = (f, head2) => Math.max(head2.length, ...rows.map((r) => r[f].length));
|
|
6318
|
+
const cols = [
|
|
6319
|
+
["id", "KEY_ID"],
|
|
6320
|
+
["name", "NAME"],
|
|
6321
|
+
["env", "ENV"],
|
|
6322
|
+
["status", "STATUS"],
|
|
6323
|
+
["created", "CREATED"],
|
|
6324
|
+
["used", "LAST_USED"]
|
|
6325
|
+
];
|
|
6326
|
+
const line = (r, pad) => `${r.mark ?? " "} ${cols.map(([f, h]) => pad ? r[f].padEnd(w(f, h)) : r[f]).join(" ")} ${r.scopes}`;
|
|
6327
|
+
const head = { mark: " ", scopes: "SCOPES" };
|
|
6328
|
+
for (const [f, h] of cols) head[f] = h;
|
|
6329
|
+
const out = [line(head, true), ...rows.map((r) => line(r, true))];
|
|
6330
|
+
if (currentKeyId) out.push("* = the key this CLI is using for this project");
|
|
6331
|
+
return out;
|
|
6332
|
+
}
|
|
6333
|
+
|
|
6117
6334
|
// src/secretsValue.ts
|
|
6118
6335
|
var ARGV_SECRET_WARNING = `WARNING: --value <v> puts the secret on the command line \u2014 it lands in shell history and is
|
|
6119
6336
|
visible in the process list. Prefer stdin: printf '%s' "$VAL" | vxil secrets set <feature>/<name>`;
|
|
@@ -7158,7 +7375,7 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
|
|
|
7158
7375
|
result.skipped++;
|
|
7159
7376
|
continue;
|
|
7160
7377
|
}
|
|
7161
|
-
const
|
|
7378
|
+
const data3 = {};
|
|
7162
7379
|
let rowErr;
|
|
7163
7380
|
for (const f of mapping.fields) {
|
|
7164
7381
|
const raw = row2[f.source];
|
|
@@ -7174,11 +7391,11 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
|
|
|
7174
7391
|
unresolvedOptional.set(f.target, (unresolvedOptional.get(f.target) ?? 0) + 1);
|
|
7175
7392
|
continue;
|
|
7176
7393
|
}
|
|
7177
|
-
|
|
7394
|
+
data3[f.target] = mapped;
|
|
7178
7395
|
continue;
|
|
7179
7396
|
}
|
|
7180
7397
|
if (f.storageObject) {
|
|
7181
|
-
|
|
7398
|
+
data3[f.target] = resolveObjectId(String(raw), filesMaps) ?? String(raw);
|
|
7182
7399
|
continue;
|
|
7183
7400
|
}
|
|
7184
7401
|
const coerced = coerceValue(raw, f);
|
|
@@ -7186,7 +7403,7 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
|
|
|
7186
7403
|
rowErr = coerced.err;
|
|
7187
7404
|
break;
|
|
7188
7405
|
}
|
|
7189
|
-
|
|
7406
|
+
data3[f.target] = coerced.value;
|
|
7190
7407
|
}
|
|
7191
7408
|
if (rowErr) {
|
|
7192
7409
|
result.skipped++;
|
|
@@ -7195,8 +7412,8 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
|
|
|
7195
7412
|
continue;
|
|
7196
7413
|
}
|
|
7197
7414
|
if (deps.dryRun) continue;
|
|
7198
|
-
if (dedupeField &&
|
|
7199
|
-
const filter = encodeURIComponent(JSON.stringify({ [dedupeField.target]:
|
|
7415
|
+
if (dedupeField && data3[dedupeField.target] !== void 0) {
|
|
7416
|
+
const filter = encodeURIComponent(JSON.stringify({ [dedupeField.target]: data3[dedupeField.target] }));
|
|
7200
7417
|
const q = await deps.api("GET", `/v1/cms/items/${enc}?filter=${filter}&limit=1`);
|
|
7201
7418
|
const items = q.body.data?.items ?? [];
|
|
7202
7419
|
if (q.status === 200 && items.length > 0 && typeof items[0].item_id === "string") {
|
|
@@ -7205,7 +7422,7 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
|
|
|
7205
7422
|
continue;
|
|
7206
7423
|
}
|
|
7207
7424
|
}
|
|
7208
|
-
const res = await deps.api("POST", `/v1/cms/items/${enc}`, { data:
|
|
7425
|
+
const res = await deps.api("POST", `/v1/cms/items/${enc}`, { data: data3, status: itemStatus });
|
|
7209
7426
|
if (res.status === 201 || res.status === 200) {
|
|
7210
7427
|
const itemId = res.body.data?.item_id;
|
|
7211
7428
|
if (typeof itemId !== "string") {
|
|
@@ -11136,7 +11353,7 @@ function printEnvelope({ status, body }) {
|
|
|
11136
11353
|
async function prompt(q, opts = {}) {
|
|
11137
11354
|
const stdin = process.stdin;
|
|
11138
11355
|
if (opts.hidden && stdin.isTTY && stdin.setRawMode) {
|
|
11139
|
-
process.
|
|
11356
|
+
process.stderr.write(q);
|
|
11140
11357
|
return await new Promise((resolve8) => {
|
|
11141
11358
|
stdin.setRawMode(true);
|
|
11142
11359
|
stdin.resume();
|
|
@@ -11147,11 +11364,11 @@ async function prompt(q, opts = {}) {
|
|
|
11147
11364
|
stdin.setRawMode(false);
|
|
11148
11365
|
stdin.pause();
|
|
11149
11366
|
stdin.removeListener("data", onData);
|
|
11150
|
-
process.
|
|
11367
|
+
process.stderr.write("\n");
|
|
11151
11368
|
resolve8(val);
|
|
11152
11369
|
} else if (ch === "") {
|
|
11153
11370
|
stdin.setRawMode(false);
|
|
11154
|
-
process.
|
|
11371
|
+
process.stderr.write("\n");
|
|
11155
11372
|
process.exit(130);
|
|
11156
11373
|
} else if (ch === "\x7F" || ch === "\b") {
|
|
11157
11374
|
val = val.slice(0, -1);
|
|
@@ -11162,7 +11379,7 @@ async function prompt(q, opts = {}) {
|
|
|
11162
11379
|
stdin.on("data", onData);
|
|
11163
11380
|
});
|
|
11164
11381
|
}
|
|
11165
|
-
const rl = createInterface({ input: process.stdin, output: process.
|
|
11382
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
11166
11383
|
return await new Promise((res) => rl.question(q, (a) => {
|
|
11167
11384
|
rl.close();
|
|
11168
11385
|
res(a.trim());
|
|
@@ -11252,6 +11469,39 @@ async function resolveDashCookie(dash) {
|
|
|
11252
11469
|
if (!cookie) fail("login succeeded but returned no session cookie");
|
|
11253
11470
|
return cookie;
|
|
11254
11471
|
}
|
|
11472
|
+
async function interactiveLogin(dash) {
|
|
11473
|
+
const email = flag("email") || process.env.VXIL_DEV_EMAIL || void 0;
|
|
11474
|
+
const password = flag("password") || process.env.VXIL_DEV_PASSWORD || void 0;
|
|
11475
|
+
const mustPrompt = !email || !password;
|
|
11476
|
+
if (mustPrompt && (!process.stdin.isTTY || !process.stdout.isTTY || jsonOut)) {
|
|
11477
|
+
fail("need a dashboard session \u2014 run `vxil login` first (or pass --email/--password, or set VXIL_DEV_EMAIL/VXIL_DEV_PASSWORD); a non-interactive run cannot prompt");
|
|
11478
|
+
}
|
|
11479
|
+
const e = email ?? await prompt("dashboard email: ");
|
|
11480
|
+
const p = password ?? await prompt("password: ", { hidden: true });
|
|
11481
|
+
const r = await dash("POST", "/dashboard/login", { email: e, password: p });
|
|
11482
|
+
if (r.status !== 200) fail(`login failed: ${r.status} ${JSON.stringify(r.json).slice(0, 160)}`);
|
|
11483
|
+
const cookie = r.cookie?.split(";")[0];
|
|
11484
|
+
if (!cookie) fail("login succeeded but returned no session cookie");
|
|
11485
|
+
if (mustPrompt) {
|
|
11486
|
+
const creds = loadCredentials();
|
|
11487
|
+
creds.session = cookie;
|
|
11488
|
+
saveCredentials(creds);
|
|
11489
|
+
console.error("\u2713 signed in \u2014 session stored in ~/.vxil/credentials.json");
|
|
11490
|
+
}
|
|
11491
|
+
return cookie;
|
|
11492
|
+
}
|
|
11493
|
+
async function withDashSession(dash, run) {
|
|
11494
|
+
const creds = loadCredentials();
|
|
11495
|
+
if (creds.session) {
|
|
11496
|
+
const r2 = await run(creds.session);
|
|
11497
|
+
if (!("unauthorized" in r2)) return r2;
|
|
11498
|
+
console.error("dashboard session expired \u2014 sign in again\u2026");
|
|
11499
|
+
}
|
|
11500
|
+
const cookie = await interactiveLogin(dash);
|
|
11501
|
+
const r = await run(cookie);
|
|
11502
|
+
if ("unauthorized" in r) fail("the dashboard rejected the fresh session \u2014 run `vxil login` and retry");
|
|
11503
|
+
return r;
|
|
11504
|
+
}
|
|
11255
11505
|
function requireApi(sel = "prod", opts = {}) {
|
|
11256
11506
|
const t = resolveTargetOrFail(sel, opts.exitCode ?? 1);
|
|
11257
11507
|
if (!t.apiKey) fail(noKeyMessage(t), opts.exitCode ?? 1);
|
|
@@ -12200,6 +12450,102 @@ try {
|
|
|
12200
12450
|
}
|
|
12201
12451
|
break;
|
|
12202
12452
|
}
|
|
12453
|
+
case "keys": {
|
|
12454
|
+
const [sub, arg] = positional(0);
|
|
12455
|
+
const KEYS_USAGE = "usage: vxil keys mint --name <name> --scopes <a,b,c> [--env live|test] [--store] [--json] \xB7 vxil keys list [--json] \xB7 vxil keys revoke <key_id> [--yes]";
|
|
12456
|
+
if (sub === "help" || hasFlag("help")) {
|
|
12457
|
+
console.log(`${KEYS_USAGE}
|
|
12458
|
+
mint a NARROW scoped key for a server on the linked project \u2014 scopes are validated
|
|
12459
|
+
locally (admin refused); the key prints ONCE on stdout (--json \u2192 { api_key, key_id,
|
|
12460
|
+
scopes }) and is stored as this machine's CLI key only under --store
|
|
12461
|
+
list names, ids, scopes, env, created, last-used \u2014 never a secret; * marks the CLI's key
|
|
12462
|
+
revoke type the key id to confirm (--yes for scripts); refuses the CLI's own key unless --yes
|
|
12463
|
+
all three need a dashboard session: the one \`vxil login\` stored, or a sign-in at the terminal
|
|
12464
|
+
(--email/--password for a script; that session is not stored). A piped / --json run never prompts
|
|
12465
|
+
valid scopes: ${MINTABLE_SCOPES.join(", ")}`);
|
|
12466
|
+
break;
|
|
12467
|
+
}
|
|
12468
|
+
if (sub !== "mint" && sub !== "list" && sub !== "revoke") fail(KEYS_USAGE);
|
|
12469
|
+
const mintArgs = sub === "mint" ? (() => {
|
|
12470
|
+
const name = parseKeyName(flag("name"));
|
|
12471
|
+
if ("error" in name) fail(name.error);
|
|
12472
|
+
const scopes = parseScopes(flag("scopes"));
|
|
12473
|
+
if ("error" in scopes) fail(scopes.error);
|
|
12474
|
+
const env = parseKeyEnv(flag("env"));
|
|
12475
|
+
if ("error" in env) fail(env.error);
|
|
12476
|
+
return { name: name.name, scopes: scopes.scopes, env: env.env };
|
|
12477
|
+
})() : void 0;
|
|
12478
|
+
const t = resolveTargetOrFail(selector());
|
|
12479
|
+
if (!t.tenantSlug) fail("no linked project \u2014 run `vxil link <slug>` (or `vxil quickstart`) first; keys are minted on the bound project");
|
|
12480
|
+
const slug = t.tenantSlug;
|
|
12481
|
+
const dash = makeDash(dashboardBase(t.baseUrl));
|
|
12482
|
+
const run = (fn) => withDashSession(dash, async (cookie) => {
|
|
12483
|
+
let id = t.tenantId && t.tenantId !== "(unknown)" ? t.tenantId : void 0;
|
|
12484
|
+
if (!id) {
|
|
12485
|
+
const f = await findTenantViaSession(dash, cookie, slug);
|
|
12486
|
+
if ("unauthorized" in f) return f;
|
|
12487
|
+
id = f.tenant.id;
|
|
12488
|
+
}
|
|
12489
|
+
return fn(cookie, id);
|
|
12490
|
+
});
|
|
12491
|
+
if (mintArgs) {
|
|
12492
|
+
announceTarget(t, "keys mint");
|
|
12493
|
+
const { result } = await run((cookie, id) => mintKeyViaSession(dash, cookie, id, mintArgs));
|
|
12494
|
+
console.log(mintStdout(result, jsonOut));
|
|
12495
|
+
let stored = false;
|
|
12496
|
+
let storeError;
|
|
12497
|
+
if (hasFlag("store")) {
|
|
12498
|
+
try {
|
|
12499
|
+
const creds = loadCredentials();
|
|
12500
|
+
creds.keys = { ...creds.keys, [slug]: result.api_key };
|
|
12501
|
+
saveCredentials(creds);
|
|
12502
|
+
stored = true;
|
|
12503
|
+
} catch (e) {
|
|
12504
|
+
storeError = e instanceof Error ? e.message : String(e);
|
|
12505
|
+
}
|
|
12506
|
+
}
|
|
12507
|
+
for (const l of mintNotices(result, jsonOut, { stored, ...storeError !== void 0 ? { storeError } : {} })) console.error(l);
|
|
12508
|
+
if (result.env !== mintArgs.env) {
|
|
12509
|
+
console.error(`vxil: warning \u2014 the server minted a '${result.env}' key although --env ${mintArgs.env} was requested; revoke it (\`vxil keys revoke ${result.key_id}\`) if the env matters`);
|
|
12510
|
+
}
|
|
12511
|
+
break;
|
|
12512
|
+
}
|
|
12513
|
+
if (sub === "list") {
|
|
12514
|
+
const { keys: keys2 } = await run((cookie, id) => listKeysViaSession(dash, cookie, id));
|
|
12515
|
+
const current = currentKeyIdFor(keys2, t.apiKey);
|
|
12516
|
+
if (jsonOut) console.log(JSON.stringify({ project: slug, current_key_id: current ?? null, keys: keys2 }, null, 2));
|
|
12517
|
+
else for (const l of renderKeyTable(keys2, current)) console.log(l);
|
|
12518
|
+
break;
|
|
12519
|
+
}
|
|
12520
|
+
const keyId = arg;
|
|
12521
|
+
if (!keyId) fail("usage: vxil keys revoke <key_id> [--yes] \u2014 `vxil keys list` shows the ids");
|
|
12522
|
+
const yes = hasFlag("yes");
|
|
12523
|
+
const { keys } = await run((cookie, id) => listKeysViaSession(dash, cookie, id));
|
|
12524
|
+
const guard = revokeGuard({ keys, keyId, currentApiKey: t.apiKey, slug, yes });
|
|
12525
|
+
if ("refuse" in guard) fail(guard.refuse);
|
|
12526
|
+
if (!guard.row) fail(`no key '${keyId}' on '${slug}' \u2014 \`vxil keys list\` shows the ids`);
|
|
12527
|
+
if (guard.row.revoked_at) fail(`'${keyId}' is already revoked`);
|
|
12528
|
+
announceTarget(t, "keys revoke");
|
|
12529
|
+
if (!yes) {
|
|
12530
|
+
if (!process.stdin.isTTY) fail("keys revoke is irreversible \u2014 pass --yes to confirm in a non-interactive run");
|
|
12531
|
+
const ans = await prompt(`revoke key '${guard.row.name ?? keyId}' (${keyId}, scopes [${(guard.row.scopes ?? []).join(", ")}])? type the key id to confirm: `);
|
|
12532
|
+
if (ans !== keyId) fail("confirmation did not match \u2014 aborted.");
|
|
12533
|
+
}
|
|
12534
|
+
await run((cookie, id) => revokeKeyViaSession(dash, cookie, id, keyId));
|
|
12535
|
+
if (guard.isCurrent) {
|
|
12536
|
+
const creds = loadCredentials();
|
|
12537
|
+
const onDisk = creds.keys !== void 0 && creds.keys[slug] !== void 0 && creds.keys[slug] === t.apiKey;
|
|
12538
|
+
if (onDisk && creds.keys) {
|
|
12539
|
+
delete creds.keys[slug];
|
|
12540
|
+
saveCredentials(creds);
|
|
12541
|
+
}
|
|
12542
|
+
const where = onDisk ? "removed from ~/.vxil/credentials.json" : `it came from ${t.keySource === "credentials" ? "~/.vxil/credentials.json" : t.keySource} \u2014 unset it`;
|
|
12543
|
+
console.error(`this was the CLI's key for '${slug}' \u2014 ${where}; run \`vxil link ${slug} --mint\` (or \`vxil keys mint \u2026 --store\`) before the next verb`);
|
|
12544
|
+
}
|
|
12545
|
+
if (jsonOut) console.log(JSON.stringify({ key_id: keyId, revoked: true }));
|
|
12546
|
+
else console.log(`\u2713 revoked '${guard.row.name ?? keyId}' (${keyId}) \u2014 the edge stops honouring it within seconds`);
|
|
12547
|
+
break;
|
|
12548
|
+
}
|
|
12203
12549
|
case "listen": {
|
|
12204
12550
|
const rawUrl = flag("forward-to");
|
|
12205
12551
|
if (!rawUrl) {
|
|
@@ -12512,9 +12858,9 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
12512
12858
|
const qs = since ? `?since=${encodeURIComponent(since)}` : "";
|
|
12513
12859
|
const res = await api("GET", `/v1/functions/${encodeURIComponent(name)}/logs${qs}`);
|
|
12514
12860
|
if (res.body.error) printEnvelope(res);
|
|
12515
|
-
const
|
|
12516
|
-
if (
|
|
12517
|
-
if (
|
|
12861
|
+
const data3 = res.body.data;
|
|
12862
|
+
if (data3.lines.length) console.log(formatLogLines(data3.lines));
|
|
12863
|
+
if (data3.next_since) since = data3.next_since;
|
|
12518
12864
|
};
|
|
12519
12865
|
await fetchPage();
|
|
12520
12866
|
if (hasFlag("tail")) {
|
|
@@ -12663,14 +13009,14 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
12663
13009
|
const { api } = requireApi(selector(), { write: "send" });
|
|
12664
13010
|
const [userId, template] = rest;
|
|
12665
13011
|
if (!userId || !template) fail("usage: vxil send <user_id> <template> --data '<json>'");
|
|
12666
|
-
let
|
|
13012
|
+
let data3 = {};
|
|
12667
13013
|
const raw = flag("data");
|
|
12668
13014
|
try {
|
|
12669
|
-
|
|
13015
|
+
data3 = raw ? JSON.parse(raw) : {};
|
|
12670
13016
|
} catch {
|
|
12671
13017
|
fail("--data must be valid JSON");
|
|
12672
13018
|
}
|
|
12673
|
-
printEnvelope(await api("POST", "/v1/notifications/send", { user_id: userId, template, data:
|
|
13019
|
+
printEnvelope(await api("POST", "/v1/notifications/send", { user_id: userId, template, data: data3 }));
|
|
12674
13020
|
break;
|
|
12675
13021
|
}
|
|
12676
13022
|
case "deliveries": {
|
|
@@ -12699,6 +13045,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
12699
13045
|
default:
|
|
12700
13046
|
console.log(`vxil \u2014 a Supabase-grade code-first CLI for the whole platform
|
|
12701
13047
|
init [--template <id>] \xB7 templates \xB7 try (keyless sandbox) \xB7 quickstart [--dev --ttl <h>] \xB7 login \xB7 link <slug> [--key <k>] [--as dev|<name>] [--mint]
|
|
13048
|
+
keys mint --name <n> --scopes <a,b,c> [--env live|test] [--store] \xB7 keys list \xB7 keys revoke <key_id> [--yes] \u2014 a NARROW scoped key for a server (shown once; needs a vxil login session)
|
|
12702
13049
|
architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
|
|
12703
13050
|
plan [--explain] \xB7 push [--dev|--target <name>|--prod] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] \xB7 pull \xB7 gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>]
|
|
12704
13051
|
push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "The vxil CLI — init, quickstart, push, gen, secrets, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
|
|
5
|
+
"description": "The vxil CLI — init, quickstart, push, gen, secrets, keys, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://vxil.com",
|
|
8
8
|
"keywords": [
|