@timekast/factory 1.18.0 → 1.20.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.
@@ -25,11 +25,12 @@ 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
- import { defaultEnvPath, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
30
+ import { createBacklogProject, deleteBacklogProject, mintBacklogKey, BacklogApiError, } from '../lib/backlog-api.js';
31
+ import { defaultEnvPath, readEnvKeyNames, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
31
32
  import { addVercelDomain, buildEnvPlan, createVercelProject, generateAuthSecret, generateMfaEncryptionKey, getLinkedRepoId, getVercelDomainStatus, GitHubAppNotInstalledError, linkGitRepo, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, writeVercelProjectJson, } from '../lib/vercel-env.js';
32
- import { writeE2eLocalCreds, writeEmailLocalCreds, writeEnvLocal } from '../lib/env-file.js';
33
+ import { writeBacklogLocalCreds, writeE2eLocalCreds, writeEmailLocalCreds, writeEnvLocal, } from '../lib/env-file.js';
33
34
  import { adoptState, ADOPT_UNKNOWN, clearProvisionState, clearState, hasProvisionState, isServiceComplete, PROVISION_DIR, PROVISION_STATE_FILE, readDestroyTarget, readProvisionState, writeProvisionState, } from '../lib/provision-state.js';
34
35
  /**
35
36
  * The THREE GitHub Secrets the E2E runner (`pnpm test:e2e` → `e2e-runner.ts` +
@@ -103,9 +104,12 @@ export function isValidSubdomainSlug(slug) {
103
104
  * The services `--services` understands, in canonical provisioning order. `github`
104
105
  * precedes `vercel`: PROV-004 (the `github` step) builds the git substrate AND
105
106
  * creates the Vercel PROJECT, whose `projectId` PROV-003 (the `vercel` env-sync
106
- * step) then consumes. The order is normalized regardless of `--services` input.
107
+ * step) then consumes. `backlog` (BSYNC-003) runs LAST — after `vercel`/`dns` have
108
+ * created `.env.local` — so it APPENDS its two keys instead of racing the Neon-string
109
+ * write (`writeEnvLocal` only creates `.env.local` when absent). The order is
110
+ * normalized regardless of `--services` input.
107
111
  */
108
- const KNOWN_SERVICES = ['neon', 'github', 'vercel', 'dns'];
112
+ const KNOWN_SERVICES = ['neon', 'github', 'vercel', 'dns', 'backlog'];
109
113
  /** The known `--dns` route tokens, for validation. */
110
114
  const KNOWN_DNS_ROUTES = ['org', 'cliente-cf', 'registrar'];
111
115
  /** Default verification-poll timeout (minutes) and interval (seconds) — issue §4. */
@@ -180,6 +184,21 @@ export function parseVerifyTimeout(raw) {
180
184
  }
181
185
  return minutes;
182
186
  }
187
+ /**
188
+ * Parse + validate `--repo=<org/name>` (BOOT-001). Absent → `undefined` (the repo
189
+ * resolution then falls back to the `origin` remote / `FACTORY_ORG/projectName`).
190
+ * A malformed value (no `/`, more than one `/`, or an empty side) throws a clear
191
+ * `CLIError` — a corrupt slug must never silently reach the GitHub/Vercel calls.
192
+ */
193
+ export function parseRepoFlag(raw) {
194
+ if (raw === undefined)
195
+ return undefined;
196
+ const parts = raw.split('/');
197
+ if (parts.length !== 2 || parts.some((part) => part.trim() === '')) {
198
+ throw new CLIError(`\`--repo\` debe tener el formato \`org/nombre\` (un solo \`/\`; recibí \`${raw}\`).`);
199
+ }
200
+ return raw;
201
+ }
183
202
  /** Parse `provision`'s argv slice into typed flags. */
184
203
  export function parseProvisionFlags(argv) {
185
204
  return {
@@ -187,6 +206,7 @@ export function parseProvisionFlags(argv) {
187
206
  team: readValueFlag(argv, 'team'),
188
207
  orgId: readValueFlag(argv, 'org-id'),
189
208
  slug: readValueFlag(argv, 'slug'),
209
+ repo: parseRepoFlag(readValueFlag(argv, 'repo')),
190
210
  domain: readValueFlag(argv, 'domain'),
191
211
  dns: parseDnsRoute(readValueFlag(argv, 'dns')),
192
212
  verifyTimeout: parseVerifyTimeout(readValueFlag(argv, 'verify-timeout')),
@@ -206,6 +226,7 @@ const SERVICE_LABEL = {
206
226
  vercel: 'Vercel (env sync por target)',
207
227
  github: 'GitHub + Vercel (sustrato git + proyecto + env + E2E)',
208
228
  dns: 'DNS + Resend (dominio del entorno)',
229
+ backlog: 'Backlog central (proyecto en backlog.timekast.mx + API key)',
209
230
  };
210
231
  /** Build the ordered plan: each requested service is `skip` if complete, else `create`. */
211
232
  function buildPlan(services, state) {
@@ -217,6 +238,11 @@ function buildPlan(services, state) {
217
238
  /** Print the plain-text plan (the `--dry-run` and pre-run narration). */
218
239
  function printPlan(result) {
219
240
  console.log(`Plan de provision para \`${result.projectName}\` (lifecycle: ${result.lifecycle}):`);
241
+ // BOOT-001: print the RESOLVED repo (--repo → origin → FACTORY_ORG/name), so a
242
+ // `--dry-run` shows the exact repo the github step / E2E secrets will target.
243
+ if (result.repo) {
244
+ console.log(` Repo de GitHub: ${result.repo}`);
245
+ }
220
246
  for (const step of result.steps) {
221
247
  const mark = step.action === 'skip' ? '⏭ (ya completado)' : '+ crear';
222
248
  console.log(` ${mark} ${SERVICE_LABEL[step.service]}`);
@@ -405,9 +431,11 @@ function neonErrorToCliError(error, projectName, context) {
405
431
  }
406
432
  /** The GitHub repo (`owner/name`) the project-scoped secret is pushed to. */
407
433
  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).
434
+ // `state.github.repo` carries the RESOLVED repo: `runProvision` seeds it before
435
+ // any step runs (BOOT-001: --repo → origin → FACTORY_ORG/name) and PROV-004
436
+ // re-records it. The fallback only covers a direct `provisionNeon` call with no
437
+ // seeded state, so the secret push is still attempted (a non-existent repo →
438
+ // §8 pending path, never an abort).
411
439
  return state.github?.repo ?? `${FACTORY_ORG}/${state.projectName}`;
412
440
  }
413
441
  /**
@@ -556,6 +584,163 @@ export async function provisionNeon(rootDir, state, deps = {}) {
556
584
  ' La API key project-scoped se creó; reintenta con `--resume` cuando el repo exista.');
557
585
  }
558
586
  }
587
+ // ─────────────────────────────────────────────────────────────────────────────
588
+ // BSYNC-003 — `backlog`: register the project in backlog.timekast.mx
589
+ // ─────────────────────────────────────────────────────────────────────────────
590
+ /** The rail key holding the org-level backlog `admin` token (create/mint/delete). */
591
+ export const BACKLOG_ADMIN_TOKEN_ENV = 'BACKLOG_ADMIN_TOKEN';
592
+ /**
593
+ * The backlog-central `internalKey` rule (`^[a-z0-9-]{3,40}$`) — DELIBERATELY DISTINCT
594
+ * from the DNS subdomain rule of {@link isValidSubdomainSlug} (no leading/trailing
595
+ * hyphen, ≤59 chars). A backlog internalKey is NOT a DNS label, so it has its own
596
+ * shape: 3–40 chars of lowercase, digits and hyphens (server-side rule, live-verified
597
+ * in `project/abraham/smoke-findings.md` Hallazgo 1 — uppercase 422s).
598
+ */
599
+ export const BACKLOG_SLUG_RE = /^[a-z0-9-]{3,40}$/;
600
+ /**
601
+ * Normalize + validate a slug as a backlog-central `internalKey` BEFORE any POST
602
+ * (issue §4/§8). Lowercases first (the server 422s on uppercase), then asserts the
603
+ * {@link BACKLOG_SLUG_RE} shape. Returns the normalized value so the POST always uses
604
+ * the canonical form. Distinct from `assertValidSubdomainSlug` (the DNS-label check).
605
+ *
606
+ * @throws {CLIError} when the slug cannot be a valid backlog internalKey.
607
+ */
608
+ export function normalizeBacklogSlug(raw) {
609
+ const slug = raw.toLowerCase();
610
+ if (!BACKLOG_SLUG_RE.test(slug)) {
611
+ throw new CLIError(`El slug \`${raw}\` no es un internalKey válido para backlog central.\n` +
612
+ 'Debe ser 3–40 caracteres de minúsculas, dígitos y guiones (`^[a-z0-9-]{3,40}$`).');
613
+ }
614
+ return slug;
615
+ }
616
+ /**
617
+ * Map a backlog-central REST failure to an actionable, es-MX `CLIError` (issue §4/§8).
618
+ * The `409 INTERNAL_KEY_TAKEN` hints at `--adopt` WITHOUT auto-adopting (plan decision
619
+ * H2: silently adopting a colliding project risks mixing two projects by accident). A
620
+ * 401/403 points at re-importing the rail token; a 422 at the slug shape.
621
+ */
622
+ function backlogErrorToCliError(error, internalKey, context) {
623
+ if (error instanceof BacklogApiError) {
624
+ switch (error.status) {
625
+ case 401:
626
+ case 403:
627
+ return new CLIError(`El \`BACKLOG_ADMIN_TOKEN\` es inválido o no autoriza ${context} (backlog respondió ${error.status}).\n` +
628
+ 'Renuévalo con `factory secrets <archivo>` y reintenta.');
629
+ case 409:
630
+ return new CLIError(`Ya existe un proyecto de backlog central con el internalKey \`${internalKey}\` ` +
631
+ '(backlog respondió 409 `INTERNAL_KEY_TAKEN`).\n' +
632
+ 'NO se auto-adopta (evita mezclar dos proyectos por accidente). Si es tu proyecto, ' +
633
+ 'reconstruye el estado con `--adopt`; si no, elige otro con `--slug`.');
634
+ case 422:
635
+ return new CLIError(`Backlog central rechazó el internalKey \`${internalKey}\` como inválido (respondió 422).\n` +
636
+ 'Usa un slug de 3–40 caracteres de minúsculas, dígitos y guiones.');
637
+ default:
638
+ return new CLIError(`Falló la llamada a backlog central en ${context}: ${error.message}`);
639
+ }
640
+ }
641
+ return new CLIError(`Falló la llamada a backlog central en ${context}: ${error?.message ?? 'error'}`);
642
+ }
643
+ /**
644
+ * BSYNC-003 — provision the backlog-central project: normalize the slug, create the
645
+ * project (`POST /projects`, org `admin` token), mint a per-project `read_write` key
646
+ * (`POST /projects/{pid}/keys`), and write BOTH the key + the project id to
647
+ * `.env.local` (`BACKLOG_API_KEY` + `BACKLOG_PROJECT_ID`, PROVISION_OWNED → never
648
+ * pushed to Vercel). State records IDs ONLY (`{ projectId, keyMinted }`) — the
649
+ * SHOW-ONCE key value is NEVER persisted anywhere but the gitignored `.env.local`.
650
+ *
651
+ * Auth split (smoke Hallazgo 1): the org `admin` token drives ONLY the two org
652
+ * endpoints here (create project + mint key); the minted `read_write` key is what the
653
+ * derivative's `backlog push`/`sync` later consume. The project id is persisted
654
+ * IMMEDIATELY after create, so a mint failure leaves a resumable partial state.
655
+ *
656
+ * No-echo: the minted key value never reaches stdout/log — only key NAMES are printed.
657
+ *
658
+ * @throws {CLIError} on a missing rail token, an invalid slug (before any POST), a
659
+ * `409 INTERNAL_KEY_TAKEN` (hint: `--adopt`, no auto-adopt), or any backlog failure.
660
+ */
661
+ export async function provisionBacklog(rootDir, state, deps = {}) {
662
+ // Fully complete only when the key was minted + written. Re-entering a partial run
663
+ // (project created, key not yet minted) finishes the mint WITHOUT re-creating.
664
+ if (state.backlog?.keyMinted)
665
+ return;
666
+ const envPath = deps.envPath ?? defaultEnvPath();
667
+ const adminToken = readGateValue(BACKLOG_ADMIN_TOKEN_ENV, envPath);
668
+ if (!adminToken) {
669
+ throw new CLIError('No se pudo leer `BACKLOG_ADMIN_TOKEN` de `~/.claude/.env`.\n' +
670
+ 'Ejecuta `factory secrets <archivo>` para bajar el token del archivo de secretos y reintenta.');
671
+ }
672
+ // Normalize + validate the slug against the backlog rule BEFORE any POST (§4/§8).
673
+ const internalKey = normalizeBacklogSlug(state.projectName);
674
+ const apiDeps = { fetchImpl: deps.fetchImpl, sleepImpl: deps.sleepImpl };
675
+ // 1. Create the project (reuse a partial run's projectId; else POST). Persist the id
676
+ // IMMEDIATELY so a mint failure leaves a resumable partial state (IDs only).
677
+ let projectId;
678
+ if (state.backlog?.projectId) {
679
+ projectId = state.backlog.projectId;
680
+ }
681
+ else {
682
+ let project;
683
+ try {
684
+ project = await createBacklogProject(adminToken, { name: state.projectName, internalKey }, apiDeps);
685
+ }
686
+ catch (error) {
687
+ throw backlogErrorToCliError(error, internalKey, 'crear el proyecto');
688
+ }
689
+ projectId = project.id;
690
+ state.backlog = { projectId };
691
+ writeProvisionState(rootDir, state);
692
+ }
693
+ // 2. Mint the per-project `read_write` key (SHOW-ONCE). Its VALUE goes straight to
694
+ // `.env.local`; it is NEVER persisted in the state nor printed (no-echo).
695
+ let apiKey;
696
+ try {
697
+ apiKey = await mintBacklogKey(adminToken, projectId, `${state.projectName}-provision`, apiDeps);
698
+ }
699
+ catch (error) {
700
+ throw backlogErrorToCliError(error, internalKey, 'mintar la API key del proyecto');
701
+ }
702
+ // 3. Write BACKLOG_API_KEY + BACKLOG_PROJECT_ID to `.env.local` (0600, PROVISION_OWNED).
703
+ // writeBacklogLocalCreds CREATES `.env.local` when absent (a standalone `backlog`
704
+ // run has no Neon/Vercel step to seed it). No-echo: print only KEY NAMES.
705
+ if (writeBacklogLocalCreds(rootDir, projectId, apiKey)) {
706
+ console.log(' + .env.local (BACKLOG_API_KEY · BACKLOG_PROJECT_ID — registro en backlog.timekast.mx)');
707
+ }
708
+ else {
709
+ console.warn('⚠ No se pudieron escribir las credenciales de backlog en `.env.local`. ' +
710
+ 'El proyecto se creó; agrégalas a mano (la key es show-once, revísala en la app).');
711
+ }
712
+ // 4. Mark done — key minted + written. State holds IDs only (never the key value).
713
+ state.backlog = { projectId, keyMinted: true };
714
+ writeProvisionState(rootDir, state);
715
+ }
716
+ /**
717
+ * Resolve whether the `backlog` service can run given the rail token. UNLIKE the
718
+ * fail-closed rail tokens (Neon/Vercel abort when missing), a missing
719
+ * `BACKLOG_ADMIN_TOKEN` degrades GRACEFULLY (issue AC): a WARN + skip when `backlog`
720
+ * is ONE of several requested services (the rest continue), but a hard error when it
721
+ * is the SOLE service requested (there'd be nothing to do). Returns the services to
722
+ * actually run — `backlog` stripped when skipped. Pure + FS-light: `envPath` is
723
+ * explicit so the unit test drives every branch against a temp rail.
724
+ *
725
+ * Runs BEFORE the preflight, so a stripped `backlog` never reaches
726
+ * `assertGateTokensPresent` (which would otherwise fail-close on the missing token).
727
+ *
728
+ * @throws {CLIError} when `backlog` is the sole requested service and the token is absent.
729
+ */
730
+ export function resolveBacklogService(services, envPath = defaultEnvPath()) {
731
+ if (!services.includes('backlog'))
732
+ return services;
733
+ if (readEnvKeyNames(envPath).has(BACKLOG_ADMIN_TOKEN_ENV))
734
+ return services;
735
+ if (services.length === 1) {
736
+ throw new CLIError('Falta `BACKLOG_ADMIN_TOKEN` en `~/.claude/.env` y `backlog` es el único servicio pedido.\n' +
737
+ 'Impórtalo con `factory secrets <archivo>` (el master del org) y reintenta.');
738
+ }
739
+ console.warn('⚠ Falta `BACKLOG_ADMIN_TOKEN` en `~/.claude/.env` — omito el servicio `backlog` ' +
740
+ '(los demás continúan).\n' +
741
+ ' Impórtalo con `factory secrets <archivo>` para registrar el proyecto en backlog.timekast.mx.');
742
+ return services.filter((s) => s !== 'backlog');
743
+ }
559
744
  /**
560
745
  * Resolve the four pooled+direct Neon connection strings, ALWAYS by re-deriving
561
746
  * fresh from Neon — provision persists NO credentials file (Neon is the source of
@@ -696,10 +881,10 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
696
881
  // Mint an INDEPENDENT AUTH_SECRET per environment (issue §4 — never shared).
697
882
  // POOLED strings → the Vercel runtime DATABASE_URL (serverless/concurrent).
698
883
  // 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).
884
+ // dies if the rpID changes, so the custom-domain swap (--domain / PROV-006)
885
+ // re-points the PRODUCTION WEBAUTHN_RP_ID too (`updateDomainEnv`, BOOT-004).
886
+ // MFA_ENCRYPTION_KEY is minted independently per env (base64url) so a derivative
887
+ // with MFA enabled never boots broken (P-1).
703
888
  const rpDomains = provisionDomains(state.projectName);
704
889
  const plan = buildEnvPlan({
705
890
  mainDatabaseUrl: neonSecrets.mainPooled,
@@ -811,6 +996,19 @@ export { writeEnvLocal };
811
996
  function repoHttpsUrl(repoSlug) {
812
997
  return `https://github.com/${repoSlug}.git`;
813
998
  }
999
+ /**
1000
+ * Parse the `owner/name` slug out of a GitHub `origin` URL, HTTPS or SSH
1001
+ * (`https://github.com/Owner/repo[.git]`, `git@github.com:Owner/repo[.git]`,
1002
+ * `ssh://git@github.com/Owner/repo[.git]`). Returns `null` for anything else —
1003
+ * including non-GitHub hosts, deliberately: deriving a slug from a GitLab/other
1004
+ * origin and re-pointing it at `github.com` (see {@link repoHttpsUrl}) would
1005
+ * silently retarget the project, so an unparsable origin falls back to the
1006
+ * historical `FACTORY_ORG/projectName` default instead (BOOT-001).
1007
+ */
1008
+ export function parseOriginRepoSlug(originUrl) {
1009
+ const match = /^(?:https:\/\/|ssh:\/\/git@|git@)github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(originUrl.trim());
1010
+ return match ? `${match[1]}/${match[2]}` : null;
1011
+ }
814
1012
  /** Default git runner — `execa` in the derived dir, no shell. */
815
1013
  async function gitInDir(args, cwd) {
816
1014
  const { stdout } = await execa('git', args, { cwd });
@@ -831,6 +1029,47 @@ async function readOriginUrl(runGit, cwd) {
831
1029
  return null;
832
1030
  }
833
1031
  }
1032
+ /**
1033
+ * Resolve the GitHub repo slug (`owner/name`) the `github` step targets (BOOT-001
1034
+ * — decouples the repo from the project slug). Priority:
1035
+ * 1. The repo PERSISTED in the state (a prior run's authority — a `--resume`
1036
+ * must never silently re-point a repo already in progress; a CONFLICTING
1037
+ * `--repo` throws instead, mirroring the `--force` discipline of the other
1038
+ * steps — discard the state with `--force` to start over).
1039
+ * 2. An explicit `--repo=<org/name>` (the dev's stated intent — wins over an
1040
+ * `origin` pointing elsewhere; the remote is updated via `buildGitSubstrate`'s
1041
+ * existing same-repo/`--force` flow).
1042
+ * 3. The existing `origin` remote, when it parses as a GitHub repo (a derived
1043
+ * project whose repo name ≠ slug, or living outside the Factory org).
1044
+ * 4. The historical default `${FACTORY_ORG}/${projectName}` (unchanged behavior
1045
+ * when there is no flag and no `origin`).
1046
+ */
1047
+ export async function resolveRepoSlug(rootDir, projectName, persistedRepo, explicitRepo, runGit) {
1048
+ if (persistedRepo) {
1049
+ // GitHub slugs are case-insensitive — only a REAL divergence is a conflict.
1050
+ if (explicitRepo && explicitRepo.toLowerCase() !== persistedRepo.toLowerCase()) {
1051
+ throw new CLIError(`El estado de provision ya registra el repo \`${persistedRepo}\` (de un run anterior), ` +
1052
+ `distinto de \`--repo=${explicitRepo}\`.\n` +
1053
+ 'En `--resume` el estado es la autoridad; usa `--force` para descartarlo y empezar de cero con el repo nuevo.');
1054
+ }
1055
+ return persistedRepo;
1056
+ }
1057
+ if (explicitRepo)
1058
+ return explicitRepo;
1059
+ const fallback = `${FACTORY_ORG}/${projectName}`;
1060
+ const origin = await readOriginUrl(runGit, rootDir);
1061
+ if (origin) {
1062
+ const derived = parseOriginRepoSlug(origin);
1063
+ if (derived) {
1064
+ // An origin at the SAME repo as the canonical default (case drift only, e.g.
1065
+ // `TimeKast/Acme` vs `TimeKast/acme`) keeps the CANONICAL slug, so
1066
+ // `buildGitSubstrate` re-cases the remote exactly as it always did (GitHub
1067
+ // slugs are case-insensitive — reuse that normalization, don't fork it).
1068
+ return derived.toLowerCase() === fallback.toLowerCase() ? fallback : derived;
1069
+ }
1070
+ }
1071
+ return fallback;
1072
+ }
834
1073
  /**
835
1074
  * Build the git substrate in the documented, asserted ORDER (issue §6):
836
1075
  * 1. rename `master` → `main` (only when the current branch is `master`)
@@ -870,11 +1109,14 @@ async function buildGitSubstrate(rootDir, repoSlug, runGit, force) {
870
1109
  if (existingOrigin && existingOrigin !== url) {
871
1110
  // GitHub repo URLs are case-insensitive: an `origin` differing ONLY by case
872
1111
  // (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) {
1112
+ // points at the SAME repo. So does an SSH origin (`git@github.com:Org/name.git`)
1113
+ // whose parsed `owner/name` matches the resolved slug (BOOT-001 — the slug may
1114
+ // have been DERIVED from that very origin). Either way, re-point it to the
1115
+ // canonical HTTPS URL silently — no `--force` needed. A genuinely different
1116
+ // repo → throw unless `--force` resets it.
1117
+ const sameRepo = existingOrigin.toLowerCase() === url.toLowerCase() ||
1118
+ parseOriginRepoSlug(existingOrigin)?.toLowerCase() === repoSlug.toLowerCase();
1119
+ if (!sameRepo && !force) {
878
1120
  throw new CLIError(`El repo ya tiene un remote \`origin\` apuntando a otro URL (\`${existingOrigin}\` ≠ \`${url}\`).\n` +
879
1121
  'Usa `--force` para sobrescribirlo, o limpia el remote si reutilizas este directorio.');
880
1122
  }
@@ -988,7 +1230,10 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
988
1230
  }
989
1231
  const runGit = deps.runGit ?? gitInDir;
990
1232
  const apiDeps = { fetchImpl: deps.fetchImpl };
991
- const repoSlug = `${FACTORY_ORG}/${state.projectName}`;
1233
+ // BOOT-001: the repo is RESOLVED (--repo → existing origin → FACTORY_ORG/name),
1234
+ // no longer hardcoded to `${FACTORY_ORG}/${projectName}`. On resume the persisted
1235
+ // `state.github.repo` is the authority (a conflicting `--repo` throws).
1236
+ const repoSlug = await resolveRepoSlug(rootDir, state.projectName, state.github?.repo, deps.repo, runGit);
992
1237
  // Record the repo on the state up-front so the Neon secret push (PROV-002) and a
993
1238
  // `--resume` target it; this is durable even if a later REST step fails.
994
1239
  state.github = { repo: repoSlug };
@@ -1287,44 +1532,262 @@ async function promptConfirm(message) {
1287
1532
  const { ok } = await prompts({ type: 'confirm', name: 'ok', message, initial: false });
1288
1533
  return ok === true;
1289
1534
  }
1535
+ /** The `status` Resend reports once its DNS records validated (the poll's goal). */
1536
+ const RESEND_DOMAIN_VERIFIED_STATUS = 'verified';
1537
+ /** Shared headers for every Resend REST call (Bearer auth + JSON) — no-echo. */
1538
+ function resendHeaders(resendKey) {
1539
+ return {
1540
+ Authorization: `Bearer ${resendKey}`,
1541
+ 'Content-Type': 'application/json',
1542
+ };
1543
+ }
1544
+ /**
1545
+ * Build the FQDN for a record name Resend returns (BOOT-003 §4). Resend hands
1546
+ * record names RELATIVE to the registered domain (`send` → `send.updates.miapp.com`,
1547
+ * `resend._domainkey` → `resend._domainkey.updates.miapp.com`); Cloudflare's
1548
+ * `dns_records` API wants the fully-qualified name. Defensive on shape drift: a
1549
+ * name that already carries the domain (or the apex marker `@`) passes through.
1550
+ */
1551
+ export function resendRecordFqdn(name, domain) {
1552
+ if (name === '' || name === '@' || name === domain)
1553
+ return domain;
1554
+ if (name.endsWith(`.${domain}`))
1555
+ return name;
1556
+ return `${name}.${domain}`;
1557
+ }
1558
+ /**
1559
+ * Look up an existing Resend domain by NAME — `GET /domains` (list) then
1560
+ * `GET /domains/{id}` for its records. This is the `--resume` path (issue §8):
1561
+ * a re-run's `POST /domains` 403s when the domain already exists in THIS account,
1562
+ * so recovering the domain object lets the step re-ensure the DNS records and
1563
+ * re-poll verification instead of dead-ending on a warning. Returns `null` when
1564
+ * the domain is NOT in this account (then it really is "another account" and the
1565
+ * caller keeps the preserved warning behavior — issue §4). Best-effort: any
1566
+ * network/API failure degrades to `null`, never a throw.
1567
+ */
1568
+ async function findResendDomain(domain, resendKey, fetchImpl) {
1569
+ try {
1570
+ const listResponse = await fetchImpl(`${RESEND_API_BASE}/domains`, {
1571
+ method: 'GET',
1572
+ headers: resendHeaders(resendKey),
1573
+ signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
1574
+ });
1575
+ if (!listResponse.ok)
1576
+ return null;
1577
+ const list = (await listResponse.json());
1578
+ const match = (list.data ?? []).find((d) => d.name === domain);
1579
+ if (!match?.id)
1580
+ return null;
1581
+ const getResponse = await fetchImpl(`${RESEND_API_BASE}/domains/${match.id}`, {
1582
+ method: 'GET',
1583
+ headers: resendHeaders(resendKey),
1584
+ signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
1585
+ });
1586
+ // Detail fetch failed → still return the id (poll works; records unknown → none created).
1587
+ if (!getResponse.ok)
1588
+ return { id: match.id, name: domain };
1589
+ return (await getResponse.json());
1590
+ }
1591
+ catch {
1592
+ return null;
1593
+ }
1594
+ }
1595
+ /**
1596
+ * Trigger Resend's DNS verification for a domain — `POST /domains/{id}/verify`
1597
+ * (BOOT-003 §4). Without this call Resend never starts checking the records and
1598
+ * the domain sits `not_started` forever (the exact bug this issue fixes). Best
1599
+ * effort: a failure here is non-fatal (the poll surfaces the real outcome, and
1600
+ * the step is re-runnable with `--resume`).
1601
+ */
1602
+ async function triggerResendVerification(domainId, resendKey, fetchImpl) {
1603
+ try {
1604
+ await fetchImpl(`${RESEND_API_BASE}/domains/${domainId}/verify`, {
1605
+ method: 'POST',
1606
+ headers: resendHeaders(resendKey),
1607
+ signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
1608
+ });
1609
+ }
1610
+ catch {
1611
+ // Non-fatal — the verification poll below reports the actual state.
1612
+ }
1613
+ }
1614
+ /**
1615
+ * Poll `GET /domains/{id}` until Resend reports `status: verified` or the timeout
1616
+ * budget runs out (BOOT-003 §4) — the Resend sibling of {@link pollDomainVerification},
1617
+ * same injectable `sleep`/`now`/timeout seams so the unit test never waits for real.
1618
+ * Returns `true` once verified; `false` on timeout OR on any network/API failure
1619
+ * (non-fatal either way — the DNS records are already created, Resend keeps
1620
+ * checking them in background, and the step is re-runnable with `--resume`).
1621
+ */
1622
+ async function pollResendDomainVerification(domainId, resendKey, deps, fetchImpl) {
1623
+ const sleep = deps.sleep ?? realSleep;
1624
+ const now = deps.now ?? Date.now;
1625
+ const timeoutMs = (deps.verifyTimeoutMin ?? DEFAULT_VERIFY_TIMEOUT_MIN) * 60_000;
1626
+ const intervalMs = VERIFY_POLL_INTERVAL_SEC * 1_000;
1627
+ const deadline = now() + timeoutMs;
1628
+ // First check immediately (the domain may already be verified — idempotent re-run).
1629
+ for (;;) {
1630
+ let status;
1631
+ try {
1632
+ const response = await fetchImpl(`${RESEND_API_BASE}/domains/${domainId}`, {
1633
+ method: 'GET',
1634
+ headers: resendHeaders(resendKey),
1635
+ signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
1636
+ });
1637
+ if (!response.ok)
1638
+ return false;
1639
+ status = (await response.json()).status;
1640
+ }
1641
+ catch {
1642
+ // A failure while POLLING is non-fatal: pending + resumable, never a throw.
1643
+ return false;
1644
+ }
1645
+ if (status === RESEND_DOMAIN_VERIFIED_STATUS)
1646
+ return true;
1647
+ if (now() >= deadline)
1648
+ return false;
1649
+ console.log(` … esperando verificación del dominio en Resend (reintento en ${VERIFY_POLL_INTERVAL_SEC}s)`);
1650
+ await sleep(intervalMs);
1651
+ }
1652
+ }
1290
1653
  /**
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.
1654
+ * Register the client mail domain in Resend AND make it verifiable end-to-end
1655
+ * (BOOT-003): `POST /domains { name }` with the rail's `RESEND_API_KEY`, then
1656
+ * CREATE the DNS records Resend returns (DKIM/SPF/MX — exactly the ones it
1657
+ * returns, never an assumed fixed set, §8) in the client's Cloudflare zone via
1658
+ * {@link ensureDnsRecord} (create-or-PATCH, idempotent on re-run, never delete),
1659
+ * trigger verification, and poll until `verified` or timeout.
1660
+ *
1661
+ * `dns` carries the zone + token already resolved for the client-domain CNAME
1662
+ * (same org/cliente-cf route); `null` = the `registrar` route (no DNS API — the
1663
+ * records must be created by hand in the Resend dashboard; extending
1664
+ * `printManualRecords` to them is an explicit follow-up, issue §9).
1665
+ *
1666
+ * Non-fatal by design — the domain switch already happened on Vercel:
1667
+ * - absent `RESEND_API_KEY` → warning (mail stays unconfigured).
1668
+ * - `POST /domains` rejected → recover the domain from THIS account when it
1669
+ * exists (`--resume`, §8) or keep the preserved warning ("another account", §4).
1670
+ * - a record create/PATCH failure → warning; the poll is skipped (it cannot
1671
+ * succeed with missing records) and the step stays re-runnable.
1672
+ * - poll timeout → warning + `pending-verification` note, NEVER a hard failure
1673
+ * (§8 — the records are in place; Resend keeps verifying in background).
1674
+ *
1675
+ * No-echo: the Resend key and Cloudflare token never reach stdout.
1296
1676
  */
1297
- async function registerResendDomain(domain, envPath, fetchImpl) {
1677
+ async function registerResendDomain(domain, envPath, deps, fetchImpl, dns) {
1298
1678
  const resendKey = readGateValue('RESEND_API_KEY', envPath);
1299
1679
  if (!resendKey) {
1300
1680
  console.warn('⚠ No hay `RESEND_API_KEY` en `~/.claude/.env` — omito el alta del dominio en Resend ' +
1301
1681
  '(bájala con `factory secrets <archivo>` y reintenta con `--resume`).');
1302
1682
  return;
1303
1683
  }
1684
+ // 1. Register (or recover, on re-run) the domain in Resend.
1685
+ let resendDomain = null;
1304
1686
  try {
1305
1687
  const response = await fetchImpl(`${RESEND_API_BASE}/domains`, {
1306
1688
  method: 'POST',
1307
- headers: {
1308
- Authorization: `Bearer ${resendKey}`,
1309
- 'Content-Type': 'application/json',
1310
- },
1689
+ headers: resendHeaders(resendKey),
1311
1690
  body: JSON.stringify({ name: domain }),
1312
1691
  signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
1313
1692
  });
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;
1693
+ if (response.ok) {
1694
+ resendDomain = (await response.json());
1695
+ console.log(` + Resend: dominio ${domain} registrado`);
1696
+ }
1697
+ else {
1698
+ // Rejected (e.g. 403 already-exists). If the domain lives in THIS account
1699
+ // (a `--resume` re-run), recover it and continue; otherwise keep the
1700
+ // preserved warning behavior (issue §4) — warn + continue, never abort.
1701
+ const status = response.status;
1702
+ resendDomain = await findResendDomain(domain, resendKey, fetchImpl);
1703
+ if (resendDomain) {
1704
+ console.log(` ↻ Resend: dominio ${domain} ya registrado en esta cuenta (reuso sus records)`);
1705
+ }
1706
+ else {
1707
+ console.warn(`⚠ Resend rechazó el alta de \`${domain}\` (status ${status}). ` +
1708
+ 'Quizá ya está verificado en otra cuenta — `EMAIL_FROM` se actualiza igual; ' +
1709
+ 'verifica el dominio a mano en Resend si hace falta.');
1710
+ return;
1711
+ }
1320
1712
  }
1321
- console.log(` + Resend: dominio ${domain} registrado (verifica los records DNS en Resend)`);
1322
1713
  }
1323
1714
  catch (error) {
1324
1715
  // A network/timeout failure on Resend is non-fatal — the domain switch already
1325
1716
  // happened on Vercel; Resend verification is re-runnable with `--resume`.
1326
1717
  console.warn(`⚠ No se pudo dar de alta \`${domain}\` en Resend (${error?.message ?? 'error'}). ` +
1327
1718
  'Reintenta con `--resume`; `EMAIL_FROM` ya se actualizó.');
1719
+ return;
1720
+ }
1721
+ if (resendDomain.status === RESEND_DOMAIN_VERIFIED_STATUS) {
1722
+ console.log(` ✔ Resend: dominio ${domain} ya verificado`);
1723
+ return;
1724
+ }
1725
+ // 2. Create the DNS records Resend returned — the ones it RETURNS, no fixed set (§8).
1726
+ if (!dns) {
1727
+ // `registrar` route: no DNS API — the records live in the Resend dashboard and
1728
+ // must be created by hand (printing them here is a follow-up — issue §9).
1729
+ console.warn(`⚠ (registrar) Los records DNS de Resend para \`${domain}\` no se crean automáticamente. ` +
1730
+ 'Créalos a mano desde el dashboard de Resend y verifica el dominio ahí.');
1731
+ return;
1732
+ }
1733
+ const records = resendDomain.records ?? [];
1734
+ const cloudflareDeps = { fetchImpl };
1735
+ let allEnsured = true;
1736
+ for (const record of records) {
1737
+ const type = record.type?.toUpperCase();
1738
+ if ((type !== 'TXT' && type !== 'MX') || !record.name || !record.value) {
1739
+ // Only the types Resend requires are supported (issue §9) — anything else
1740
+ // (or a malformed record) is surfaced for manual creation, never guessed.
1741
+ console.warn(` ⚠ Record de Resend no soportado o incompleto (${record.record ?? record.type ?? '?'}) — créalo a mano en Cloudflare.`);
1742
+ allEnsured = false;
1743
+ continue;
1744
+ }
1745
+ const spec = {
1746
+ type,
1747
+ name: resendRecordFqdn(record.name, domain),
1748
+ content: record.value,
1749
+ ...(record.priority !== undefined ? { priority: record.priority } : {}),
1750
+ };
1751
+ try {
1752
+ const outcome = await ensureDnsRecord(dns.zoneId, spec, dns.token, cloudflareDeps);
1753
+ const mark = outcome === 'created'
1754
+ ? '+'
1755
+ : outcome === 'updated'
1756
+ ? '↻ (contenido actualizado)'
1757
+ : '⏭ (ya estaba)';
1758
+ console.log(` ${mark} ${spec.type} ${spec.name} (record ${record.record ?? 'DNS'} de Resend)`);
1759
+ }
1760
+ catch (error) {
1761
+ // Non-fatal (the mail path is additive): warn with the actionable mapping
1762
+ // and keep going — the step is re-runnable with `--resume`.
1763
+ allEnsured = false;
1764
+ const detail = error instanceof CloudflareApiError
1765
+ ? cloudflareErrorToCliError(error, `crear el record ${spec.type} \`${spec.name}\``)
1766
+ .message
1767
+ : (error?.message ?? 'error');
1768
+ console.warn(` ⚠ No se pudo asegurar el record ${spec.type} \`${spec.name}\`: ${detail}`);
1769
+ }
1770
+ }
1771
+ if (!allEnsured) {
1772
+ console.warn(`⚠ El dominio \`${domain}\` queda pending en Resend (faltaron records DNS). ` +
1773
+ 'Corrige lo anterior y reintenta con `--resume`.');
1774
+ return;
1775
+ }
1776
+ // 3. Trigger verification + poll until `verified` or timeout (never a hard fail).
1777
+ if (!resendDomain.id) {
1778
+ console.warn(`⚠ Resend no devolvió un id para \`${domain}\` — no puedo sondear la verificación. ` +
1779
+ 'Revisa el dominio en el dashboard de Resend.');
1780
+ return;
1781
+ }
1782
+ await triggerResendVerification(resendDomain.id, resendKey, fetchImpl);
1783
+ const verified = await pollResendDomainVerification(resendDomain.id, resendKey, deps, fetchImpl);
1784
+ if (verified) {
1785
+ console.log(` ✔ Resend: dominio ${domain} verificado`);
1786
+ }
1787
+ else {
1788
+ // §8: the records ARE created — pending-verification, resumable, never fatal.
1789
+ console.warn(`⚠ El dominio \`${domain}\` quedó pending-verification en Resend (los records DNS ya están; ` +
1790
+ 'Resend sigue verificando en background). Reintenta con `--resume` o revisa el dashboard.');
1328
1791
  }
1329
1792
  }
1330
1793
  /** Print the exact DNS records the owner must create for the `--dns=registrar` path. */
@@ -1421,6 +1884,9 @@ async function resolveDnsToken(route, envPath, headless) {
1421
1884
  * - domain already attached to Vercel (409) → only polls, never re-adds.
1422
1885
  * - interim retire fails → WARNING, the step is NOT marked failed (interim stays).
1423
1886
  * - Resend already verified elsewhere → warning + continue.
1887
+ * - Resend DNS records (BOOT-003) → `ensureDnsRecord` create-or-PATCH (a rotated
1888
+ * DKIM is PATCHed in place, never deleted); a Resend poll timeout is a WARNING
1889
+ * (pending-verification, resumable), never a step failure.
1424
1890
  * - `AUTH_URL`/`NEXTAUTH_URL` already correct → the upsert PATCH is omitted.
1425
1891
  *
1426
1892
  * On timeout the state is left `clientDomainStatus: pending-verification` (resumable);
@@ -1456,6 +1922,9 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1456
1922
  throw error;
1457
1923
  }
1458
1924
  // 2. DNS record: org/cliente-cf CREATE the CNAME; registrar only PRINTS it (§4).
1925
+ // The resolved zone + token are kept for step 6: the Resend DKIM/SPF/MX records
1926
+ // live in the SAME client zone (`updates.<domain>` shares the apex — BOOT-003).
1927
+ let resendDns = null;
1459
1928
  if (route === 'registrar') {
1460
1929
  // Print the canonical record now; the poll loop re-prints Vercel's verification.
1461
1930
  printManualRecords(domain, []);
@@ -1474,6 +1943,7 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1474
1943
  }
1475
1944
  throw error; // resolveZoneIdForName's own CLIError (zone not found) propagates.
1476
1945
  }
1946
+ resendDns = { zoneId, token: cfToken };
1477
1947
  try {
1478
1948
  const cnameOutcome = await ensureCnameRecord(zoneId, domain, cfToken, cloudflareDeps);
1479
1949
  const mark = cnameOutcome === 'created'
@@ -1514,7 +1984,9 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1514
1984
  // 6. Register the client's MAIL SUBDOMAIN (`updates.<domain>`) in Resend — NOT the
1515
1985
  // apex, which holds the client's corporate MX/SPF (Google Workspace, M365, …).
1516
1986
  // Verifying Resend on a dedicated subdomain keeps the client's own mail intact.
1517
- await registerResendDomain(mailDomainFor(domain), envPath, fetchImpl);
1987
+ // BOOT-003: also CREATES the DKIM/SPF/MX records Resend returns (in the same
1988
+ // client zone as the CNAME) and polls until Resend reports `verified`.
1989
+ await registerResendDomain(mailDomainFor(domain), envPath, deps, fetchImpl, resendDns);
1518
1990
  // 7. Record the client domain as LIVE and persist.
1519
1991
  state.dns = {
1520
1992
  ...(state.dns ?? {
@@ -1532,6 +2004,12 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
1532
2004
  * pre-approves; otherwise a TTY prompt asks (headless without `--confirm` → NOT
1533
2005
  * retired). The DEV domain (`{slug}-dev.timekast.mx`) is ALWAYS conserved. A
1534
2006
  * removal failure is a WARNING, never a step failure (issue §8 — interim stays up).
2007
+ *
2008
+ * BOOT-007: once Vercel detaches the interim, its CNAME in the org `timekast.mx`
2009
+ * zone is deleted too — otherwise it stays orphaned, resolving to Vercel's generic
2010
+ * target and serving a 404 forever. The delete lives INSIDE the confirmed block
2011
+ * (a declined prompt never touches Cloudflare) and is SKIPPED when the Vercel
2012
+ * detach failed (the interim is still live — its CNAME must keep resolving).
1535
2013
  */
1536
2014
  async function retireInterimDomain(projectId, token, teamId, state, deps, fetchImpl) {
1537
2015
  const interim = provisionDomains(state.projectName).prod;
@@ -1558,15 +2036,78 @@ async function retireInterimDomain(projectId, token, teamId, state, deps, fetchI
1558
2036
  // the step is NOT marked failed, the dev retires it manually later.
1559
2037
  console.warn(`⚠ No se pudo retirar el dominio interino \`${interim}\` (${error?.message ?? 'error'}).\n` +
1560
2038
  ' Queda activo; retíralo a mano en Vercel o reintenta con `--resume`. El paso NO falla por esto.');
2039
+ // BOOT-007: the interim is still attached to Vercel — its CNAME stays too.
2040
+ return;
1561
2041
  }
1562
2042
  // The DEV domain is intentionally never touched (issue §4 — conserved).
2043
+ // BOOT-007: the interim is off Vercel — delete its now-orphaned CNAME as well.
2044
+ await deleteInterimCname(interim, deps.envPath ?? defaultEnvPath(), fetchImpl);
1563
2045
  }
1564
2046
  /**
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).
2047
+ * Delete the interim CNAME (`{slug}.timekast.mx`) from the org Cloudflare zone
2048
+ * after Vercel detached the domain (BOOT-007). Without this, the record stays
2049
+ * orphaned: it keeps resolving to `cname.vercel-dns.com` while no Vercel project
2050
+ * answers for the host → a 404 forever, dead weight in the `timekast.mx` zone.
2051
+ *
2052
+ * Runs ONLY inside the already-confirmed retire block (a declined prompt never
2053
+ * reaches here). Best-effort by design (issue §4 + §8): EVERY failure — missing
2054
+ * rail token, zone not visible to the token (the `resolveZoneId` CLIError that is
2055
+ * hard in the CREATION path), an out-of-scope token, a 5xx on the DELETE — is a
2056
+ * WARNING, never a step failure; the record stays and the cleanup is retryable.
2057
+ * Idempotent: an already-absent record (`findCnameRecord` → null, or a 404 on the
2058
+ * DELETE) is a narrated no-op. The record content is NOT validated before deleting
2059
+ * (this is a retirement, not a repair — issue §8). The DEV domain's CNAME is never
2060
+ * touched: only the prod interim name is looked up.
2061
+ */
2062
+ async function deleteInterimCname(interim, envPath, fetchImpl) {
2063
+ const warnRetryable = (reason) => {
2064
+ console.warn(`⚠ No se pudo borrar el CNAME interino \`${interim}\` en Cloudflare (${reason}).\n` +
2065
+ ` El registro queda en la zona \`${TIMEKAST_ZONE_NAME}\`; bórralo a mano o reintenta. El paso NO falla por esto.`);
2066
+ };
2067
+ const cloudflareToken = readCloudflareApiKey(envPath);
2068
+ if (!cloudflareToken) {
2069
+ warnRetryable('sin `CLOUDFLARE_API_TOKEN` en el rail');
2070
+ return;
2071
+ }
2072
+ const cloudflareDeps = { fetchImpl };
2073
+ try {
2074
+ const zoneId = await resolveZoneId(cloudflareToken, cloudflareDeps);
2075
+ const record = await findCnameRecord(zoneId, interim, cloudflareToken, cloudflareDeps);
2076
+ // Same outcome narration as the Vercel retire above (no-echo — only the
2077
+ // domain NAME prints; the token never crosses this boundary).
2078
+ const outcome = record
2079
+ ? await deleteDnsRecord(zoneId, record.id, cloudflareToken, cloudflareDeps)
2080
+ : 'absent';
2081
+ console.log(outcome === 'absent'
2082
+ ? ` ⏭ (ya no estaba) CNAME interino ${interim} en Cloudflare`
2083
+ : ` - CNAME interino ${interim} borrado de Cloudflare`);
2084
+ }
2085
+ catch (error) {
2086
+ // §8: zone not visible (CLIError) / out-of-scope token / 5xx → warning,
2087
+ // retryable — never the hard CLIError the creation path uses.
2088
+ warnRetryable(error?.message ?? 'error');
2089
+ }
2090
+ }
2091
+ /**
2092
+ * Update `EMAIL_FROM` + `AUTH_URL`/`NEXTAUTH_URL` + `NEXT_PUBLIC_APP_URL` +
2093
+ * `WEBAUTHN_RP_ID` to the client domain in the Vercel env, per-target and
2094
+ * idempotently (PROV-006 §4 + BOOT-004). Lists the existing vars once and upserts
2095
+ * each entry via `upsertEnvVar`, which reconciles a key that the substrate left
2096
+ * SPLIT per target (e.g. `NEXT_PUBLIC_APP_URL` as `['production']` + `['preview']`
2097
+ * from `buildEnvPlan`) into ONE combined-target record (BOOT-002 — a single-record
2098
+ * PATCH would 400 against the sibling). A second run over the reconciled state
2099
+ * re-PATCHes in place: no duplicate records, no 400 (issue §8).
2100
+ *
2101
+ * `WEBAUTHN_RP_ID` is re-pointed for PRODUCTION ONLY: the interim dev domain stays
2102
+ * the preview rpID (the dev domain is never retired — see `retireInterimDomain`),
2103
+ * so `buildEnvPlan`'s intentional split pair is preserved — the single-target plan
2104
+ * only PATCHes the overlapping production record and never touches the preview
2105
+ * sibling (no BOOT-002 400 reintroduced). Changing the rpID invalidates every
2106
+ * passkey registered under the interim rpID and the CLI cannot count them (that
2107
+ * table lives in the derivative's DB), so a warning prints UNCONDITIONALLY.
2108
+ *
2109
+ * No-echo: only KEY NAMES + targets print (the values here are public, but the
2110
+ * discipline holds).
1570
2111
  */
1571
2112
  async function updateDomainEnv(projectId, token, teamId, domain, deps) {
1572
2113
  const authUrl = `https://${domain}`;
@@ -1583,12 +2124,22 @@ async function updateDomainEnv(projectId, token, teamId, domain, deps) {
1583
2124
  // Keep the public app origin in lockstep with AUTH_URL once a client domain lands
1584
2125
  // (the substrate set it to the interim domain in buildEnvPlan; this re-points it).
1585
2126
  { key: 'NEXT_PUBLIC_APP_URL', value: authUrl, target: ['production', 'preview'] },
2127
+ // WebAuthn rpID is the registrable HOST, never a URL — the bare domain (BOOT-004).
2128
+ // PRODUCTION only: the interim dev domain remains the preview rpID (the dev domain
2129
+ // is never retired), so the intentional split pair from buildEnvPlan is preserved.
2130
+ { key: 'WEBAUTHN_RP_ID', value: domain, target: ['production'] },
1586
2131
  ];
1587
2132
  const existing = await listEnvVars(projectId, token, teamId, deps);
1588
2133
  for (const entry of plan) {
1589
2134
  const outcome = await upsertEnvVar(projectId, token, teamId, entry, existing, deps);
1590
2135
  console.log(` ${outcome === 'created' ? '+' : '↻'} ${entry.key} [${entry.target.join(', ')}]`);
1591
2136
  }
2137
+ // BOOT-004: changing the rpID orphans every passkey registered under the interim
2138
+ // rpID. The CLI cannot query how many exist (that table lives in the derivative's
2139
+ // DB, unreachable from provisioning), so the warning is UNCONDITIONAL.
2140
+ console.warn(`⚠ WEBAUTHN_RP_ID (production) ahora es \`${domain}\`: cualquier passkey registrada ` +
2141
+ 'bajo el rpID interino queda invalidada — esos usuarios deben registrar una nueva ' +
2142
+ 'passkey en el dominio definitivo.');
1592
2143
  }
1593
2144
  /**
1594
2145
  * Print the resolved client-domain operation tree for `--domain --dry-run`
@@ -1609,7 +2160,14 @@ function printClientDomainPlan(domain, interim, route, confirm) {
1609
2160
  console.log(` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim} ` +
1610
2161
  `(el dev ${domain}-dev se conserva)`);
1611
2162
  console.log(` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · AUTH_URL=https://${domain} · NEXTAUTH_URL=https://${domain} · NEXT_PUBLIC_APP_URL=https://${domain}`);
2163
+ console.log(` ↻ WEBAUTHN_RP_ID=${domain} (solo production — las passkeys del rpID interino quedan invalidadas; el preview conserva el rpID dev)`);
1612
2164
  console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
2165
+ if (route === 'registrar') {
2166
+ console.log(' · (registrar) los records DNS de Resend (DKIM/SPF/MX) se crean a mano en el dashboard de Resend');
2167
+ }
2168
+ else {
2169
+ console.log(` + records DNS de Resend (DKIM/SPF/MX, los que devuelva) en la zona ${apexZoneOf(domain)} + sondeo hasta verified`);
2170
+ }
1613
2171
  console.log('\n(`--dry-run`) No se ejecutó ninguna llamada a API ni se escribió estado.');
1614
2172
  }
1615
2173
  /**
@@ -1631,7 +2189,10 @@ async function runServiceStep(service, rootDir, state, flags) {
1631
2189
  // PROV-004: git substrate (rename → remote add → push main) + create the
1632
2190
  // Vercel project + connect git (GitHub App) + PATCH production-branch +
1633
2191
  // .vercel/project.json + activate E2E. Migrations run in vercel-build, not a workflow.
1634
- await provisionGithubVercel(rootDir, state, flags.team, { force: flags.force });
2192
+ await provisionGithubVercel(rootDir, state, flags.team, {
2193
+ force: flags.force,
2194
+ repo: flags.repo,
2195
+ });
1635
2196
  break;
1636
2197
  case 'dns':
1637
2198
  // PROV-005: attach the interim domains to Vercel (dev → develop, prod →
@@ -1639,6 +2200,12 @@ async function runServiceStep(service, rootDir, state, flags) {
1639
2200
  // (RESEND_API_KEY + the fixed EMAIL_FROM), then `state.dns = { domain, devDomain }`.
1640
2201
  await provisionDns(rootDir, state, flags.team);
1641
2202
  break;
2203
+ case 'backlog':
2204
+ // BSYNC-003: register the project in backlog.timekast.mx (POST /projects with the
2205
+ // org admin token) + mint a per-project read_write key + write BACKLOG_API_KEY /
2206
+ // BACKLOG_PROJECT_ID to `.env.local` (PROVISION_OWNED — never to Vercel).
2207
+ await provisionBacklog(rootDir, state, {});
2208
+ break;
1642
2209
  default:
1643
2210
  throw new CLIError(`Paso de provision no implementado: \`${service}\`.`);
1644
2211
  }
@@ -1962,6 +2529,10 @@ function describeDestroyPlan(target) {
1962
2529
  if (target.githubRepo) {
1963
2530
  lines.push(` - Repo GitHub ${target.githubRepo} — \`gh repo delete\` (sesión del dev)`);
1964
2531
  }
2532
+ if (target.backlogProjectId) {
2533
+ lines.push(` - Proyecto backlog central ${target.backlogProjectId} — DELETE /projects ` +
2534
+ '(soft-delete, `BACKLOG_ADMIN_TOKEN` del rail)');
2535
+ }
1965
2536
  if (lines.length === 0) {
1966
2537
  lines.push(' (No hay recursos registrados que destruir — el estado no tiene ids.)');
1967
2538
  }
@@ -2148,6 +2719,38 @@ async function destroyGithubRepo(target, runGh) {
2148
2719
  return { resource, status: 'failed', detail: message || 'error en `gh repo delete`' };
2149
2720
  }
2150
2721
  }
2722
+ /**
2723
+ * Soft-delete the backlog-central project via `DELETE /projects/{pid}` (BSYNC-003).
2724
+ * INTENTIONALLY DIFFERENT from the other providers' teardown: those hard-delete with
2725
+ * the DEV's own session (`gh`/`vercel`) — backlog has no such per-dev session, so it
2726
+ * reuses the rail's `BACKLOG_ADMIN_TOKEN` (the smoke confirmed the admin token DELETEs
2727
+ * a project). The delete is a SOFT delete (deactivates, no cascade) and IDEMPOTENT: a
2728
+ * repeat returns 200, and a 404 (already gone) is treated as "already clean" — same
2729
+ * 404-tolerance principle as {@link destroyNeonProject} (issue §8).
2730
+ */
2731
+ async function destroyBacklogProject(target, adminToken, deps) {
2732
+ const resource = `Proyecto backlog central (${target.backlogProjectId})`;
2733
+ if (!target.backlogProjectId) {
2734
+ return {
2735
+ resource: 'Proyecto backlog central',
2736
+ status: 'skipped',
2737
+ detail: 'sin id en el estado',
2738
+ };
2739
+ }
2740
+ try {
2741
+ await deleteBacklogProject(adminToken, target.backlogProjectId, deps);
2742
+ return { resource, status: 'removed', detail: 'desactivado (soft-delete idempotente)' };
2743
+ }
2744
+ catch (error) {
2745
+ if (error instanceof BacklogApiError && error.status === 404) {
2746
+ return { resource, status: 'removed', detail: 'ya estaba desactivado (404)' };
2747
+ }
2748
+ const detail = error instanceof BacklogApiError
2749
+ ? `backlog respondió ${error.status}`
2750
+ : (error?.message ?? 'error de red');
2751
+ return { resource, status: 'failed', detail };
2752
+ }
2753
+ }
2151
2754
  /**
2152
2755
  * Orchestrate the per-provider teardown (issue §4 + §8). Each provider failure is
2153
2756
  * logged and the run CONTINUES with the others; the outcomes are collected so the
@@ -2250,6 +2853,24 @@ async function teardownProviders(target, deps) {
2250
2853
  if (target.githubRepo) {
2251
2854
  outcomes.push(await destroyGithubRepo(target, deps.runGh));
2252
2855
  }
2856
+ // 5. Backlog central — soft-delete the project via `DELETE /projects` with the rail's
2857
+ // `BACKLOG_ADMIN_TOKEN`. UNLIKE the providers above (deleted with the dev's own
2858
+ // session), backlog only HAS the org admin token — create+delete-capable per the
2859
+ // smoke — so the teardown reuses it. A missing token is a `failed` (keeps the
2860
+ // state + flags the orphan), never a silent skip. Soft + idempotent (404 → gone).
2861
+ if (target.backlogProjectId) {
2862
+ const backlogToken = readGateValue(BACKLOG_ADMIN_TOKEN_ENV, deps.envPath);
2863
+ if (!backlogToken) {
2864
+ outcomes.push({
2865
+ resource: `Proyecto backlog central (${target.backlogProjectId})`,
2866
+ status: 'failed',
2867
+ detail: 'sin `BACKLOG_ADMIN_TOKEN` en el rail — desactívalo a mano en la app',
2868
+ });
2869
+ }
2870
+ else {
2871
+ outcomes.push(await destroyBacklogProject(target, backlogToken, { fetchImpl: deps.fetchImpl }));
2872
+ }
2873
+ }
2253
2874
  return outcomes;
2254
2875
  }
2255
2876
  /** Print the per-provider teardown report (es-MX) and flag the not-removed ones. */
@@ -2387,6 +3008,12 @@ export async function runProvision(flags) {
2387
3008
  // with no `--services` (a deferred go-live over an existing provision) or
2388
3009
  // alongside the initial run. It always runs AFTER the service steps.
2389
3010
  const hasDomain = Boolean(flags.domain);
3011
+ // Backlog token gate (BSYNC-003): degrade gracefully vs the fail-closed rail tokens.
3012
+ // A missing `BACKLOG_ADMIN_TOKEN` strips `backlog` (WARN) when it is one of several
3013
+ // services, or throws when it is the only one — BEFORE the preflight so the stripped
3014
+ // service never reaches the fail-closed `assertGateTokensPresent`. Reads the rail for
3015
+ // presence only (no mutation) → safe to run ahead of the `--dry-run` return too.
3016
+ flags.services = resolveBacklogService(flags.services);
2390
3017
  // 1. Flag validation.
2391
3018
  if (flags.services.length === 0 && !hasDomain) {
2392
3019
  throw new CLIError('No seleccionaste servicios. Usa `--services=neon,vercel,github,dns` ' +
@@ -2431,6 +3058,16 @@ export async function runProvision(flags) {
2431
3058
  // In patch mode the projectName comes from the loaded state (the interim slug);
2432
3059
  // a fresh run derives it from flags (the scaffold default).
2433
3060
  const projectName = state?.projectName ?? deriveProjectName(flags, rootDir);
3061
+ // BOOT-001: resolve the target GitHub repo ONCE (--repo → existing origin →
3062
+ // FACTORY_ORG/projectName) for every consumer of the slug: the plan/report
3063
+ // (`--dry-run` included), the Neon E2E-secret push (which runs BEFORE the github
3064
+ // step) and the github step itself. Reading the local `origin` is a read-only
3065
+ // git call — safe under `--dry-run` (no REST, no state write). On resume the
3066
+ // persisted repo is the authority (a conflicting `--repo` throws, never a
3067
+ // silent re-point).
3068
+ const repoSlug = flags.services.includes('github') || flags.services.includes('neon')
3069
+ ? await resolveRepoSlug(rootDir, projectName, state?.github?.repo, flags.repo, gitInDir)
3070
+ : undefined;
2434
3071
  const steps = buildPlan(flags.services, state);
2435
3072
  const result = {
2436
3073
  projectName,
@@ -2439,6 +3076,9 @@ export async function runProvision(flags) {
2439
3076
  resumed: (flags.resume || standaloneDomain) && !flags.force,
2440
3077
  steps,
2441
3078
  };
3079
+ if (repoSlug) {
3080
+ result.repo = repoSlug;
3081
+ }
2442
3082
  if (hasDomain) {
2443
3083
  result.clientDomain = { domain: flags.domain, route: flags.dns };
2444
3084
  }
@@ -2472,6 +3112,13 @@ export async function runProvision(flags) {
2472
3112
  scope: flags.team ?? flags.orgId ?? '',
2473
3113
  createdAt: new Date().toISOString(),
2474
3114
  };
3115
+ // BOOT-001: record the resolved repo BEFORE the steps run, so the Neon E2E-secret
3116
+ // push (`neonSecretRepo`, which runs before the github step) targets the resolved
3117
+ // repo — not the historical `${FACTORY_ORG}/${projectName}` fallback. Never
3118
+ // clobbers a resumed state's repo (that one is the authority).
3119
+ if (repoSlug && !runState.github?.repo) {
3120
+ runState.github = { repo: repoSlug };
3121
+ }
2475
3122
  // Ensure the state dir exists + a baseline file before the first step, so a
2476
3123
  // step failure on the very first service still leaves a resumable state.
2477
3124
  writeProvisionState(rootDir, runState);