@timekast/factory 1.24.0 → 1.26.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.
@@ -94,6 +94,8 @@ export async function runAdd(flags = { core: false, full: false }) {
94
94
  // `add` is interactive: keep the legacy confirm prompt. Only the GUI/headless
95
95
  // caller passes `--yes` (via `update` directly), never this redirect.
96
96
  yes: false,
97
+ // The legacy `add` redirect never requests the GUI signal — only `update --launcher` does.
98
+ launcher: false,
97
99
  });
98
100
  return;
99
101
  }
@@ -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
- await runInstall(rootDir);
982
+ // Run-scoped (GAP-001): a no-op when the initial-migration step already
983
+ // installed this root in the same run — never twice, never skipped by a
984
+ // `node_modules` that happens to exist.
985
+ await ensureDeps(rootDir, deps.runInstall);
955
986
  }
956
987
  catch (error) {
957
988
  console.warn(`⚠ \`pnpm install\` falló en el directorio del derivado (${error?.message ?? 'error'}).\n` +
@@ -1334,8 +1365,10 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
1334
1365
  * Commit the activated E2E workflow (`e2e.yml`) and push it to BOTH `main` and
1335
1366
  * `develop` in the derived repo. `factory new` pushed the initial scaffold commit
1336
1367
  * before provision activated this workflow, so it lives only in the working tree
1337
- * until committed here. (Migrations run in the Vercel build via `vercel-build`, not a
1338
- * workflow — there is nothing to commit for them.) Idempotent — when a `--resume`
1368
+ * until committed here. (Migrations are not part of THIS commit, but they do have one:
1369
+ * `ensureInitialMigration` commits the `0000` earlier in the run, with its own pathspec.
1370
+ * The Vercel build only APPLIES it — `vercel-build` runs `pnpm db:migrate && pnpm build`,
1371
+ * there is no migration workflow.) Idempotent — when a `--resume`
1339
1372
  * finds it already committed, `git add` stages nothing and the commit/push is skipped.
1340
1373
  */
1341
1374
  async function commitAndPushWorkflows(rootDir, runGit) {
@@ -1375,6 +1408,329 @@ async function commitProvisionState(rootDir) {
1375
1408
  // Non-fatal: the local state is intact; the dev can commit it later.
1376
1409
  }
1377
1410
  }
1411
+ /** Default `pnpm db:generate` runner — `execa` in the derived dir, no shell. */
1412
+ async function drizzleGenerate(cwd) {
1413
+ await execa('pnpm', ['db:generate'], { cwd });
1414
+ }
1415
+ /** The commit subject of the generated migration (English — GIT.md §3.1). */
1416
+ const INITIAL_MIGRATION_COMMIT = 'chore(db): add the initial database migration';
1417
+ /** The plan label of the step (es-MX), printed by {@link printPlan}. */
1418
+ const INITIAL_MIGRATION_LABEL = 'Migración inicial de la base (0000 de Drizzle)';
1419
+ /**
1420
+ * The branch the step commits on and pushes: the one `vercel-build` runs
1421
+ * `pnpm db:migrate && pnpm build` from for PRODUCTION. Named, never inlined
1422
+ * (CODING.md §5) — it appears in the checkout guard, in both pushes and in the
1423
+ * remote confirmation, and those four have to mean the same branch.
1424
+ */
1425
+ const MIGRATION_BRANCH = 'main';
1426
+ /** The secondary branch the same commit is fast-forwarded onto (the working branch). */
1427
+ const MIGRATION_DEV_BRANCH = 'develop';
1428
+ /**
1429
+ * The pre-`main` default branch name. `buildGitSubstrate` renames it to
1430
+ * {@link MIGRATION_BRANCH} before pushing, which is why the checkout guard below treats
1431
+ * a repo sitting on it as reaching `main` after all — when that step is in the plan.
1432
+ */
1433
+ const LEGACY_DEFAULT_BRANCH = 'master';
1434
+ /**
1435
+ * The GREENFIELD guard: the run creates BIRTH infrastructure.
1436
+ *
1437
+ * 🔴 Not merely "there is no `.sql` yet". Without this guard any other door into
1438
+ * provision — a `--services=dns` for a deferred go-live, a standalone `--domain`, a
1439
+ * `--resume` — would generate a `0000` on one of the LIVE derivatives that have no
1440
+ * migrations, commit it, and (with no `github` step to push it) push it too. The next
1441
+ * deploy would run `CREATE TABLE` against a populated database and the build would die.
1442
+ *
1443
+ * 🔴 And the SHAPE OF THE PLAN is not birth either — that was the first version of this
1444
+ * guard, and it left two doors open onto exactly the derivatives it was written to
1445
+ * protect:
1446
+ *
1447
+ * 1. **`--force` NEVER counts as greenfield.** `runProvision` calls
1448
+ * `clearProvisionState` before `buildPlan`, so with `--force` there is no state to
1449
+ * read and the plan comes back with EVERY service in `create` — the shape of a
1450
+ * bootstrap, produced by a flag whose whole job is to destroy the only evidence that
1451
+ * this project already went through provisioning. A `--services=neon,github --force`
1452
+ * over a live derivative would have generated and pushed a `0000` on it.
1453
+ * 2. **`github: create` only counts WITH a prior state.** A live derivative provisioned
1454
+ * before the CLI existed has no `.timekast/provision.json`, so nothing throws, the
1455
+ * state is `null` and the substrate reads as missing. `github` still counts — that is
1456
+ * the interrupted-bootstrap case retaken with `--resume`, where `neon` is already in
1457
+ * `skip` while the substrate is not there yet — but only when a prior state PROVES
1458
+ * the bootstrap started. Without it, `github: create` proves nothing.
1459
+ *
1460
+ * 3. **`neon: create` needs corroboration too**, and this is the subtlest of the three.
1461
+ * "A database is being created, so it is empty" conflates two different facts: that
1462
+ * a NEW database exists, and that the database THE DEPLOY WILL MIGRATE is that one.
1463
+ * `provisionNeon` only creates the project and uploads E2E secrets — production's
1464
+ * connection string is written by the `vercel` step. So on a derivative that is
1465
+ * already live, a new Neon project changes nothing about where the deploy points,
1466
+ * and the `0000` would be applied against the populated database it has always used.
1467
+ * Creating a database therefore counts only alongside the run also wiring the app
1468
+ * up: `vercel` (which writes that connection string) or `github` (which creates the
1469
+ * substrate carrying the code).
1470
+ *
1471
+ * The population this protects is specific and real: a derivative born before the kit
1472
+ * generated migrations has its tables and its data from a `db:push`, and NO `.sql` at
1473
+ * all — so the "already has migrations" latch does not catch it. What such a project
1474
+ * needs is its `0000` recorded as ALREADY APPLIED (a per-environment baseline), never
1475
+ * executed: drizzle emits `CREATE TABLE` without `IF NOT EXISTS`, so running it fails
1476
+ * with `relation "users" already exists` and the build stops deploying. That baseline is
1477
+ * deliberately out of this step's scope.
1478
+ *
1479
+ * The cost of the third rule is one degraded case, and it is the right way to be wrong:
1480
+ * an interrupted bootstrap missing ONLY the database will not generate the migration by
1481
+ * itself. The dev runs `pnpm db:generate`, and both `factory doctor` and `pnpm preflight`
1482
+ * now say so. A false negative costs one command; a false positive costs a client's
1483
+ * deploy.
1484
+ */
1485
+ export function isGreenfieldRun({ steps, force, hadPriorState }) {
1486
+ if (force)
1487
+ return false;
1488
+ const creates = (service) => steps.some((step) => step.service === service && step.action === 'create');
1489
+ // A new database does NOT prove birth on its own — see the docblock. It needs the run
1490
+ // to also be wiring the app up: `vercel` writes production's connection string, and
1491
+ // `github` creates the substrate that carries the code.
1492
+ if (creates('neon'))
1493
+ return creates('vercel') || creates('github');
1494
+ // No new database, but the substrate is being born and a prior state proves the
1495
+ // bootstrap already started — the interrupted run, retaken.
1496
+ return creates('github') && hadPriorState;
1497
+ }
1498
+ /**
1499
+ * Will the initial-migration step do work on this run? Greenfield guard (the plan + the
1500
+ * run's own evidence) AND the disk decision (`decideInitialMigration`). Read-only — safe
1501
+ * to evaluate while the plan is assembled, i.e. under `--dry-run`.
1502
+ */
1503
+ export function shouldRunInitialMigration(rootDir, evidence) {
1504
+ return isGreenfieldRun(evidence) && decideInitialMigration(rootDir).run;
1505
+ }
1506
+ /**
1507
+ * The branch `HEAD` points at, or `null` when git cannot name one.
1508
+ *
1509
+ * 🔴 Why the step reads it at all: `git add` / `git commit -- <pathspec>` land on the
1510
+ * CURRENT branch, while the push names a REF (`push origin main`). Nothing used to read
1511
+ * the branch, so a dev standing on `develop` — or on a detached `HEAD` — got the commit
1512
+ * on that branch and a push of a `main` that does NOT carry the `.sql`: a green exit code
1513
+ * over exactly the failure this step exists to prevent.
1514
+ *
1515
+ * Never throws, and a detached `HEAD` (which `--abbrev-ref` reports as the literal
1516
+ * `HEAD`) is `null` too: both are "not the migration branch", which is the fail-closed
1517
+ * answer for the push.
1518
+ */
1519
+ async function readCurrentBranch(rootDir, runGit) {
1520
+ try {
1521
+ const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], rootDir)).stdout.trim();
1522
+ return branch && branch !== 'HEAD' ? branch : null;
1523
+ }
1524
+ catch {
1525
+ return null;
1526
+ }
1527
+ }
1528
+ /**
1529
+ * The commit `HEAD` points at, or `null` when git cannot say. Only read for the detached
1530
+ * warning: there the hash is the ONLY handle the dev has on the commit that was just
1531
+ * made, so the message has to carry it.
1532
+ */
1533
+ async function readHeadSha(rootDir, runGit) {
1534
+ try {
1535
+ return (await runGit(['rev-parse', 'HEAD'], rootDir)).stdout.trim() || null;
1536
+ }
1537
+ catch {
1538
+ return null;
1539
+ }
1540
+ }
1541
+ /**
1542
+ * Confirm the just-pushed commit actually reached the remote, and SAY SO. A push that
1543
+ * silently did not land is the failure this whole step exists to prevent, so an
1544
+ * unconfirmable remote is a NOISY warning, never an assumption. Non-fatal: the commit
1545
+ * is already durable locally.
1546
+ */
1547
+ async function confirmMigrationOnRemote(rootDir, runGit) {
1548
+ try {
1549
+ const local = (await runGit(['rev-parse', 'HEAD'], rootDir)).stdout.trim();
1550
+ const remoteLine = (await runGit(['ls-remote', 'origin', `refs/heads/${MIGRATION_BRANCH}`], rootDir)).stdout
1551
+ .trim()
1552
+ .split(/\s+/)[0];
1553
+ if (local && remoteLine && local === remoteLine) {
1554
+ console.log(` ✔ Migración inicial confirmada en el remoto (origin/main ${local.slice(0, 7)}).`);
1555
+ return;
1556
+ }
1557
+ console.warn('⚠ No pude confirmar que la migración inicial esté en el remoto ' +
1558
+ `(origin/main = \`${remoteLine || 'sin respuesta'}\`, local = \`${local || 'desconocido'}\`).\n` +
1559
+ ' Revisa `git log origin/main` ANTES del primer deploy: si el `.sql` no está en el ' +
1560
+ 'remoto, el build migrará una base vacía.');
1561
+ }
1562
+ catch (error) {
1563
+ console.warn(`⚠ No pude confirmar la migración inicial en el remoto (${error?.message ?? 'error'}).\n` +
1564
+ ' Revisa `git log origin/main` ANTES del primer deploy.');
1565
+ }
1566
+ }
1567
+ /**
1568
+ * Generate the initial Drizzle migration, commit it with its OWN pathspec and get it to
1569
+ * the remote BEFORE Vercel builds the tree (PROV-004 pushes `main`; `vercel-build` runs
1570
+ * `pnpm db:migrate && pnpm build`). Without this a fresh derivative deploys against an
1571
+ * empty database.
1572
+ *
1573
+ * Order and its reasons:
1574
+ * 1. Guard + decision — greenfield run AND a derivative that needs it (see
1575
+ * {@link isGreenfieldRun} / `decideInitialMigration`). Anything else returns.
1576
+ * 2. `ensureDeps` — `drizzle-kit` has to exist. Run-scoped, shared with the `vercel` step.
1577
+ * 3. `pnpm db:generate` — writes `0000_*.sql`. No DB connection needed
1578
+ * (`drizzle.config.ts` reads `process.env.DATABASE_URL!` without throwing).
1579
+ * 4. `git rev-parse --abbrev-ref HEAD` + `git add` + `git status` +
1580
+ * `git commit -- <pathspec>` — NEVER a bare commit: with a shared index it would
1581
+ * swallow whatever else is staged (GIT.md §3.6.1). The branch is read because the
1582
+ * commit lands on it while the push names a ref (see {@link readCurrentBranch}).
1583
+ * 5. Push, DISCRIMINATED by the plan: when the `github` step runs it pushes `main`
1584
+ * itself (`buildGitSubstrate`), so pushing here would be redundant; when it does not,
1585
+ * this step is the only thing that can get the `.sql` to the remote.
1586
+ *
1587
+ * Failure policy: `install`/`generate` ABORT with a `CLIError` — the step runs before the
1588
+ * `github` step, so aborting here means nothing was pushed and nothing was deployed. A git
1589
+ * IDENTITY failure while committing is a non-fatal warning (the `.sql` is already on disk),
1590
+ * mirroring `new.ts`'s precedent. A commit on a branch that is not `main`, and a failed
1591
+ * push, are warnings too — and each push is judged on its own (step 5 below).
1592
+ */
1593
+ export async function ensureInitialMigration(rootDir, evidence, deps = {}) {
1594
+ const { steps } = evidence;
1595
+ if (!isGreenfieldRun(evidence)) {
1596
+ // Silence would be wrong in ONE case, and it is one the CLI itself recommends:
1597
+ // `--force` is the flag this command suggests for restarting a failed bootstrap
1598
+ // ("descártalo y empieza de cero"), and a dev who follows that advice would end up
1599
+ // with a derivative that has no `0000` and no word about it. The step still stands
1600
+ // down — `--force` cannot prove birth — but it says so and hands over the remedy.
1601
+ if (evidence.force) {
1602
+ console.log(' ⏭ Con `--force` no genero la migración inicial: el flag descarta el estado de ' +
1603
+ 'provision, que es la única evidencia de si este proyecto ya nació — y sobre un ' +
1604
+ 'derivado vivo la generaría contra una base poblada.\n' +
1605
+ ' Si esto ES un bootstrap: retómalo con `--resume`, o genera la migración a mano ' +
1606
+ '(`pnpm db:generate`) y commitéala antes del primer deploy. `factory doctor` te lo ' +
1607
+ 'recuerda si falta.');
1608
+ }
1609
+ return;
1610
+ }
1611
+ const decision = decideInitialMigration(rootDir);
1612
+ if (!decision.run) {
1613
+ console.log(` ⏭ ${MIGRATION_SKIP_MESSAGES[decision.reason]}`);
1614
+ return;
1615
+ }
1616
+ const runGenerate = deps.runGenerate ?? drizzleGenerate;
1617
+ const runGit = deps.runGit ?? gitInDir;
1618
+ // 2. Dependencies (drizzle-kit). Run-scoped — the `vercel` step reuses this install.
1619
+ try {
1620
+ await ensureDeps(rootDir, deps.runInstall);
1621
+ }
1622
+ catch (error) {
1623
+ throw new CLIError(`No se pudieron instalar las dependencias del derivado (${error?.message ?? 'error'}).\n` +
1624
+ 'Sin ellas no hay `drizzle-kit` para generar la migración inicial, y el primer deploy ' +
1625
+ 'correría contra una base vacía. Corre `pnpm install` a mano y reintenta con `--resume`.');
1626
+ }
1627
+ // 3. Generate the `0000`.
1628
+ try {
1629
+ await runGenerate(rootDir);
1630
+ }
1631
+ catch (error) {
1632
+ throw new CLIError(`\`pnpm db:generate\` falló en el derivado (${error?.message ?? 'error'}).\n` +
1633
+ 'Sin la migración inicial el primer deploy correría contra una base vacía. Revisa el ' +
1634
+ 'schema en `src/lib/db/schema/`, genera a mano y reintenta con `--resume`.');
1635
+ }
1636
+ if (!hasGeneratedMigrations(rootDir)) {
1637
+ console.warn(`⚠ \`pnpm db:generate\` terminó sin escribir ningún \`.sql\` en \`${MIGRATIONS_DIR}/\`.\n` +
1638
+ ' Revisa el schema antes del primer deploy: no hay migración inicial que commitear.');
1639
+ return;
1640
+ }
1641
+ console.log(` + ${MIGRATIONS_DIR}/ (migración inicial generada)`);
1642
+ // 4. Commit with its OWN pathspec, preceded by a status of that same path. The branch
1643
+ // is read BEFORE the commit — that is the only thing that says where the commit
1644
+ // will actually land (`readCurrentBranch`).
1645
+ const branch = await readCurrentBranch(rootDir, runGit);
1646
+ try {
1647
+ await runGit(['add', '--', MIGRATIONS_DIR], rootDir);
1648
+ const pending = (await runGit(['status', '--porcelain', '--', MIGRATIONS_DIR], rootDir)).stdout.trim();
1649
+ if (!pending) {
1650
+ console.log(' ⏭ La migración inicial ya estaba commiteada — nada que pushear.');
1651
+ return;
1652
+ }
1653
+ await runGit(['commit', '-m', INITIAL_MIGRATION_COMMIT, '--', MIGRATIONS_DIR], rootDir);
1654
+ }
1655
+ catch (error) {
1656
+ // Precedent `new.ts`: a git identity failure is a warning, not an abort — the
1657
+ // migration is on disk and the dev can commit it.
1658
+ //
1659
+ // UNSTAGE FIRST. The `add` above already staged the path, and leaving it there hands
1660
+ // the `.sql` to whatever commits next: `commitProvisionState` reads the WHOLE index
1661
+ // and commits bare, so the migration would ride into the run's own state commit
1662
+ // under an unrelated subject. Failing to unstage is not fatal either — the file is
1663
+ // on disk regardless, which is the guarantee this catch exists to state.
1664
+ try {
1665
+ await runGit(['reset', '--', MIGRATIONS_DIR], rootDir);
1666
+ }
1667
+ catch {
1668
+ /* best-effort: the warning below is still true */
1669
+ }
1670
+ console.warn(`⚠ No pude commitear la migración inicial (${error?.message ?? 'error'}).\n` +
1671
+ ` El \`.sql\` quedó en \`${MIGRATIONS_DIR}/\`: commitéalo y pushéalo ANTES del primer deploy ` +
1672
+ '(configura tu identidad con `git config user.email` si ese fue el motivo).');
1673
+ return;
1674
+ }
1675
+ // 5. Push — discriminated by whether the `github` step runs in THIS run.
1676
+ const githubPushes = steps.some((step) => step.service === 'github' && step.action === 'create');
1677
+ // 4b. The commit landed on `branch`, not necessarily on the branch the push names.
1678
+ // A mismatch is NOISY and stops the push: pushing `main` from here would send a
1679
+ // `main` that does NOT carry the `.sql` and exit 0 — the very failure this step
1680
+ // exists to prevent, in green. The `.sql` is on disk and committed where the dev is.
1681
+ //
1682
+ // ONE exception, and it is not a loophole: a repo still on `master` when the
1683
+ // `github` step is about to run. That step RENAMES `master` → `main` before
1684
+ // pushing (`buildGitSubstrate`, step 1), so the commit does reach `main` with the
1685
+ // `.sql` inside — warning there would be alarmist about a case that works.
1686
+ const substrateWillRename = githubPushes && branch === LEGACY_DEFAULT_BRANCH;
1687
+ if (branch !== MIGRATION_BRANCH && !substrateWillRename) {
1688
+ // A detached HEAD has no branch to merge, so the commit is reachable ONLY by its
1689
+ // hash — naming it is the difference between an actionable warning and a scavenger
1690
+ // hunt through the reflog.
1691
+ const headSha = branch === null ? await readHeadSha(rootDir, runGit) : null;
1692
+ console.warn(`⚠ La migración inicial se commiteó en \`${branch ?? `HEAD desprendido (${headSha ?? 'sha desconocido'})`}\`, no en ` +
1693
+ `\`${MIGRATION_BRANCH}\` — no la pusheo.\n` +
1694
+ ` El push nombra la rama \`${MIGRATION_BRANCH}\` y el commit quedó en otra: pushear ` +
1695
+ `mandaría un \`${MIGRATION_BRANCH}\` SIN el \`.sql\` y el build migraría una base vacía.\n` +
1696
+ (branch === null
1697
+ ? ` Con \`HEAD\` desprendido no hay rama que mergear: el commit \`${headSha ?? '(desconocido)'}\` ` +
1698
+ `solo es alcanzable por su hash. Recupéralo con \`git branch <nombre> ${headSha ?? '<sha>'}\`, ` +
1699
+ `mergéalo a \`${MIGRATION_BRANCH}\` y pushea a mano ANTES del primer deploy.\n`
1700
+ : ` Pásate a \`${MIGRATION_BRANCH}\` (o mergea esa rama ahí) y pushea a mano ANTES del ` +
1701
+ 'primer deploy.\n'));
1702
+ return;
1703
+ }
1704
+ if (githubPushes) {
1705
+ console.log(' ↑ Migración inicial commiteada (el push de `main` lo hace el paso de GitHub).');
1706
+ return;
1707
+ }
1708
+ // Un `try` por push, y no uno compartido: `main` es el que le importa a producción
1709
+ // (`vercel-build` = `pnpm db:migrate && pnpm build`) y `develop` puede fallar solo
1710
+ // (non-fast-forward con un remoto divergido). Con un `try` compartido, esa falla de
1711
+ // `develop` imprimía "no pude pushear la migración" y se saltaba la confirmación —
1712
+ // un diagnóstico falso justo cuando `main` SÍ había llegado.
1713
+ try {
1714
+ await runGit(['push', 'origin', MIGRATION_BRANCH], rootDir);
1715
+ }
1716
+ catch (error) {
1717
+ console.warn(`⚠ No pude pushear la migración inicial (${error?.message ?? 'error'}).\n` +
1718
+ ' Pushéala a mano ANTES del primer deploy: el build corre `pnpm db:migrate` contra el ' +
1719
+ 'árbol remoto.');
1720
+ return;
1721
+ }
1722
+ try {
1723
+ await runGit(['push', 'origin', `${MIGRATION_BRANCH}:${MIGRATION_DEV_BRANCH}`], rootDir);
1724
+ console.log(` ↑ Migración inicial commiteada + pusheada (${MIGRATION_BRANCH} + ${MIGRATION_DEV_BRANCH}).`);
1725
+ }
1726
+ catch (error) {
1727
+ console.warn(`⚠ La migración inicial SÍ llegó a \`${MIGRATION_BRANCH}\`, pero no pude pushearla a ` +
1728
+ `\`${MIGRATION_DEV_BRANCH}\` (${error?.message ?? 'error'}).\n` +
1729
+ ` El deploy de producción está cubierto (\`vercel-build\` migra desde \`${MIGRATION_BRANCH}\`); ` +
1730
+ `sincroniza \`${MIGRATION_DEV_BRANCH}\` a mano cuando puedas.`);
1731
+ }
1732
+ await confirmMigrationOnRemote(rootDir, runGit);
1733
+ }
1378
1734
  /** Map a Cloudflare REST failure to an actionable, es-MX `CLIError` (issue §8 edges). */
