@timekast/factory 1.24.0 → 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 +423 -28
- package/dist/commands/update.js +11 -0
- package/dist/lib/initial-migration.js +131 -0
- package/dist/lib/retrofit-reminder.js +228 -0
- package/dist/lib/vercel-env.js +31 -0
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -12,15 +12,26 @@
|
|
|
12
12
|
* `src/` + boilerplate deps are the derived project's responsibility
|
|
13
13
|
* post-bootstrap, and that the patch channel for `src/` is EPIC 2 (agentic
|
|
14
14
|
* update, not yet available).
|
|
15
|
+
* - MISSING INITIAL MIGRATION: a derivative that ships a Drizzle schema and no
|
|
16
|
+
* generated `.sql`, so its `vercel-build` (`pnpm db:migrate && pnpm build`)
|
|
17
|
+
* would migrate an empty database. INFORMATIONAL — see below.
|
|
15
18
|
*
|
|
16
19
|
* No lockfile (pre-CLI repo) → delegate to the "run factory:update" advice, exit
|
|
17
20
|
* 0, never abort (same pattern as `status` / the §8 edge case).
|
|
21
|
+
*
|
|
22
|
+
* 🔴 EVERY CHECK HERE IS ADVISORY. `runDoctor` always exits 0 by contract; the
|
|
23
|
+
* sibling entrypoint {@link runLauncherDoctor} is the one that throws `CLIError(1)`
|
|
24
|
+
* on any `status: 'fail'`. A report-only finding therefore belongs in
|
|
25
|
+
* `computeDoctor`/`renderDoctor` and NOWHERE ELSE — moving it to the launcher would
|
|
26
|
+
* turn a notice into a blocked command through the back door. If it ever has to
|
|
27
|
+
* live there, it goes as `warn` (`LauncherCheckStatus`), which never trips the exit.
|
|
18
28
|
*/
|
|
19
29
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
20
30
|
import path from 'node:path';
|
|
21
31
|
import { CLIError } from '../lib/cli-error.js';
|
|
22
32
|
import { collectClaudePaths } from '../lib/claude-paths.js';
|
|
23
33
|
import { CLAUDE_MD_FILE } from '../lib/constants.js';
|
|
34
|
+
import { MIGRATIONS_DIR, SCHEMA_DIR, needsInitialMigration, readMigrationFacts, } from '../lib/initial-migration.js';
|
|
24
35
|
import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
|
|
25
36
|
import { runLauncherPreflight, } from '../lib/preflight.js';
|
|
26
37
|
import { detectRepo } from '../lib/repo-detection.js';
|
|
@@ -46,6 +57,18 @@ export const A1_WARNING = 'Aviso de seguridad (A1): el boilerplate `src/` y sus
|
|
|
46
57
|
'dependencia) NO llega por este canal. El canal de parches de `src/` es EPIC 2 ' +
|
|
47
58
|
'(update agentico con merge inteligente), aún no disponible. Mantén `src/` y tus ' +
|
|
48
59
|
'dependencias al día por tu cuenta mientras tanto.';
|
|
60
|
+
/**
|
|
61
|
+
* The literal notice for a derivative whose schema has no generated migration.
|
|
62
|
+
* Named constant (never an inline string — CODING.md §5) and built from the SAME
|
|
63
|
+
* directory constants the provision step uses, so a path can never drift between
|
|
64
|
+
* what `provision` writes and what `doctor` reports. Exported for the tests.
|
|
65
|
+
*/
|
|
66
|
+
export const MISSING_INITIAL_MIGRATION_NOTICE = `⚠ Falta la migración inicial: hay schema en \`${SCHEMA_DIR}/\` y ningún \`.sql\` en ` +
|
|
67
|
+
`\`${MIGRATIONS_DIR}/\`.\n` +
|
|
68
|
+
' El deploy corre `pnpm db:migrate && pnpm build`, así que el primer build migraría una ' +
|
|
69
|
+
'base vacía. Genera la migración con `pnpm db:generate` y commitéala antes del próximo ' +
|
|
70
|
+
'deploy.\n' +
|
|
71
|
+
' (Aviso informativo: `factory doctor` no bloquea — sale con éxito de todos modos.)';
|
|
49
72
|
/** Parse `doctor`'s argv slice into flags. Unknown flags are ignored. */
|
|
50
73
|
export function parseDoctorFlags(argv) {
|
|
51
74
|
return {
|
|
@@ -114,6 +137,10 @@ export function computeDoctor(deps = {}) {
|
|
|
114
137
|
// `doctor` from a subdir of a managed repo reports the real state instead of
|
|
115
138
|
// "no lockfile" (consistency with `add`/`update`). Tests inject `rootDir`.
|
|
116
139
|
const rootDir = deps.rootDir ?? detectRepo(process.cwd()).repoRoot ?? process.cwd();
|
|
140
|
+
// Initial-migration gap (GAP-002). Delegated WHOLE to the provision predicate —
|
|
141
|
+
// facts read from disk, decision (Factory exception included) taken there. Doctor
|
|
142
|
+
// does not re-derive "is this the Factory": one copy of that rule, in one place.
|
|
143
|
+
const missingInitialMigration = needsInitialMigration(readMigrationFacts(rootDir));
|
|
117
144
|
if (!hasLockfile(rootDir)) {
|
|
118
145
|
return {
|
|
119
146
|
orphans: [],
|
|
@@ -121,6 +148,9 @@ export function computeDoctor(deps = {}) {
|
|
|
121
148
|
unimportedRules: [],
|
|
122
149
|
claudeMdMissing: false,
|
|
123
150
|
a1Warning: A1_WARNING,
|
|
151
|
+
// Always false on this branch, and by the predicate rather than by a literal:
|
|
152
|
+
// no lockfile IS the Factory signal, which `needsInitialMigration` short-circuits.
|
|
153
|
+
missingInitialMigration,
|
|
124
154
|
noLockfile: true,
|
|
125
155
|
beta: false,
|
|
126
156
|
};
|
|
@@ -174,6 +204,7 @@ export function computeDoctor(deps = {}) {
|
|
|
174
204
|
unimportedRules,
|
|
175
205
|
claudeMdMissing,
|
|
176
206
|
a1Warning: A1_WARNING,
|
|
207
|
+
missingInitialMigration,
|
|
177
208
|
noLockfile: false,
|
|
178
209
|
beta,
|
|
179
210
|
};
|
|
@@ -181,6 +212,8 @@ export function computeDoctor(deps = {}) {
|
|
|
181
212
|
/** Render the doctor result to the console. The A1 notice is ALWAYS last. */
|
|
182
213
|
function renderDoctor(result) {
|
|
183
214
|
if (result.noLockfile) {
|
|
215
|
+
// Nothing to say about migrations here: no lockfile IS the Factory signal, so
|
|
216
|
+
// `missingInitialMigration` is false by construction on this branch.
|
|
184
217
|
console.log('Sin lockfile detectado.');
|
|
185
218
|
console.log('Ejecuta `factory:update` para inicializar el tracking de versión.');
|
|
186
219
|
// The A1 notice is emitted even without a lockfile (always present).
|
|
@@ -223,6 +256,12 @@ function renderDoctor(result) {
|
|
|
223
256
|
}
|
|
224
257
|
}
|
|
225
258
|
}
|
|
259
|
+
// Initial-migration gap (GAP-002): reported, never enforced. It sits outside the
|
|
260
|
+
// orphans/conflicts block because it is about the derivative's `src/`, not about
|
|
261
|
+
// brain drift — a repo with a spotless brain can still be missing its `0000`.
|
|
262
|
+
if (result.missingInitialMigration) {
|
|
263
|
+
console.log(`\n${MISSING_INITIAL_MIGRATION_NOTICE}`);
|
|
264
|
+
}
|
|
226
265
|
// The A1 security notice is always emitted at the end (design §1 + §11).
|
|
227
266
|
console.log(`\n${result.a1Warning}`);
|
|
228
267
|
}
|
|
@@ -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, getVercelProjectByName, GitHubAppNotInstalledError, linkGitRepo, listProjectDomains, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, VERCEL_AUTO_APEX, 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) {
|
|
@@ -2678,7 +3034,7 @@ function describeDestroyPlan(target) {
|
|
|
2678
3034
|
lines.push(` - CNAME Cloudflare ${domain} — DELETE /zones/.../dns_records`);
|
|
2679
3035
|
}
|
|
2680
3036
|
if (target.vercelProjectId) {
|
|
2681
|
-
lines.push(` - Proyecto Vercel ${target.vercelProjectId} —
|
|
3037
|
+
lines.push(` - Proyecto Vercel ${target.vercelProjectId} — DELETE /v9/projects (token del rail)`);
|
|
2682
3038
|
}
|
|
2683
3039
|
if (target.githubRepo) {
|
|
2684
3040
|
lines.push(` - Repo GitHub ${target.githubRepo} — \`gh repo delete\` (sesión del dev)`);
|
|
@@ -2807,36 +3163,44 @@ async function destroyCloudflareRecord(domain, zoneId, token, fetchImpl) {
|
|
|
2807
3163
|
}
|
|
2808
3164
|
}
|
|
2809
3165
|
/**
|
|
2810
|
-
* Remove the Vercel PROJECT
|
|
2811
|
-
*
|
|
2812
|
-
*
|
|
2813
|
-
*
|
|
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.
|
|
2814
3182
|
*/
|
|
2815
|
-
async function destroyVercelProject(target,
|
|
3183
|
+
async function destroyVercelProject(target, token, fetchImpl) {
|
|
2816
3184
|
const resource = `Proyecto Vercel ${target.slug}`;
|
|
2817
|
-
|
|
2818
|
-
// positional arg to `vercel remove`. A malformed slug (leading `-` = flag
|
|
2819
|
-
// smuggling, or a non-subdomain shape) is NEVER passed to the command. It is a
|
|
2820
|
-
// `failed` (not a silent skip) because a Vercel project IS recorded but cannot be
|
|
2821
|
-
// torn down — so `hadFailures` keeps the state (no orphan), per FIX 3.
|
|
2822
|
-
if (!isValidSubdomainSlug(target.slug)) {
|
|
3185
|
+
if (!target.vercelProjectId) {
|
|
2823
3186
|
return {
|
|
2824
3187
|
resource: 'Proyecto Vercel',
|
|
2825
3188
|
status: 'failed',
|
|
2826
|
-
detail:
|
|
3189
|
+
detail: 'el estado no registró `vercel.projectId` — bórralo a mano en Vercel',
|
|
2827
3190
|
};
|
|
2828
3191
|
}
|
|
2829
3192
|
try {
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
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
|
+
};
|
|
2833
3201
|
}
|
|
2834
3202
|
catch (error) {
|
|
2835
|
-
|
|
2836
|
-
if (/not found|no such|does not exist|404/i.test(message)) {
|
|
2837
|
-
return { resource, status: 'removed', detail: 'ya no existía (vercel)' };
|
|
2838
|
-
}
|
|
2839
|
-
return { resource, status: 'failed', detail: message || 'error en `vercel remove`' };
|
|
3203
|
+
return { resource, status: 'failed', detail: error?.message ?? 'error' };
|
|
2840
3204
|
}
|
|
2841
3205
|
}
|
|
2842
3206
|
/**
|
|
@@ -3017,9 +3381,21 @@ async function teardownProviders(target, deps) {
|
|
|
3017
3381
|
}
|
|
3018
3382
|
}
|
|
3019
3383
|
}
|
|
3020
|
-
// 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.
|
|
3021
3388
|
if (target.vercelProjectId) {
|
|
3022
|
-
|
|
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
|
+
}
|
|
3023
3399
|
}
|
|
3024
3400
|
// 4. GitHub repo — the dev's `gh repo delete` (NEVER a shared org token).
|
|
3025
3401
|
if (target.githubRepo) {
|
|
@@ -3209,6 +3585,12 @@ export async function runProvision(flags) {
|
|
|
3209
3585
|
// patch — it cannot clobber, so it is treated like `--resume` for state-reading.
|
|
3210
3586
|
const standaloneDomain = hasDomain && flags.services.length === 0;
|
|
3211
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);
|
|
3212
3594
|
if (flags.force) {
|
|
3213
3595
|
clearProvisionState(rootDir);
|
|
3214
3596
|
}
|
|
@@ -3251,6 +3633,14 @@ export async function runProvision(flags) {
|
|
|
3251
3633
|
if (repoSlug) {
|
|
3252
3634
|
result.repo = repoSlug;
|
|
3253
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
|
+
}
|
|
3254
3644
|
if (hasDomain) {
|
|
3255
3645
|
result.clientDomain = { domain: flags.domain, route: flags.dns };
|
|
3256
3646
|
}
|
|
@@ -3296,6 +3686,11 @@ export async function runProvision(flags) {
|
|
|
3296
3686
|
// Ensure the state dir exists + a baseline file before the first step, so a
|
|
3297
3687
|
// step failure on the very first service still leaves a resumable state.
|
|
3298
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);
|
|
3299
3694
|
for (const step of steps) {
|
|
3300
3695
|
if (step.action === 'skip')
|
|
3301
3696
|
continue;
|
package/dist/commands/update.js
CHANGED
|
@@ -48,6 +48,7 @@ import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLo
|
|
|
48
48
|
import { detectProfile, maintainKitScripts, setAgentKitVersion } from '../lib/package-json.js';
|
|
49
49
|
import { runPreflight } from '../lib/preflight.js';
|
|
50
50
|
import { printProvisionPointer } from '../lib/provision-pointer.js';
|
|
51
|
+
import { printRetrofitReminder } from '../lib/retrofit-reminder.js';
|
|
51
52
|
import { hasProvisionState } from '../lib/provision-state.js';
|
|
52
53
|
import { detectRepo } from '../lib/repo-detection.js';
|
|
53
54
|
import { downloadBetaProfileTarball, downloadProfileTarball, stageProfileTarball, } from '../lib/unpack.js';
|
|
@@ -849,6 +850,16 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
849
850
|
// the prompt, passed `--theirs-all`, or ran headless — there is no second path. The
|
|
850
851
|
// package.json mutations ride the SAME closing summary, not three loose notices.
|
|
851
852
|
reportSummary(diff, manifest, theirsConflicts, pkgMutations);
|
|
853
|
+
// 🔴 The retrofit reminder, on EVERY applied update and regardless of how conflicts
|
|
854
|
+
// were resolved — `--theirs-all`, `--mine-all`, per-file answers or none at all. A
|
|
855
|
+
// retrofit is not a conflict: it is work this command never attempted, living outside
|
|
856
|
+
// the tree it is allowed to touch. Deliberately NOT suppressed headless, unlike the
|
|
857
|
+
// pointer below: an agent updating unattended is the reader least able to notice a
|
|
858
|
+
// missing manual step on its own. Read from disk, so it lists what just arrived.
|
|
859
|
+
// `oldLock.version` is the version BEFORE this update, which is what separates the guides
|
|
860
|
+
// that just arrived from the ones that were already sitting there. A legacy repo carries
|
|
861
|
+
// `0.0.0`, so every guide reads as new — correct: it has seen none of them.
|
|
862
|
+
printRetrofitReminder({ rootDir, fromVersion: oldLock.version });
|
|
852
863
|
// End-pointer to the next step (PVSKILL-002). Reached only after a clean apply;
|
|
853
864
|
// `--verify`/`--resume` short-circuit above (verify NEVER prints), an error path
|
|
854
865
|
// throws earlier, and headless `--yes` is suppressed (CI noise).
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The initial-migration predicate and the canonical "is this checkout the Factory"
|
|
3
|
+
* signal (GAP-001, fixed by `project/planning/decisions/ADR-003-factory-signal.md`).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS MODULE EXISTS. `factory provision` never ran `db:generate`, so a fresh
|
|
6
|
+
* derivative was born with a schema and no migrations while its `vercel-build`
|
|
7
|
+
* (`pnpm db:migrate && pnpm build`) migrated an empty database on the first deploy.
|
|
8
|
+
* The provision step that closes that gap has to decide, from disk alone, whether
|
|
9
|
+
* generating and committing a `0000` is the right thing to do — and the cost of a
|
|
10
|
+
* wrong "yes" is asymmetric.
|
|
11
|
+
*
|
|
12
|
+
* 🔴 THE SIGNAL FAILS TOWARD "DO NOT TOUCH". `.timekast/lockfile.json` is written by
|
|
13
|
+
* `new`/`add`/`update` in every derivative and does NOT exist in the Factory, so its
|
|
14
|
+
* ABSENCE means "this is the Factory, or a checkout that cannot be identified" and the
|
|
15
|
+
* predicate answers `false`. A false positive INSIDE the Factory would commit a `0000`
|
|
16
|
+
* here, and since the `full` distribution profile is `include: ["**"]` without excluding
|
|
17
|
+
* `src/lib/db/migrations/`, that `.sql` would ride the tarball into every new derivative
|
|
18
|
+
* — breaking BR-FACTORY-005 from the origin. A false negative only leaves the migration
|
|
19
|
+
* ungenerated, which the dev fixes with one `pnpm db:generate`.
|
|
20
|
+
*
|
|
21
|
+
* If this signal ever disagrees with the markdown detection `/deploy` uses (`is_factory`
|
|
22
|
+
* in `project/planning/project-config.md`), the ABSENCE OF THE LOCKFILE WINS and nothing
|
|
23
|
+
* is mutated — see ADR-003, which is the SSOT of that tie-break, not this comment.
|
|
24
|
+
*
|
|
25
|
+
* The predicate is intentionally PURE (it takes resolved facts, not a directory), so the
|
|
26
|
+
* `scripts/tools/` side of the distribution frontier can CALQUE it — never import it —
|
|
27
|
+
* the same way `backlog-parse.ts` calques `update-board.ts`.
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { hasLockfile } from './lockfile.js';
|
|
32
|
+
/** Drizzle schema directory of a kit derivative (`drizzle.config.ts` `schema`). */
|
|
33
|
+
export const SCHEMA_DIR = 'src/lib/db/schema';
|
|
34
|
+
/**
|
|
35
|
+
* Drizzle migrations output directory (`drizzle.config.ts` `out`). Doubles as the
|
|
36
|
+
* COMMIT PATHSPEC of the provision step: with a shared index (multi-agent, `--resume`,
|
|
37
|
+
* `--adopt` over a live repo) a bare `git commit` would swallow whatever else is staged
|
|
38
|
+
* (GIT.md §3.6.1).
|
|
39
|
+
*/
|
|
40
|
+
export const MIGRATIONS_DIR = 'src/lib/db/migrations';
|
|
41
|
+
/** Manifest of a Node derivative — its absence means there is nothing to install. */
|
|
42
|
+
export const PACKAGE_JSON_FILE = 'package.json';
|
|
43
|
+
/** The kit's package manager. Its lockfile is the positive signal for a pnpm project. */
|
|
44
|
+
export const PNPM_LOCKFILE = 'pnpm-lock.yaml';
|
|
45
|
+
/** Lockfiles of the other package managers — present WITHOUT pnpm's means "not ours". */
|
|
46
|
+
export const FOREIGN_LOCKFILES = ['package-lock.json', 'yarn.lock', 'bun.lockb'];
|
|
47
|
+
/** Extension of a generated drizzle migration. */
|
|
48
|
+
export const MIGRATION_SQL_EXT = '.sql';
|
|
49
|
+
/**
|
|
50
|
+
* Should the initial migration be generated for this checkout?
|
|
51
|
+
*
|
|
52
|
+
* PURE by design (see the module header): the caller resolves the facts, this decides.
|
|
53
|
+
* `isFactory` is checked FIRST and unconditionally — the fail-closed rule outranks
|
|
54
|
+
* every other consideration, including a project-config that claims otherwise.
|
|
55
|
+
*/
|
|
56
|
+
export function needsInitialMigration({ hasSchema, hasMigrations, isFactory, }) {
|
|
57
|
+
if (isFactory)
|
|
58
|
+
return false; // fail-closed: absence of the lockfile always wins
|
|
59
|
+
if (!hasSchema)
|
|
60
|
+
return false; // nothing to generate from
|
|
61
|
+
return !hasMigrations; // idempotence: an existing `.sql` means it was already done
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The canonical machine signal (ADR-003): a derivative HAS `.timekast/lockfile.json`
|
|
65
|
+
* (written by `new`/`add`/`update`); the Factory does not. Absent → treat as Factory.
|
|
66
|
+
*/
|
|
67
|
+
export function isFactoryCheckout(rootDir) {
|
|
68
|
+
return !hasLockfile(rootDir);
|
|
69
|
+
}
|
|
70
|
+
/** True when the derivative ships a Drizzle schema directory. */
|
|
71
|
+
export function hasSchemaDir(rootDir) {
|
|
72
|
+
return existsSync(join(rootDir, SCHEMA_DIR));
|
|
73
|
+
}
|
|
74
|
+
/** True when at least one generated `.sql` already lives in the migrations directory. */
|
|
75
|
+
export function hasGeneratedMigrations(rootDir) {
|
|
76
|
+
const dir = join(rootDir, MIGRATIONS_DIR);
|
|
77
|
+
if (!existsSync(dir))
|
|
78
|
+
return false;
|
|
79
|
+
try {
|
|
80
|
+
return readdirSync(dir).some((entry) => entry.toLowerCase().endsWith(MIGRATION_SQL_EXT));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false; // unreadable directory reads as "cannot confirm" → do not generate
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Resolve the three facts from disk, ready for {@link needsInitialMigration}. */
|
|
87
|
+
export function readMigrationFacts(rootDir) {
|
|
88
|
+
return {
|
|
89
|
+
hasSchema: hasSchemaDir(rootDir),
|
|
90
|
+
hasMigrations: hasGeneratedMigrations(rootDir),
|
|
91
|
+
isFactory: isFactoryCheckout(rootDir),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The es-MX narration per skip reason. Kept next to the reasons (and not inlined at the
|
|
96
|
+
* call site) so the copy is a named constant, never a magic string (CODING.md §5).
|
|
97
|
+
*/
|
|
98
|
+
export const MIGRATION_SKIP_MESSAGES = {
|
|
99
|
+
factory: 'No hay `.timekast/lockfile.json`: este checkout no se identifica como derivado ' +
|
|
100
|
+
'(podría ser el Factory) — omito la migración inicial.',
|
|
101
|
+
'no-schema': `No hay \`${SCHEMA_DIR}/\` en el repo — omito la migración inicial.`,
|
|
102
|
+
'has-migrations': `Ya hay migraciones en \`${MIGRATIONS_DIR}/\` — no genero otra.`,
|
|
103
|
+
'no-package-json': `No hay \`${PACKAGE_JSON_FILE}\` en el repo — omito la migración inicial.`,
|
|
104
|
+
'not-pnpm': 'El repo usa otro gestor de paquetes (no hay `pnpm-lock.yaml`) — omito la migración ' +
|
|
105
|
+
'inicial; genérala con el tooling del proyecto.',
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Resolve {@link needsInitialMigration} plus the two tolerance edges the kit owes a
|
|
109
|
+
* divergent derivative (BR-FACTORY-006): no `package.json`, or a non-pnpm project. Both
|
|
110
|
+
* warn and continue — they never abort the provisioning.
|
|
111
|
+
*
|
|
112
|
+
* This does NOT evaluate the greenfield guard (whether the RUN creates birth
|
|
113
|
+
* infrastructure): that one reads the plan, not the disk, and lives with the plan.
|
|
114
|
+
*/
|
|
115
|
+
export function decideInitialMigration(rootDir) {
|
|
116
|
+
const facts = readMigrationFacts(rootDir);
|
|
117
|
+
if (facts.isFactory)
|
|
118
|
+
return { run: false, reason: 'factory' };
|
|
119
|
+
if (!facts.hasSchema)
|
|
120
|
+
return { run: false, reason: 'no-schema' };
|
|
121
|
+
if (facts.hasMigrations)
|
|
122
|
+
return { run: false, reason: 'has-migrations' };
|
|
123
|
+
if (!existsSync(join(rootDir, PACKAGE_JSON_FILE))) {
|
|
124
|
+
return { run: false, reason: 'no-package-json' };
|
|
125
|
+
}
|
|
126
|
+
const foreign = FOREIGN_LOCKFILES.some((lock) => existsSync(join(rootDir, lock)));
|
|
127
|
+
if (foreign && !existsSync(join(rootDir, PNPM_LOCKFILE))) {
|
|
128
|
+
return { run: false, reason: 'not-pnpm' };
|
|
129
|
+
}
|
|
130
|
+
return { run: true };
|
|
131
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The retrofit reminder printed at the tail of every applied `factory update`.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS. `update` refreshes the brain mechanically: it copies files, and when a file
|
|
5
|
+
* conflicts it asks and applies whichever side the dev picked. What it CANNOT do is the work
|
|
6
|
+
* that lives outside the tracked tree — a step to paste into a workflow the kit is not allowed
|
|
7
|
+
* to touch, a config the derivative owns, a decision only the project can make. Those ship as
|
|
8
|
+
* retrofit guides, and a guide nobody is told about is a guide nobody reads: the update ends
|
|
9
|
+
* "✔ actualizado", the dev moves on, and the half of the change that needed hands never lands.
|
|
10
|
+
*
|
|
11
|
+
* 🔴 PRINTED ON EVERY OUTCOME, unlike its sibling {@link printProvisionPointer}. `--theirs-all`,
|
|
12
|
+
* `--mine-all`, per-file choices, or no conflicts at all — the reminder does not depend on any
|
|
13
|
+
* of that, because a retrofit is not a conflict: it is work the update never attempted. And it
|
|
14
|
+
* is NOT suppressed headless. The pointer suppresses there to keep CI logs quiet; this one does
|
|
15
|
+
* the opposite, because the agent running the update unattended is precisely the reader who
|
|
16
|
+
* cannot notice a missing step on its own, and an agent reads the transcript it is handed.
|
|
17
|
+
*
|
|
18
|
+
* The list is read from disk AFTER the apply, so it reflects the guides that just arrived
|
|
19
|
+
* rather than a list baked into this binary that would drift from what the kit ships.
|
|
20
|
+
*/
|
|
21
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
/** Where the kit ships its retrofit guides, inside the derivative. */
|
|
24
|
+
export const RETROFITS_DIR = '.claude/docs/retrofits';
|
|
25
|
+
/**
|
|
26
|
+
* The blockquote fields a guide uses to say who it is for, in the order they are tried.
|
|
27
|
+
*
|
|
28
|
+
* The corpus is heterogeneous by history — some guides lead with `Aplica si:`, older ones with
|
|
29
|
+
* `Para quién:` or `Audience:`. Rather than rewrite ten documents to satisfy a parser, the
|
|
30
|
+
* parser accepts what they already say, and a guide matching none of these is still LISTED,
|
|
31
|
+
* just without its one-liner. Silence about a guide would be the one failure that matters.
|
|
32
|
+
*/
|
|
33
|
+
const APPLICABILITY_FIELDS = [
|
|
34
|
+
'Aplica si:',
|
|
35
|
+
'Aplica a:',
|
|
36
|
+
'Para quién:',
|
|
37
|
+
'Para:',
|
|
38
|
+
'Audience:',
|
|
39
|
+
'Qué es:',
|
|
40
|
+
'Para quien:',
|
|
41
|
+
];
|
|
42
|
+
/** How much of the applicability line survives — enough to decide, short enough to scan. */
|
|
43
|
+
const SUMMARY_MAX = 96;
|
|
44
|
+
/**
|
|
45
|
+
* The field every guide declares: the first kit release that shipped it.
|
|
46
|
+
*
|
|
47
|
+
* Normalized across the corpus in v11.9 by asking git which release tag CONTAINS the commit
|
|
48
|
+
* that created each file (`git tag --contains`), rather than inferring it from dates. It is
|
|
49
|
+
* what lets the reminder separate "arrived in THIS update" from "was already here".
|
|
50
|
+
*/
|
|
51
|
+
const SHIPPED_SINCE_FIELD = 'Disponible desde:';
|
|
52
|
+
/**
|
|
53
|
+
* Compare two dotted versions. Returns >0 when `a` is newer.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately naive — no pre-release handling: these are kit release numbers, and a guide is
|
|
56
|
+
* never stamped with a beta. Non-numeric junk sorts as 0 rather than throwing, because a
|
|
57
|
+
* malformed field must degrade to "cannot tell", never break the reminder.
|
|
58
|
+
*/
|
|
59
|
+
export function compareVersions(a, b) {
|
|
60
|
+
const pa = a.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
61
|
+
const pb = b.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
62
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
63
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
64
|
+
if (diff !== 0)
|
|
65
|
+
return diff;
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
/** Pull the `Disponible desde: kit vX.Y.Z` version out of a guide's header. */
|
|
70
|
+
export function extractSince(markdown) {
|
|
71
|
+
for (const line of markdown.split('\n').slice(0, 20)) {
|
|
72
|
+
const at = line.indexOf(SHIPPED_SINCE_FIELD);
|
|
73
|
+
if (at === -1)
|
|
74
|
+
continue;
|
|
75
|
+
const match = line.slice(at).match(/v?(\d+\.\d+(?:\.\d+)?)/);
|
|
76
|
+
if (match)
|
|
77
|
+
return match[1];
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Strip markdown emphasis and links down to the words, for a terminal line.
|
|
83
|
+
*
|
|
84
|
+
* 🔴 Only `**` and backticks come out. A lone `_` or `*` is left alone, because in this corpus
|
|
85
|
+
* both are punctuation of an IDENTIFIER far more often than emphasis: stripping them turned
|
|
86
|
+
* `NEXT_PUBLIC_AUTH_*` into `NEXTPUBLICAUTH` inside the very guide about that variable. Leaving
|
|
87
|
+
* a stray emphasis marker on screen is the cheaper mistake. These guides bold with `**`.
|
|
88
|
+
*/
|
|
89
|
+
function plainText(raw) {
|
|
90
|
+
return raw
|
|
91
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // [text](link) → text
|
|
92
|
+
.replace(/\*\*/g, '') // bold — the only emphasis these headers use
|
|
93
|
+
.replace(/`/g, '')
|
|
94
|
+
.replace(/\s+/g, ' ')
|
|
95
|
+
.trim();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Pull the applicability one-liner out of a guide's header.
|
|
99
|
+
*
|
|
100
|
+
* Only the first lines are scanned: every guide states this in its opening blockquote, and
|
|
101
|
+
* reading further would start matching prose from the body.
|
|
102
|
+
*/
|
|
103
|
+
export function extractApplicability(markdown) {
|
|
104
|
+
const head = markdown.split('\n').slice(0, 12);
|
|
105
|
+
for (const field of APPLICABILITY_FIELDS) {
|
|
106
|
+
for (const line of head) {
|
|
107
|
+
const at = line.indexOf(field);
|
|
108
|
+
if (at === -1)
|
|
109
|
+
continue;
|
|
110
|
+
const text = plainText(line.slice(at + field.length));
|
|
111
|
+
if (!text)
|
|
112
|
+
continue;
|
|
113
|
+
return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// The "Retrofit shipped (…) — aplica a X" shape of the older guides.
|
|
117
|
+
for (const line of head) {
|
|
118
|
+
const dash = line.match(/—\s*(aplica a .+)$/i);
|
|
119
|
+
if (dash) {
|
|
120
|
+
const text = plainText(dash[1]);
|
|
121
|
+
return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The guides present in this checkout, alphabetical. An absent or empty directory yields `[]`
|
|
128
|
+
* — a `core`-profile derivative does not receive them, and it must not be told to read a
|
|
129
|
+
* directory it does not have.
|
|
130
|
+
*/
|
|
131
|
+
export function listRetrofits(rootDir, deps = {}) {
|
|
132
|
+
const readDir = deps.readDir ?? ((dir) => readdirSync(dir));
|
|
133
|
+
const readFile = deps.readFile ?? ((file) => readFileSync(file, 'utf8'));
|
|
134
|
+
let names;
|
|
135
|
+
try {
|
|
136
|
+
names = readDir(join(rootDir, RETROFITS_DIR));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return []; // no directory → nothing to remind about
|
|
140
|
+
}
|
|
141
|
+
return names
|
|
142
|
+
.filter((n) => n.endsWith('.md'))
|
|
143
|
+
.sort()
|
|
144
|
+
.map((name) => {
|
|
145
|
+
const path = `${RETROFITS_DIR}/${name}`;
|
|
146
|
+
let applies = null;
|
|
147
|
+
let since = null;
|
|
148
|
+
try {
|
|
149
|
+
const markdown = readFile(join(rootDir, RETROFITS_DIR, name));
|
|
150
|
+
applies = extractApplicability(markdown);
|
|
151
|
+
since = extractSince(markdown);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// unreadable → still listed, just bare
|
|
155
|
+
}
|
|
156
|
+
return { slug: name.replace(/\.md$/, ''), path, applies, since };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Split the guides into the ones this update just brought and the ones already there.
|
|
161
|
+
*
|
|
162
|
+
* `fromVersion` is the lockfile's version BEFORE the update. Without it — a first install, or
|
|
163
|
+
* a lockfile too old to say — everything lands in `carried`, which reads as "review them all"
|
|
164
|
+
* and is the honest answer: the reminder cannot know what you already saw.
|
|
165
|
+
*
|
|
166
|
+
* 🔴 A guide whose header states no version also lands in `carried`, NEVER in `arrived`. Both
|
|
167
|
+
* buckets are shown, so nothing is hidden either way; putting an unknown in `arrived` would
|
|
168
|
+
* cry wolf on every single update forever, which is how a notice trains people to skip it.
|
|
169
|
+
*/
|
|
170
|
+
export function splitByArrival(entries, fromVersion) {
|
|
171
|
+
if (!fromVersion)
|
|
172
|
+
return { arrived: [], carried: entries };
|
|
173
|
+
const arrived = entries.filter((e) => e.since && compareVersions(e.since, fromVersion) > 0);
|
|
174
|
+
const carried = entries.filter((e) => !arrived.includes(e));
|
|
175
|
+
return { arrived, carried };
|
|
176
|
+
}
|
|
177
|
+
/** Render the reminder, or `null` when this checkout ships no guides. */
|
|
178
|
+
export function renderRetrofitReminder(entries, fromVersion = null) {
|
|
179
|
+
if (entries.length === 0)
|
|
180
|
+
return null;
|
|
181
|
+
const { arrived, carried } = splitByArrival(entries, fromVersion);
|
|
182
|
+
const rule = '═'.repeat(74);
|
|
183
|
+
const lines = [
|
|
184
|
+
'',
|
|
185
|
+
rule,
|
|
186
|
+
' ⚠️ REVISA LAS GUÍAS DE RETROFIT ANTES DE DAR EL UPDATE POR TERMINADO',
|
|
187
|
+
rule,
|
|
188
|
+
'',
|
|
189
|
+
' El update mueve archivos. Lo que NO puede mover es lo que vive fuera del árbol',
|
|
190
|
+
' que el kit controla: un step en TU workflow, un config que es tuyo, una decisión',
|
|
191
|
+
' que solo tu proyecto puede tomar. Eso viaja como guía, y NADA de esto se aplicó.',
|
|
192
|
+
'',
|
|
193
|
+
];
|
|
194
|
+
const describe = (entry) => {
|
|
195
|
+
const out = [` · ${entry.slug}`];
|
|
196
|
+
if (entry.applies)
|
|
197
|
+
out.push(` ${entry.applies}`);
|
|
198
|
+
return out;
|
|
199
|
+
};
|
|
200
|
+
if (arrived.length > 0) {
|
|
201
|
+
lines.push(` ▸ NUEVAS EN ESTE UPDATE (${arrived.length}) — empieza por aquí:`, '');
|
|
202
|
+
for (const entry of arrived)
|
|
203
|
+
lines.push(...describe(entry));
|
|
204
|
+
lines.push('');
|
|
205
|
+
}
|
|
206
|
+
if (carried.length > 0) {
|
|
207
|
+
// The heading changes with what we can claim: with no `fromVersion` there is no "already
|
|
208
|
+
// had", only "here they are".
|
|
209
|
+
lines.push(arrived.length > 0
|
|
210
|
+
? ` ▸ Ya estaban (${carried.length}) — por si nunca las aplicaste:`
|
|
211
|
+
: ` ▸ Guías disponibles (${carried.length}):`, '');
|
|
212
|
+
for (const entry of carried)
|
|
213
|
+
lines.push(...describe(entry));
|
|
214
|
+
lines.push('');
|
|
215
|
+
}
|
|
216
|
+
lines.push(` No todas te aplican: cada una abre diciendo a quién le toca. En ${RETROFITS_DIR}/`, ' Si trabajas con un agente, pídele que las lea y te diga cuáles te aplican.', rule, '');
|
|
217
|
+
return lines.join('\n');
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Print the reminder. Called on EVERY applied update — see the module header for why this one
|
|
221
|
+
* has no headless guard and no outcome condition.
|
|
222
|
+
*/
|
|
223
|
+
export function printRetrofitReminder(opts) {
|
|
224
|
+
const log = opts.log ?? ((m) => console.log(m));
|
|
225
|
+
const rendered = renderRetrofitReminder(listRetrofits(opts.rootDir, opts), opts.fromVersion ?? null);
|
|
226
|
+
if (rendered)
|
|
227
|
+
log(rendered);
|
|
228
|
+
}
|
package/dist/lib/vercel-env.js
CHANGED
|
@@ -303,6 +303,37 @@ export async function removeVercelDomain(projectId, name, token, teamId, deps =
|
|
|
303
303
|
throw error;
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Delete a Vercel PROJECT over REST, with the same token that created it.
|
|
308
|
+
*
|
|
309
|
+
* 🔴 Why REST and not the dev's `vercel remove`. The project is CREATED by
|
|
310
|
+
* {@link createVercelProject} with the rail token and a `teamId`, so it lives inside the
|
|
311
|
+
* team. `vercel remove` runs against whatever scope the dev's CLI session happens to be
|
|
312
|
+
* authenticated to — in a measured teardown that was a personal scope, so the delete failed
|
|
313
|
+
* with "Not authorized … under scope" and left a live project nobody was going to look for.
|
|
314
|
+
* Create and delete have to be able to reach the same place.
|
|
315
|
+
*
|
|
316
|
+
* This is not a widening of the rail token's blast radius, and it is not a new policy either:
|
|
317
|
+
* {@link removeVercelDomain}, called from the very same teardown, already deletes over REST
|
|
318
|
+
* with this token. The project delete was the outlier.
|
|
319
|
+
*
|
|
320
|
+
* Idempotency (same contract as its domain sibling): a 404 means it is already gone and
|
|
321
|
+
* resolves to `'absent'`, so a re-run after a partial teardown is a no-op.
|
|
322
|
+
*
|
|
323
|
+
* @throws {VercelApiError} on any non-2xx EXCEPT 404 (which → `'absent'`).
|
|
324
|
+
*/
|
|
325
|
+
export async function removeVercelProject(projectId, token, teamId, deps = {}) {
|
|
326
|
+
try {
|
|
327
|
+
await vercelApi('DELETE', `/v9/projects/${encodeURIComponent(projectId)}`, token, teamId, deps);
|
|
328
|
+
return 'removed';
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (error instanceof VercelApiError && error.status === 404) {
|
|
332
|
+
return 'absent';
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
306
337
|
/** Page size for the project listing — Vercel's documented maximum (`limit` ≤ 100). */
|
|
307
338
|
const VERCEL_PROJECTS_PAGE_SIZE = 100;
|
|
308
339
|
/** Hard stop on the pagination loop — 20 full pages is 2000 projects, far past any team. */
|