@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.
- package/dist/commands/doctor.js +39 -0
- package/dist/commands/provision.js +561 -51
- package/dist/commands/update.js +11 -0
- package/dist/lib/initial-migration.js +131 -0
- package/dist/lib/neon-api.js +45 -14
- package/dist/lib/retrofit-reminder.js +228 -0
- package/dist/lib/vercel-env.js +99 -10
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
1338
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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.
|
|
2363
|
-
*
|
|
2364
|
-
*
|
|
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 —
|
|
2371
|
-
'
|
|
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
|
-
'
|
|
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) —
|
|
2503
|
-
|
|
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} —
|
|
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
|
|
2708
|
-
*
|
|
2709
|
-
*
|
|
2710
|
-
*
|
|
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,
|
|
3183
|
+
async function destroyVercelProject(target, token, fetchImpl) {
|
|
2713
3184
|
const resource = `Proyecto Vercel ${target.slug}`;
|
|
2714
|
-
|
|
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:
|
|
3189
|
+
detail: 'el estado no registró `vercel.projectId` — bórralo a mano en Vercel',
|
|
2724
3190
|
};
|
|
2725
3191
|
}
|
|
2726
3192
|
try {
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|