1379
1735
  function cloudflareErrorToCliError(error, context) {
1380
1736
  switch (error.status) {
@@ -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} — \`vercel remove\` (sesión del dev)`);
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 via the dev's `vercel` CLI (`vercel remove <slug>
2811
- * --yes`) — issue §4: the project delete uses the DEV's session, never a shared org
2812
- * token (the shared org Vercel token is create/configure-only). A "not found" stderr
2813
- * is treated as already-removed (§8).
3166
+ * Remove the Vercel PROJECT over REST, with the rail token that created it.
3167
+ *
3168
+ * 🔴 It used to shell out to the dev's `vercel remove <slug> --yes`, on the principle that a
3169
+ * delete should use the DEV's session and never a shared org token. Measured teardown of a
3170
+ * real ephemeral stack (2026-08-17) showed the principle unreachable in practice: the project
3171
+ * is created by the rail token WITH a `teamId`, so it lives in the team, while the dev's CLI
3172
+ * session sits in whatever scope they last logged into. The delete failed with
3173
+ * `Not authorized … under scope "…"` and left a live, billable project behind — exactly the
3174
+ * outcome `--ephemeral` exists to prevent.
3175
+ *
3176
+ * The REST path also removes the whole class of shell-argument hazards the old code had to
3177
+ * guard against (a slug from the low-trust state file becoming a positional arg), and it
3178
+ * targets the project by ID rather than by name.
3179
+ *
3180
+ * Consistency note: {@link destroyVercelDomain}, called from the same teardown, ALREADY
3181
+ * deleted over REST with this token. The project delete was the outlier, not the rule.
2814
3182
  */
2815
- async function destroyVercelProject(target, runVercel) {
3183
+ async function destroyVercelProject(target, token, fetchImpl) {
2816
3184
  const resource = `Proyecto Vercel ${target.slug}`;
2817
- // FIX 2: the slug came from the low-trust state file and is about to become a
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: `slug malformado en el estado (\`${target.slug}\`) — bórralo a mano en Vercel`,
3189
+ detail: 'el estado no registró `vercel.projectId` — bórralo a mano en Vercel',
2827
3190
  };
