@vxil/cli 0.35.1 → 0.36.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.
Files changed (2) hide show
  1. package/dist/vxil-main.js +118 -11
  2. package/package.json +1 -1
package/dist/vxil-main.js CHANGED
@@ -7260,18 +7260,30 @@ var init_src = __esm({
7260
7260
  function rulesSummaryOf(r) {
7261
7261
  const s = r.body.data?.rules_summary;
7262
7262
  if (!isObj(s) || !Array.isArray(s.added) || !Array.isArray(s.removed)) return void 0;
7263
- return { added: s.added.filter(isLine), removed: s.removed.filter(isLine) };
7263
+ const dropped = Array.isArray(s.dropped_waiting) ? s.dropped_waiting.filter(isDropped) : [];
7264
+ return { added: s.added.filter(isLine), removed: s.removed.filter(isLine), ...dropped.length ? { dropped_waiting: dropped } : {} };
7265
+ }
7266
+ function droppedWaitingLine(list) {
7267
+ if (!list?.length) return null;
7268
+ const what = list.slice(0, 10).map((d) => `${oneLine(d.kind)} on ${oneLine(d.collection)}${d.quote ? ` ("${oneLine(d.quote)}")` : ""}`).join("; ");
7269
+ const more = list.length > 10 ? `; and ${list.length - 10} more` : "";
7270
+ return `${list.length} ${list.length === 1 ? "rule that was waiting for Apply was" : "rules that were waiting for Apply were"} dropped: ${what}${more}`;
7264
7271
  }
7265
7272
  function rulesInfoOf(r) {
7266
7273
  const s = r.body.data?.rules_info;
7267
7274
  return Array.isArray(s) ? s.map((v) => isLine(v) ? v : void 0) : void 0;
7268
7275
  }
7269
7276
  function formatRulesSummary(s, needsAck = false) {
7270
- if (!s.added.length && !s.removed.length) return " rules (server summary): no rule added or removed";
7277
+ const dropped = droppedWaitingLine(s.dropped_waiting);
7278
+ if (!s.added.length && !s.removed.length) {
7279
+ return dropped ? ` rules (server summary): no rule added or removed
7280
+ ! ${dropped}` : " rules (server summary): no rule added or removed";
7281
+ }
7271
7282
  return [
7272
7283
  " rules (server summary):",
7273
7284
  ...s.added.flatMap((l) => [` + ${oneLine(l.sentence)} ${enforcedTag(l)}`, ...unwrittenLine(l)]),
7274
- ...s.removed.map((l) => ` - ${oneLine(l.sentence)} ${enforcedTag(l)}${needsAck ? NEEDS_ACK : ""}`)
7285
+ ...s.removed.map((l) => ` - ${oneLine(l.sentence)} ${enforcedTag(l)}${needsAck ? NEEDS_ACK : ""}`),
7286
+ ...dropped ? [` ! ${dropped}`] : []
7275
7287
  ].join("\n");
7276
7288
  }
7277
7289
  function configChangeLines(feature, changes, summary, needsAck = false) {
@@ -7332,7 +7344,7 @@ ${tail}`;
7332
7344
  return `${head}export default defineConfig(${marked.slice(0, at)}${block}${marked.slice(at + needle.length)});
7333
7345
  ${tail}`;
7334
7346
  }
7335
- var isObj, isLine, oneLine, enforcedTag, NEEDS_ACK, unwrittenLine, RULES_MARK;
7347
+ var isObj, isLine, oneLine, isDropped, enforcedTag, NEEDS_ACK, unwrittenLine, RULES_MARK;
7336
7348
  var init_cmsRulesView = __esm({
7337
7349
  "src/cmsRulesView.ts"() {
7338
7350
  "use strict";
@@ -7341,6 +7353,7 @@ var init_cmsRulesView = __esm({
7341
7353
  isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
7342
7354
  isLine = (v) => isObj(v) && typeof v.app === "string" && typeof v.kind === "string" && typeof v.collection === "string" && typeof v.sentence === "string" && (v.enforced_by === "app" || v.enforced_by === "server");
7343
7355
  oneLine = (s) => s.replace(/[\r\n\u2028\u2029]+/g, " ").trim();
7356
+ isDropped = (v) => isObj(v) && typeof v.kind === "string" && typeof v.collection === "string" && typeof v.quote === "string";
7344
7357
  enforcedTag = (l) => l.enforced_by === "server" ? "[enforced_by: server]" : `[enforced_by: app \u2014 only writes through ${oneLine(l.app)}]`;
7345
7358
  NEEDS_ACK = " (needs --allow-rule-removal)";
7346
7359
  unwrittenLine = (l) => {
@@ -9019,8 +9032,8 @@ var init_lib = __esm({
9019
9032
  };
9020
9033
  CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
9021
9034
  VXIL_CONFIG_PKG_VERSION = "0.26.1";
9022
- VXIL_SDK_PKG_VERSION = "0.35.0";
9023
- VXIL_CLI_PKG_VERSION = "0.35.1";
9035
+ VXIL_SDK_PKG_VERSION = "0.35.2";
9036
+ VXIL_CLI_PKG_VERSION = "0.36.0";
9024
9037
  SCAFFOLD_NODE_TYPES_RANGE = "^24.0.0";
9025
9038
  SCAFFOLD_TSCONFIG = {
9026
9039
  compilerOptions: {
@@ -19364,6 +19377,7 @@ var EXTRA_KNOWN_FLAGS = [
19364
19377
  "include-empty",
19365
19378
  "include-password-hashes",
19366
19379
  "with-password-hashes",
19380
+ "password-hash-column",
19367
19381
  "with-oauth-identities",
19368
19382
  "keep-source-id",
19369
19383
  "keep-timestamps",
@@ -20458,7 +20472,7 @@ function inferMapping(source, opts = {}) {
20458
20472
  const cmsNames = [...cmsByName.keys()];
20459
20473
  const loadOrder = topoOrder(cmsNames, source.foreignKeys, profile?.table, warnings);
20460
20474
  if (source.authUsers?.present) {
20461
- residuals.push(source.authUsers.hasPasswordHash ? "passwords: hashes are NOT imported by default \u2014 `vxil migrate data --with-password-hashes` carries them (bcrypt, verified and rehashed at each user's first sign-in); without it users re-auth via magic-link/OTP/social/reset on first sign-in" : "auth: credentials do not transfer \u2014 users re-auth via their existing OTP/magic-link/social flow post-cutover");
20475
+ residuals.push(source.authUsers.hasPasswordHash ? "passwords: hashes are NOT imported by default \u2014 `vxil migrate data --with-password-hashes` carries them (bcrypt, verified and rehashed at each user's first sign-in; a generic-pg / csv-json source also names the column with `--password-hash-column <column>`); without it users re-auth via magic-link/OTP/social/reset on first sign-in" : "auth: credentials do not transfer \u2014 users re-auth via their existing OTP/magic-link/social flow post-cutover");
20462
20476
  }
20463
20477
  for (const chk of source.checks ?? []) {
20464
20478
  if (!cmsByName.has(lastSeg(chk.table))) continue;
@@ -21100,7 +21114,7 @@ function credentialEntry(row2, withHashes) {
21100
21114
  async function loadCredentials2({ deps, adapter, state, save, opts, delta = false }) {
21101
21115
  const result = { table: KEY, read: 0, written: 0, skipped: 0, errors: [], cursor: null };
21102
21116
  if (!adapter.readCredentials) {
21103
- result.errors.push("credentials: this source has no credential reader (supabase only today) \u2014 post the hashes yourself with the SDK `auth.users.import()`, or drop --with-password-hashes / --oidc-issuer");
21117
+ result.errors.push("credentials: this source has no credential reader \u2014 post the hashes yourself with the SDK `auth.users.import()`, or drop --with-password-hashes / --oidc-issuer");
21104
21118
  return result;
21105
21119
  }
21106
21120
  const cursors = cursorsFor(state, delta);
@@ -21115,14 +21129,16 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
21115
21129
  const reasons = /* @__PURE__ */ new Map();
21116
21130
  let would = deps.dryRun ? {} : null;
21117
21131
  let withPassword = 0;
21132
+ let refused = 0;
21118
21133
  let withLinks = 0;
21119
21134
  let linked = 0;
21120
- const readOpts = opts.withIdentities ? { identities: true } : void 0;
21135
+ const readOpts = opts.withIdentities || opts.withHashes ? { ...opts.withIdentities ? { identities: true } : {}, ...opts.withHashes ? { hashes: true } : {} } : void 0;
21121
21136
  for await (const batch of adapter.readCredentials(cursors[KEY] ?? void 0, readOpts)) {
21122
21137
  const entries = [];
21123
21138
  const digests = [];
21124
21139
  for (const row2 of batch.rows) {
21125
21140
  result.read++;
21141
+ if (opts.withHashes && row2.hash_refused) refused++;
21126
21142
  const entry = credentialEntry(row2, opts.withHashes);
21127
21143
  const h = rowHash({ ...entry, issuer: opts.oidcIssuer ?? null });
21128
21144
  if (sent[row2.id] === h) {
@@ -21204,6 +21220,9 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
21204
21220
  const list = [...reasons].map(([r, n]) => `${r}=${n}`).join(" ");
21205
21221
  deps.log(`credentials: ${[...reasons.values()].reduce((a, b2) => a + b2, 0)} user(s) not imported (${list}) \u2014 they sign in once another way (magic link, OTP, a reset); \`not_registered\` means the users step did not load them`);
21206
21222
  }
21223
+ if (refused > 0) {
21224
+ deps.log(`credentials: ${refused} password hash(es) are not bcrypt (cost 4\u201310), the only format the import verifies \u2014 ${deps.dryRun ? "they would NOT be sent" : "NOT sent"}; those users are imported without a password and sign in once another way (magic link, OTP, a reset)`);
21225
+ }
21207
21226
  result.cursor = deps.dryRun ? cursors[KEY] ?? null : null;
21208
21227
  return result;
21209
21228
  }
