@timekast/factory 1.18.0 → 1.19.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.
@@ -85,6 +85,7 @@ export async function runAdd(flags = { core: false, full: false }) {
85
85
  mineAll: false,
86
86
  resume: false,
87
87
  verify: false,
88
+ dryRun: false,
88
89
  commit: false,
89
90
  noCommit: false,
90
91
  // A legacy `add` redirect is never a beta channel operation.
@@ -21,6 +21,12 @@ import { pushEnvPlan, vercelProjectPath, VercelApiError, } from '../lib/vercel-e
21
21
  // Re-exported so existing importers (and `cli/tests/unit/env-push.test.ts`) keep
22
22
  // resolving PROVISION_OWNED from this module after it moved to `lib/env-file.js`.
23
23
  export { PROVISION_OWNED };
24
+ /**
25
+ * Post-push notice: freshly uploaded env vars stay INERT until the next Vercel
26
+ * deployment (stale-config window — see BOOT-010). Printed only after a
27
+ * successful push with at least one real change (created or updated).
28
+ */
29
+ export const REDEPLOY_NOTICE = 'ℹ️ Las variables aplican en el próximo deployment — redeploya el proyecto en Vercel para que tomen efecto.';
24
30
  export function parseEnvPushFlags(argv) {
25
31
  return { dryRun: argv.includes('--dry-run') };
26
32
  }
@@ -114,6 +120,9 @@ export async function runEnvPush(flags, rootDir = process.cwd()) {
114
120
  const result = await pushEnvPlan(projectId, token, teamId, plan);
115
121
  console.log(`✔ env push: ${result.created.length} creadas, ${result.updated.length} actualizadas ` +
116
122
  '(production, preview).');
123
+ if (result.created.length > 0 || result.updated.length > 0) {
124
+ console.log(REDEPLOY_NOTICE);
125
+ }
117
126
  }
118
127
  catch (error) {
119
128
  if (error instanceof VercelApiError) {
@@ -25,7 +25,7 @@ import { execa } from 'execa';
25
25
  import prompts from 'prompts';
26
26
  import { FACTORY_ORG } from '../lib/constants.js';
27
27
  import { CLIError } from '../lib/cli-error.js';
28
- import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, ensureCnameRecord, findCnameRecord, resolveZoneId, resolveZoneIdForName, CNAME_VERCEL_TARGET, TIMEKAST_ZONE_NAME, provisionDomains, } from '../lib/cloudflare-api.js';
28
+ import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, deleteDnsRecord, ensureCnameRecord, ensureDnsRecord, findCnameRecord, resolveZoneId, resolveZoneIdForName, CNAME_VERCEL_TARGET, TIMEKAST_ZONE_NAME, provisionDomains, } from '../lib/cloudflare-api.js';
29
29
  import { createDevelopBranch, createNeonProject, createProjectApiKey, deleteNeonApiKeysByName, deleteNeonProject, getConnectionUri, listNeonBranches, listNeonProjects, NeonApiError, NEON_API_BASE, NEON_DEVELOP_BRANCH, } from '../lib/neon-api.js';
30
30
  import { defaultEnvPath, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
31
31
  import { addVercelDomain, buildEnvPlan, createVercelProject, generateAuthSecret, generateMfaEncryptionKey, getLinkedRepoId, getVercelDomainStatus, GitHubAppNotInstalledError, linkGitRepo, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, writeVercelProjectJson, } from '../lib/vercel-env.js';
@@ -180,6 +180,21 @@ export function parseVerifyTimeout(raw) {
180
180
  }
181
181
  return minutes;
182
182
  }
183
+ /**
184
+ * Parse + validate `--repo=<org/name>` (BOOT-001). Absent → `undefined` (the repo
185
+ * resolution then falls back to the `origin` remote / `FACTORY_ORG/projectName`).
186
+ * A malformed value (no `/`, more than one `/`, or an empty side) throws a clear
187
+ * `CLIError` — a corrupt slug must never silently reach the GitHub/Vercel calls.
188
+ */
189
+ export function parseRepoFlag(raw) {
190
+ if (raw === undefined)
191
+ return undefined;
192
+ const parts = raw.split('/');
193
+ if (parts.length !== 2 || parts.some((part) => part.trim() === '')) {
194
+ throw new CLIError(`\`--repo\` debe tener el formato \`org/nombre\` (un solo \`/\`; recibí \`${raw}\`).`);
195
+ }
196
+ return raw;
197
+ }
183
198
  /** Parse `provision`'s argv slice into typed flags. */
184
199
  export function parseProvisionFlags(argv) {
185
200
  return {
@@ -187,6 +202,7 @@ export function parseProvisionFlags(argv) {
187
202
  team: readValueFlag(argv, 'team'),
188
203
  orgId: readValueFlag(argv, 'org-id'),
189
204
  slug: readValueFlag(argv, 'slug'),
205
+ repo: parseRepoFlag(readValueFlag(argv, 'repo')),
190
206
  domain: readValueFlag(argv, 'domain'),
191
207
  dns: parseDnsRoute(readValueFlag(argv, 'dns')),
192
208
  verifyTimeout: parseVerifyTimeout(readValueFlag(argv, 'verify-timeout')),
@@ -217,6 +233,11 @@ function buildPlan(services, state) {
217
233
  /** Print the plain-text plan (the `--dry-run` and pre-run narration). */
218
234
  function printPlan(result) {
219
235
  console.log(`Plan de provision para \`${result.projectName}\` (lifecycle: ${result.lifecycle}):`);
236
+ // BOOT-001: print the RESOLVED repo (--repo → origin → FACTORY_ORG/name), so a
237
+ // `--dry-run` shows the exact repo the github step / E2E secrets will target.
238
+ if (result.repo) {
239
+ console.log(` Repo de GitHub: ${result.repo}`);
240
+ }
220
241
  for (const step of result.steps) {
221
242
  const mark = step.action === 'skip' ? '⏭ (ya completado)' : '+ crear';
222
243
  console.log(` ${mark} ${SERVICE_LABEL[step.service]}`);
@@ -405,9 +426,11 @@ function neonErrorToCliError(error, projectName, context) {
405
426
  }
406
427
  /** The GitHub repo (`owner/name`) the project-scoped secret is pushed to. */
407
428
  function neonSecretRepo(state) {
408
- // PROV-004 records the created repo in `state.github.repo`; before it runs we
409
- // fall back to the canonical `org/projectName` so the secret push is attempted
410
- // (a non-existent repo → §8 pending path, never an abort).
429
+ // `state.github.repo` carries the RESOLVED repo: `runProvision` seeds it before
430
+ // any step runs (BOOT-001: --repo → origin → FACTORY_ORG/name) and PROV-004
431
+ // re-records it. The fallback only covers a direct `provisionNeon` call with no
432
+ // seeded state, so the secret push is still attempted (a non-existent repo →
433
+ // §8 pending path, never an abort).
411
434
  return state.github?.repo ?? `${FACTORY_ORG}/${state.projectName}`;
412
435
  }
413
436
  /**
@@ -696,10 +719,10 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
696
719
  // Mint an INDEPENDENT AUTH_SECRET per environment (issue §4 — never shared).
697
720
  // POOLED strings → the Vercel runtime DATABASE_URL (serverless/concurrent).
698
721
  // WebAuthn rpID = the EXACT host per env (provision's interim domains). A passkey
699
- // dies if the rpID changes, so a later custom-domain swap (--domain / PROV-006)
700
- // must also re-point WEBAUTHN_RP_ID — tracked as a follow-up. MFA_ENCRYPTION_KEY
701
- // is minted independently per env (base64url) so a derivative with MFA enabled
702
- // never boots broken (P-1).
722
+ // dies if the rpID changes, so the custom-domain swap (--domain / PROV-006)
723
+ // re-points the PRODUCTION WEBAUTHN_RP_ID too (`updateDomainEnv`, BOOT-004).
724
+ // MFA_ENCRYPTION_KEY is minted independently per env (base64url) so a derivative
725
+ // with MFA enabled never boots broken (P-1).
703
726
  const rpDomains = provisionDomains(state.projectName);
704
727
  const plan = buildEnvPlan({
705
728
  mainDatabaseUrl: neonSecrets.mainPooled,
@@ -811,6 +834,19 @@ export { writeEnvLocal };
811
834
  function repoHttpsUrl(repoSlug) {
812
835
  return `https://github.com/${repoSlug}.git`;
813
836
  }
837
+ /**
838
+ * Parse the `owner/name` slug out of a GitHub `origin` URL, HTTPS or SSH
839
+ * (`https://github.com/Owner/repo[.git]`, `git@github.com:Owner/repo[.git]`,
840
+ * `ssh://git@github.com/Owner/repo[.git]`). Returns `null` for anything else —
841
+ * including non-GitHub hosts, deliberately: deriving a slug from a GitLab/other
842
+ * origin and re-pointing it at `github.com` (see {@link repoHttpsUrl}) would
843
+ * silently retarget the project, so an unparsable origin falls back to the
844
+ * historical `FACTORY_ORG/projectName` default instead (BOOT-001).
845
+ */
846
+ export function parseOriginRepoSlug(originUrl) {
847
+ const match = /^(?:https:\/\/|ssh:\/\/git@|git@)github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(originUrl.trim());
848
+ return match ? `${match[1]}/${match[2]}` : null;
849
+ }
814
850
  /** Default git runner — `execa` in the derived dir, no shell. */
815
851
  async function gitInDir(args, cwd) {
816
852
  const { stdout } = await execa('git', args, { cwd });
@@ -831,6 +867,47 @@ async function readOriginUrl(runGit, cwd) {
831
867
  return null;
832
868
  }
833
869
  }
870
+ /**
871
+ * Resolve the GitHub repo slug (`owner/name`) the `github` step targets (BOOT-001
872
+ * — decouples the repo from the project slug). Priority:
873
+ * 1. The repo PERSISTED in the state (a prior run's authority — a `--resume`
874
+ * must never silently re-point a repo already in progress; a CONFLICTING
875
+ * `--repo` throws instead, mirroring the `--force` discipline of the other
876
+ * steps — discard the state with `--force` to start over).
877
+ * 2. An explicit `--repo=<org/name>` (the dev's stated intent — wins over an
878
+ * `origin` pointing elsewhere; the remote is updated via `buildGitSubstrate`'s
879
+ * existing same-repo/`--force` flow).
880
+ * 3. The existing `origin` remote, when it parses as a GitHub repo (a derived
881
+ * project whose repo name ≠ slug, or living outside the Factory org).
882
+ * 4. The historical default `${FACTORY_ORG}/${projectName}` (unchanged behavior
883
+ * when there is no flag and no `origin`).
884
+ */
885
+ export async function resolveRepoSlug(rootDir, projectName, persistedRepo, explicitRepo, runGit) {
886
+ if (persistedRepo) {
887
+ // GitHub slugs are case-insensitive — only a REAL divergence is a conflict.
888
+ if (explicitRepo && explicitRepo.toLowerCase() !== persistedRepo.toLowerCase()) {
889
+ throw new CLIError(`El estado de provision ya registra el repo \`${persistedRepo}\` (de un run anterior), ` +
890
+ `distinto de \`--repo=${explicitRepo}\`.\n` +
891
+ 'En `--resume` el estado es la autoridad; usa `--force` para descartarlo y empezar de cero con el repo nuevo.');
892
+ }
893
+ return persistedRepo;
894
+ }
895
+ if (explicitRepo)
896
+ return explicitRepo;
897
+ const fallback = `${FACTORY_ORG}/${projectName}`;
898
+ const origin = await readOriginUrl(runGit, rootDir);
899
+ if (origin) {
900
+ const derived = parseOriginRepoSlug(origin);
901
+ if (derived) {
902
+ // An origin at the SAME repo as the canonical default (case drift only, e.g.
903
+ // `TimeKast/Acme` vs `TimeKast/acme`) keeps the CANONICAL slug, so
904
+ // `buildGitSubstrate` re-cases the remote exactly as it always did (GitHub
905
+ // slugs are case-insensitive — reuse that normalization, don't fork it).
906
+ return derived.toLowerCase() === fallback.toLowerCase() ? fallback : derived;
907
+ }
908
+ }
909
+ return fallback;
910
+ }
834
911
  /**
835
912
  * Build the git substrate in the documented, asserted ORDER (issue §6):
836
913
  * 1. rename `master` → `main` (only when the current branch is `master`)
@@ -870,11 +947,14 @@ async function buildGitSubstrate(rootDir, repoSlug, runGit, force) {
870
947
  if (existingOrigin && existingOrigin !== url) {
871
948
  // GitHub repo URLs are case-insensitive: an `origin` differing ONLY by case
872
949
  // (e.g. `TimeKast/Edmond.git` vs the canonical lowercase `TimeKast/edmond.git`)
873
- // points at the SAME repo. Re-case it to the canonical URL silently — no `--force`
874
- // needed. A difference BEYOND case is a genuinely different repo → throw unless
875
- // `--force` resets it.
876
- const sameRepoDifferentCase = existingOrigin.toLowerCase() === url.toLowerCase();
877
- if (!sameRepoDifferentCase && !force) {
950
+ // points at the SAME repo. So does an SSH origin (`git@github.com:Org/name.git`)
951
+ // whose parsed `owner/name` matches the resolved slug (BOOT-001 — the slug may
952
+ // have been DERIVED from that very origin). Either way, re-point it to the
953
+ // canonical HTTPS URL silently — no `--force` needed. A genuinely different
954
+ // repo → throw unless `--force` resets it.
955
+ const sameRepo = existingOrigin.toLowerCase() === url.toLowerCase() ||
956
+ parseOriginRepoSlug(existingOrigin)?.toLowerCase() === repoSlug.toLowerCase();
957
+ if (!sameRepo && !force) {
878
958
  throw new CLIError(`El repo ya tiene un remote \`origin\` apuntando a otro URL (\`${existingOrigin}\` ≠ \`${url}\`).\n` +
879
959
  'Usa `--force` para sobrescribirlo, o limpia el remote si reutilizas este directorio.');
880
960
  }
@@ -988,7 +1068,10 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
988
1068
  }
989
1069
  const runGit = deps.runGit ?? gitInDir;
990
1070
  const apiDeps = { fetchImpl: deps.fetchImpl };
991
- const repoSlug = `${FACTORY_ORG}/${state.projectName}`;
1071
+ // BOOT-001: the repo is RESOLVED (--repo → existing origin → FACTORY_ORG/name),
1072
+ // no longer hardcoded to `${FACTORY_ORG}/${projectName}`. On resume the persisted
1073
+ // `state.github.repo` is the authority (a conflicting `--repo` throws).
1074
+ const repoSlug = await resolveRepoSlug(rootDir, state.projectName, state.github?.repo, deps.repo, runGit);
992
1075
  // Record the repo on the state up-front so the Neon secret push (PROV-002) and a
993
1076
  // `--resume` target it; this is durable even if a later REST step fails.
994
1077
  state.github = { repo: repoSlug };
@@ -1287,44 +1370,262 @@ async function promptConfirm(message) {
1287
1370
  const { ok } = await prompts({ type: 'confirm', name: 'ok', message, initial: false });
1288
1371
  return ok === true;
1289
1372
  }
1373
+ /** The `status` Resend reports once its DNS records validated (the poll's goal). */
1374
+ const RESEND_DOMAIN_VERIFIED_STATUS = 'verified';
1375
+ /** Shared headers for every Resend REST call (Bearer auth + JSON) — no-echo. */
1376
+ function resendHeaders(resendKey) {
1377
+ return {
1378
+ Authorization: `Bearer ${resendKey}`,
1379
+ 'Content-Type': 'application/json',
1380
+ };
1381
+ }
1382
+ /**
1383
+ * Build the FQDN for a record name Resend returns (BOOT-003 §4). Resend hands
1384
+ * record names RELATIVE to the registered domain (`send` → `send.updates.miapp.com`,
1385
+ * `resend._domainkey` → `resend._domainkey.updates.miapp.com`); Cloudflare's
1386
+ * `dns_records` API wants the fully-qualified name. Defensive on shape drift: a
1387
+ * name that already carries the domain (or the apex marker `@`) passes through.
1388
+ */
1389
+ export function resendRecordFqdn(name, domain) {
1390
+ if (name === '' || name === '@' || name === domain)
1391
+ return domain;
1392
+ if (name.endsWith(`.${domain}`))
1393
+ return name;
1394
+ return `${name}.${domain}`;
1395
+ }
1396
+ /**
1397
+ * Look up an existing Resend domain by NAME — `GET /domains` (list) then
1398
+ * `GET /domains/{id}` for its records. This is the `--resume` path (issue §8):
1399
+ * a re-run's `POST /domains` 403s when the domain already exists in THIS account,
1400
+ * so recovering the domain object lets the step re-ensure the DNS records and
1401
+ * re-poll verification instead of dead-ending on a warning. Returns `null` when
1402
+ * the domain is NOT in this account (then it really is "another account" and the
1403
+ * caller keeps the preserved warning behavior — issue §4). Best-effort: any
1404
+ * network/API failure degrades to `null`, never a throw.
1405
+ */
1406
+ async function findResendDomain(domain, resendKey, fetchImpl) {
1407
+ try {
1408
+ const listResponse = await fetchImpl(`${RESEND_API_BASE}/domains`, {
1409
+ method: 'GET',
1410
+ headers: resendHeaders(resendKey),
1411
+ signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
1412
+ });
1413
+ if (!listResponse.ok)
1414
+ return null;
1415
+ const list = (await listResponse.json());
1416
+ const match = (list.data ?? []).find((d) => d.name === domain);
1417
+ if (!match?.id)
1418
+ return null;
1419
+ const getResponse = await fetchImpl(`${RESEND_API_BASE}/domains/${match.id}`, {
1420
+ method: 'GET',
1421
+ headers: resendHeaders(resendKey),
1422
+ signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
1423
+ });
1424
+ // Detail fetch failed → still return the id (poll works; records unknown → none created).
1425
+ if (!getResponse.ok)
1426
+ return { id: match.id, name: domain };
1427
+ return (await getResponse.json());
1428
+ }
1429
+ catch {
1430
+ return null;
1431
+ }
1432
+ }
1433
+ /**
1434
+ * Trigger Resend's DNS verification for a domain — `POST /domains/{id}/verify`
1435
+ * (BOOT-003 §4). Without this call Resend never starts checking the records and
1436
+ * the domain sits `not_started` forever (the exact bug this issue fixes). Best
1437
+ * effort: a failure here is non-fatal (the poll surfaces the real outcome, and
1438
+ * the step is re-runnable with `--resume`).
1439
+ */
1440
+ async function triggerResendVerification(domainId, resendKey, fetchImpl) {
1441
+ try {
1442
+ await fetchImpl(`${RESEND_API_BASE}/domains/${domainId}/verify`, {
1443
+ method: 'POST',
1444
+ headers: resendHeaders(resendKey),
1445
+ signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
1446
+ });
1447
+ }
1448
+ catch {
1449
+ // Non-fatal — the verification poll below reports the actual state.
1450
+ }
1451
+ }
1452
+ /**
1453
+ * Poll `GET /domains/{id}` until Resend reports `status: verified` or the timeout
1454
+ * budget runs out (BOOT-003 §4) — the Resend sibling of {@link pollDomainVerification},
1455
+ * same injectable `sleep`/`now`/timeout seams so the unit test never waits for real.
1456
+ * Returns `true` once verified; `false` on timeout OR on any network/API failure
1457
+ * (non-fatal either way — the DNS records are already created, Resend keeps
1458
+ * checking them in background, and the step is re-runnable with `--resume`).
1459
+ */
1460
+ async function pollResendDomainVerification(domainId, resendKey, deps, fetchImpl) {
1461
+ const sleep = deps.sleep ?? realSleep;
1462
+ const now = deps.now ?? Date.now;
1463
+ const timeoutMs = (deps.verifyTimeoutMin ?? DEFAULT_VERIFY_TIMEOUT_MIN) * 60_000;
1464
+ const intervalMs = VERIFY_POLL_INTERVAL_SEC * 1_000;
1465
+ const deadline = now() + timeoutMs;
1466
+ // First check immediately (the domain may already be verified — idempotent re-run).
1467
+ for (;;) {
1468
+ let status;
1469
+ try {
1470
+ const response = await fetchImpl(`${RESEND_API_BASE}/domains/${domainId}`, {
1471
+ method: 'GET',
1472
+ headers: resendHeaders(resendKey),
1473
+ signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
1474
+ });
1475
+ if (!response.ok)
1476
+ return false;
1477
+ status = (await response.json()).status;
1478
+ }
1479
+ catch {
1480
+ // A failure while POLLING is non-fatal: pending + resumable, never a throw.
1481
+ return false;
1482
+ }
1483
+ if (status === RESEND_DOMAIN_VERIFIED_STATUS)
1484
+ return true;
1485
+ if (now() >= deadline)
1486
+ return false;
1487
+ console.log(` … esperando verificación del dominio en Resend (reintento en ${VERIFY_POLL_INTERVAL_SEC}s)`);
1488
+ await sleep(intervalMs);
1489
+ }
1490
+ }
1290
1491
  /**
1291
- * Register the client domain in Resend — `POST /domains { name }` (PROV-006 §4),
1292
- * reusing the rail's `RESEND_API_KEY`. Idempotency (issue §8): Resend returns an
1293
- * error when the domain is already verified on ANOTHER account; that is a WARNING,
1294
- * not an abort — `EMAIL_FROM` is updated regardless. An absent `RESEND_API_KEY` is
1295
- * also a warning (mail stays unconfigured). No-echo: the key never reaches stdout.
1492
+ * Register the client mail domain in Resend AND make it verifiable end-to-end
1493
+ * (BOOT-003): `POST /domains { name }` with the rail's `RESEND_API_KEY`, then
1494
+ * CREATE the DNS records Resend returns (DKIM/SPF/MX — exactly the ones it
1495
+ * returns, never an assumed fixed set, §8) in the client's Cloudflare zone via
1496
+ * {@link ensureDnsRecord} (create-or-PATCH, idempotent on re-run, never delete),
1497
+ * trigger verification, and poll until `verified` or timeout.
1498
+ *
1499
+ * `dns` carries the zone + token already resolved for the client-domain CNAME
1500
+ * (same org/cliente-cf route); `null` = the `registrar` route (no DNS API — the
1501
+ * records must be created by hand in the Resend dashboard; extending
1502
+ * `printManualRecords` to them is an explicit follow-up, issue §9).
1503
+ *
1504
+ * Non-fatal by design — the domain switch already happened on Vercel:
1505
+ * - absent `RESEND_API_KEY` → warning (mail stays unconfigured).
1506
+ * - `POST /domains` rejected → recover the domain from THIS account when it
1507
+ * exists (`--resume`, §8) or keep the preserved warning ("another account", §4).
1508
+ * - a record create/PATCH failure → warning; the poll is skipped (it cannot
1509
+ * succeed with missing records) and the step stays re-runnable.
1510
+ * - poll timeout → warning + `pending-verification` note, NEVER a hard failure
1511
+ * (§8 — the records are in place; Resend keeps verifying in background).
1512
+ *
1513
+ * No-echo: the Resend key and Cloudflare token never reach stdout.
1296
1514
  */
1297
- async function registerResendDomain(domain, envPath, fetchImpl) {
1515
+ async function registerResendDomain(domain, envPath, deps, fetchImpl, dns) {
1298
1516
  const resendKey = readGateValue('RESEND_API_KEY', envPath);
1299
1517
  if (!resendKey) {
1300
1518
  console.warn('⚠ No hay `RESEND_API_KEY` en `~/.claude/.env` — omito el alta del dominio en Resend ' +
1301
1519
  '(bájala con `factory secrets <archivo>` y reintenta con `--resume`).');
1302
1520
  return;
1303
1521
  }
1522
+ // 1. Register (or recover, on re-run) the domain in Resend.
1523
+ let resendDomain = null;
1304
1524
  try {
1305
1525
  const response = await fetchImpl(`${RESEND_API_BASE}/domains`, {
1306
1526
  method: 'POST',
1307
- headers: {
1308
- Authorization: `Bearer ${resendKey}`,
1309
- 'Content-Type': 'application/json',
1310
- },
1527
+ headers: resendHeaders(resendKey),
1311
1528
  body: JSON.stringify({ name: domain }),
1312
1529
  signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
1313
1530
  });
1314
- if (!response.ok) {
1315
- // Already verified elsewhere / validation error → warn + continue (issue §8).
1316
- console.warn(`⚠ Resend rechazó el alta de \`${domain}\` (status ${response.status}). ` +
1317
- 'Quizá ya está verificado en otra cuenta — `EMAIL_FROM` se actualiza igual; ' +
1318
- 'verifica el dominio a mano en Resend si hace falta.');
1319
- return;
1531
+ if (response.ok) {
1532
+ resendDomain = (await response.json());
1533
+ console.log(` + Resend: dominio ${domain} registrado`);
1534
+ }
1535
+ else {
1536
+ // Rejected (e.g. 403 already-exists). If the domain lives in THIS account
1537
+ // (a `--resume` re-run), recover it and continue; otherwise keep the
1538
+ // preserved warning behavior (issue §4) — warn + continue, never abort.
1539
+ const status = response.status;
1540
+ resendDomain = await findResendDomain(domain, resendKey, fetchImpl);
1541
+ if (resendDomain) {
1542
+ console.log(` ↻ Resend: dominio ${domain} ya registrado en esta cuenta (reuso sus records)`);
1543
+ }
1544
+ else {
1545
+ console.warn(`⚠ Resend rechazó el alta de \`${domain}\` (status ${status}). ` +
1546
+ 'Quizá ya está verificado en otra cuenta — `EMAIL_FROM` se actualiza igual; ' +
1547
+ 'verifica el dominio a mano en Resend si hace falta.');
1548
+ return;
1549
+ }
1320
1550
  }
1321
- console.log(` + Resend: dominio ${domain} registrado (verifica los records DNS en Resend)`);
1322
1551
  }
1323
1552
  catch (error) {
1324
1553
  // A network/timeout failure on Resend is non-fatal — the domain switch already
1325
1554
  // happened on Vercel; Resend verification is re-runnable with `--resume`.
1326
1555
  console.warn(`⚠ No se pudo dar de alta \`${domain}\` en Resend (${error?.message ?? 'error'}). ` +
1327
1556
  'Reintenta con `--resume`; `EMAIL_FROM` ya se actualizó.');
1557
+ return;
1558
+ }
1559
+ if (resendDomain.status === RESEND_DOMAIN_VERIFIED_STATUS) {
1560
+ console.log(` ✔ Resend: dominio ${domain} ya verificado`);
1561
+ return;
1562
+ }
1563
+ // 2. Create the DNS records Resend returned — the ones it RETURNS, no fixed set (§8).
1564
+ if (!dns) {
1565
+ // `registrar` route: no DNS API — the records live in the Resend dashboard and
1566
+ // must be created by hand (printing them here is a follow-up — issue §9).
1567
+ console.warn(`⚠ (registrar) Los records DNS de Resend para \`${domain}\` no se crean automáticamente. ` +
1568
+ 'Créalos a mano desde el dashboard de Resend y verifica el dominio ahí.');
1569
+ return;
1570
+ }
1571
+ const records = resendDomain.records ?? [];
1572
+ const cloudflareDeps = { fetchImpl };
1573
+ let allEnsured = true;
1574
+ for (const record of records) {
1575
+ const type = record.type?.toUpperCase();
1576
+ if ((type !== 'TXT' && type !== 'MX') || !record.name || !record.value) {
1577
+ // Only the types Resend requires are supported (issue §9) — anything else
1578
+ // (or a malformed record) is surfaced for manual creation, never guessed.
1579
+ console.warn(` ⚠ Record de Resend no soportado o incompleto (${record.record ?? record.type ?? '?'}) — créalo a mano en Cloudflare.`);
1580
+ allEnsured = false;
1581
+ continue;
1582
+ }
1583
+ const spec = {
1584
+ type,
1585
+ name: resendRecordFqdn(record.name, domain),
1586
+ content: record.value,
1587
+ ...(record.priority !== undefined ? { priority: record.priority } : {}),
1588
+ };
1589
+ try {
1590
+ const outcome = await ensureDnsRecord(dns.zoneId, spec, dns.token, cloudflareDeps);
1591
+ const mark = outcome === 'created'
1592
+ ? '+'
1593
+ : outcome === 'updated'
1594
+ ? '↻ (contenido actualizado)'
1595
+ : '⏭ (ya estaba)';
1596
+ console.log(` ${mark} ${spec.type} ${spec.name} (record ${record.record ?? 'DNS'} de Resend)`);
1597
+ }
1598
+ catch (error) {
1599
+ // Non-fatal (the mail path is additive): warn with the actionable mapping
1600
+ // and keep going — the step is re-runnable with `--resume`.
1601
+ allEnsured = false;
1602
+ const detail = error instanceof CloudflareApiError
1603
+ ? cloudflareErrorToCliError(error, `crear el record ${spec.type} \`${spec.name}\``)
1604
+ .message
1605
+ : (error?.message ?? 'error');
1606
+ console.warn(` ⚠ No se pudo asegurar el record ${spec.type} \`${spec.name}\`: ${detail}`);
1607
+ }
1608
+ }
1609
+ if (!allEnsured) {
1610
+ console.warn(`⚠ El dominio \`${domain}\` queda pending en Resend (faltaron records DNS). ` +
1611
+ 'Corrige lo anterior y reintenta con `--resume`.');
1612
+ return;
1613
+ }
1614
+ // 3. Trigger verification + poll until `verified` or timeout (never a hard fail).
1615
+ if (!resendDomain.id) {
1616
+ console.warn(`⚠ Resend no devolvió un id para \`${domain}\` — no puedo sondear la verificación. ` +
1617
+ 'Revisa el dominio en el dashboard de Resend.');
1618
+ return;
1619
+ }
1620
+ await triggerResendVerification(resendDomain.id, resendKey, fetchImpl);
1621
+ const verified = await pollResendDomainVerification(resendDomain.id, resendKey, deps, fetchImpl);
1622
+ if (verified) {
1623
+ console.log(` ✔ Resend: dominio ${domain} verificado`);
1624
+ }
1625
+ else {
1626
+ // §8: the records ARE created — pending-verification, resumable, never fatal.
1627
+ console.warn(`⚠ El dominio \`${domain}\` quedó pending-verification en Resend (los records DNS ya están; ` +
1628
+ 'Resend sigue verificando en background). Reintenta con `--resume` o revisa el dashboard.');
1328
1629
  }
1329
1630
  }
1330
1631
  /** Print the exact DNS records the owner must create for the `--dns=registrar` path. */
@@ -1421,6 +1722,9 @@ async function resolveDnsToken(route, envPath, headless) {
1421
1722
  * - domain already attached to Vercel (409) → only polls, never re-adds.
1422
1723
  * - interim retire fails → WARNING, the step is NOT marked failed (interim stays).
1423
1724
  * - Resend already verified elsewhere → warning + continue.
1725
+ * - Resend DNS records (BOOT-003) → `ensureDnsRecord` create-or-PATCH (a rotated
1726
+ * DKIM is PATCHed in place, never deleted); a Resend poll timeout is a WARNING
1727
+ * (pending-verification, resumable), never a step failure.
1424
1728
  * - `AUTH_URL`/`NEXTAUTH_URL` already correct → the upsert PATCH is omitted.
1425
1729
  *
1426
1730
  * On timeout the state is left `clientDomainStatus: pending-verification` (resumable);
@@ -1456,6 +1760,9 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1456
1760
  throw error;
1457
1761
  }
1458
1762
  // 2. DNS record: org/cliente-cf CREATE the CNAME; registrar only PRINTS it (§4).
1763
+ // The resolved zone + token are kept for step 6: the Resend DKIM/SPF/MX records
1764
+ // live in the SAME client zone (`updates.<domain>` shares the apex — BOOT-003).
1765
+ let resendDns = null;
1459
1766
  if (route === 'registrar') {
1460
1767
  // Print the canonical record now; the poll loop re-prints Vercel's verification.
1461
1768
  printManualRecords(domain, []);
@@ -1474,6 +1781,7 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1474
1781
  }
1475
1782
  throw error; // resolveZoneIdForName's own CLIError (zone not found) propagates.
1476
1783
  }
1784
+ resendDns = { zoneId, token: cfToken };
1477
1785
  try {
1478
1786
  const cnameOutcome = await ensureCnameRecord(zoneId, domain, cfToken, cloudflareDeps);
1479
1787
  const mark = cnameOutcome === 'created'
@@ -1514,7 +1822,9 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1514
1822
  // 6. Register the client's MAIL SUBDOMAIN (`updates.<domain>`) in Resend — NOT the
1515
1823
  // apex, which holds the client's corporate MX/SPF (Google Workspace, M365, …).
1516
1824
  // Verifying Resend on a dedicated subdomain keeps the client's own mail intact.
1517
- await registerResendDomain(mailDomainFor(domain), envPath, fetchImpl);
1825
+ // BOOT-003: also CREATES the DKIM/SPF/MX records Resend returns (in the same
1826
+ // client zone as the CNAME) and polls until Resend reports `verified`.
1827
+ await registerResendDomain(mailDomainFor(domain), envPath, deps, fetchImpl, resendDns);
1518
1828
  // 7. Record the client domain as LIVE and persist.
1519
1829
  state.dns = {
1520
1830
  ...(state.dns ?? {
@@ -1532,6 +1842,12 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1532
1842
  * pre-approves; otherwise a TTY prompt asks (headless without `--confirm` → NOT
1533
1843
  * retired). The DEV domain (`{slug}-dev.timekast.mx`) is ALWAYS conserved. A
1534
1844
  * removal failure is a WARNING, never a step failure (issue §8 — interim stays up).
1845
+ *
1846
+ * BOOT-007: once Vercel detaches the interim, its CNAME in the org `timekast.mx`
1847
+ * zone is deleted too — otherwise it stays orphaned, resolving to Vercel's generic
1848
+ * target and serving a 404 forever. The delete lives INSIDE the confirmed block
1849
+ * (a declined prompt never touches Cloudflare) and is SKIPPED when the Vercel
1850
+ * detach failed (the interim is still live — its CNAME must keep resolving).
1535
1851
  */
1536
1852
  async function retireInterimDomain(projectId, token, teamId, state, deps, fetchImpl) {
1537
1853
  const interim = provisionDomains(state.projectName).prod;
@@ -1558,15 +1874,78 @@ async function retireInterimDomain(projectId, token, teamId, state, deps, fetchI
1558
1874
  // the step is NOT marked failed, the dev retires it manually later.
1559
1875
  console.warn(`⚠ No se pudo retirar el dominio interino \`${interim}\` (${error?.message ?? 'error'}).\n` +
1560
1876
  ' Queda activo; retíralo a mano en Vercel o reintenta con `--resume`. El paso NO falla por esto.');
1877
+ // BOOT-007: the interim is still attached to Vercel — its CNAME stays too.
1878
+ return;
1561
1879
  }
1562
1880
  // The DEV domain is intentionally never touched (issue §4 — conserved).
1881
+ // BOOT-007: the interim is off Vercel — delete its now-orphaned CNAME as well.
1882
+ await deleteInterimCname(interim, deps.envPath ?? defaultEnvPath(), fetchImpl);
1883
+ }
1884
+ /**
1885
+ * Delete the interim CNAME (`{slug}.timekast.mx`) from the org Cloudflare zone
1886
+ * after Vercel detached the domain (BOOT-007). Without this, the record stays
1887
+ * orphaned: it keeps resolving to `cname.vercel-dns.com` while no Vercel project
1888
+ * answers for the host → a 404 forever, dead weight in the `timekast.mx` zone.
1889
+ *
1890
+ * Runs ONLY inside the already-confirmed retire block (a declined prompt never
1891
+ * reaches here). Best-effort by design (issue §4 + §8): EVERY failure — missing
1892
+ * rail token, zone not visible to the token (the `resolveZoneId` CLIError that is
1893
+ * hard in the CREATION path), an out-of-scope token, a 5xx on the DELETE — is a
1894
+ * WARNING, never a step failure; the record stays and the cleanup is retryable.
1895
+ * Idempotent: an already-absent record (`findCnameRecord` → null, or a 404 on the
1896
+ * DELETE) is a narrated no-op. The record content is NOT validated before deleting
1897
+ * (this is a retirement, not a repair — issue §8). The DEV domain's CNAME is never
1898
+ * touched: only the prod interim name is looked up.
1899
+ */
1900
+ async function deleteInterimCname(interim, envPath, fetchImpl) {
1901
+ const warnRetryable = (reason) => {
1902
+ console.warn(`⚠ No se pudo borrar el CNAME interino \`${interim}\` en Cloudflare (${reason}).\n` +
1903
+ ` El registro queda en la zona \`${TIMEKAST_ZONE_NAME}\`; bórralo a mano o reintenta. El paso NO falla por esto.`);
1904
+ };
1905
+ const cloudflareToken = readCloudflareApiKey(envPath);
1906
+ if (!cloudflareToken) {
1907
+ warnRetryable('sin `CLOUDFLARE_API_TOKEN` en el rail');
1908
+ return;
1909
+ }
1910
+ const cloudflareDeps = { fetchImpl };
1911
+ try {
1912
+ const zoneId = await resolveZoneId(cloudflareToken, cloudflareDeps);
1913
+ const record = await findCnameRecord(zoneId, interim, cloudflareToken, cloudflareDeps);
1914
+ // Same outcome narration as the Vercel retire above (no-echo — only the
1915
+ // domain NAME prints; the token never crosses this boundary).
1916
+ const outcome = record
1917
+ ? await deleteDnsRecord(zoneId, record.id, cloudflareToken, cloudflareDeps)
1918
+ : 'absent';
1919
+ console.log(outcome === 'absent'
1920
+ ? ` ⏭ (ya no estaba) CNAME interino ${interim} en Cloudflare`
1921
+ : ` - CNAME interino ${interim} borrado de Cloudflare`);
1922
+ }
1923
+ catch (error) {
1924
+ // §8: zone not visible (CLIError) / out-of-scope token / 5xx → warning,
1925
+ // retryable — never the hard CLIError the creation path uses.
1926
+ warnRetryable(error?.message ?? 'error');
1927
+ }
1563
1928
  }
1564
1929
  /**
1565
- * Update `EMAIL_FROM` + `AUTH_URL`/`NEXTAUTH_URL` + `NEXT_PUBLIC_APP_URL` to the client domain in the
1566
- * Vercel env, per-target and idempotently (PROV-006 §4). Lists the existing vars
1567
- * once; an `AUTH_URL`/`NEXTAUTH_URL`/`EMAIL_FROM` already set to the target value
1568
- * has its PATCH omitted (issue §8 — second run is a no-op). No-echo: only KEY
1569
- * NAMES + targets print (the values here are public, but the discipline holds).
1930
+ * Update `EMAIL_FROM` + `AUTH_URL`/`NEXTAUTH_URL` + `NEXT_PUBLIC_APP_URL` +
1931
+ * `WEBAUTHN_RP_ID` to the client domain in the Vercel env, per-target and
1932
+ * idempotently (PROV-006 §4 + BOOT-004). Lists the existing vars once and upserts
1933
+ * each entry via `upsertEnvVar`, which reconciles a key that the substrate left
1934
+ * SPLIT per target (e.g. `NEXT_PUBLIC_APP_URL` as `['production']` + `['preview']`
1935
+ * from `buildEnvPlan`) into ONE combined-target record (BOOT-002 — a single-record
1936
+ * PATCH would 400 against the sibling). A second run over the reconciled state
1937
+ * re-PATCHes in place: no duplicate records, no 400 (issue §8).
1938
+ *
1939
+ * `WEBAUTHN_RP_ID` is re-pointed for PRODUCTION ONLY: the interim dev domain stays
1940
+ * the preview rpID (the dev domain is never retired — see `retireInterimDomain`),
1941
+ * so `buildEnvPlan`'s intentional split pair is preserved — the single-target plan
1942
+ * only PATCHes the overlapping production record and never touches the preview
1943
+ * sibling (no BOOT-002 400 reintroduced). Changing the rpID invalidates every
1944
+ * passkey registered under the interim rpID and the CLI cannot count them (that
1945
+ * table lives in the derivative's DB), so a warning prints UNCONDITIONALLY.
1946
+ *
1947
+ * No-echo: only KEY NAMES + targets print (the values here are public, but the
1948
+ * discipline holds).
1570
1949
  */
1571
1950
  async function updateDomainEnv(projectId, token, teamId, domain, deps) {
1572
1951
  const authUrl = `https://${domain}`;
@@ -1583,12 +1962,22 @@ async function updateDomainEnv(projectId, token, teamId, domain, deps) {
1583
1962
  // Keep the public app origin in lockstep with AUTH_URL once a client domain lands
1584
1963
  // (the substrate set it to the interim domain in buildEnvPlan; this re-points it).
1585
1964
  { key: 'NEXT_PUBLIC_APP_URL', value: authUrl, target: ['production', 'preview'] },
1965
+ // WebAuthn rpID is the registrable HOST, never a URL — the bare domain (BOOT-004).
1966
+ // PRODUCTION only: the interim dev domain remains the preview rpID (the dev domain
1967
+ // is never retired), so the intentional split pair from buildEnvPlan is preserved.
1968
+ { key: 'WEBAUTHN_RP_ID', value: domain, target: ['production'] },
1586
1969
  ];
1587
1970
  const existing = await listEnvVars(projectId, token, teamId, deps);
1588
1971
  for (const entry of plan) {
1589
1972
  const outcome = await upsertEnvVar(projectId, token, teamId, entry, existing, deps);
1590
1973
  console.log(` ${outcome === 'created' ? '+' : '↻'} ${entry.key} [${entry.target.join(', ')}]`);
1591
1974
  }
1975
+ // BOOT-004: changing the rpID orphans every passkey registered under the interim
1976
+ // rpID. The CLI cannot query how many exist (that table lives in the derivative's
1977
+ // DB, unreachable from provisioning), so the warning is UNCONDITIONAL.
1978
+ console.warn(`⚠ WEBAUTHN_RP_ID (production) ahora es \`${domain}\`: cualquier passkey registrada ` +
1979
+ 'bajo el rpID interino queda invalidada — esos usuarios deben registrar una nueva ' +
1980
+ 'passkey en el dominio definitivo.');
1592
1981
  }
1593
1982
  /**
1594
1983
  * Print the resolved client-domain operation tree for `--domain --dry-run`
@@ -1609,7 +1998,14 @@ function printClientDomainPlan(domain, interim, route, confirm) {
1609
1998
  console.log(` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim} ` +
1610
1999
  `(el dev ${domain}-dev se conserva)`);
1611
2000
  console.log(` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · AUTH_URL=https://${domain} · NEXTAUTH_URL=https://${domain} · NEXT_PUBLIC_APP_URL=https://${domain}`);
2001
+ console.log(` ↻ WEBAUTHN_RP_ID=${domain} (solo production — las passkeys del rpID interino quedan invalidadas; el preview conserva el rpID dev)`);
1612
2002
  console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
2003
+ if (route === 'registrar') {
2004
+ console.log(' · (registrar) los records DNS de Resend (DKIM/SPF/MX) se crean a mano en el dashboard de Resend');
2005
+ }
2006
+ else {
2007
+ console.log(` + records DNS de Resend (DKIM/SPF/MX, los que devuelva) en la zona ${apexZoneOf(domain)} + sondeo hasta verified`);
2008
+ }
1613
2009
  console.log('\n(`--dry-run`) No se ejecutó ninguna llamada a API ni se escribió estado.');
1614
2010
  }
1615
2011
  /**
@@ -1631,7 +2027,10 @@ async function runServiceStep(service, rootDir, state, flags) {
1631
2027
  // PROV-004: git substrate (rename → remote add → push main) + create the
1632
2028
  // Vercel project + connect git (GitHub App) + PATCH production-branch +
1633
2029
  // .vercel/project.json + activate E2E. Migrations run in vercel-build, not a workflow.
1634
- await provisionGithubVercel(rootDir, state, flags.team, { force: flags.force });
2030
+ await provisionGithubVercel(rootDir, state, flags.team, {
2031
+ force: flags.force,
2032
+ repo: flags.repo,
2033
+ });
1635
2034
  break;
1636
2035
  case 'dns':
1637
2036
  // PROV-005: attach the interim domains to Vercel (dev → develop, prod →
@@ -2431,6 +2830,16 @@ export async function runProvision(flags) {
2431
2830
  // In patch mode the projectName comes from the loaded state (the interim slug);
2432
2831
  // a fresh run derives it from flags (the scaffold default).
2433
2832
  const projectName = state?.projectName ?? deriveProjectName(flags, rootDir);
2833
+ // BOOT-001: resolve the target GitHub repo ONCE (--repo → existing origin →
2834
+ // FACTORY_ORG/projectName) for every consumer of the slug: the plan/report
2835
+ // (`--dry-run` included), the Neon E2E-secret push (which runs BEFORE the github
2836
+ // step) and the github step itself. Reading the local `origin` is a read-only
2837
+ // git call — safe under `--dry-run` (no REST, no state write). On resume the
2838
+ // persisted repo is the authority (a conflicting `--repo` throws, never a
2839
+ // silent re-point).
2840
+ const repoSlug = flags.services.includes('github') || flags.services.includes('neon')
2841
+ ? await resolveRepoSlug(rootDir, projectName, state?.github?.repo, flags.repo, gitInDir)
2842
+ : undefined;
2434
2843
  const steps = buildPlan(flags.services, state);
2435
2844
  const result = {
2436
2845
  projectName,
@@ -2439,6 +2848,9 @@ export async function runProvision(flags) {
2439
2848
  resumed: (flags.resume || standaloneDomain) && !flags.force,
2440
2849
  steps,
2441
2850
  };
2851
+ if (repoSlug) {
2852
+ result.repo = repoSlug;
2853
+ }
2442
2854
  if (hasDomain) {
2443
2855
  result.clientDomain = { domain: flags.domain, route: flags.dns };
2444
2856
  }
@@ -2472,6 +2884,13 @@ export async function runProvision(flags) {
2472
2884
  scope: flags.team ?? flags.orgId ?? '',
2473
2885
  createdAt: new Date().toISOString(),
2474
2886
  };
2887
+ // BOOT-001: record the resolved repo BEFORE the steps run, so the Neon E2E-secret
2888
+ // push (`neonSecretRepo`, which runs before the github step) targets the resolved
2889
+ // repo — not the historical `${FACTORY_ORG}/${projectName}` fallback. Never
2890
+ // clobbers a resumed state's repo (that one is the authority).
2891
+ if (repoSlug && !runState.github?.repo) {
2892
+ runState.github = { repo: repoSlug };
2893
+ }
2475
2894
  // Ensure the state dir exists + a baseline file before the first step, so a
2476
2895
  // step failure on the very first service still leaves a resumable state.
2477
2896
  writeProvisionState(rootDir, runState);
@@ -58,6 +58,7 @@ export function parseUpdateFlags(argv) {
58
58
  mineAll: argv.includes('--mine-all'),
59
59
  resume: argv.includes('--resume'),
60
60
  verify: argv.includes('--verify'),
61
+ dryRun: argv.includes('--dry-run'),
61
62
  core: argv.includes('--core'),
62
63
  full: argv.includes('--full'),
63
64
  commit: argv.includes('--commit'),
@@ -395,6 +396,26 @@ function syncDerivedPrettierignore(rootDir, stagedDir) {
395
396
  console.warn('Aviso: no se pudo sincronizar `.prettierignore`; el cerebro se instaló igual.');
396
397
  }
397
398
  }
399
+ /** Exit code of `--dry-run` when the preview found conflicts (0 = none). Lets a
400
+ * non-interactive caller (the GUI) branch on the result instead of clobbering. */
401
+ const DRY_RUN_CONFLICTS_EXIT = 2;
402
+ /**
403
+ * `--dry-run`: preview the plan (buckets + the explicit conflict list) and apply
404
+ * NOTHING. Prints the conflicting paths so a human or agent sees exactly what
405
+ * collides before deciding. The GUI runs this first: no conflicts → it is safe to
406
+ * apply `--theirs-all` (only adds/overwrites of clean files); conflicts → hand off
407
+ * to the CLI for per-file resolution instead of silently taking theirs.
408
+ */
409
+ function reportDryRun(diff, manifest) {
410
+ console.log(`\n🔍 Vista previa de update a v${manifest.version} — no se modificó nada:`);
411
+ reportSummary(diff, manifest);
412
+ if (diff.conflicts.length > 0) {
413
+ console.log('\nArchivos en conflicto (los modificaste tú y el Factory también los cambió):');
414
+ for (const c of diff.conflicts)
415
+ console.log(` • ${c.path}`);
416
+ console.log('\nPara resolverlos, corre `factory update` (sin `--dry-run`): pregunta archivo por archivo.');
417
+ }
418
+ }
398
419
  /** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
399
420
  function reportSummary(diff, manifest) {
400
421
  if (diff.deleteSilent.length > 0) {
@@ -469,6 +490,9 @@ export async function runUpdate(flags, deps = {}) {
469
490
  if (flags.beta && flags.stable) {
470
491
  throw new CLIError('No puedes combinar `--beta` con `--stable`.');
471
492
  }
493
+ if (flags.dryRun && (flags.verify || flags.resume)) {
494
+ throw new CLIError('No puedes combinar `--dry-run` con `--verify` ni `--resume`.');
495
+ }
472
496
  // --verify and --resume short-circuit (no download).
473
497
  if (flags.verify) {
474
498
  runVerify(rootDir);
@@ -529,6 +553,14 @@ export async function runUpdate(flags, deps = {}) {
529
553
  'El release está mal publicado; no se modificó nada.');
530
554
  }
531
555
  if (legacy) {
556
+ // --dry-run on a no-lockfile repo: nothing to conflict with (first sync has no
557
+ // prior version), so report clean and STOP — never auto-register under dry-run.
558
+ if (flags.dryRun) {
559
+ console.log('\n🔍 Vista previa: este repo aún no tiene lockfile. El primer `factory update` ' +
560
+ 'registra el cerebro; en el primer sync no hay conflictos posibles.');
561
+ process.exitCode = 0;
562
+ return;
563
+ }
532
564
  await applyLegacy(rootDir, stagedDir, manifest);
533
565
  // Legacy auto-register succeeded → end-pointer (PVSKILL-002).
534
566
  printProvisionPointer({ assumeYes: flags.yes });
@@ -542,6 +574,14 @@ export async function runUpdate(flags, deps = {}) {
542
574
  ]);
543
575
  const disk = hashDiskPaths(rootDir, trackedPaths);
544
576
  const diff = diffLockfiles(oldLock, manifest, disk);
577
+ // --dry-run: preview + STOP. No prompts, no apply, no lockfile, no commit.
578
+ // Exit 2 when conflicts exist so the GUI branches on it (0 otherwise). `return`
579
+ // runs the finally → tmpRoot is cleaned (no update-state was written).
580
+ if (flags.dryRun) {
581
+ reportDryRun(diff, manifest);
582
+ process.exitCode = diff.conflicts.length > 0 ? DRY_RUN_CONFLICTS_EXIT : 0;
583
+ return;
584
+ }
545
585
  // Resolve conflicts.
546
586
  const theirsConflicts = [];
547
587
  for (const conflict of diff.conflicts) {
package/dist/index.js CHANGED
@@ -69,6 +69,7 @@ Sobre \`update\`:
69
69
  --mine-all Conserva tu versión en todos los conflictos
70
70
  --resume Retoma un update interrumpido sin re-descargar
71
71
  --verify Reporta diferencias disco-vs-lockfile sin modificar nada
72
+ --dry-run Previsualiza el plan (conflictos incluidos) SIN aplicar; sale 2 si hay conflictos
72
73
  --beta Entra al canal beta (instala el último pre-release \`-beta.N\`)
73
74
  --stable Sale del canal beta y baja al estable (limpia \`beta\` del lockfile)
74
75
  --yes / -y Auto-confirma el auto-registro de un repo legacy (sin lockfile) —
@@ -16,8 +16,12 @@
16
16
  * actionable `CLIError` (issue §8 Gherkin) — it NEVER deletes a record to recover.
17
17
  *
18
18
  * What this module does NOT do:
19
- * - Delete a DNS record. The idempotent path is create-or-PATCH; an existing
20
- * record is never removed without explicit human confirmation (issue §8).
19
+ * - Delete a DNS record to RECOVER from an error. The idempotent provisioning
20
+ * path is create-or-PATCH; a record is never removed to "fix" a failed call
21
+ * (issue §8). The ONE deletion shipped here ({@link deleteDnsRecord},
22
+ * BOOT-007) is DELIBERATE cleanup inside a flow a human already confirmed
23
+ * (retiring the interim domain after the client domain went live) — it is
24
+ * never an automatic recovery move.
21
25
  * - Read or log a token value. The token arrives as an argument and is handed
22
26
  * straight to the `Authorization` header; it never reaches stdout.
23
27
  *
@@ -241,3 +245,109 @@ export async function ensureCnameRecord(zoneId, name, token, deps = {}) {
241
245
  }
242
246
  return 'unchanged';
243
247
  }
248
+ /**
249
+ * Delete a DNS record by id — `DELETE /zones/{id}/dns_records/{recordId}`
250
+ * (BOOT-007). The ONE deletion in this module, and it is DELIBERATE: it runs only
251
+ * inside a flow the human already confirmed (retiring the interim domain after the
252
+ * client domain verified — the orphaned interim CNAME would otherwise resolve to
253
+ * {@link CNAME_VERCEL_TARGET} and serve a 404 forever). It is NEVER used to
254
+ * recover from a failed call — the module-header contract stands.
255
+ *
256
+ * Mirrors `removeVercelDomain`'s 404 tolerance: a record already gone (deleted by
257
+ * hand or by a previous run) is `absent`, not an error — the cleanup is idempotent.
258
+ *
259
+ * @throws {CloudflareApiError} on any non-2xx other than 404 / `success: false`.
260
+ */
261
+ export async function deleteDnsRecord(zoneId, recordId, token, deps = {}) {
262
+ try {
263
+ await cloudflareApi('DELETE', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, token, deps);
264
+ return 'removed';
265
+ }
266
+ catch (error) {
267
+ if (error instanceof CloudflareApiError && error.status === 404)
268
+ return 'absent';
269
+ throw error;
270
+ }
271
+ }
272
+ /**
273
+ * Normalize a record content for comparison (issue §8 idempotency). Cloudflare
274
+ * returns TXT content wrapped in double quotes (`"v=spf1 …"`) while Resend hands
275
+ * it bare — a byte-compare would false-positive a "different content" and PATCH
276
+ * on every re-run. Stripping ONE layer of surrounding quotes + trimming makes the
277
+ * re-run comparison stable without touching the value actually stored.
278
+ */
279
+ function normalizeRecordContent(value) {
280
+ const trimmed = value.trim();
281
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
282
+ return trimmed.slice(1, -1);
283
+ }
284
+ return trimmed;
285
+ }
286
+ /**
287
+ * Find an existing record of `spec.type` for `name` in the zone (issue §8
288
+ * idempotency) — same lookup shape as {@link findCnameRecord}, parameterized on
289
+ * the type. Returns the record (so the caller can compare content and PATCH a
290
+ * rotated value) or `null` when absent.
291
+ *
292
+ * @throws {CloudflareApiError} on any non-2xx / `success: false`.
293
+ */
294
+ export async function findDnsRecord(zoneId, type, name, token, deps = {}) {
295
+ const data = (await cloudflareApi('GET', `/zones/${encodeURIComponent(zoneId)}/dns_records`, token, deps, undefined, { type, name }));
296
+ return (data.result ?? []).find((r) => r.name === name && r.type === type) ?? null;
297
+ }
298
+ /**
299
+ * Create a TXT/MX record — `POST /zones/{id}/dns_records` (BOOT-003 §4), same
300
+ * call shape as {@link createCnameRecord} (`ttl: 1` = auto). TXT/MX cannot be
301
+ * proxied, so no `proxied` flag is sent. `priority` rides along for MX only.
302
+ *
303
+ * @throws {CloudflareApiError} on any non-2xx / `success: false`.
304
+ */
305
+ export async function createDnsRecord(zoneId, spec, token, deps = {}) {
306
+ await cloudflareApi('POST', `/zones/${encodeURIComponent(zoneId)}/dns_records`, token, deps, {
307
+ type: spec.type,
308
+ name: spec.name,
309
+ content: spec.content,
310
+ ttl: 1,
311
+ ...(spec.priority !== undefined ? { priority: spec.priority } : {}),
312
+ });
313
+ }
314
+ /**
315
+ * PATCH an existing record to the content/priority the spec demands — `PATCH
316
+ * /zones/{id}/dns_records/{recordId}` (issue §8: a DKIM rotation leaves a record
317
+ * with STALE content; we repoint it in place). Same principle as
318
+ * {@link unproxyCnameRecord}: we NEVER delete a record to "fix" it.
319
+ *
320
+ * @throws {CloudflareApiError} on any non-2xx / `success: false`.
321
+ */
322
+ export async function patchDnsRecord(zoneId, recordId, spec, token, deps = {}) {
323
+ await cloudflareApi('PATCH', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, token, deps, {
324
+ type: spec.type,
325
+ name: spec.name,
326
+ content: spec.content,
327
+ ...(spec.priority !== undefined ? { priority: spec.priority } : {}),
328
+ });
329
+ }
330
+ /**
331
+ * Idempotently ensure a TXT/MX record matching `spec` exists in the zone
332
+ * (BOOT-003 §4 + §8) — the TXT/MX sibling of {@link ensureCnameRecord}. Returns
333
+ * the outcome so the caller can narrate it:
334
+ * - `created` — no record existed → POSTed a fresh one
335
+ * - `updated` — a record existed with DIFFERENT content/priority (e.g. a DKIM
336
+ * rotation) → PATCHed it in place (never deleted — §8)
337
+ * - `unchanged` — a record with the same content already existed → nothing to do
338
+ *
339
+ * @throws {CloudflareApiError} on any non-2xx / `success: false` (caller maps it).
340
+ */
341
+ export async function ensureDnsRecord(zoneId, spec, token, deps = {}) {
342
+ const existing = await findDnsRecord(zoneId, spec.type, spec.name, token, deps);
343
+ if (!existing) {
344
+ await createDnsRecord(zoneId, spec, token, deps);
345
+ return 'created';
346
+ }
347
+ const sameContent = normalizeRecordContent(existing.content) === normalizeRecordContent(spec.content);
348
+ const samePriority = spec.priority === undefined || existing.priority === spec.priority;
349
+ if (sameContent && samePriority)
350
+ return 'unchanged';
351
+ await patchDnsRecord(zoneId, existing.id, spec, token, deps);
352
+ return 'updated';
353
+ }
@@ -78,10 +78,13 @@ export function writeEnvLocal(rootDir, neon, opts = {}) {
78
78
  body = setEnvLine(body, 'DATABASE_URL', neon.developDirect);
79
79
  body = setEnvLine(body, 'DATABASE_URL_MAIN', neon.mainDirect);
80
80
  body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
81
- writeFileSync(localPath, body, 'utf8');
82
- // `.env.local` holds DB strings + AUTH_SECRET → owner-only (0600). Same
83
- // discipline the secrets rail uses; matters on the headless multi-tenant
84
- // Agent Server where the default umask would leave it world-readable.
81
+ // `.env.local` holds DB strings + AUTH_SECRET → owner-only (0600) from birth.
82
+ // The `mode: 0o600` makes the file created 0600 with no world-readable window
83
+ // between create and chmod — critical on the headless multi-tenant Agent Server
84
+ // where the default umask would otherwise leave one tenant's secrets briefly
85
+ // readable by others. `mode` only applies when the file is CREATED, so the
86
+ // chmod below still covers the `--force` overwrite path (file already exists).
87
+ writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
85
88
  chmodSync(localPath, 0o600);
86
89
  return 'written';
87
90
  }
@@ -104,7 +107,7 @@ export function writeE2eLocalCreds(rootDir, projectId, apiKey) {
104
107
  let body = readFileSync(localPath, 'utf8');
105
108
  body = setEnvLine(body, 'NEON_PROJECT_ID', projectId);
106
109
  body = setEnvLine(body, 'NEON_API_KEY', apiKey);
107
- writeFileSync(localPath, body, 'utf8');
110
+ writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
108
111
  chmodSync(localPath, 0o600); // holds NEON_API_KEY → owner-only.
109
112
  return true;
110
113
  }
@@ -133,7 +136,7 @@ export function writeEmailLocalCreds(rootDir, resendKey, emailFrom) {
133
136
  body = setEnvLine(body, 'EMAIL_PROVIDER', 'resend');
134
137
  body = setEnvLine(body, 'RESEND_API_KEY', resendKey);
135
138
  body = setEnvLine(body, 'EMAIL_FROM', emailFrom);
136
- writeFileSync(localPath, body, 'utf8');
139
+ writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
137
140
  chmodSync(localPath, 0o600); // holds RESEND_API_KEY → owner-only.
138
141
  return true;
139
142
  }
@@ -41,6 +41,12 @@ const DIVIDER = /^#\s*-{3,}\s*$/;
41
41
  const ACTIVE = /^([A-Z][A-Z0-9_]*)=(.*)$/;
42
42
  const COMMENTED = /^#\s*([A-Z][A-Z0-9_]*)=(.*)$/;
43
43
  const SECRET_KEY = /(_SECRET|_KEY|_TOKEN|_PASSWORD)$/;
44
+ /**
45
+ * `NEXT_PUBLIC_*` vars are public by Next.js convention — they're inlined into
46
+ * the client bundle, so they can never be secrets regardless of their suffix
47
+ * (e.g. NEXT_PUBLIC_AUTH_PASSWORD is a boolean feature flag, not a credential).
48
+ */
49
+ const PUBLIC_PREFIX = /^NEXT_PUBLIC_/;
44
50
  function unquote(raw) {
45
51
  const v = raw.trim();
46
52
  if (v.length >= 2 &&
@@ -104,10 +110,16 @@ export function parseEnvExample(text) {
104
110
  }
105
111
  return entries;
106
112
  }
107
- function questionKind(key, defaultValue) {
113
+ /**
114
+ * Classify a var into the widget the wizard renders. Secret detection delegates
115
+ * to `isSecretKey` so classification, masking and review all share one rule
116
+ * (secret suffix AND not `NEXT_PUBLIC_*` — public-by-convention vars are never
117
+ * secrets, e.g. NEXT_PUBLIC_AUTH_PASSWORD="true" is a boolean flag).
118
+ */
119
+ export function questionKind(key, defaultValue) {
108
120
  if (SELECTS[key])
109
121
  return 'select';
110
- if (SECRET_KEY.test(key))
122
+ if (isSecretKey(key))
111
123
  return 'secret';
112
124
  if (defaultValue === 'true' || defaultValue === 'false')
113
125
  return 'boolean';
@@ -189,9 +201,13 @@ export function applyWizardAnswers(rootDir, answers) {
189
201
  // secret values, (c) auto-generate what a dev can't type, and (d) catch broken
190
202
  // setups (a provider flag on with empty creds). All pure over their inputs.
191
203
  // ─────────────────────────────────────────────────────────────────────────────
192
- /** True when `key`'s value is a secret (never printed in review/summary output). */
204
+ /**
205
+ * True when `key`'s value is a secret (never printed in review/summary output).
206
+ * `NEXT_PUBLIC_*` keys are excluded: Next.js inlines them into the client bundle,
207
+ * so they're public by convention even when their suffix looks secret-ish.
208
+ */
193
209
  export function isSecretKey(key) {
194
- return SECRET_KEY.test(key);
210
+ return !PUBLIC_PREFIX.test(key) && SECRET_KEY.test(key);
195
211
  }
196
212
  /** Parse an env-file body into `{ KEY: value }` (unquoted), skipping comments/blanks. */
197
213
  export function parseEnvBody(body) {
@@ -247,8 +263,12 @@ const OAUTH_PROVIDERS = [
247
263
  ];
248
264
  /**
249
265
  * Warn (do NOT block) about half-configured setups that would ship a broken app:
250
- * an OAuth provider enabled with empty creds → broken login button; notifications
251
- * enabled with no VAPID keys → dead push. Pure over the resolved env map.
266
+ * an OAuth flag on with missing creds → `next build` FAILS (BOOT-005: auth.ts
267
+ * resolves the provider credentials at module load, i.e. during the build — a
268
+ * deploy that never ships on derivatives whose frozen src/ predates the auth.ts
269
+ * guard); notifications enabled with no VAPID keys → dead push. Pure over the
270
+ * resolved env map. Warn-never-block by contract (headless-safe): the wizard
271
+ * stays pure + non-interactive, so escalation is message-only.
252
272
  */
253
273
  export function checkEnvCompleteness(env) {
254
274
  const warnings = [];
@@ -257,8 +277,10 @@ export function checkEnvCompleteness(env) {
257
277
  continue;
258
278
  const missing = [p.id, p.secret].filter((k) => !env[k]);
259
279
  if (missing.length) {
260
- warnings.push(`Login con ${p.label} activado pero falta ${missing.join(' + ')} → el botón de login ` +
261
- `quedará roto. Complétalas o pon ${p.flag}="false".`);
280
+ warnings.push(`Login con ${p.label} activado pero falta ${missing.join(' + ')} → EL BUILD FALLARÁ ` +
281
+ `mientras ${p.flag} quede en "true" sin credenciales (next build resuelve las ` +
282
+ `credenciales del provider al compilar — el deploy nunca sale). ` +
283
+ `Complétalas o pon ${p.flag}="false".`);
262
284
  }
263
285
  }
264
286
  if (env['NEXT_PUBLIC_NOTIFICATIONS_ENABLED'] === 'true' &&
@@ -334,19 +334,53 @@ export async function listEnvVars(projectId, token, teamId, deps = {}) {
334
334
  return data.envs ?? [];
335
335
  }
336
336
  /**
337
- * Idempotently set ONE env var for its targets (issue §4 + §8). If an existing
338
- * var already covers any of the requested targets, it is PATCHed
339
- * (`/v10/projects/{id}/env/{envId}`); otherwise it is POSTed
340
- * (`/v10/projects/{id}/env`). Either way the call threads `?teamId=`.
337
+ * Idempotently set ONE env var for its targets (issue §4 + §8). If existing vars
338
+ * already cover any of the requested targets, they are RECONCILED into a single
339
+ * record (PATCH `/v10/projects/{id}/env/{envId}`, DELETEing any surplus record —
340
+ * see below); otherwise the var is POSTed (`/v10/projects/{id}/env`). Either way
341
+ * every call threads `?teamId=`.
341
342
  *
342
343
  * Matching is by `key` + target overlap — a var pushed for `production` does not
343
344
  * clobber the same key's `preview` entry (they are distinct Vercel env records,
344
- * which is exactly how `AUTH_SECRET` can differ per environment).
345
+ * which is exactly how `AUTH_SECRET` can differ per environment). Same-key
346
+ * records whose target does NOT overlap the plan are never touched, so the
347
+ * intentional split pairs `buildEnvPlan` creates (distinct values per env) are
348
+ * never collapsed by a per-target push.
349
+ *
350
+ * Split-target reconciliation (BOOT-002): `buildEnvPlan` intentionally creates
351
+ * SPLIT records per target for some keys (e.g. `NEXT_PUBLIC_APP_URL` as
352
+ * `['production']` + `['preview']`). When a later plan re-points that key with a
353
+ * COMBINED target (`['production','preview']` — e.g. the `--domain` go-live),
354
+ * ALL overlapping records are gathered, the surplus ones are DELETEd FIRST
355
+ * (Vercel rejects a PATCH whose expanded target collides with a sibling record),
356
+ * and the first record is then PATCHed with the plan's value + target — leaving
357
+ * ONE consistent record for the key. A re-run over the already-reconciled state
358
+ * finds a single record and simply re-PATCHes it in place: no duplicate, no 400.
359
+ *
360
+ * @throws {CLIError} when a surplus-record DELETE fails mid-reconciliation — the
361
+ * message names the key and the step stays re-runnable with `--resume` (the next
362
+ * run re-lists the records and retries the cleanup).
363
+ * @throws {VercelApiError} on any other non-2xx (the caller maps it).
345
364
  */
346
365
  export async function upsertEnvVar(projectId, token, teamId, plan, existing, deps = {}) {
347
- const match = existing.find((e) => e.key === plan.key && (e.target ?? []).some((t) => plan.target.includes(t)));
348
- if (match) {
349
- await vercelApi('PATCH', `/v10/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(match.id)}`, token, teamId, deps, {
366
+ // EVERY record of the key whose target overlaps the plan — not just the first
367
+ // (a combined-target plan over split records must reconcile the whole set).
368
+ const matches = existing.filter((e) => e.key === plan.key && (e.target ?? []).some((t) => plan.target.includes(t)));
369
+ if (matches.length > 0) {
370
+ const [survivor, ...surplus] = matches;
371
+ // DELETE the surplus records BEFORE the PATCH: expanding the survivor's
372
+ // target while a sibling still owns one of those targets 400s on Vercel.
373
+ for (const record of surplus) {
374
+ try {
375
+ await vercelApi('DELETE', `/v10/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(record.id)}`, token, teamId, deps);
376
+ }
377
+ catch (error) {
378
+ throw new CLIError(`No se pudo eliminar el record duplicado de \`${plan.key}\` en Vercel al reconciliar ` +
379
+ `sus targets (${error?.message ?? 'error desconocido'}).\n` +
380
+ 'La variable quedó a medio reconciliar; reintenta con `--resume` para completar la limpieza.');
381
+ }
382
+ }
383
+ await vercelApi('PATCH', `/v10/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(survivor.id)}`, token, teamId, deps, {
350
384
  value: plan.value,
351
385
  target: plan.target,
352
386
  type: 'encrypted',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",