@timekast/factory 1.23.1 → 1.25.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.
@@ -29,7 +29,8 @@ import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, deleteDnsRecord, e
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 { createBacklogProject, deleteBacklogProject, mintBacklogKey, BacklogApiError, } from '../lib/backlog-api.js';
31
31
  import { defaultEnvPath, readEnvKeyNames, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
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 { addVercelDomain, buildEnvPlan, createVercelProject, generateAuthSecret, generateMfaEncryptionKey, getLinkedRepoId, getVercelDomainStatus, getVercelProjectByName, GitHubAppNotInstalledError, linkGitRepo, listProjectDomains, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, removeVercelProject, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, VERCEL_AUTO_APEX, writeVercelProjectJson, } from '../lib/vercel-env.js';
33
+ import { decideInitialMigration, hasGeneratedMigrations, MIGRATION_SKIP_MESSAGES, MIGRATIONS_DIR, } from '../lib/initial-migration.js';
33
34
  import { writeBacklogLocalCreds, writeE2eLocalCreds, writeEmailLocalCreds, writeEnvLocal, } from '../lib/env-file.js';
34
35
  import { adoptState, ADOPT_UNKNOWN, clearProvisionState, clearState, hasProvisionState, isServiceComplete, PROVISION_DIR, PROVISION_STATE_FILE, readDestroyTarget, readProvisionState, writeProvisionState, } from '../lib/provision-state.js';
35
36
  /**
@@ -257,6 +258,12 @@ function printPlan(result) {
257
258
  console.log(` · Resend env: RESEND_API_KEY [production, preview] + EMAIL_FROM=${PROVISION_EMAIL_FROM}`);
258
259
  }
259
260
  }
261
+ // GAP-001: the initial migration is not a service, it is a step of the bootstrap —
262
+ // announced with the plan so a `--dry-run` states it without generating anything.
263
+ if (result.initialMigration) {
264
+ console.log(` + crear ${INITIAL_MIGRATION_LABEL}`);
265
+ console.log(' · se commitea con pathspec propio antes del primer deploy');
266
+ }
260
267
  if (result.dryRun) {
261
268
  console.log('\n(`--dry-run`) No se ejecutó ninguna llamada a API ni se escribió estado.');
262
269
  }
@@ -834,6 +841,28 @@ function readSharedVercelEnv(envPath) {
834
841
  async function pnpmInstall(cwd) {
835
842
  await execa('pnpm', ['install'], { cwd });
836
843
  }
844
+ /**
845
+ * The roots this PROCESS already installed. A provision run targets ONE root, so this
846
+ * is the run-scoped flag the initial-migration step and the `vercel` step share: the
847
+ * first of the two pays for `pnpm install`, the second is a no-op.
848
+ *
849
+ * 🔴 It is NOT `existsSync('node_modules')`. Having the directory does not mean having
850
+ * the dependencies at their current versions, and the `vercel` step installs
851
+ * unconditionally today — gating on presence would be a silent regression of that
852
+ * guarantee (GAP-001 §8). A failed install is deliberately NOT recorded, so the next
853
+ * caller retries it.
854
+ */
855
+ const installedRoots = new Set();
856
+ /**
857
+ * `pnpm install` in the derived dir, at most ONCE per process per root. The seam is the
858
+ * caller's `runInstall` (tests never run a real install); the default is {@link pnpmInstall}.
859
+ */
860
+ export async function ensureDeps(rootDir, runInstall = pnpmInstall) {
861
+ if (installedRoots.has(rootDir))
862
+ return;
863
+ await runInstall(rootDir);
864
+ installedRoots.add(rootDir);
865
+ }
837
866
  /**
838
867
  * PROV-003 — sync env into the Vercel project: thread `teamId` on EVERY REST call,
839
868
  * push variables PER TARGET (production vs preview), mint an INDEPENDENT
@@ -949,9 +978,11 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
949
978
  // `pnpm install` the derived dir (issue §4). A missing package.json (non-Node
950
979
  // derivative) is reported, NOT fatal (issue §8).
951
980
  if (existsSync(join(rootDir, 'package.json'))) {
952
- const runInstall = deps.runInstall ?? pnpmInstall;
953
981
  try {
954
- await runInstall(rootDir);
982
+ // Run-scoped (GAP-001): a no-op when the initial-migration step already
983
+ // installed this root in the same run — never twice, never skipped by a
984
+ // `node_modules` that happens to exist.
985
+ await ensureDeps(rootDir, deps.runInstall);
955
986
  }
956
987
  catch (error) {
957
988
  console.warn(`⚠ \`pnpm install\` falló en el directorio del derivado (${error?.message ?? 'error'}).\n` +
@@ -1334,8 +1365,10 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
1334
1365
  * Commit the activated E2E workflow (`e2e.yml`) and push it to BOTH `main` and
1335
1366
  * `develop` in the derived repo. `factory new` pushed the initial scaffold commit
1336
1367
  * before provision activated this workflow, so it lives only in the working tree
1337
- * until committed here. (Migrations run in the Vercel build via `vercel-build`, not a
1338
- * workflow — there is nothing to commit for them.) Idempotent — when a `--resume`
1368
+ * until committed here. (Migrations are not part of THIS commit, but they do have one:
1369
+ * `ensureInitialMigration` commits the `0000` earlier in the run, with its own pathspec.
1370
+ * The Vercel build only APPLIES it — `vercel-build` runs `pnpm db:migrate && pnpm build`,
1371
+ * there is no migration workflow.) Idempotent — when a `--resume`
1339
1372
  * finds it already committed, `git add` stages nothing and the commit/push is skipped.
1340
1373
  */
1341
1374
  async function commitAndPushWorkflows(rootDir, runGit) {
@@ -1375,6 +1408,329 @@ async function commitProvisionState(rootDir) {
1375
1408
  // Non-fatal: the local state is intact; the dev can commit it later.
1376
1409
  }
1377
1410
  }
1411
+ /** Default `pnpm db:generate` runner — `execa` in the derived dir, no shell. */
1412
+ async function drizzleGenerate(cwd) {
1413
+ await execa('pnpm', ['db:generate'], { cwd });
1414
+ }
1415
+ /** The commit subject of the generated migration (English — GIT.md §3.1). */
1416
+ const INITIAL_MIGRATION_COMMIT = 'chore(db): add the initial database migration';
1417
+ /** The plan label of the step (es-MX), printed by {@link printPlan}. */
1418
+ const INITIAL_MIGRATION_LABEL = 'Migración inicial de la base (0000 de Drizzle)';
1419
+ /**
1420
+ * The branch the step commits on and pushes: the one `vercel-build` runs
1421
+ * `pnpm db:migrate && pnpm build` from for PRODUCTION. Named, never inlined
1422
+ * (CODING.md §5) — it appears in the checkout guard, in both pushes and in the
1423
+ * remote confirmation, and those four have to mean the same branch.
1424
+ */
1425
+ const MIGRATION_BRANCH = 'main';
1426
+ /** The secondary branch the same commit is fast-forwarded onto (the working branch). */
1427
+ const MIGRATION_DEV_BRANCH = 'develop';
1428
+ /**
1429
+ * The pre-`main` default branch name. `buildGitSubstrate` renames it to
1430
+ * {@link MIGRATION_BRANCH} before pushing, which is why the checkout guard below treats
1431
+ * a repo sitting on it as reaching `main` after all — when that step is in the plan.
1432
+ */
1433
+ const LEGACY_DEFAULT_BRANCH = 'master';
1434
+ /**
1435
+ * The GREENFIELD guard: the run creates BIRTH infrastructure.
1436
+ *
1437
+ * 🔴 Not merely "there is no `.sql` yet". Without this guard any other door into
1438
+ * provision — a `--services=dns` for a deferred go-live, a standalone `--domain`, a
1439
+ * `--resume` — would generate a `0000` on one of the LIVE derivatives that have no
1440
+ * migrations, commit it, and (with no `github` step to push it) push it too. The next
1441
+ * deploy would run `CREATE TABLE` against a populated database and the build would die.
1442
+ *
1443
+ * 🔴 And the SHAPE OF THE PLAN is not birth either — that was the first version of this
1444
+ * guard, and it left two doors open onto exactly the derivatives it was written to
1445
+ * protect:
1446
+ *
1447
+ * 1. **`--force` NEVER counts as greenfield.** `runProvision` calls
1448
+ * `clearProvisionState` before `buildPlan`, so with `--force` there is no state to
1449
+ * read and the plan comes back with EVERY service in `create` — the shape of a
1450
+ * bootstrap, produced by a flag whose whole job is to destroy the only evidence that
1451
+ * this project already went through provisioning. A `--services=neon,github --force`
1452
+ * over a live derivative would have generated and pushed a `0000` on it.
1453
+ * 2. **`github: create` only counts WITH a prior state.** A live derivative provisioned
1454
+ * before the CLI existed has no `.timekast/provision.json`, so nothing throws, the
1455
+ * state is `null` and the substrate reads as missing. `github` still counts — that is
1456
+ * the interrupted-bootstrap case retaken with `--resume`, where `neon` is already in
1457
+ * `skip` while the substrate is not there yet — but only when a prior state PROVES
1458
+ * the bootstrap started. Without it, `github: create` proves nothing.
1459
+ *
1460
+ * 3. **`neon: create` needs corroboration too**, and this is the subtlest of the three.
1461
+ * "A database is being created, so it is empty" conflates two different facts: that
1462
+ * a NEW database exists, and that the database THE DEPLOY WILL MIGRATE is that one.
1463
+ * `provisionNeon` only creates the project and uploads E2E secrets — production's
1464
+ * connection string is written by the `vercel` step. So on a derivative that is
1465
+ * already live, a new Neon project changes nothing about where the deploy points,
1466
+ * and the `0000` would be applied against the populated database it has always used.
1467
+ * Creating a database therefore counts only alongside the run also wiring the app
1468
+ * up: `vercel` (which writes that connection string) or `github` (which creates the
1469
+ * substrate carrying the code).
1470
+ *
1471
+ * The population this protects is specific and real: a derivative born before the kit
1472
+ * generated migrations has its tables and its data from a `db:push`, and NO `.sql` at
1473
+ * all — so the "already has migrations" latch does not catch it. What such a project
1474
+ * needs is its `0000` recorded as ALREADY APPLIED (a per-environment baseline), never
1475
+ * executed: drizzle emits `CREATE TABLE` without `IF NOT EXISTS`, so running it fails
1476
+ * with `relation "users" already exists` and the build stops deploying. That baseline is
1477
+ * deliberately out of this step's scope.
1478
+ *
1479
+ * The cost of the third rule is one degraded case, and it is the right way to be wrong:
1480
+ * an interrupted bootstrap missing ONLY the database will not generate the migration by
1481
+ * itself. The dev runs `pnpm db:generate`, and both `factory doctor` and `pnpm preflight`
1482
+ * now say so. A false negative costs one command; a false positive costs a client's
1483
+ * deploy.
1484
+ */
1485
+ export function isGreenfieldRun({ steps, force, hadPriorState }) {
1486
+ if (force)
1487
+ return false;
1488
+ const creates = (service) => steps.some((step) => step.service === service && step.action === 'create');
1489
+ // A new database does NOT prove birth on its own — see the docblock. It needs the run
1490
+ // to also be wiring the app up: `vercel` writes production's connection string, and
1491
+ // `github` creates the substrate that carries the code.
1492
+ if (creates('neon'))
1493
+ return creates('vercel') || creates('github');
1494
+ // No new database, but the substrate is being born and a prior state proves the
1495
+ // bootstrap already started — the interrupted run, retaken.
1496
+ return creates('github') && hadPriorState;
1497
+ }
1498
+ /**
1499
+ * Will the initial-migration step do work on this run? Greenfield guard (the plan + the
1500
+ * run's own evidence) AND the disk decision (`decideInitialMigration`). Read-only — safe
1501
+ * to evaluate while the plan is assembled, i.e. under `--dry-run`.
1502
+ */
1503
+ export function shouldRunInitialMigration(rootDir, evidence) {
1504
+ return isGreenfieldRun(evidence) && decideInitialMigration(rootDir).run;
1505
+ }
1506
+ /**
1507
+ * The branch `HEAD` points at, or `null` when git cannot name one.
1508
+ *
1509
+ * 🔴 Why the step reads it at all: `git add` / `git commit -- <pathspec>` land on the
1510
+ * CURRENT branch, while the push names a REF (`push origin main`). Nothing used to read
1511
+ * the branch, so a dev standing on `develop` — or on a detached `HEAD` — got the commit
1512
+ * on that branch and a push of a `main` that does NOT carry the `.sql`: a green exit code
1513
+ * over exactly the failure this step exists to prevent.
1514
+ *
1515
+ * Never throws, and a detached `HEAD` (which `--abbrev-ref` reports as the literal
1516
+ * `HEAD`) is `null` too: both are "not the migration branch", which is the fail-closed
1517
+ * answer for the push.
1518
+ */
1519
+ async function readCurrentBranch(rootDir, runGit) {
1520
+ try {
1521
+ const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], rootDir)).stdout.trim();
1522
+ return branch && branch !== 'HEAD' ? branch : null;
1523
+ }
1524
+ catch {
1525
+ return null;
1526
+ }
1527
+ }
1528
+ /**
1529
+ * The commit `HEAD` points at, or `null` when git cannot say. Only read for the detached
1530
+ * warning: there the hash is the ONLY handle the dev has on the commit that was just
1531
+ * made, so the message has to carry it.
1532
+ */
1533
+ async function readHeadSha(rootDir, runGit) {
1534
+ try {
1535
+ return (await runGit(['rev-parse', 'HEAD'], rootDir)).stdout.trim() || null;
1536
+ }
1537
+ catch {
1538
+ return null;
1539
+ }
1540
+ }
1541
+ /**
1542
+ * Confirm the just-pushed commit actually reached the remote, and SAY SO. A push that
1543
+ * silently did not land is the failure this whole step exists to prevent, so an
1544
+ * unconfirmable remote is a NOISY warning, never an assumption. Non-fatal: the commit
1545
+ * is already durable locally.
1546
+ */
1547
+ async function confirmMigrationOnRemote(rootDir, runGit) {
1548
+ try {
1549
+ const local = (await runGit(['rev-parse', 'HEAD'], rootDir)).stdout.trim();
1550
+ const remoteLine = (await runGit(['ls-remote', 'origin', `refs/heads/${MIGRATION_BRANCH}`], rootDir)).stdout
1551
+ .trim()
1552
+ .split(/\s+/)[0];
1553
+ if (local && remoteLine && local === remoteLine) {
1554
+ console.log(` ✔ Migración inicial confirmada en el remoto (origin/main ${local.slice(0, 7)}).`);
1555
+ return;
1556
+ }
1557
+ console.warn('⚠ No pude confirmar que la migración inicial esté en el remoto ' +
1558
+ `(origin/main = \`${remoteLine || 'sin respuesta'}\`, local = \`${local || 'desconocido'}\`).\n` +
1559
+ ' Revisa `git log origin/main` ANTES del primer deploy: si el `.sql` no está en el ' +
1560
+ 'remoto, el build migrará una base vacía.');
1561
+ }
1562
+ catch (error) {
1563
+ console.warn(`⚠ No pude confirmar la migración inicial en el remoto (${error?.message ?? 'error'}).\n` +
1564
+ ' Revisa `git log origin/main` ANTES del primer deploy.');
1565
+ }
1566
+ }
1567
+ /**
1568
+ * Generate the initial Drizzle migration, commit it with its OWN pathspec and get it to
1569
+ * the remote BEFORE Vercel builds the tree (PROV-004 pushes `main`; `vercel-build` runs
1570
+ * `pnpm db:migrate && pnpm build`). Without this a fresh derivative deploys against an
1571
+ * empty database.
1572
+ *
1573
+ * Order and its reasons:
1574
+ * 1. Guard + decision — greenfield run AND a derivative that needs it (see
1575
+ * {@link isGreenfieldRun} / `decideInitialMigration`). Anything else returns.
1576
+ * 2. `ensureDeps` — `drizzle-kit` has to exist. Run-scoped, shared with the `vercel` step.
1577
+ * 3. `pnpm db:generate` — writes `0000_*.sql`. No DB connection needed
1578
+ * (`drizzle.config.ts` reads `process.env.DATABASE_URL!` without throwing).
1579
+ * 4. `git rev-parse --abbrev-ref HEAD` + `git add` + `git status` +
1580
+ * `git commit -- <pathspec>` — NEVER a bare commit: with a shared index it would
1581
+ * swallow whatever else is staged (GIT.md §3.6.1). The branch is read because the
1582
+ * commit lands on it while the push names a ref (see {@link readCurrentBranch}).
1583
+ * 5. Push, DISCRIMINATED by the plan: when the `github` step runs it pushes `main`
1584
+ * itself (`buildGitSubstrate`), so pushing here would be redundant; when it does not,
1585
+ * this step is the only thing that can get the `.sql` to the remote.
1586
+ *
1587
+ * Failure policy: `install`/`generate` ABORT with a `CLIError` — the step runs before the
1588
+ * `github` step, so aborting here means nothing was pushed and nothing was deployed. A git
1589
+ * IDENTITY failure while committing is a non-fatal warning (the `.sql` is already on disk),
1590
+ * mirroring `new.ts`'s precedent. A commit on a branch that is not `main`, and a failed
1591
+ * push, are warnings too — and each push is judged on its own (step 5 below).
1592
+ */
1593
+ export async function ensureInitialMigration(rootDir, evidence, deps = {}) {
1594
+ const { steps } = evidence;
1595
+ if (!isGreenfieldRun(evidence)) {
1596
+ // Silence would be wrong in ONE case, and it is one the CLI itself recommends:
1597
+ // `--force` is the flag this command suggests for restarting a failed bootstrap
1598
+ // ("descártalo y empieza de cero"), and a dev who follows that advice would end up
1599
+ // with a derivative that has no `0000` and no word about it. The step still stands
1600
+ // down — `--force` cannot prove birth — but it says so and hands over the remedy.
1601
+ if (evidence.force) {
1602
+ console.log(' ⏭ Con `--force` no genero la migración inicial: el flag descarta el estado de ' +
1603
+ 'provision, que es la única evidencia de si este proyecto ya nació — y sobre un ' +
1604
+ 'derivado vivo la generaría contra una base poblada.\n' +
1605
+ ' Si esto ES un bootstrap: retómalo con `--resume`, o genera la migración a mano ' +
1606
+ '(`pnpm db:generate`) y commitéala antes del primer deploy. `factory doctor` te lo ' +
1607
+ 'recuerda si falta.');
1608
+ }
1609
+ return;
1610
+ }
1611
+ const decision = decideInitialMigration(rootDir);
1612
+ if (!decision.run) {
1613
+ console.log(` ⏭ ${MIGRATION_SKIP_MESSAGES[decision.reason]}`);
1614
+ return;
1615
+ }
1616
+ const runGenerate = deps.runGenerate ?? drizzleGenerate;
1617
+ const runGit = deps.runGit ?? gitInDir;
1618
+ // 2. Dependencies (drizzle-kit). Run-scoped — the `vercel` step reuses this install.
1619
+ try {
1620
+ await ensureDeps(rootDir, deps.runInstall);
1621
+ }
1622
+ catch (error) {
1623
+ throw new CLIError(`No se pudieron instalar las dependencias del derivado (${error?.message ?? 'error'}).\n` +
1624
+ 'Sin ellas no hay `drizzle-kit` para generar la migración inicial, y el primer deploy ' +
1625
+ 'correría contra una base vacía. Corre `pnpm install` a mano y reintenta con `--resume`.');
1626
+ }
1627
+ // 3. Generate the `0000`.
1628
+ try {
1629
+ await runGenerate(rootDir);
1630
+ }
1631
+ catch (error) {
1632
+ throw new CLIError(`\`pnpm db:generate\` falló en el derivado (${error?.message ?? 'error'}).\n` +
1633
+ 'Sin la migración inicial el primer deploy correría contra una base vacía. Revisa el ' +
1634
+ 'schema en `src/lib/db/schema/`, genera a mano y reintenta con `--resume`.');
1635
+ }
1636
+ if (!hasGeneratedMigrations(rootDir)) {
1637
+ console.warn(`⚠ \`pnpm db:generate\` terminó sin escribir ningún \`.sql\` en \`${MIGRATIONS_DIR}/\`.\n` +
1638
+ ' Revisa el schema antes del primer deploy: no hay migración inicial que commitear.');
1639
+ return;
1640
+ }
1641
+ console.log(` + ${MIGRATIONS_DIR}/ (migración inicial generada)`);
1642
+ // 4. Commit with its OWN pathspec, preceded by a status of that same path. The branch
1643
+ // is read BEFORE the commit — that is the only thing that says where the commit
1644
+ // will actually land (`readCurrentBranch`).
1645
+ const branch = await readCurrentBranch(rootDir, runGit);
1646
+ try {
1647
+ await runGit(['add', '--', MIGRATIONS_DIR], rootDir);
1648
+ const pending = (await runGit(['status', '--porcelain', '--', MIGRATIONS_DIR], rootDir)).stdout.trim();
1649
+ if (!pending) {
1650
+ console.log(' ⏭ La migración inicial ya estaba commiteada — nada que pushear.');
1651
+ return;
1652
+ }
1653
+ await runGit(['commit', '-m', INITIAL_MIGRATION_COMMIT, '--', MIGRATIONS_DIR], rootDir);
1654
+ }
1655
+ catch (error) {
1656
+ // Precedent `new.ts`: a git identity failure is a warning, not an abort — the
1657
+ // migration is on disk and the dev can commit it.
1658
+ //
1659
+ // UNSTAGE FIRST. The `add` above already staged the path, and leaving it there hands
1660
+ // the `.sql` to whatever commits next: `commitProvisionState` reads the WHOLE index
1661
+ // and commits bare, so the migration would ride into the run's own state commit
1662
+ // under an unrelated subject. Failing to unstage is not fatal either — the file is
1663
+ // on disk regardless, which is the guarantee this catch exists to state.
1664
+ try {
1665
+ await runGit(['reset', '--', MIGRATIONS_DIR], rootDir);
1666
+ }
1667
+ catch {
1668
+ /* best-effort: the warning below is still true */
1669
+ }
1670
+ console.warn(`⚠ No pude commitear la migración inicial (${error?.message ?? 'error'}).\n` +
1671
+ ` El \`.sql\` quedó en \`${MIGRATIONS_DIR}/\`: commitéalo y pushéalo ANTES del primer deploy ` +
1672
+ '(configura tu identidad con `git config user.email` si ese fue el motivo).');
1673
+ return;
1674
+ }
1675
+ // 5. Push — discriminated by whether the `github` step runs in THIS run.
1676
+ const githubPushes = steps.some((step) => step.service === 'github' && step.action === 'create');
1677
+ // 4b. The commit landed on `branch`, not necessarily on the branch the push names.
1678
+ // A mismatch is NOISY and stops the push: pushing `main` from here would send a
1679
+ // `main` that does NOT carry the `.sql` and exit 0 — the very failure this step
1680
+ // exists to prevent, in green. The `.sql` is on disk and committed where the dev is.
1681
+ //
1682
+ // ONE exception, and it is not a loophole: a repo still on `master` when the
1683
+ // `github` step is about to run. That step RENAMES `master` → `main` before
1684
+ // pushing (`buildGitSubstrate`, step 1), so the commit does reach `main` with the
1685
+ // `.sql` inside — warning there would be alarmist about a case that works.
1686
+ const substrateWillRename = githubPushes && branch === LEGACY_DEFAULT_BRANCH;
1687
+ if (branch !== MIGRATION_BRANCH && !substrateWillRename) {
1688
+ // A detached HEAD has no branch to merge, so the commit is reachable ONLY by its
1689
+ // hash — naming it is the difference between an actionable warning and a scavenger
1690
+ // hunt through the reflog.
1691
+ const headSha = branch === null ? await readHeadSha(rootDir, runGit) : null;
1692
+ console.warn(`⚠ La migración inicial se commiteó en \`${branch ?? `HEAD desprendido (${headSha ?? 'sha desconocido'})`}\`, no en ` +
1693
+ `\`${MIGRATION_BRANCH}\` — no la pusheo.\n` +
1694
+ ` El push nombra la rama \`${MIGRATION_BRANCH}\` y el commit quedó en otra: pushear ` +
1695
+ `mandaría un \`${MIGRATION_BRANCH}\` SIN el \`.sql\` y el build migraría una base vacía.\n` +
1696
+ (branch === null
1697
+ ? ` Con \`HEAD\` desprendido no hay rama que mergear: el commit \`${headSha ?? '(desconocido)'}\` ` +
1698
+ `solo es alcanzable por su hash. Recupéralo con \`git branch <nombre> ${headSha ?? '<sha>'}\`, ` +
1699
+ `mergéalo a \`${MIGRATION_BRANCH}\` y pushea a mano ANTES del primer deploy.\n`
1700
+ : ` Pásate a \`${MIGRATION_BRANCH}\` (o mergea esa rama ahí) y pushea a mano ANTES del ` +
1701
+ 'primer deploy.\n'));
1702
+ return;
1703
+ }
1704
+ if (githubPushes) {
1705
+ console.log(' ↑ Migración inicial commiteada (el push de `main` lo hace el paso de GitHub).');
1706
+ return;
1707
+ }
1708
+ // Un `try` por push, y no uno compartido: `main` es el que le importa a producción
1709
+ // (`vercel-build` = `pnpm db:migrate && pnpm build`) y `develop` puede fallar solo
1710
+ // (non-fast-forward con un remoto divergido). Con un `try` compartido, esa falla de
1711
+ // `develop` imprimía "no pude pushear la migración" y se saltaba la confirmación —
1712
+ // un diagnóstico falso justo cuando `main` SÍ había llegado.
1713
+ try {
1714
+ await runGit(['push', 'origin', MIGRATION_BRANCH], rootDir);
1715
+ }
1716
+ catch (error) {
1717
+ console.warn(`⚠ No pude pushear la migración inicial (${error?.message ?? 'error'}).\n` +
1718
+ ' Pushéala a mano ANTES del primer deploy: el build corre `pnpm db:migrate` contra el ' +
1719
+ 'árbol remoto.');
1720
+ return;
1721
+ }
1722
+ try {
1723
+ await runGit(['push', 'origin', `${MIGRATION_BRANCH}:${MIGRATION_DEV_BRANCH}`], rootDir);
1724
+ console.log(` ↑ Migración inicial commiteada + pusheada (${MIGRATION_BRANCH} + ${MIGRATION_DEV_BRANCH}).`);
1725
+ }
1726
+ catch (error) {
1727
+ console.warn(`⚠ La migración inicial SÍ llegó a \`${MIGRATION_BRANCH}\`, pero no pude pushearla a ` +
1728
+ `\`${MIGRATION_DEV_BRANCH}\` (${error?.message ?? 'error'}).\n` +
1729
+ ` El deploy de producción está cubierto (\`vercel-build\` migra desde \`${MIGRATION_BRANCH}\`); ` +
1730
+ `sincroniza \`${MIGRATION_DEV_BRANCH}\` a mano cuando puedas.`);
1731
+ }
1732
+ await confirmMigrationOnRemote(rootDir, runGit);
1733
+ }
1378
1734
  /** Map a Cloudflare REST failure to an actionable, es-MX `CLIError` (issue §8 edges). */
