@vxil/cli 0.35.2 → 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 +101 -7
  2. package/package.json +1 -1
package/dist/vxil-main.js CHANGED
@@ -9032,8 +9032,8 @@ var init_lib = __esm({
9032
9032
  };
9033
9033
  CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
9034
9034
  VXIL_CONFIG_PKG_VERSION = "0.26.1";
9035
- VXIL_SDK_PKG_VERSION = "0.35.1";
9036
- VXIL_CLI_PKG_VERSION = "0.35.2";
9035
+ VXIL_SDK_PKG_VERSION = "0.35.2";
9036
+ VXIL_CLI_PKG_VERSION = "0.36.0";
9037
9037
  SCAFFOLD_NODE_TYPES_RANGE = "^24.0.0";
9038
9038
  SCAFFOLD_TSCONFIG = {
9039
9039
  compilerOptions: {
@@ -19377,6 +19377,7 @@ var EXTRA_KNOWN_FLAGS = [
19377
19377
  "include-empty",
19378
19378
  "include-password-hashes",
19379
19379
  "with-password-hashes",
19380
+ "password-hash-column",
19380
19381
  "with-oauth-identities",
19381
19382
  "keep-source-id",
19382
19383
  "keep-timestamps",
@@ -20471,7 +20472,7 @@ function inferMapping(source, opts = {}) {
20471
20472
  const cmsNames = [...cmsByName.keys()];
20472
20473
  const loadOrder = topoOrder(cmsNames, source.foreignKeys, profile?.table, warnings);
20473
20474
  if (source.authUsers?.present) {
20474
- 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");
20475
20476
  }
20476
20477
  for (const chk of source.checks ?? []) {
20477
20478
  if (!cmsByName.has(lastSeg(chk.table))) continue;
@@ -21113,7 +21114,7 @@ function credentialEntry(row2, withHashes) {
21113
21114
  async function loadCredentials2({ deps, adapter, state, save, opts, delta = false }) {
21114
21115
  const result = { table: KEY, read: 0, written: 0, skipped: 0, errors: [], cursor: null };
21115
21116
  if (!adapter.readCredentials) {
21116
- 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");
21117
21118
  return result;
21118
21119
  }
21119
21120
  const cursors = cursorsFor(state, delta);
@@ -21128,14 +21129,16 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
21128
21129
  const reasons = /* @__PURE__ */ new Map();
21129
21130
  let would = deps.dryRun ? {} : null;
21130
21131
  let withPassword = 0;
21132
+ let refused = 0;
21131
21133
  let withLinks = 0;
21132
21134
  let linked = 0;
21133
- 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;
21134
21136
  for await (const batch of adapter.readCredentials(cursors[KEY] ?? void 0, readOpts)) {
21135
21137
  const entries = [];
21136
21138
  const digests = [];
21137
21139
  for (const row2 of batch.rows) {
21138
21140
  result.read++;
21141
+ if (opts.withHashes && row2.hash_refused) refused++;
21139
21142
  const entry = credentialEntry(row2, opts.withHashes);
21140
21143
  const h = rowHash({ ...entry, issuer: opts.oidcIssuer ?? null });
21141
21144
  if (sent[row2.id] === h) {
@@ -21217,6 +21220,9 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
21217
21220
  const list = [...reasons].map(([r, n]) => `${r}=${n}`).join(" ");
21218
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`);
21219
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
+ }
21220
21226
  result.cursor = deps.dryRun ? cursors[KEY] ?? null : null;
21221
21227
  return result;
21222
21228
  }
@@ -22525,7 +22531,7 @@ ${notes.length ? `${notes.join("\n")}
22525
22531
  `;
22526
22532
  }
22527
22533
  var STANDING_CAVEATS = [
22528
- "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.",
22529
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.",
22530
22536
  "Subscriptions / entitlements / tier: NEVER seeded (provider-derived truth) \u2014 rebuild from your live provider webhooks after cutover.",
22531
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.",
@@ -24905,6 +24911,36 @@ export default defineConfig({
24905
24911
  import { readFileSync as readFileSync8, readdirSync, existsSync as existsSync8, statSync } from "node:fs";
24906
24912
  import { basename as basename3, extname, join as join4, resolve as resolve6 } from "node:path";
24907
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
+
24908
24944
  // src/migrate/adapters/identity.ts
24909
24945
  var IDENTITY_COLUMNS = ["id", "email", "display_name", "avatar_url", "raw_user_meta_data"];
24910
24946
  function splitIdentityColumns(columns) {
@@ -25026,11 +25062,13 @@ var CsvJsonAdapter = class {
25026
25062
  dir;
25027
25063
  batchSize;
25028
25064
  sampleSize;
25065
+ passwordHashColumn;
25029
25066
  cache;
25030
25067
  constructor(opts) {
25031
25068
  this.dir = resolve6(opts.dir);
25032
25069
  this.batchSize = opts.batchSize ?? 500;
25033
25070
  this.sampleSize = opts.sampleSize ?? 200;
25071
+ this.passwordHashColumn = opts.passwordHashColumn;
25034
25072
  }
25035
25073
  load() {
25036
25074
  if (this.cache) return this.cache;
@@ -25277,6 +25315,28 @@ var CsvJsonAdapter = class {
25277
25315
  };
25278
25316
  }
25279
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
+ }
25280
25340
  async *paged(all, cursor) {
25281
25341
  let offset = 0;
25282
25342
  if (cursor !== void 0 && cursor !== "") {
@@ -25562,6 +25622,7 @@ var GenericPgAdapter = class {
25562
25622
  schemas;
25563
25623
  batchSize;
25564
25624
  authTable;
25625
+ passwordHashColumn;
25565
25626
  redactedUrl;
25566
25627
  /** subclass override point (supabase sets 'supabase'). */
25567
25628
  providerName = "generic-pg";
@@ -25574,6 +25635,7 @@ var GenericPgAdapter = class {
25574
25635
  this.batchSize = opts.batchSize ?? 500;
25575
25636
  this.statementTimeoutMs = Math.trunc(opts.statementTimeoutMs ?? 12e4);
25576
25637
  this.authTable = opts.authTable;
25638
+ this.passwordHashColumn = opts.passwordHashColumn;
25577
25639
  this.redactedUrl = redactSourceUrl(opts.url);
25578
25640
  this.sql = src_default(opts.url, {
25579
25641
  max: 1,
@@ -25871,6 +25933,33 @@ var GenericPgAdapter = class {
25871
25933
  const t = await this.resolveTable(source.authUsers.table);
25872
25934
  yield* this.pagedRead(t, this.identityColumns(t).keep, cursor);
25873
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
+ }
25874
25963
  async close() {
25875
25964
  await this.sql.end({ timeout: 5 });
25876
25965
  }
@@ -26141,6 +26230,7 @@ function pgOpts(opts) {
26141
26230
  url: requireUrl(opts),
26142
26231
  ...opts.schemas !== void 0 ? { schemas: opts.schemas } : {},
26143
26232
  ...opts.authTable !== void 0 ? { authTable: opts.authTable } : {},
26233
+ ...opts.passwordHashColumn !== void 0 ? { passwordHashColumn: opts.passwordHashColumn } : {},
26144
26234
  ...opts.batchSize !== void 0 ? { batchSize: opts.batchSize } : {},
26145
26235
  ...opts.statementTimeoutMs !== void 0 ? { statementTimeoutMs: opts.statementTimeoutMs } : {}
26146
26236
  };
@@ -26155,6 +26245,7 @@ var registry = /* @__PURE__ */ new Map([
26155
26245
  if (!opts.dir) throw new Error("--from csv-json needs --dir <export-directory>");
26156
26246
  return new CsvJsonAdapter({
26157
26247
  dir: opts.dir,
26248
+ ...opts.passwordHashColumn !== void 0 ? { passwordHashColumn: opts.passwordHashColumn } : {},
26158
26249
  ...opts.batchSize !== void 0 ? { batchSize: opts.batchSize } : {}
26159
26250
  });
26160
26251
  }]
@@ -30270,7 +30361,8 @@ function migrateUsage() {
30270
30361
  step on the server: created/kept/skipped by reason, nothing written) \xB7 [--file-links shared-link] stored
30271
30362
  public file URLs become stable shared links instead of object ids
30272
30363
  \xB7 [--with-oauth-identities] carry the Google/Apple/GitHub/Facebook sign-in links (needs
30273
- --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
30274
30366
  created_at / updated_at (cms creates; server key only)
30275
30367
  \xB7 [--files-owner <user id>] the owner of every migrated file (default: the path's first
30276
30368
  segment) \xB7 [--files-org-from-prefix] the path's first segment is the WORKSPACE (org_id):
@@ -30293,6 +30385,7 @@ ${migrateUsage()}`);
30293
30385
  const url = flag("url");
30294
30386
  const dir = flag("dir");
30295
30387
  const authTable = flag("auth-table");
30388
+ const passwordHashColumn = flag("password-hash-column");
30296
30389
  const schemas = flag("schemas");
30297
30390
  const batch = flag("batch");
30298
30391
  const sbUrl = process.env.SUPABASE_URL;
@@ -30302,6 +30395,7 @@ ${migrateUsage()}`);
30302
30395
  ...url !== void 0 ? { url } : {},
30303
30396
  ...dir !== void 0 ? { dir } : {},
30304
30397
  ...authTable !== void 0 ? { authTable } : {},
30398
+ ...passwordHashColumn !== void 0 ? { passwordHashColumn } : {},
30305
30399
  ...schemas !== void 0 ? { schemas: schemas.split(",").map((s) => s.trim()).filter(Boolean) } : {},
30306
30400
  ...batch !== void 0 ? { batchSize: Number(batch) } : {},
30307
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.2",
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",