@timekast/factory 1.25.0 → 1.27.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.
@@ -94,6 +94,8 @@ export async function runAdd(flags = { core: false, full: false }) {
94
94
  // `add` is interactive: keep the legacy confirm prompt. Only the GUI/headless
95
95
  // caller passes `--yes` (via `update` directly), never this redirect.
96
96
  yes: false,
97
+ // The legacy `add` redirect never requests the GUI signal — only `update --launcher` does.
98
+ launcher: false,
97
99
  });
98
100
  return;
99
101
  }
@@ -13,8 +13,9 @@
13
13
  * post-bootstrap, and that the patch channel for `src/` is EPIC 2 (agentic
14
14
  * update, not yet available).
15
15
  * - MISSING INITIAL MIGRATION: a derivative that ships a Drizzle schema and no
16
- * generated `.sql`, so its `vercel-build` (`pnpm db:migrate && pnpm build`)
17
- * would migrate an empty database. INFORMATIONAL — see below.
16
+ * generated `.sql`, so its `vercel-build` (`pnpm build && pnpm db:migrate`)
17
+ * has nothing to apply and its first deploy never creates the tables.
18
+ * INFORMATIONAL — see below.
18
19
  *
19
20
  * No lockfile (pre-CLI repo) → delegate to the "run factory:update" advice, exit
20
21
  * 0, never abort (same pattern as `status` / the §8 edge case).
@@ -65,8 +66,8 @@ export const A1_WARNING = 'Aviso de seguridad (A1): el boilerplate `src/` y sus
65
66
  */
66
67
  export const MISSING_INITIAL_MIGRATION_NOTICE = `⚠ Falta la migración inicial: hay schema en \`${SCHEMA_DIR}/\` y ningún \`.sql\` en ` +
67
68
  `\`${MIGRATIONS_DIR}/\`.\n` +
68
- ' El deploy corre `pnpm db:migrate && pnpm build`, así que el primer build migraría una ' +
69
- 'base vacía. Genera la migración con `pnpm db:generate` y commitéala antes del próximo ' +
69
+ ' El deploy corre `pnpm build && pnpm db:migrate`, así que el primer deploy no crearía ' +
70
+ 'ninguna tabla. Genera la migración con `pnpm db:generate` y commitéala antes del próximo ' +
70
71
  'deploy.\n' +
71
72
  ' (Aviso informativo: `factory doctor` no bloquea — sale con éxito de todos modos.)';
72
73
  /** Parse `doctor`'s argv slice into flags. Unknown flags are ignored. */
@@ -13,7 +13,7 @@
13
13
  import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { CLIError } from '../lib/cli-error.js';
16
- import { PROVISION_OWNED, setEnvLine } from '../lib/env-file.js';
16
+ import { PER_ENV_MARKER, PER_ENV_REGISTRY_FILE, PROVISION_OWNED, addToPerEnvRegistry, findInlinePerEnvMarkers, findPerEnvMarkedKeys, listEnvKeys, readPerEnvRegistry, setEnvLine, } from '../lib/env-file.js';
17
17
  import { applyDefaultOverrides, applyWizardAnswers, buildEnvReview, buildQuestionPlan, checkEnvCompleteness, generateVapidKeys, isQuestionActive, parseEnvBody, parseEnvExample, provisionEmailDefaults, } from '../lib/env-wizard.js';
18
18
  import { defaultEnvPath, readVercelToken } from '../lib/provision-preflight.js';
19
19
  import { readProvisionState } from '../lib/provision-state.js';
@@ -30,23 +30,40 @@ export const REDEPLOY_NOTICE = 'ℹ️ Las variables aplican en el próximo dep
30
30
  export function parseEnvPushFlags(argv) {
31
31
  return { dryRun: argv.includes('--dry-run') };
32
32
  }
33
+ /** No extra exclusions — the common case (the derivative declared nothing). */
34
+ const NO_PER_ENV_KEYS = new Set();
33
35
  /**
34
- * Parse `.env.local` into the product vars to push: skip comments/blanks, the
35
- * provision-owned keys, and empty values. First-`=` split; surrounding quotes
36
- * stripped (mirrors the rail reader in provision.ts).
36
+ * Read `.env.local` as text, or `''` when it is absent or unreadable.
37
+ *
38
+ * Fail-SAFE on purpose, and the polarity is the opposite of `readPerEnvRegistry`:
39
+ * this file is the source of VALUES, so an empty body means "there is nothing to
40
+ * push" — the harmless direction. The registry is the source of EXCLUSIONS, where
41
+ * an empty result would mean "push everything", so it throws instead. Do NOT
42
+ * copy this `catch` over there.
37
43
  */