1379
1735
  function cloudflareErrorToCliError(error, context) {
1380
1736
  switch (error.status) {
@@ -2279,19 +2635,58 @@ async function resolveCandidate(provider, candidates, deps, recordAmbiguity) {
2279
2635
  const chosen = await select(provider, candidates);
2280
2636
  return chosen?.id ?? null;
2281
2637
  }
2282
- /** Reconstruct the Vercel project id (read-only) — filter by name/slug. */
2638
+ /**
2639
+ * Normalize a provider-side project name for slug comparison. Provider consoles
2640
+ * hold DISPLAY names — a Neon project created as `Aditivo CRM` is the same stack
2641
+ * as the `aditivo-crm` directory — so a raw string compare answers "was this
2642
+ * created by the CLI", not "is this my project". Lowercase, non-alphanumerics
2643
+ * collapsed to a single `-`, edges trimmed: `Aditivo CRM` → `aditivo-crm`.
2644
+ */
2645
+ function normalizeProjectName(name) {
2646
+ return name
2647
+ .toLowerCase()
2648
+ .replace(/[^a-z0-9]+/g, '-')
2649
+ .replace(/^-+|-+$/g, '');
2650
+ }
2651
+ /**
2652
+ * Pick the candidate pool for a slug out of a provider's project list: an EXACT
2653
+ * (normalized) name match wins alone; otherwise every normalized name that
2654
+ * contains the slug (or is contained by it) is surfaced for disambiguation.
2655
+ */
2656
+ function matchProjectsBySlug(projects, slug) {
2657
+ const target = normalizeProjectName(slug);
2658
+ const matches = projects.filter((p) => {
2659
+ const name = normalizeProjectName(p.name);
2660
+ return name === target || name.includes(target) || target.includes(name);
2661
+ });
2662
+ const exact = matches.filter((p) => normalizeProjectName(p.name) === target);
2663
+ return exact.length === 1 ? exact : matches;
2664
+ }
2665
+ /**
2666
+ * Reconstruct the Vercel project id (read-only).
2667
+ *
2668
+ * The exact lookup goes FIRST — `GET /v9/projects/{name}` answers "is there a
2669
+ * project called `{slug}`" in one request. The listing is the fallback for a
2670
+ * project whose name merely resembles the directory, and it is the reason this
2671
+ * order matters: the listing is paginated, and a team with a real fleet used to
2672
+ * bury the project past the first page, where "not in this page" was recorded
2673
+ * as `unknown` — indistinguishable from "does not exist".
2674
+ */
2283
2675
  async function adoptVercel(slug, teamId, token, deps, recordAmbiguity) {
2676
+ const exact = await getVercelProjectByName(slug, token, teamId, { fetchImpl: deps.fetchImpl });
2677
+ if (exact)
2678
+ return exact.id;
2284
2679
  const projects = await listVercelProjects(token, teamId, { fetchImpl: deps.fetchImpl });
2285
2680
  const candidates = projectsToCandidates(projects, slug, teamId);
2681
+ if (candidates.length === 0) {
2682
+ console.log(` · Ningún proyecto de Vercel se parece a \`${slug}\` (revisé ${projects.length} del team \`${teamId}\`) — \`vercelProjectId: unknown\`.`);
2683
+ return null;
2684
+ }
2286
2685
  return resolveCandidate('Vercel', candidates, deps, recordAmbiguity);
2287
2686
  }
2288
2687
  /** Map Vercel project summaries to candidates, keeping only slug-name matches. */
2289
2688
  function projectsToCandidates(projects, slug, teamId) {
2290
- const matches = projects.filter((p) => p.name === slug || p.name.includes(slug) || slug.includes(p.name));
2291
- // Prefer an EXACT name match alone (unambiguous); otherwise surface all matches.
2292
- const exact = matches.filter((p) => p.name === slug);
2293
- const pool = exact.length === 1 ? exact : matches;
2294
- return pool.map((p) => ({
2689
+ return matchProjectsBySlug(projects, slug).map((p) => ({
2295
2690
  id: p.id,
2296
2691
  name: p.name,
2297
2692
  url: `https://vercel.com/${teamId}/${p.name}`,
@@ -2303,16 +2698,17 @@ function projectsToCandidates(projects, slug, teamId) {
2303
2698
  * the `develop` one. A branch lookup failure leaves the branch id `unknown`
2304
2699
  * without failing the whole adopt (issue §4).
2305
2700
  */
2306
- async function adoptNeon(slug, apiKey, deps, recordAmbiguity) {
2307
- const projects = await listNeonProjects(apiKey, { fetchImpl: deps.fetchImpl });
2308
- const matches = projects.filter((p) => p.name === slug || p.name.includes(slug) || slug.includes(p.name));
2309
- const exact = matches.filter((p) => p.name === slug);
2310
- const pool = exact.length === 1 ? exact : matches;
2311
- const candidates = pool.map((p) => ({
2701
+ async function adoptNeon(slug, apiKey, orgId, deps, recordAmbiguity) {
2702
+ const projects = await listNeonProjects(apiKey, orgId, { fetchImpl: deps.fetchImpl });
2703
+ const candidates = matchProjectsBySlug(projects, slug).map((p) => ({
2312
2704
  id: p.id,
2313
2705
  name: p.name,
2314
2706
  url: `https://console.neon.tech/app/projects/${p.id}`,
2315
2707
  }));
2708
+ if (candidates.length === 0) {
2709
+ console.log(` · Ningún proyecto de Neon se parece a \`${slug}\` (revisé ${projects.length} de la org) — \`neonProjectId: unknown\`.`);
2710
+ return { projectId: null, branchDevId: null };
2711
+ }
2316
2712
  const projectId = await resolveCandidate('Neon', candidates, deps, recordAmbiguity);
2317
2713
  if (!projectId)
2318
2714
  return { projectId: null, branchDevId: null };
@@ -2359,17 +2755,23 @@ async function adoptGithub(slug, deps) {
2359
2755
  * source, through the very door DOM-001 closed from the other side.
2360
2756
  *
2361
2757
  * So it probes the org zones in order and keeps the one whose CNAMEs actually
2362
- * exist. Deriving from the slug survives only as the LAST resort — no token, no
2363
- * zone reachable, or a project whose CNAMEs are genuinely absent — where naming a
2364
- * domain by convention is all anyone can do, and the narration says so.
2758
+ * exist. When none does, it asks VERCEL where the app answers — a project living
2759
+ * on the client's own domain (`crm.cliente.mx`) has no CNAME in any org zone, and
2760
+ * for it the org-zone question has no true answer: the conventional name would be
2761
+ * a host that resolves nowhere, written into the field destroy and the WebAuthn
2762
+ * rpID both read.
2763
+ *
2764
+ * Deriving from the slug survives only as the LAST resort — no token, no zone
2765
+ * reachable, and no Vercel project to ask — where naming a domain by convention
2766
+ * is all anyone can do, and the narration says so.
2365
2767
  */
2366
- async function adoptDns(slug, deps) {
2768
+ async function adoptDns(slug, deps, vercel) {
2367
2769
  const domains = provisionDomains(slug);
2368
2770
  const cfToken = readCloudflareApiKey(deps.envPath ?? defaultEnvPath());
2369
2771
  if (!cfToken) {
2370
- console.warn('⚠ No hay `CLOUDFLARE_API_TOKEN` en el rail — registro los dominios por nombre sin ' +
2371
- 'confirmar sus CNAMEs (es read-only; corre `factory secrets <archivo>` si quieres la verificación).');
2372
- return domains;
2772
+ console.warn('⚠ No hay `CLOUDFLARE_API_TOKEN` en el rail — no puedo confirmar CNAMEs ' +
2773
+ '(es read-only; corre `factory secrets <archivo>` si quieres la verificación).');
2774
+ return (await adoptDomainsFromVercel(vercel, deps)) ?? narrateDerivedDomains(domains);
2373
2775
  }
2374
2776
  const cfDeps = { fetchImpl: deps.fetchImpl };
2375
2777
  for (const zone of ORG_ZONE_NAMES) {
@@ -2407,9 +2809,60 @@ async function adoptDns(slug, deps) {
2407
2809
  }
2408
2810
  }
2409
2811
  console.log(` · Sin CNAMEs en ninguna zona del org (${ORG_ZONE_NAMES.join(', ')}) — ` +
2410
- 'registro los dominios por nombre en la zona actual.');
2812
+ 'le pregunto a Vercel dónde responde el proyecto.');
2813
+ return (await adoptDomainsFromVercel(vercel, deps)) ?? narrateDerivedDomains(domains);
2814
+ }
2815
+ /** Say out loud that these hosts are a convention, not a confirmed fact. */
2816
+ function narrateDerivedDomains(domains) {
2817
+ console.warn(`⚠ No pude confirmar dominios en ningún proveedor — registro los de convención ` +
2818
+ `(\`${domains.prod}\` / \`${domains.dev}\`). Si el proyecto usa otro dominio, corrígelos ` +
2819
+ 'a mano en `.timekast/provision.json` antes de cualquier `--destroy`.');
2411
2820
  return domains;
2412
2821
  }
2822
+ /**
2823
+ * Read the project's REAL hosts off Vercel (read-only) and split them into the
2824
+ * dev/prod pair the state holds. A domain pinned to the `develop` branch is dev;
2825
+ * an unpinned one is prod. Custom domains win over the `*.vercel.app` Vercel
2826
+ * mints on its own — but the auto host is kept when a role has nothing else, so
2827
+ * the rpID of a dev environment that never got a custom domain is still true.
2828
+ *
2829
+ * Returns `null` when there is no project to ask, the call fails, or the project
2830
+ * has no domains at all — the caller falls back to the conventional names.
2831
+ */
2832
+ async function adoptDomainsFromVercel(vercel, deps) {
2833
+ if (!vercel)
2834
+ return null;
2835
+ let domains;
2836
+ try {
2837
+ domains = await listProjectDomains(vercel.projectId, vercel.token, vercel.teamId, {
2838
+ fetchImpl: deps.fetchImpl,
2839
+ });
2840
+ }
2841
+ catch {
2842
+ console.warn('⚠ No se pudo leer los dominios del proyecto en Vercel (read-only) — continúo.');
2843
+ return null;
2844
+ }
2845
+ if (domains.length === 0)
2846
+ return null;
2847
+ const isAuto = (d) => d.apexName === VERCEL_AUTO_APEX;
2848
+ const isDev = (d) => d.gitBranch === NEON_DEVELOP_BRANCH;
2849
+ const pick = (wantDev) => domains.find((d) => isDev(d) === wantDev && !isAuto(d)) ??
2850
+ domains.find((d) => isDev(d) === wantDev);
2851
+ const prod = pick(false);
2852
+ const dev = pick(true);
2853
+ if (!prod && !dev)
2854
+ return null;
2855
+ const resolved = { dev: dev?.name ?? '', prod: prod?.name ?? '' };
2856
+ if (!resolved.prod || !resolved.dev) {
2857
+ // One role has no host at all: report it rather than inventing its twin.
2858
+ console.log(` ℹ Vercel solo tiene dominio de ${resolved.prod ? 'producción' : 'develop'} para este proyecto.`);
2859
+ }
2860
+ console.log(` ✔ Dominios tomados de Vercel — prod: ${resolved.prod || '(ninguno)'} · dev: ${resolved.dev || '(ninguno)'}`);
2861
+ return {
2862
+ dev: resolved.dev || ADOPT_UNKNOWN,
2863
+ prod: resolved.prod || ADOPT_UNKNOWN,
2864
+ };
2865
+ }
2413
2866
  /** Print the reconstructed adopt state in plain es-MX (the non-`--json` narration). */
2414
2867
  function printAdoptState(result) {
2415
2868
  const s = result.state;
@@ -2481,9 +2934,12 @@ export async function runAdopt(flags, deps = {}) {
2481
2934
  let neonProjectId = null;
2482
2935
  let neonBranchDevId = null;
2483
2936
  const neonApiKey = readNeonApiKey(envPath);
2937
+ // Same rail value the create path threads (`NEON_ORGANIZATION_ID`): Neon REQUIRES
2938
+ // it on an org-scoped key, and answers `400 org_id is required` without it.
2939
+ const neonOrgId = readGateValue('NEON_ORGANIZATION_ID', envPath) ?? undefined;
2484
2940
  if (neonApiKey) {
2485
2941
  try {
2486
- const neon = await adoptNeon(slug, neonApiKey, adeps, (list) => {
2942
+ const neon = await adoptNeon(slug, neonApiKey, neonOrgId, adeps, (list) => {
2487
2943
  candidates.neon = list;
2488
2944
  });
2489
2945
  neonProjectId = neon.projectId;
@@ -2499,8 +2955,11 @@ export async function runAdopt(flags, deps = {}) {
2499
2955
  }
2500
2956
  // GitHub (gh repo view) — uses the dev's gh auth (no rail token needed).
2501
2957
  const githubRepo = await adoptGithub(slug, adeps);
2502
- // DNS (GET dns_records) — domain names always derivable; CNAME check best-effort.
2503
- const domains = await adoptDns(slug, adeps);
2958
+ // DNS (GET dns_records) — the org zones first, then the project's own Vercel
2959
+ // domains, and only then the conventional names (see `adoptDns`).
2960
+ const domains = await adoptDns(slug, adeps, vercelProjectId && vercelToken && flags.team
2961
+ ? { projectId: vercelProjectId, teamId: flags.team, token: vercelToken }
2962
+ : null);
2504
2963
  const partial = {
2505
2964
  slug,
2506
2965
  vercelProjectId: vercelProjectId ?? ADOPT_UNKNOWN,
@@ -2575,7 +3034,7 @@ function describeDestroyPlan(target) {
2575
3034
  lines.push(` - CNAME Cloudflare ${domain} — DELETE /zones/.../dns_records`);
2576
3035
  }
2577
3036
  if (target.vercelProjectId) {
2578
- lines.push(` - Proyecto Vercel ${target.vercelProjectId} — \`vercel remove\` (sesión del dev)`);
3037
+ lines.push(` - Proyecto Vercel ${target.vercelProjectId} — DELETE /v9/projects (token del rail)`);
2579
3038
  }
2580
3039
  if (target.githubRepo) {
2581
3040
  lines.push(` - Repo GitHub ${target.githubRepo} — \`gh repo delete\` (sesión del dev)`);
@@ -2704,36 +3163,44 @@ async function destroyCloudflareRecord(domain, zoneId, token, fetchImpl) {
2704
3163
  }
2705
3164
  }
2706
3165
  /**
2707
- * Remove the Vercel PROJECT via the dev's `vercel` CLI (`vercel remove <slug>
2708
- * --yes`) — issue §4: the project delete uses the DEV's session, never a shared org
2709
- * token (the shared org Vercel token is create/configure-only). A "not found" stderr
2710
- * is treated as already-removed (§8).
3166
+ * Remove the Vercel PROJECT over REST, with the rail token that created it.
3167
+ *
3168
+ * 🔴 It used to shell out to the dev's `vercel remove <slug> --yes`, on the principle that a
3169
+ * delete should use the DEV's session and never a shared org token. Measured teardown of a
3170
+ * real ephemeral stack (2026-08-17) showed the principle unreachable in practice: the project
3171
+ * is created by the rail token WITH a `teamId`, so it lives in the team, while the dev's CLI
3172
+ * session sits in whatever scope they last logged into. The delete failed with
3173
+ * `Not authorized … under scope "…"` and left a live, billable project behind — exactly the
3174
+ * outcome `--ephemeral` exists to prevent.
3175
+ *
3176
+ * The REST path also removes the whole class of shell-argument hazards the old code had to
3177
+ * guard against (a slug from the low-trust state file becoming a positional arg), and it
3178
+ * targets the project by ID rather than by name.
3179
+ *
3180
+ * Consistency note: {@link destroyVercelDomain}, called from the same teardown, ALREADY
3181
+ * deleted over REST with this token. The project delete was the outlier, not the rule.
2711
3182
  */
2712
- async function destroyVercelProject(target, runVercel) {
3183
+ async function destroyVercelProject(target, token, fetchImpl) {
2713
3184
  const resource = `Proyecto Vercel ${target.slug}`;
2714
- // FIX 2: the slug came from the low-trust state file and is about to become a
2715
- // positional arg to `vercel remove`. A malformed slug (leading `-` = flag
2716
- // smuggling, or a non-subdomain shape) is NEVER passed to the command. It is a
2717
- // `failed` (not a silent skip) because a Vercel project IS recorded but cannot be
2718
- // torn down — so `hadFailures` keeps the state (no orphan), per FIX 3.
2719
- if (!isValidSubdomainSlug(target.slug)) {
3185
+ if (!target.vercelProjectId) {
2720
3186
  return {
2721
3187
  resource: 'Proyecto Vercel',
2722
3188
  status: 'failed',
2723
- detail: `slug malformado en el estado (\`${target.slug}\`) — bórralo a mano en Vercel`,
3189
+ detail: 'el estado no registró `vercel.projectId` — bórralo a mano en Vercel',
2724
3190
  };
2725
3191
  }
2726
3192
  try {
2727
- // `--` ends option parsing so the slug can never be read as a flag.
2728
- await runVercel(['remove', '--yes', '--', target.slug]);
2729
- return { resource, status: 'removed', detail: 'eliminado vía `vercel remove`' };
3193
+ const outcome = await removeVercelProject(target.vercelProjectId, token, target.scope ?? '', {
3194
+ fetchImpl,
3195
+ });
3196
+ return {
3197
+ resource,
3198
+ status: 'removed',
3199
+ detail: outcome === 'absent' ? 'ya no existía (404)' : 'eliminado',
3200
+ };
2730
3201
  }
2731
3202
  catch (error) {
2732
- const message = error?.message ?? '';
2733
- if (/not found|no such|does not exist|404/i.test(message)) {
2734
- return { resource, status: 'removed', detail: 'ya no existía (vercel)' };
2735
- }
2736
- return { resource, status: 'failed', detail: message || 'error en `vercel remove`' };
3203
+ return { resource, status: 'failed', detail: error?.message ?? 'error' };
2737
3204
  }
2738
3205
  }
2739
3206
  /**
@@ -2847,6 +3314,18 @@ async function teardownProviders(target, deps) {
2847
3314
  // was never found and stayed behind. Keyed by domain→zone-id, resolved on demand.
2848
3315
  const zoneIdByDomain = new Map();
2849
3316
  for (const domain of target.domains) {
3317
+ // A `*.vercel.app` host reaches the state only via `--adopt`, which records the
3318
+ // auto host when a role has no custom domain. Vercel mints and owns it: there is
3319
+ // no CNAME of ours to delete and no zone to resolve, so both branches below would
3320
+ // report a `failed` for a resource that was never ours.
3321
+ if (domain.endsWith(`.${VERCEL_AUTO_APEX}`)) {
3322
+ outcomes.push({
3323
+ resource: `Dominio ${domain}`,
3324
+ status: 'skipped',
3325
+ detail: 'host automático de Vercel — se va con el proyecto',
3326
+ });
3327
+ continue;
3328
+ }
2850
3329
  // The domain is registered in the state, so there IS a resource to remove. If
2851
3330
  // a prerequisite is missing (FIX 3: e.g. `scope` null on an adopted stack, or
2852
3331
  // no token), emit `failed` — NOT a silent skip — so `hadFailures` keeps the
@@ -2902,9 +3381,21 @@ async function teardownProviders(target, deps) {
2902
3381
  }
2903
3382
  }
2904
3383
  }
2905
- // 3. Vercel PROJECT — the dev's `vercel remove` (NEVER a shared org token).
3384
+ // 3. Vercel PROJECT — REST with the rail token, the same one that created it (and the same
3385
+ // one the domain removal above already uses). The dev's `vercel` session cannot be
3386
+ // assumed to sit in the team the project lives in; when it does not, the delete fails
3387
+ // and leaves a billable orphan.
2906
3388
  if (target.vercelProjectId) {
2907
- outcomes.push(await destroyVercelProject(target, deps.runVercel));
3389
+ if (!vercelToken) {
3390
+ outcomes.push({
3391
+ resource: `Proyecto Vercel ${target.slug}`,
3392
+ status: 'failed',
3393
+ detail: 'sin `VERCEL_TOKEN` en el rail — bórralo a mano en Vercel',
3394
+ });
3395
+ }
3396
+ else {
3397
+ outcomes.push(await destroyVercelProject(target, vercelToken, deps.fetchImpl));
3398
+ }
2908
3399
  }
2909
3400
  // 4. GitHub repo — the dev's `gh repo delete` (NEVER a shared org token).
2910
3401
  if (target.githubRepo) {
@@ -3094,6 +3585,12 @@ export async function runProvision(flags) {
3094
3585
  // patch — it cannot clobber, so it is treated like `--resume` for state-reading.
3095
3586
  const standaloneDomain = hasDomain && flags.services.length === 0;
3096
3587
  // 2. Resolve state. `--force` wins over `--resume` (issue §8): discard + restart.
3588
+ // 🔴 GAP-001: capture whether this project had a provision state BEFORE the discard.
3589
+ // `clearProvisionState` runs ahead of `buildPlan`, so after it every service reads as
3590
+ // `create` and the plan is indistinguishable from a bootstrap. That fact is the only
3591
+ // evidence the greenfield guard has that this project already went through
3592
+ // provisioning, and this line is the last moment it exists (see `isGreenfieldRun`).
3593
+ const hadPriorState = hasProvisionState(rootDir);
3097
3594
  if (flags.force) {
3098
3595
  clearProvisionState(rootDir);
3099
3596
  }
@@ -3136,6 +3633,14 @@ export async function runProvision(flags) {
3136
3633
  if (repoSlug) {
3137
3634
  result.repo = repoSlug;
3138
3635
  }
3636
+ // GAP-001: decide the initial-migration step WITH the plan (read-only: the greenfield
3637
+ // guard reads the plan + this run's own evidence, the rest reads disk), so `--dry-run`
3638
+ // announces it below and the execution further down uses the very same predicate over
3639
+ // the very same evidence object.
3640
+ const greenfield = { steps, force: flags.force, hadPriorState };
3641
+ if (shouldRunInitialMigration(rootDir, greenfield)) {
3642
+ result.initialMigration = true;
3643
+ }
3139
3644
  if (hasDomain) {
3140
3645
  result.clientDomain = { domain: flags.domain, route: flags.dns };
3141
3646
  }
@@ -3181,6 +3686,11 @@ export async function runProvision(flags) {
3181
3686
  // Ensure the state dir exists + a baseline file before the first step, so a
3182
3687
  // step failure on the very first service still leaves a resumable state.
3183
3688
  writeProvisionState(rootDir, runState);
3689
+ // 5.0. Initial migration (GAP-001) — BEFORE the steps, so the `.sql` is committed
3690
+ // before the `github` step pushes `main` and Vercel builds the remote tree
3691
+ // (`vercel-build` = `pnpm db:migrate && pnpm build`). A failed generate/install
3692
+ // aborts here, with nothing pushed and nothing deployed.
3693
+ await ensureInitialMigration(rootDir, greenfield);
3184
3694
  for (const step of steps) {
3185
3695
  if (step.action === 'skip')
3186
3696
  continue;