@@ -22512,7 +22531,7 @@ ${notes.length ? `${notes.join("\n")}
22512
22531
  `;
22513
22532
  }
22514
22533
  var STANDING_CAVEATS = [
22515
- "Passwords: imported only with `vxil migrate data --with-password-hashes` (supabase sources: bcrypt, verified and replaced at each user's first sign-in \u2014 no forced re-sign-in); without it, or for a hash it cannot accept, users re-auth once via magic-link/OTP/social/reset.",
22534
+ "Passwords: imported only with `vxil migrate data --with-password-hashes` (bcrypt, verified and replaced at each user's first sign-in \u2014 no forced re-sign-in; generic-pg / csv-json sources also name the hash column with `--password-hash-column <column>`); without it, or for a hash it cannot accept, users re-auth once via magic-link/OTP/social/reset.",
22516
22535
  "Push device tokens: vxil has no push provider \u2014 token rows migrate as ordinary owner-scoped cms app data, and sending is a function over egress (the push-notifications blueprint); or skip those tables and let clients re-enroll post-cutover.",
22517
22536
  "Subscriptions / entitlements / tier: NEVER seeded (provider-derived truth) \u2014 rebuild from your live provider webhooks after cutover.",
22518
22537
  "DB triggers / RPCs / pg_cron / row-level access rules: not auto-migrated \u2014 hand-wire as vxil functions (cms-hook/cron/http triggers; cms-hooks are async post-commit, never in-transaction) + per-collection ownerField.",
@@ -24892,6 +24911,36 @@ export default defineConfig({
24892
24911
  import { readFileSync as readFileSync8, readdirSync, existsSync as existsSync8, statSync } from "node:fs";
24893
24912
  import { basename as basename3, extname, join as join4, resolve as resolve6 } from "node:path";
24894
24913
 
24914
+ // src/migrate/adapters/hashImport.ts
24915
+ var BCRYPT_IMPORT_RE = /^\$2[aby]\$(\d{2})\$[./A-Za-z0-9]{53}$/;
24916
+ var BCRYPT_IMPORT_MIN_COST = 4;
24917
+ var BCRYPT_IMPORT_MAX_COST = 10;
24918
+ function importableBcrypt(v) {
24919
+ if (typeof v !== "string") return null;
24920
+ const h = v.trim();
24921
+ const m = BCRYPT_IMPORT_RE.exec(h);
24922
+ if (!m) return null;
24923
+ const cost = Number(m[1]);
24924
+ return cost >= BCRYPT_IMPORT_MIN_COST && cost <= BCRYPT_IMPORT_MAX_COST ? h : null;
24925
+ }
24926
+ function genericCredentialRow(id, raw) {
24927
+ const empty = raw === null || raw === void 0 || typeof raw === "string" && raw.trim() === "";
24928
+ const hash = empty ? null : importableBcrypt(raw);
24929
+ return {
24930
+ id,
24931
+ password_hash: hash,
24932
+ // a generic source has no confirmed-address column vxil can trust
24933
+ email_verified: false,
24934
+ ...!empty && hash === null ? { hash_refused: true } : {}
24935
+ };
24936
+ }
24937
+ function missingHashColumnError(from) {
24938
+ return new Error(`--from ${from} --with-password-hashes needs --password-hash-column <column>: the auth table column that holds each user's bcrypt hash (only bcrypt is imported; other formats are reported and not sent)`);
24939
+ }
24940
+ function noIdentitiesError(from) {
24941
+ return new Error(`--with-oauth-identities reads Supabase auth.identities \u2014 a ${from} source has none; drop the flag`);
24942
+ }
24943
+
24895
24944
  // src/migrate/adapters/identity.ts
24896
24945
  var IDENTITY_COLUMNS = ["id", "email", "display_name", "avatar_url", "raw_user_meta_data"];
24897
24946
  function splitIdentityColumns(columns) {
@@ -25013,11 +25062,13 @@ var CsvJsonAdapter = class {
25013
25062
  dir;
25014
25063
  batchSize;
25015
25064
  sampleSize;
25065
+ passwordHashColumn;
25016
25066
  cache;
25017
25067
  constructor(opts) {
25018
25068
  this.dir = resolve6(opts.dir);
25019
25069
  this.batchSize = opts.batchSize ?? 500;
25020
25070
  this.sampleSize = opts.sampleSize ?? 200;
25071
+ this.passwordHashColumn = opts.passwordHashColumn;
25021
25072
  }
25022
25073
  load() {
25023
25074
  if (this.cache) return this.cache;
@@ -25264,6 +25315,28 @@ var CsvJsonAdapter = class {
25264
25315
  };
25265
25316
  }
25266
25317
  }
25318
+ /** The opt-in credentials read — the authTable's `id` plus the
25319
+ * ONE column the operator named (--password-hash-column), bcrypt only
25320
+ * (hashImport.ts); without --with-password-hashes no hash is read. */
25321
+ async *readCredentials(cursor, opts = {}) {
25322
+ if (opts.identities) throw noIdentitiesError("csv-json");
25323
+ const { schema, tables } = this.load();
25324
+ if (!schema.authUsers?.present || !schema.authUsers.table) {
25325
+ throw new Error("csv-json: no authTable declared in _schema.json \u2014 credentials step unavailable");
25326
+ }
25327
+ const t = tables.get(schema.authUsers.table);
25328
+ const hashCol = opts.hashes ? this.passwordHashColumn : void 0;
25329
+ if (opts.hashes && hashCol === void 0) throw missingHashColumnError("csv-json");
25330
+ if (hashCol !== void 0 && !t.columns.some((c) => c.name === hashCol)) {
25331
+ throw new Error(`--password-hash-column '${hashCol}' is not a column of the authTable '${t.name}'`);
25332
+ }
25333
+ for await (const batch of this.paged(t.rows, cursor)) {
25334
+ yield {
25335
+ rows: batch.rows.filter((r) => r.id !== null && r.id !== void 0 && r.id !== "").map((r) => genericCredentialRow(String(r.id), hashCol !== void 0 ? r[hashCol] : null)),
25336
+ nextCursor: batch.nextCursor
25337
+ };
25338
+ }
25339
+ }
25267
25340
  async *paged(all, cursor) {
25268
25341
  let offset = 0;
25269
25342
  if (cursor !== void 0 && cursor !== "") {
@@ -25549,6 +25622,7 @@ var GenericPgAdapter = class {
25549
25622
  schemas;
25550
25623
  batchSize;
25551
25624
  authTable;
25625
+ passwordHashColumn;
25552
25626
  redactedUrl;
25553
25627
  /** subclass override point (supabase sets 'supabase'). */
25554
25628
  providerName = "generic-pg";
@@ -25561,6 +25635,7 @@ var GenericPgAdapter = class {
25561
25635
  this.batchSize = opts.batchSize ?? 500;
25562
25636
  this.statementTimeoutMs = Math.trunc(opts.statementTimeoutMs ?? 12e4);
25563
25637
  this.authTable = opts.authTable;
25638
+ this.passwordHashColumn = opts.passwordHashColumn;
25564
25639
  this.redactedUrl = redactSourceUrl(opts.url);
25565
25640
  this.sql = src_default(opts.url, {
25566
25641
  max: 1,
@@ -25858,6 +25933,33 @@ var GenericPgAdapter = class {
25858
25933
  const t = await this.resolveTable(source.authUsers.table);
25859
25934
  yield* this.pagedRead(t, this.identityColumns(t).keep, cursor);
25860
25935
  }
25936
+ /** The opt-in credentials read of a generic source — the auth
25937
+ * table's `id` plus the ONE column the operator named
25938
+ * (--password-hash-column), bcrypt only (hashImport.ts). The hashes are
25939
+ * held in memory and sent only in the import body, never logged. Without
25940
+ * --with-password-hashes (an --oidc-issuer-only run) no hash is read. */
25941
+ async *readCredentials(cursor, opts = {}) {
25942
+ if (opts.identities) throw noIdentitiesError(this.providerName);
25943
+ const source = await this.introspect();
25944
+ if (!source.authUsers?.present || !source.authUsers.table) {
25945
+ throw new Error("no auth table configured \u2014 pass --auth-table for generic-pg sources");
25946
+ }
25947
+ const t = await this.resolveTable(source.authUsers.table);
25948
+ const hashCol = opts.hashes ? this.passwordHashColumn : void 0;
25949
+ if (opts.hashes && hashCol === void 0) throw missingHashColumnError(this.providerName);
25950
+ if (hashCol !== void 0 && !t.columns.some((c) => c.name === hashCol)) {
25951
+ throw new Error(`--password-hash-column '${hashCol}' is not a column of the auth table '${t.name}'`);
25952
+ }
25953
+ if (!t.columns.some((c) => c.name === "id")) throw new Error(`the auth table '${t.name}' has no id column`);
25954
+ const pk = t.columns.filter((c) => c.isPk).map((c) => c.name);
25955
+ const cols = [.../* @__PURE__ */ new Set(["id", ...hashCol !== void 0 ? [hashCol] : [], ...pk.length === 1 ? pk : []])];
25956
+ for await (const batch of this.pagedRead(t, cols, cursor)) {
25957
+ yield {
25958
+ rows: batch.rows.filter((r) => r.id !== null && r.id !== void 0 && r.id !== "").map((r) => genericCredentialRow(String(r.id), hashCol !== void 0 ? r[hashCol] : null)),
25959
+ nextCursor: batch.nextCursor
25960
+ };
25961
+ }
25962
+ }
25861
25963
  async close() {
25862
25964
  await this.sql.end({ timeout: 5 });
25863
25965
  }
@@ -26128,6 +26230,7 @@ function pgOpts(opts) {
26128
26230
  url: requireUrl(opts),
26129
26231
  ...opts.schemas !== void 0 ? { schemas: opts.schemas } : {},
26130
26232
  ...opts.authTable !== void 0 ? { authTable: opts.authTable } : {},
26233
+ ...opts.passwordHashColumn !== void 0 ? { passwordHashColumn: opts.passwordHashColumn } : {},
26131
26234
  ...opts.batchSize !== void 0 ? { batchSize: opts.batchSize } : {},
26132
26235
  ...opts.statementTimeoutMs !== void 0 ? { statementTimeoutMs: opts.statementTimeoutMs } : {}
26133
26236
  };
@@ -26142,6 +26245,7 @@ var registry = /* @__PURE__ */ new Map([
26142
26245
  if (!opts.dir) throw new Error("--from csv-json needs --dir <export-directory>");
26143
26246
  return new CsvJsonAdapter({
26144
26247
  dir: opts.dir,
26248
+ ...opts.passwordHashColumn !== void 0 ? { passwordHashColumn: opts.passwordHashColumn } : {},
26145
26249
  ...opts.batchSize !== void 0 ? { batchSize: opts.batchSize } : {}
26146
26250
  });
26147
26251
  }]
@@ -30257,7 +30361,8 @@ function migrateUsage() {
30257
30361
  step on the server: created/kept/skipped by reason, nothing written) \xB7 [--file-links shared-link] stored
30258
30362
  public file URLs become stable shared links instead of object ids
30259
30363
  \xB7 [--with-oauth-identities] carry the Google/Apple/GitHub/Facebook sign-in links (needs
30260
- --with-password-hashes in the same run) \xB7 [--keep-timestamps] keep each row's source
30364
+ --with-password-hashes in the same run) \xB7 generic-pg / csv-json: --with-password-hashes
30365
+ --password-hash-column <column> (bcrypt only; other formats are reported, not sent) \xB7 [--keep-timestamps] keep each row's source
30261
30366
  created_at / updated_at (cms creates; server key only)
30262
30367
  \xB7 [--files-owner <user id>] the owner of every migrated file (default: the path's first
30263
30368
  segment) \xB7 [--files-org-from-prefix] the path's first segment is the WORKSPACE (org_id):
@@ -30280,6 +30385,7 @@ ${migrateUsage()}`);
30280
30385
  const url = flag("url");
30281
30386
  const dir = flag("dir");
30282
30387
  const authTable = flag("auth-table");
30388
+ const passwordHashColumn = flag("password-hash-column");
30283
30389
  const schemas = flag("schemas");
30284
30390
  const batch = flag("batch");
30285
30391
  const sbUrl = process.env.SUPABASE_URL;
@@ -30289,6 +30395,7 @@ ${migrateUsage()}`);
30289
30395
  ...url !== void 0 ? { url } : {},
30290
30396
  ...dir !== void 0 ? { dir } : {},
30291
30397
  ...authTable !== void 0 ? { authTable } : {},
30398
+ ...passwordHashColumn !== void 0 ? { passwordHashColumn } : {},
30292
30399
  ...schemas !== void 0 ? { schemas: schemas.split(",").map((s) => s.trim()).filter(Boolean) } : {},
30293
30400
  ...batch !== void 0 ? { batchSize: Number(batch) } : {},
30294
30401
  ...from === "supabase" && sbUrl && sbKey ? { storage: { url: sbUrl, serviceKey: sbKey } } : {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/cli",
3
- "version": "0.35.1",
3
+ "version": "0.36.0",
4
4
  "private": false,
5
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",