38
- export function readProductVars(filePath) {
39
- const out = {};
44
+ function readEnvBody(filePath) {
40
45
  if (!existsSync(filePath))
41
- return out;
42
- let text;
46
+ return '';
43
47
  try {
44
- text = readFileSync(filePath, 'utf8');
48
+ return readFileSync(filePath, 'utf8');
45
49
  }
46
50
  catch {
47
- return out;
51
+ return '';
48
52
  }
49
- for (const line of text.split('\n')) {
53
+ }
54
+ /**
55
+ * Parse an `.env.local` body into the product vars to push: skip comments/blanks,
56
+ * the provision-owned keys, the per-environment keys the derivative declared, and
57
+ * empty values. First-`=` split; surrounding quotes stripped (mirrors the rail
58
+ * reader in provision.ts).
59
+ *
60
+ * `perEnvKeys` only ever SUBTRACTS from the push — it is applied ALONGSIDE
61
+ * `PROVISION_OWNED` (a union), never instead of it, so a derivative cannot name a
62
+ * provision-owned key to opt it back in.
63
+ */
64
+ export function parseProductVars(body, perEnvKeys = NO_PER_ENV_KEYS) {
65
+ const out = {};
66
+ for (const line of body.split('\n')) {
50
67
  const trimmed = line.trimStart();
51
68
  if (trimmed === '' || trimmed.startsWith('#'))
52
69
  continue;
@@ -54,7 +71,7 @@ export function readProductVars(filePath) {
54
71
  if (eq === -1)
55
72
  continue;
56
73
  const key = line.slice(0, eq).trim();
57
- if (key === '' || PROVISION_OWNED.has(key))
74
+ if (key === '' || PROVISION_OWNED.has(key) || perEnvKeys.has(key))
58
75
  continue;
59
76
  let value = line.slice(eq + 1).replace(/\s+$/, '');
60
77
  if (value.length >= 2 &&
@@ -68,6 +85,10 @@ export function readProductVars(filePath) {
68
85
  }
69
86
  return out;
70
87
  }
88
+ /** {@link parseProductVars} over the file at `filePath`. */
89
+ export function readProductVars(filePath, perEnvKeys = NO_PER_ENV_KEYS) {
90
+ return parseProductVars(readEnvBody(filePath), perEnvKeys);
91
+ }
71
92
  /** Read `{ projectId, teamId }` from `.vercel/project.json` (written by provision). */
72
93
  function readProjectLink(rootDir) {
73
94
  const file = vercelProjectPath(rootDir);
@@ -90,13 +111,65 @@ function readProjectLink(rootDir) {
90
111
  return { projectId, teamId };
91
112
  }
92
113
  export async function runEnvPush(flags, rootDir = process.cwd()) {
93
- const productVars = readProductVars(path.join(rootDir, '.env.local'));
114
+ const body = readEnvBody(path.join(rootDir, '.env.local'));
115
+ // Resolve the derivative's per-environment declaration BEFORE anything can reach
116
+ // Vercel: a present-but-broken registry throws here and aborts the whole run
117
+ // (`--dry-run` included), instead of showing a plan as if nothing were protected.
118
+ const registry = readPerEnvRegistry(rootDir);
119
+ const marked = findPerEnvMarkedKeys(body);
120
+ const pendingAdds = marked.filter((key) => !registry.keys.includes(key));
121
+ // Reconciliation: a marker the registry lacks is APPENDED to it (add-only — see
122
+ // `addToPerEnvRegistry`). `--dry-run` reports it and writes nothing.
123
+ if (pendingAdds.length > 0 && !flags.dryRun) {
124
+ addToPerEnvRegistry(rootDir, registry, pendingAdds);
125
+ }
126
+ // Protected = registry ∪ marker ∪ PROVISION_OWNED. A union, never a replacement:
127
+ // the derivative's declaration can only SUBTRACT variables from the push.
128
+ const perEnvKeys = new Set([...registry.keys, ...marked]);
129
+ const productVars = parseProductVars(body, perEnvKeys);
94
130
  const keys = Object.keys(productVars).sort();
95
- if (keys.length === 0) {
96
- console.log('No hay variables de producto que subir en `.env.local` ' +
97
- '(todo es provision-owned o está vacío).');
98
- return;
131
+ const envKeys = listEnvKeys(body);
132
+ const omittedProvisionOwned = envKeys.filter((key) => PROVISION_OWNED.has(key)).sort();
133
+ // Provision-owned wins the label, so a redundant declaration is listed ONCE.
134
+ const omittedPerEnv = envKeys
135
+ .filter((key) => perEnvKeys.has(key) && !PROVISION_OWNED.has(key))
136
+ .sort();
137
+ const declaredButAbsent = [...perEnvKeys].filter((key) => !envKeys.includes(key)).sort();
138
+ if (pendingAdds.length > 0) {
139
+ const names = pendingAdds.join(', ');
140
+ console.log(flags.dryRun
141
+ ? `ℹ️ Agregaría a \`${PER_ENV_REGISTRY_FILE}\` por el marcador ` +
142
+ `\`${PER_ENV_MARKER}\`: ${names} (--dry-run: no escribí nada).`
143
+ : `✔ Agregué a \`${PER_ENV_REGISTRY_FILE}\` (marcador \`${PER_ENV_MARKER}\`): ${names}.`);
144
+ }
145
+ // 🔴 An INLINE marker ABORTS, it does not warn. Warning and pushing anyway shipped a value the
146
+ // code had ALREADY PROVEN corrupt: `parseProductVars` splits on the first `=` and strips no
147
+ // inline comment, so `KEY="x" # @per-env` uploads the literal `"x" # @per-env`. Aborting is
148
+ // exactly as loud as the warning — the dev sees the wrong form and fixes it — and it is the
149
+ // only option that also protects. Filtered to keys the registry does NOT already cover: for a
150
+ // protected one the diagnosis would be false (it does not go up at all), and a message that
151
+ // sometimes lies trains everyone to skip reading it.
152
+ const inlineMarked = findInlinePerEnvMarkers(body).filter((key) => !perEnvKeys.has(key));
153
+ if (inlineMarked.length > 0) {
154
+ throw new CLIError(`${inlineMarked.map((key) => `\`${key}\``).join(', ')} lleva \`${PER_ENV_MARKER}\` en la MISMA ` +
155
+ 'línea de la asignación. Ahí no protege nada, y además el valor subiría con el comentario ' +
156
+ 'pegado (a Vercel llegaría el texto literal, comillas incluidas).\n' +
157
+ ` El marcador va en un comentario en la línea de ARRIBA (\`SK.md §7.2\`). Aborté el push.`);
158
+ }
159
+ for (const key of declaredButAbsent) {
160
+ console.log(`⚠ \`${key}\` está declarada por ambiente pero no aparece en \`.env.local\` ` +
161
+ '— revisa si es un typo: así no está protegiendo nada.');
99
162
  }
163
+ /** Names + reason, NEVER values: the omitted vars are the secret-bearing ones. */
164
+ const printOmitted = () => {
165
+ if (omittedProvisionOwned.length === 0 && omittedPerEnv.length === 0)
166
+ return;
167
+ console.log('\nOmitidas (no se envían):');
168
+ for (const key of omittedProvisionOwned)
169
+ console.log(` ${key} — provision-owned`);
170
+ for (const key of omittedPerEnv)
171
+ console.log(` ${key} — declarada por ambiente`);
172
+ };
100
173
  const targets = ['production', 'preview'];
101
174
  const plan = keys.map((key) => ({
102
175
  key,
@@ -104,12 +177,23 @@ export async function runEnvPush(flags, rootDir = process.cwd()) {
104
177
  target: targets,
105
178
  }));
106
179
  if (flags.dryRun) {
107
- console.log(`Plan (${plan.length} vars → production, preview):`);
108
- for (const key of keys)
109
- console.log(` ${key}`);
180
+ if (keys.length === 0) {
181
+ console.log('No hay variables de producto que subir en `.env.local`.');
182
+ }
183
+ else {
184
+ console.log(`Plan (${plan.length} vars → production, preview):`);
185
+ for (const key of keys)
186
+ console.log(` ${key}`);
187
+ }
188
+ printOmitted();
110
189
  console.log('\n(--dry-run: nada se envió a Vercel.)');
111
190
  return;
112
191
  }
192
+ if (keys.length === 0) {
193
+ console.log('No hay variables de producto que subir en `.env.local` ' +
194
+ '(todo es provision-owned, está declarado por ambiente, o está vacío).');
195
+ return;
196
+ }
113
197
  const { projectId, teamId } = readProjectLink(rootDir);
114
198
  const token = readVercelToken(defaultEnvPath());
115
199
  if (!token) {
@@ -219,9 +303,11 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
219
303
  }
220
304
  /**
221
305
  * `factory env wizard --review [--json]` — a secret-safe summary of the resolved
222
- * `.env.local`. Non-secret values are shown; secret values are masked; empty ones
223
- * are flagged. This is what the guided flow shows the dev at the end (the agent
224
- * NEVER echoes raw secrets) — the dev opens `.env.local` to see the full values.
306
+ * `.env.local`. Non-secret values are shown; secret values are masked down to their
307
+ * last four characters, enough to tell WHICH value is in place while rotating and
308
+ * useless on their own; empty ones are flagged. This is what the guided flow shows
309
+ * the dev at the end (the agent NEVER echoes a raw secret) — the dev opens
310
+ * `.env.local` to see the full values.
225
311
  */
226
312
  export function runEnvWizardReview(flags, rootDir = process.cwd()) {
227
313
  const localPath = path.join(rootDir, '.env.local');
@@ -252,7 +338,7 @@ export function runEnvWizardReview(flags, rootDir = process.cwd()) {
252
338
  for (const w of warnings)
253
339
  console.log(` ⚠ ${w}`);
254
340
  }
255
- console.log('\nAbre `.env.local` para ver los valores completos (los secretos no se imprimen).');
341
+ console.log('\nAbre `.env.local` para ver los valores completos (de un secreto solo se ven sus últimos 4).');
256
342
  }
257
343
  /**
258
344
  * `factory env wizard --gen-vapid` — generate a VAPID keypair and write it to
@@ -1245,7 +1245,7 @@ export function writeE2eWorkflow(rootDir) {
1245
1245
  * PROV-004 — the `github` step: build the git substrate, create the Vercel PROJECT,
1246
1246
  * connect the GitHub repo (GitHub App gate), confirm `productionBranch: main`,
1247
1247
  * write `.vercel/project.json`, and activate the E2E workflow. Migrations run in the
1248
- * Vercel build (`vercel-build`: `pnpm db:migrate && pnpm build`), NOT a GitHub Actions
1248
+ * Vercel build (`vercel-build`: `pnpm build && pnpm db:migrate`), NOT a GitHub Actions
1249
1249
  * workflow. HIGH-risk: every failure is recoverable via `--resume`.
1250
1250
  *
1251
1251
  * Order (asserted by the unit test): git substrate (rename → remote add → push
@@ -1334,7 +1334,7 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
1334
1334
  // the template is absent. It lands in the working tree and is committed below.
1335
1335
  //
1336
1336
  // Migrations are NOT a GitHub Actions workflow: they run in the Vercel build
1337
- // (`vercel-build`: `pnpm db:migrate && pnpm build`, shipped in package.json),
1337
+ // (`vercel-build`: `pnpm build && pnpm db:migrate`, shipped in package.json),
1338
1338
  // so every deployment migrates ITS target's DB (production←main, preview←develop)
1339
1339
  // before building — no migrate.yml, no Deploy Hook, no manual step.
1340
1340
  const e2e = writeE2eWorkflow(rootDir);
@@ -1367,7 +1367,7 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
1367
1367
  * before provision activated this workflow, so it lives only in the working tree
1368
1368
  * until committed here. (Migrations are not part of THIS commit, but they do have one:
1369
1369
  * `ensureInitialMigration` commits the `0000` earlier in the run, with its own pathspec.
1370
- * The Vercel build only APPLIES it — `vercel-build` runs `pnpm db:migrate && pnpm build`,
1370
+ * The Vercel build only APPLIES it — `vercel-build` runs `pnpm build && pnpm db:migrate`,
1371
1371
  * there is no migration workflow.) Idempotent — when a `--resume`
1372
1372
  * finds it already committed, `git add` stages nothing and the commit/push is skipped.
1373
1373
  */
@@ -1418,7 +1418,7 @@ const INITIAL_MIGRATION_COMMIT = 'chore(db): add the initial database migration'
1418
1418
  const INITIAL_MIGRATION_LABEL = 'Migración inicial de la base (0000 de Drizzle)';
1419
1419
  /**
1420
1420
  * The branch the step commits on and pushes: the one `vercel-build` runs
1421
- * `pnpm db:migrate && pnpm build` from for PRODUCTION. Named, never inlined
1421
+ * `pnpm build && pnpm db:migrate` from for PRODUCTION. Named, never inlined
1422
1422
  * (CODING.md §5) — it appears in the checkout guard, in both pushes and in the
1423
1423
  * remote confirmation, and those four have to mean the same branch.
1424
1424
  */
@@ -1567,7 +1567,7 @@ async function confirmMigrationOnRemote(rootDir, runGit) {
1567
1567
  /**
1568
1568
  * Generate the initial Drizzle migration, commit it with its OWN pathspec and get it to
1569
1569
  * the remote BEFORE Vercel builds the tree (PROV-004 pushes `main`; `vercel-build` runs
1570
- * `pnpm db:migrate && pnpm build`). Without this a fresh derivative deploys against an
1570
+ * `pnpm build && pnpm db:migrate`). Without this a fresh derivative deploys against an
1571
1571
  * empty database.
1572
1572
  *
1573
1573
  * Order and its reasons:
@@ -1706,7 +1706,7 @@ export async function ensureInitialMigration(rootDir, evidence, deps = {}) {
1706
1706
  return;
1707
1707
  }
1708
1708
  // Un `try` por push, y no uno compartido: `main` es el que le importa a producción
1709
- // (`vercel-build` = `pnpm db:migrate && pnpm build`) y `develop` puede fallar solo
1709
+ // (`vercel-build` = `pnpm build && pnpm db:migrate`) y `develop` puede fallar solo
1710
1710
  // (non-fast-forward con un remoto divergido). Con un `try` compartido, esa falla de
1711
1711
  // `develop` imprimía "no pude pushear la migración" y se saltaba la confirmación —
1712
1712
  // un diagnóstico falso justo cuando `main` SÍ había llegado.
@@ -2233,7 +2233,7 @@ async function resolveDnsToken(route, envPath, headless) {
2233
2233
  * PROV-006 — attach the client's DEFINITIVE domain to the Vercel project, create
2234
2234
  * (or print, for `registrar`) its DNS record, poll Vercel until verified, and on
2235
2235
  * success: retire the interim prod domain (with explicit confirmation), update
2236
- * `EMAIL_FROM` + `AUTH_URL`/`NEXTAUTH_URL`, and register the domain in Resend.
2236
+ * `EMAIL_FROM` + `NEXT_PUBLIC_APP_URL`, and register the domain in Resend.
2237
2237
  *
2238
2238
  * Standalone-safe: runnable as a PATCH over an existing provision (the `dns` step
2239
2239
  * of PROV-005 already attached the interim domains) or inside the initial run. It
@@ -2246,7 +2246,7 @@ async function resolveDnsToken(route, envPath, headless) {
2246
2246
  * - Resend DNS records (BOOT-003) → `ensureDnsRecord` create-or-PATCH (a rotated
2247
2247
  * DKIM is PATCHed in place, never deleted); a Resend poll timeout is a WARNING
2248
2248
  * (pending-verification, resumable), never a step failure.
2249
- * - `AUTH_URL`/`NEXTAUTH_URL` already correct → the upsert PATCH is omitted.
2249
+ * - `EMAIL_FROM`/`NEXT_PUBLIC_APP_URL` already correct → the upsert PATCH is omitted.
2250
2250
  *
2251
2251
  * On timeout the state is left `clientDomainStatus: pending-verification` (resumable);
2252
2252
  * on success it is `live`.
@@ -2462,7 +2462,7 @@ async function deleteInterimCname(interim, envPath, fetchImpl) {
2462
2462
  }
2463
2463
  }
2464
2464
  /**
2465
- * Update `EMAIL_FROM` + `AUTH_URL`/`NEXTAUTH_URL` + `NEXT_PUBLIC_APP_URL` +
2465
+ * Update `EMAIL_FROM` + `NEXT_PUBLIC_APP_URL` +
2466
2466
  * `WEBAUTHN_RP_ID` to the client domain in the Vercel env, per-target and
2467
2467
  * idempotently (PROV-006 §4 + BOOT-004). Lists the existing vars once and upserts
2468
2468
  * each entry via `upsertEnvVar`, which reconciles a key that the substrate left
@@ -2483,20 +2483,27 @@ async function deleteInterimCname(interim, envPath, fetchImpl) {
2483
2483
  * discipline holds).
2484
2484
  */
2485
2485
  async function updateDomainEnv(projectId, token, teamId, domain, deps) {
2486
- const authUrl = `https://${domain}`;
2486
+ const appOrigin = `https://${domain}`;
2487
2487
  const plan = [
2488
2488
  // EMAIL_FROM uses the dedicated mail subdomain (`updates.<domain>`), NOT the apex
2489
- // — the apex carries the client's corporate mail. AUTH_URL stays the app domain.
2489
+ // — the apex carries the client's corporate mail.
2490
2490
  {
2491
2491
  key: 'EMAIL_FROM',
2492
2492
  value: `noreply@${mailDomainFor(domain)}`,
2493
2493
  target: ['production', 'preview'],
2494
2494
  },
2495
- { key: 'AUTH_URL', value: authUrl, target: ['production', 'preview'] },
2496
- { key: 'NEXTAUTH_URL', value: authUrl, target: ['production', 'preview'] },
2497
- // Keep the public app origin in lockstep with AUTH_URL once a client domain lands
2495
+ // 🔴 AUTH_URL / NEXTAUTH_URL are DELIBERATELY ABSENT — do not re-add them.
2496
+ // `next-auth/lib/env.js` rewrites the origin of every auth request to their value,
2497
+ // so scoping either to `preview` pins all branch deployments' callbacks to this one
2498
+ // host and kills their sign-in. `trustHost` (auth.config.ts) already infers the
2499
+ // origin per request, which is correct on production AND on each preview.
2500
+ // Auth needs nothing here; the domain reaches the app through NEXT_PUBLIC_APP_URL
2501
+ // below, which is what `getAppUrl()` reads for email links (src/lib/env.ts).
2502
+ // A project that enables OAuth and must pin the callback to a fixed host sets it
2503
+ // manually, `production` only, knowing it forfeits preview sign-in.
2504
+ // Keep the public app origin pointing at the client domain once it lands
2498
2505
  // (the substrate set it to the interim domain in buildEnvPlan; this re-points it).
2499
- { key: 'NEXT_PUBLIC_APP_URL', value: authUrl, target: ['production', 'preview'] },
2506
+ { key: 'NEXT_PUBLIC_APP_URL', value: appOrigin, target: ['production', 'preview'] },
2500
2507
  // WebAuthn rpID is the registrable HOST, never a URL — the bare domain (BOOT-004).
2501
2508
  // PRODUCTION only: the interim dev domain remains the preview rpID (the dev domain
2502
2509
  // is never retired), so the intentional split pair from buildEnvPlan is preserved.
@@ -2532,7 +2539,7 @@ function printClientDomainPlan(domain, interim, route, confirm) {
2532
2539
  console.log(` … sondea GET /v9/projects/.../domains/${domain} hasta verified:true (o timeout)`);
2533
2540
  console.log(` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim} ` +
2534
2541
  `(el dev ${domain}-dev se conserva)`);
2535
- console.log(` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · AUTH_URL=https://${domain} · NEXTAUTH_URL=https://${domain} · NEXT_PUBLIC_APP_URL=https://${domain}`);
2542
+ console.log(` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · NEXT_PUBLIC_APP_URL=https://${domain}`);
2536
2543
  console.log(` ↻ WEBAUTHN_RP_ID=${domain} (solo production — las passkeys del rpID interino quedan invalidadas; el preview conserva el rpID dev)`);
2537
2544
  console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
2538
2545
  if (route === 'registrar') {
@@ -3688,7 +3695,7 @@ export async function runProvision(flags) {
3688
3695
  writeProvisionState(rootDir, runState);
3689
3696
  // 5.0. Initial migration (GAP-001) — BEFORE the steps, so the `.sql` is committed
3690
3697
  // before the `github` step pushes `main` and Vercel builds the remote tree
3691
- // (`vercel-build` = `pnpm db:migrate && pnpm build`). A failed generate/install
3698
+ // (`vercel-build` = `pnpm build && pnpm db:migrate`). A failed generate/install
3692
3699
  // aborts here, with nothing pushed and nothing deployed.
3693
3700
  await ensureInitialMigration(rootDir, greenfield);
3694
3701
  for (const step of steps) {