@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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/dist/vxil.js +380 -33
  3. 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 data2 = res.body.data;
784
- const cmsHooks = data2?.cms_hook_subscriptions ?? null;
785
- const webhooks = data2?.webhook_subscriptions ?? null;
786
- const warnings2 = (data2?.warnings ?? []).map((message) => ({ fn: name, message: String(message) }));
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, data2) {
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(data2));
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 data2(x) {
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", data2);
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", data2);
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", data2);
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", data2);
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 data2(x2) {
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: data2 }] = await lo.read(l);
3187
- this.push(data2);
3188
- if (data2.length < size2)
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 linkViaSession(dash, cookie, slug, keyName) {
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 data2 = {};
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
- data2[f.target] = mapped;
7394
+ data3[f.target] = mapped;
7178
7395
  continue;
7179
7396
  }
7180
7397
  if (f.storageObject) {
7181
- data2[f.target] = resolveObjectId(String(raw), filesMaps) ?? String(raw);
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
- data2[f.target] = coerced.value;
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 && data2[dedupeField.target] !== void 0) {
7199
- const filter = encodeURIComponent(JSON.stringify({ [dedupeField.target]: data2[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: data2, status: itemStatus });
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.stdout.write(q);
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.stdout.write("\n");
11367
+ process.stderr.write("\n");
11151
11368
  resolve8(val);
11152
11369
  } else if (ch === "") {
11153
11370
  stdin.setRawMode(false);
11154
- process.stdout.write("\n");
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.stdout });
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 data2 = res.body.data;
12516
- if (data2.lines.length) console.log(formatLogLines(data2.lines));
12517
- if (data2.next_since) since = data2.next_since;
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 data2 = {};
13012
+ let data3 = {};
12667
13013
  const raw = flag("data");
12668
13014
  try {
12669
- data2 = raw ? JSON.parse(raw) : {};
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: data2 }));
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.2",
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": [