2828
3191
  }
2829
3192
  try {
2830
- // `--` ends option parsing so the slug can never be read as a flag.
2831
- await runVercel(['remove', '--yes', '--', target.slug]);
2832
- return { resource, status: 'removed', detail: 'eliminado vía `vercel remove`' };
3193
+ const outcome = await removeVercelProject(target.vercelProjectId, token, target.scope ?? '', {
3194
+ fetchImpl,
3195
+ });
3196
+ return {
3197
+ resource,
3198
+ status: 'removed',
3199
+ detail: outcome === 'absent' ? 'ya no existía (404)' : 'eliminado',
3200
+ };
2833
3201
  }
2834
3202
  catch (error) {
2835
- const message = error?.message ?? '';
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 dev's `vercel remove` (NEVER a shared org token).
3384
+ // 3. Vercel PROJECT — REST with the rail token, the same one that created it (and the same
3385
+ // one the domain removal above already uses). The dev's `vercel` session cannot be
3386
+ // assumed to sit in the team the project lives in; when it does not, the delete fails
3387
+ // and leaves a billable orphan.
3021
3388
  if (target.vercelProjectId) {
3022
- outcomes.push(await destroyVercelProject(target, deps.runVercel));
3389
+ if (!vercelToken) {
3390
+ outcomes.push({
3391
+ resource: `Proyecto Vercel ${target.slug}`,
3392
+ status: 'failed',
3393
+ detail: 'sin `VERCEL_TOKEN` en el rail — bórralo a mano en Vercel',
3394
+ });
3395
+ }
3396
+ else {
3397
+ outcomes.push(await destroyVercelProject(target, vercelToken, deps.fetchImpl));
3398
+ }
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;
@@ -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';
@@ -66,6 +67,7 @@ export function parseUpdateFlags(argv) {
66
67
  beta: argv.includes('--beta'),
67
68
  stable: argv.includes('--stable'),
68
69
  yes: argv.includes('--yes') || argv.includes('-y'),
70
+ launcher: argv.includes('--launcher'),
69
71
  };
70
72
  }
71
73
  /** Default acquire: download the sticky-profile tarball, stage + validate it. */
@@ -565,7 +567,17 @@ function syncDerivedPrettierignore(rootDir, stagedDir) {
565
567
  const KIT_TESTS_DIR = 'scripts/tools/__tests__/';
566
568
  /** Exit code of `--dry-run` when the preview found conflicts (0 = none). Lets a
567
569
  * non-interactive caller (the GUI) branch on the result instead of clobbering. */
568
- const DRY_RUN_CONFLICTS_EXIT = 2;
570
+ /**
571
+ * 🔴 FROZEN WIRE FORMAT — the same class of contract as the retrofit signal marker
572
+ * (ADR-004 §1/§4), and for the same reason. `update --dry-run` reports "the preview found
573
+ * conflicts" with this code, and the GUI launcher reads it to decide whether it may chain the
574
+ * real apply. Its twin is calqued BY VALUE in `desktop/renderer/panels/update-project.tsx`
575
+ * (`DRY_RUN_CONFLICTS_EXIT`) — two npm packages with no dependency edge, so no compiler will
576
+ * ever catch a drift. Changing this number silently kills the launcher's clobber guard, which
577
+ * is exactly the failure that shipped once already (see `cli/src/lib/exit-code.ts`).
578
+ * Exported so the golden pin in the test suite can assert it.
579
+ */
580
+ export const DRY_RUN_CONFLICTS_EXIT = 2;
569
581
  /**
570
582
  * `--dry-run`: preview the plan (buckets + the explicit conflict list) and apply
571
583
  * NOTHING. Prints the conflicting paths so a human or agent sees exactly what
@@ -849,6 +861,16 @@ export async function runUpdate(flags, deps = {}) {
849
861
  // the prompt, passed `--theirs-all`, or ran headless — there is no second path. The
850
862
  // package.json mutations ride the SAME closing summary, not three loose notices.
851
863
  reportSummary(diff, manifest, theirsConflicts, pkgMutations);
864
+ // 🔴 The retrofit reminder, on EVERY applied update and regardless of how conflicts
865
+ // were resolved — `--theirs-all`, `--mine-all`, per-file answers or none at all. A
866
+ // retrofit is not a conflict: it is work this command never attempted, living outside
867
+ // the tree it is allowed to touch. Deliberately NOT suppressed headless, unlike the
868
+ // pointer below: an agent updating unattended is the reader least able to notice a
869
+ // missing manual step on its own. Read from disk, so it lists what just arrived.
870
+ // `oldLock.version` is the version BEFORE this update, which is what separates the guides
871
+ // that just arrived from the ones that were already sitting there. A legacy repo carries
872
+ // `0.0.0`, so every guide reads as new — correct: it has seen none of them.
873
+ printRetrofitReminder({ rootDir, fromVersion: oldLock.version, guiSignal: flags.launcher });
852
874
  // End-pointer to the next step (PVSKILL-002). Reached only after a clean apply;
853
875
  // `--verify`/`--resume` short-circuit above (verify NEVER prints), an error path
854
876
  // throws earlier, and headless `--yes` is suppressed (CI noise).
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { readFileSync } from 'node:fs';
10
10
  import { dirname, join } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { CLIError } from './lib/cli-error.js';
13
+ import { resolveSuccessExitCode } from './lib/exit-code.js';
13
14
  import { parseAddFlags, runAdd } from './commands/add.js';
14
15
  import { parseBacklogPushFlags, parseBacklogSyncFlags, runBacklogPush, runBacklogSync, } from './commands/backlog.js';
15
16
  import { parseEnvPushFlags, parseEnvWizardFlags, runEnvPush, runEnvWizardApply, runEnvWizardGenVapid, runEnvWizardPlan, runEnvWizardReview, } from './commands/env.js';
@@ -328,7 +329,11 @@ async function main(argv) {
328
329
  }
329
330
  }
330
331
  main(process.argv.slice(2)).then(() => {
331
- process.exit(0);
332
+ // NOT a bare `process.exit(0)`. An explicit argument overrides `process.exitCode`, so
333
+ // hardcoding 0 threw away the outcome code of any command that succeeded but had
334
+ // something to report — `update --dry-run`'s "there are conflicts" (exit 2) among them,
335
+ // which left the launcher's clobber guard dead. See `resolveSuccessExitCode`.
336
+ process.exit(resolveSuccessExitCode(process.exitCode));
332
337
  }, (error) => {
333
338
  if (error instanceof CLIError) {
334
339
  // An empty message is intentional (the command already rendered its own
@@ -214,6 +214,44 @@ export const GENERATE_REFERENCE_GATE_FILE = 'scripts/tools/generate-schema.ts';
214
214
  * FILE-GATED on the launcher, like `GENERATOR_SCRIPTS`: a `core`/non-Next derivative has no
215
215
  * `dev.mjs` and never receives an alias for a dev server it does not run.
216
216
  */
217
+ /**
218
+ * `verify` — la compuerta de calidad, movida de una cadena de shell a un script del cerebro.
219
+ *
220
+ * WHY THE CLI OWNS IT. Mismo razonamiento que `dev:next` justo abajo, y por la misma causa:
221
+ * el valor vive en `package.json`, que nace congelado en el derivado y no viaja en el set
222
+ * trackeado, así que `factory update` no lo alcanza por sí solo. La diferencia es QUÉ se gana
223
+ * al alcanzarlo. Aquí el valor nuevo no es otro parámetro: es un PUNTERO a un archivo que sí
224
+ * viaja (`scripts/**`). Sanar esta clave una vez es lo que vuelve viva a la compuerta — de ahí
225
+ * en adelante el Factory la mejora editando el script, y la flota lo recibe en el update
226
+ * siguiente sin que nadie abra su repo. Es la única entrada de este registro cuyo objetivo es
227
+ * dejar de necesitar entradas en este registro.
228
+ *
229
+ * FILE-GATED sobre el script mismo: `applyPlan` escribe `scripts/**` ANTES de que corra el
230
+ * mantenimiento del `package.json`, así que en la misma pasada el archivo ya está en disco
231
+ * cuando se evalúa el gate. Un derivado `core` que no reciba el archivo nunca recibe un alias
232
+ * apuntando a un script que no tiene.
233
+ */
234
+ export const VERIFY_SCRIPT_NAME = 'verify';
235
+ export const VERIFY_SCRIPT_FILE = 'scripts/tools/verify.mjs';
236
+ export const VERIFY_SCRIPT_CMD = `node ${VERIFY_SCRIPT_FILE}`;
237
+ /**
238
+ * La cadena de shell que el kit shippeó antes del script. Listada como COMANDO COMPLETO —
239
+ * igual que los heaps de `dev:next`— para que el sanado sea exact-match: un derivado que le
240
+ * agregó un paso propio (`&& pnpm format:check`, un `test:e2e`) tiene un valor que el kit
241
+ * nunca shippeó, y se queda EXACTAMENTE como está. Esa es la mitad importante del contrato:
242
+ * la compuerta se moderniza sola donde nadie la tocó, y no pisa a quien la hizo suya.
243
+ */
244
+ /**
245
+ * `verify:quick` — el bucle de trabajo, NO la compuerta.
246
+ *
247
+ * Comparte archivo y gate con `verify` (mismo script, distinto flag), así que un derivado que
248
+ * recibe uno recibe el otro. No tiene entrada en `OBSOLETE_SCRIPT_CMDS` y no la va a tener: es
249
+ * un alias que el kit nunca shippeó antes, así que no hay valor viejo que sanar — solo
250
+ * insert-if-missing. Si un equipo ya tenía un `verify:quick` propio, ese gana y se queda.
251
+ */
252
+ export const VERIFY_QUICK_SCRIPT_NAME = 'verify:quick';
253
+ export const VERIFY_QUICK_SCRIPT_CMD = `node ${VERIFY_SCRIPT_FILE} --quick`;
254
+ export const VERIFY_OBSOLETE_CMDS = ['pnpm lint && pnpm typecheck && pnpm test'];
217
255
  export const DEV_NEXT_SCRIPT_NAME = 'dev:next';
218
256
  export const DEV_NEXT_GATE_FILE = 'scripts/tools/dev.mjs';
219
257
  export const DEV_NEXT_HEAP_MB = 4096;
@@ -269,6 +307,7 @@ export const OBSOLETE_SCRIPT_CMDS = {
269
307
  // Every heap ceiling the kit shipped for the dev server before the current one. Listed as
270
308
  // whole COMMANDS (not just the number) so healing stays exact-match: a derivative that
271
309
  // appended a flag of its own, or picked a ceiling the kit never shipped, is left alone.
310
+ [VERIFY_SCRIPT_NAME]: VERIFY_OBSOLETE_CMDS,
272
311
  [DEV_NEXT_SCRIPT_NAME]: DEV_NEXT_OBSOLETE_HEAPS_MB.map((mb) => `NODE_OPTIONS='--max-old-space-size=${mb}' next dev --turbopack`),
273
312
  };
274
313
  /**
@@ -0,0 +1,31 @@
1
+ /**
2
+ * How the CLI turns a resolved `main()` into a process exit code.
3
+ *
4
+ * 🔴 The bug this exists to fix, stated plainly: `main(...).then(() => process.exit(0))`
5
+ * passes an EXPLICIT code, and an explicit argument to `process.exit` overrides whatever a
6
+ * command assigned to `process.exitCode`. So a command that finished its work successfully
7
+ * but wanted to report a non-zero *outcome* had its signal silently dropped.
8
+ *
9
+ * That is not hypothetical. `update --dry-run` sets `process.exitCode = 2` when it finds
10
+ * conflicts, and the GUI launcher chains the real apply (`--theirs-all --yes`) only when the
11
+ * preview exited 0 — its comment says "never clobber local edits with `--theirs-all` behind
12
+ * the dev's back". With the 2 discarded, that guard was dead: the launcher read 0 on every
13
+ * preview and applied unconditionally, overwriting local edits to tracked kit files.
14
+ *
15
+ * Note the distinction this function keeps: a **thrown** `CLIError` is a failure and still
16
+ * exits with its own code (that path never went through here). `process.exitCode` is for a
17
+ * command that succeeded and is reporting a RESULT — the dry-run's "there are conflicts" —
18
+ * which is exactly the case that was being swallowed.
19
+ */
20
+ export function resolveSuccessExitCode(pending) {
21
+ // Node types `process.exitCode` as `number | string | null | undefined` — `null` included,
22
+ // which is why this takes the whole union instead of narrowing at the call site. A string is
23
+ // only reachable if something assigned one; coerce rather than forward it, because
24
+ // `process.exit('2')` is not the same call as `process.exit(2)`.
25
+ if (typeof pending === 'number' && Number.isInteger(pending))
26
+ return pending;
27
+ if (typeof pending === 'string' && /^\d+$/.test(pending))
28
+ return Number(pending);
29
+ // Unset (the overwhelmingly common case) or unusable ⇒ success.
30
+ return 0;
31
+ }
@@ -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
+ }
@@ -8,7 +8,7 @@
8
8
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { CLIError } from './cli-error.js';
11
- import { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, RETIRED_SCRIPT_CMDS, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, } from './constants.js';
11
+ import { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, VERIFY_QUICK_SCRIPT_CMD, VERIFY_QUICK_SCRIPT_NAME, VERIFY_SCRIPT_CMD, VERIFY_SCRIPT_FILE, VERIFY_SCRIPT_NAME, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, RETIRED_SCRIPT_CMDS, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, } from './constants.js';
12
12
  /**
13
13
  * Auto-detect the profile to install into an existing repo from its
14
14
  * `package.json`: a `factoryVersion` field means the repo was born from the
@@ -146,6 +146,10 @@ export function kitLocalScripts(rootDir) {
146
146
  }
147
147
  if (has(GENERATE_REFERENCE_GATE_FILE))
148
148
  scripts[GENERATE_REFERENCE_NAME] = GENERATE_REFERENCE_CMD;
149
+ if (has(VERIFY_SCRIPT_FILE))
150
+ scripts[VERIFY_SCRIPT_NAME] = VERIFY_SCRIPT_CMD;
151
+ if (has(VERIFY_SCRIPT_FILE))
152
+ scripts[VERIFY_QUICK_SCRIPT_NAME] = VERIFY_QUICK_SCRIPT_CMD;
149
153
  if (has(DEV_NEXT_GATE_FILE))
150
154
  scripts[DEV_NEXT_SCRIPT_NAME] = DEV_NEXT_SCRIPT_CMD;
151
155
  return scripts;
@@ -0,0 +1,271 @@
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
+ * 🔴 FROZEN WIRE FORMAT — see ADR-004 §1/§4. Once a launcher is installed on someone else's
221
+ * machine, renaming this marker leaves it mute forever, silently: it does not error, it just
222
+ * stops seeing the signal. Never rename it; the pin in the test suite is what makes that
223
+ * mistake loud instead of silent. Adding NEW fields to the payload is fine (ADR-004 §3) —
224
+ * this is about the marker string itself and the shape it prefixes.
225
+ */
226
+ export const RETROFIT_SIGNAL_MARKER = '::timekast:retrofit-signal::';
227
+ /**
228
+ * Render the machine-readable signal line, or `null` under the same gate as the banner
229
+ * (`renderRetrofitReminder` — no guides shipped, nothing to signal either).
230
+ *
231
+ * Reuses `splitByArrival` rather than recomputing the arrival cut — that logic already lives
232
+ * there and this must never drift from what the banner counts.
233
+ *
234
+ * 🔴 One line, no pretty-printing: `JSON.stringify` with no `indent` arg. The consumer detects
235
+ * this by line PREFIX (ADR-004 §1); a payload that spans multiple lines breaks that detection.
236
+ */
237
+ export function renderRetrofitSignal(entries, fromVersion = null) {
238
+ if (renderRetrofitReminder(entries, fromVersion) === null)
239
+ return null;
240
+ const { arrived, carried } = splitByArrival(entries, fromVersion);
241
+ const payload = {
242
+ dir: RETROFITS_DIR,
243
+ arrived: arrived.length,
244
+ carried: carried.length,
245
+ };
246
+ return `${RETROFIT_SIGNAL_MARKER}${JSON.stringify(payload)}`;
247
+ }
248
+ /**
249
+ * Print the reminder. Called on EVERY applied update — see the module header for why this one
250
+ * has no headless guard and no outcome condition.
251
+ *
252
+ * 🔴 With `guiSignal: true`, the signal line is written FIRST, the banner second — not the
253
+ * other way around. `cli/src/index.ts` calls `process.exit(0)` right after `main` resolves, and
254
+ * writes to a pipe are async on Windows, where `process.exit` can truncate the tail. Under
255
+ * `--yes` the signal would otherwise be the LAST line written; writing it first leaves the
256
+ * banner (cosmetic text) as the one at risk of truncation instead of the machine-readable line.
257
+ */
258
+ export function printRetrofitReminder(opts) {
259
+ const log = opts.log ?? ((m) => console.log(m));
260
+ const entries = listRetrofits(opts.rootDir, opts);
261
+ const fromVersion = opts.fromVersion ?? null;
262
+ const rendered = renderRetrofitReminder(entries, fromVersion);
263
+ if (!rendered)
264
+ return;
265
+ if (opts.guiSignal) {
266
+ const signal = renderRetrofitSignal(entries, fromVersion);
267
+ if (signal)
268
+ log(signal);
269
+ }
270
+ log(rendered);
271
+ }
@@ -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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",