@timekast/factory 1.28.1 → 2.0.1

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.
Files changed (38) hide show
  1. package/dist/commands/backlog.js +52 -15
  2. package/dist/commands/doctor.js +210 -29
  3. package/dist/commands/env.js +154 -18
  4. package/dist/commands/invite-admin.js +62 -15
  5. package/dist/commands/provision.js +5815 -763
  6. package/dist/commands/status.js +12 -2
  7. package/dist/commands/ticket.js +4 -4
  8. package/dist/commands/update.js +97 -12
  9. package/dist/commands/vault.js +2627 -0
  10. package/dist/index.js +97 -33
  11. package/dist/lib/backlog-mapping.js +111 -18
  12. package/dist/lib/backlog-parse.js +1 -1
  13. package/dist/lib/claude-md.js +320 -0
  14. package/dist/lib/cloudflare-api.js +213 -37
  15. package/dist/lib/constants.js +70 -6
  16. package/dist/lib/env-file.js +45 -7
  17. package/dist/lib/env-wizard.js +66 -2
  18. package/dist/lib/initial-migration.js +3 -2
  19. package/dist/lib/neon-api.js +145 -10
  20. package/dist/lib/package-json.js +37 -16
  21. package/dist/lib/preflight.js +92 -38
  22. package/dist/lib/provision-preflight.js +158 -100
  23. package/dist/lib/provision-state.js +652 -20
  24. package/dist/lib/publish-engine.js +1 -1
  25. package/dist/lib/rail.js +400 -0
  26. package/dist/lib/railway-api.js +851 -0
  27. package/dist/lib/vault-adopt.js +1495 -0
  28. package/dist/lib/vault-api.js +1106 -0
  29. package/dist/lib/vault-config.js +21 -0
  30. package/dist/lib/vault-deploys.js +898 -0
  31. package/dist/lib/vercel-env.js +251 -31
  32. package/dist/lib/wrapper-wiring.js +160 -0
  33. package/dist/tui/env-wizard-flow.js +17 -3
  34. package/dist/tui/menu.js +4 -22
  35. package/dist/tui/provision-flow.js +68 -13
  36. package/package.json +1 -1
  37. package/dist/commands/secrets.js +0 -71
  38. package/dist/lib/env-merge.js +0 -238
@@ -11,14 +11,20 @@
11
11
  * installed version with "(versión remota no disponible — sin conexión)" +
12
12
  * exit 0, NOT an unhandled exception.
13
13
  *
14
- * The latest-version query IS the only network seam — tests inject
14
+ * The latest-version query IS the only network seam of `computeStatus` — tests inject
15
15
  * `deps.fetchLatest` so the engine runs with no `gh` and no network.
16
+ *
17
+ * ADOPT-004: `runStatus` adds the repo's vault MODE (`bóveda` · `declinado` · `sin decidir` ·
18
+ * `no aplica`) and, in `bóveda`, the vault ↔ Vercel DRIFT by display hash — as a separate,
19
+ * bounded, degradable step (`inspectRepoVault`), never inside `computeStatus`, which `vault adopt`
20
+ * also runs as its "brain up to date" precondition.
16
21
  */
17
22
  import { execa } from 'execa';
18
23
  import { FACTORY_REPO } from '../lib/constants.js';
19
24
  import { hasLockfile, readLockfile } from '../lib/lockfile.js';
20
25
  import { compare } from '../lib/semver.js';
21
26
  import { isKitReleaseTag } from '../lib/unpack.js';
27
+ import { inspectRepoVault, renderRepoVault, } from '../lib/vault-adopt.js';
22
28
  /**
23
29
  * The BETA channel banner (BETA-005). Emitted by `status` and `doctor` when the
24
30
  * lockfile is on the beta channel (`lockfile.beta === true`) so the dev always
@@ -166,9 +172,13 @@ function renderStatus(result) {
166
172
  */
167
173
  export async function runStatus(deps = {}, flags = { json: false }) {
168
174
  const result = await computeStatus(deps);
175
+ const vault = await inspectRepoVault(deps.rootDir ?? process.cwd(), deps.drift);
169
176
  if (flags.json) {
170
- process.stdout.write(`${JSON.stringify(result)}\n`);
177
+ const report = { ...result, vault };
178
+ process.stdout.write(`${JSON.stringify(report)}\n`);
171
179
  return;
172
180
  }
173
181
  renderStatus(result);
182
+ // The mode is reported; `status` never suggests `vault adopt` (that is `doctor`'s job).
183
+ console.log(`\n${renderRepoVault(vault, { suggestAdopt: false }).join('\n')}`);
174
184
  }
@@ -27,7 +27,7 @@
27
27
  *
28
28
  * The body travels over STDIN (`--body-file -` + `{ input }`), the same discipline
29
29
  * as `gh secret set` in `provision.ts`: it never lands in argv (visible in the
30
- * process table of the multi-tenant Agent Server) and never hits the argv limit.
30
+ * process table of a shared host) and never hits the argv limit.
31
31
  *
32
32
  * CREDENTIALS: none new. The command reuses the developer's own `gh` session via
33
33
  * the shared `runPreflight` (gh installed → authenticated → org member). A failed
@@ -96,9 +96,9 @@ export function parseTicketPushFlags(argv) {
96
96
  *
97
97
  * - `startsWith('project/factory')` accepts `project/factory-backup/…`.
98
98
  * - git versions symlinks, so a file with a perfectly legitimate ticket name can
99
- * point at `~/.claude/.env` — the org rail (VERCEL_TOKEN, NEON_API_KEY,
100
- * CLOUDFLARE_API_TOKEN, RESEND_API_KEY, BACKLOG_ADMIN_TOKEN). Publishing that
101
- * to a shared repo is the failure this guard exists to prevent.
99
+ * point at a secrets file outside the repo (a person's shell config, an old
100
+ * local copy of the org tokens…). Publishing that to a shared repo is the
101
+ * failure this guard exists to prevent.
102
102
  *
103
103
  * Unlike `assertSafeTrackedPath` (whose input is a release manifest), this input
104
104
  * comes from the user AND the file gets opened and published — string checks do
@@ -33,12 +33,13 @@
33
33
  * `update` NEVER touches `src/` — files outside the lockfile/manifest are
34
34
  * invisible (design §9 out-of-scope).
35
35
  */
36
- import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
36
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
37
37
  import { tmpdir } from 'node:os';
38
38
  import path from 'node:path';
39
39
  import { execa } from 'execa';
40
40
  import prompts from 'prompts';
41
41
  import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpdateState, validateStagedManifest, writeUpdateState, } from '../lib/atomic-swap.js';
42
+ import { applyClaudeMdRetrofit, assessClaudeMd, reportClaudeMd, } from '../lib/claude-md.js';
42
43
  import { collectClaudePaths } from '../lib/claude-paths.js';
43
44
  import { CLIError } from '../lib/cli-error.js';
44
45
  import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, LOCKFILE_FILE, PRETTIERIGNORE_FILE, PROFILES, TIMEKAST_DIR, } from '../lib/constants.js';
@@ -50,6 +51,7 @@ import { runPreflight } from '../lib/preflight.js';
50
51
  import { printProvisionPointer } from '../lib/provision-pointer.js';
51
52
  import { printRetrofitReminder } from '../lib/retrofit-reminder.js';
52
53
  import { hasProvisionState } from '../lib/provision-state.js';
54
+ import { loginCommand } from '../lib/rail.js';
53
55
  import { detectRepo } from '../lib/repo-detection.js';
54
56
  import { downloadBetaProfileTarball, downloadProfileTarball, stageProfileTarball, } from '../lib/unpack.js';
55
57
  /** Parse `update`'s argv slice into flags. Unknown flags are ignored here. */
@@ -283,7 +285,15 @@ function buildPlan(diff, theirsConflicts) {
283
285
  const deletes = diff.deleteSilent.map((e) => e.path);
284
286
  return { writes, deletes };
285
287
  }
286
- /** `--verify`: report disk-vs-lockfile drift, modify nothing. */
288
+ /**
289
+ * `--verify`: report disk-vs-lockfile drift, modify nothing.
290
+ *
291
+ * It also emits the A/B/C census of the repo's `CLAUDE.md` (PROJ-006) — the step the
292
+ * plan asks to run over the fleet BEFORE applying the retrofit for real. That is why
293
+ * the A/B/C discriminator was defined over the LOCKFILE: this path returns before
294
+ * `acquire`, so there is no incoming manifest and no network here — only the local
295
+ * lockfile and the disk.
296
+ */
287
297
  function runVerify(rootDir) {
288
298
  if (!hasLockfile(rootDir)) {
289
299
  throw new CLIError('No hay lockfile que verificar. Corre `factory update` primero.');
@@ -303,18 +313,23 @@ function runVerify(rootDir) {
303
313
  }
304
314
  if (missing.length === 0 && drifted.length === 0) {
305
315
  console.log('✔ El estado del disco coincide con el lockfile. Sin discrepancias.');
306
- return;
307
316
  }
308
- console.log('Discrepancias respecto al lockfile (no se modificó nada):');
309
- for (const p of missing)
310
- console.log(` • faltante en disco: ${p}`);
311
- for (const p of drifted)
312
- console.log(` • modificado localmente: ${p}`);
317
+ else {
318
+ console.log('Discrepancias respecto al lockfile (no se modificó nada):');
319
+ for (const p of missing)
320
+ console.log(` • faltante en disco: ${p}`);
321
+ for (const p of drifted)
322
+ console.log(` • modificado localmente: ${p}`);
323
+ }
324
+ // The census. Reported on BOTH branches: a repo with a spotless brain can still be
325
+ // the one whose `CLAUDE.md` needs the INDEX line (case B is a drift the summary
326
+ // above already listed, but case C is what a human has to act on).
327
+ reportClaudeMd(assessClaudeMd(rootDir, lock), 'preview');
313
328
  }
314
329
  /**
315
330
  * After a clean update, optionally commit the refreshed brain (local, NEVER push).
316
331
  * Decision (`commit-decision.ts`): `--no-commit`→skip · `--commit`→commit · interactive
317
- * → prompt y/n · headless → commit by default (the Agent Server needs it). Stages ONLY
332
+ * → prompt y/n · headless → commit by default (a headless run needs it). Stages ONLY
318
333
  * what the update touched (the applied manifest paths + lockfile + package.json +
319
334
  * .gitattributes) — never `git add -A`. Best-effort: no identity / no repo → warn + skip.
320
335
  */
@@ -395,16 +410,25 @@ async function runResume(rootDir, flags) {
395
410
  newLock.beta = true;
396
411
  else
397
412
  delete newLock.beta;
413
+ // Same pre-swap classification as a normal run (PROJ-006). A LEGACY resume has no
414
+ // prior lockfile, so there is no recorded hash to tell a virgin CLAUDE.md from an
415
+ // edited one — the retrofit sits that case out rather than guess.
416
+ const claudeMd = priorLock
417
+ ? assessClaudeMd(rootDir, priorLock)
418
+ : undefined;
398
419
  applyPlan(rootDir, state.stagedDir, state.plan, state.backupDir);
399
420
  writeLockfile(rootDir, newLock);
400
421
  clearUpdateState(rootDir);
422
+ const claudeMdInserted = claudeMd ? applyClaudeMdRetrofit(rootDir, claudeMd) : false;
401
423
  // Maintain dotfiles BEFORE removing the staged dir (the .gitattributes source).
402
424
  const pkgMutations = maintainDerivedDotfiles(rootDir, state.stagedDir, newLock.version);
403
- await maybeCommitBrain(rootDir, newLock.version, [...state.plan.writes, ...state.plan.deletes], flags);
425
+ await maybeCommitBrain(rootDir, newLock.version, [...state.plan.writes, ...state.plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags);
404
426
  rmSync(state.stagedDir, { recursive: true, force: true });
405
427
  // A resumed update maintains package.json exactly like a normal one, so it owes the dev the
406
428
  // same account of what it moved there (`BND-004`).
407
429
  reportPkgMutations(pkgMutations);
430
+ if (claudeMd)
431
+ reportClaudeMd(claudeMd, 'applied', claudeMdInserted);
408
432
  console.log('✔ `update` retomado y completado.');
409
433
  // A resumed update that succeeds completed the setup → show the end-pointer
410
434
  // (PVSKILL-002 §8 edge: `--resume` SÍ lo muestra). Suppressed only in headless
@@ -710,9 +734,60 @@ export async function maybeOfferAdopt(rootDir, flags, opts = {}) {
710
734
  }));
711
735
  if (answer?.adopt === true) {
712
736
  console.log('\nCorre: `factory provision --adopt --team=<teamId>`\n' +
713
- ' (asegúrate de tener los tokens en `~/.claude/.env`; impórtalos con `factory secrets <archivo>` primero).');
737
+ ' (lee los tokens de `rail-timekast` en la bóveda: entra antes con ' +
738
+ `\`${loginCommand()}\` si tu sesión caducó).`);
714
739
  }
715
740
  }
741
+ /** The repo's E2E settings file (read by `scripts/tools/e2e-runner.ts`) — dev-owned, never shipped by `update`. */
742
+ export const E2E_CONFIG_FILE = 'e2e.config.json';
743
+ /**
744
+ * After a successful update, OFFER the empty-branch E2E mode to a repo that has never decided.
745
+ *
746
+ * `e2e.config.json` lives at the repo root: `factory new` delivers it (`emptyBranch: true`) and
747
+ * `update` never creates or touches it, so a repo born before the flag keeps running E2E on a
748
+ * clone of its parent's data until someone opts in HERE. The answer is persisted either way —
749
+ * yes → `true`, no → `false` — so the question is asked once, not on every update; Esc writes
750
+ * nothing and asks again next time. Reverting is editing the value or deleting the file.
751
+ *
752
+ * Asked only when it means something: an interactive terminal, no `--yes` (GUI launcher /
753
+ * headless leave the repo exactly as it was), no decision on disk yet, and a repo that has the
754
+ * runner (`core` ships none) AND a migrations journal (without one the runner never empties).
755
+ *
756
+ * `isTTY`/`prompt` are injectable so the unit test drives every branch without a terminal.
757
+ */
758
+ export async function maybeOfferEmptyE2EBranch(rootDir, flags, opts = {}) {
759
+ const isTTY = opts.isTTY ?? Boolean(process.stdout.isTTY);
760
+ if (flags.yes || !isTTY)
761
+ return;
762
+ if (existsSync(path.join(rootDir, E2E_CONFIG_FILE)))
763
+ return;
764
+ if (!existsSync(path.join(rootDir, 'scripts/tools/e2e-runner.ts')))
765
+ return;
766
+ if (!existsSync(path.join(rootDir, 'src/lib/db/migrations/meta/_journal.json')))
767
+ return;
768
+ const log = opts.log ?? ((message) => console.log(message));
769
+ log('\nE2E con base vacía (nuevo en el kit, opcional):\n' +
770
+ ' · Gana: cada corrida vacía la branch temporal y aplica TODAS tus migraciones desde 0000, ' +
771
+ 'y ningún spec depende de datos que solo existen en develop.\n' +
772
+ ' · Implica: un spec que leía datos de develop, o una migración que no construye la base desde cero, ' +
773
+ 'va a tronar. Corre `pnpm test:e2e` después de activarlo.\n' +
774
+ ` · Se revierte: pon \`"emptyBranch": false\` en \`${E2E_CONFIG_FILE}\` o borra el archivo. ` +
775
+ 'Guía: .claude/docs/retrofits/e2e-empty-branch.md');
776
+ const ask = opts.prompt ?? prompts;
777
+ // A cancelled prompt (Esc) returns an empty object → no decision, nothing written.
778
+ const answer = (await ask({
779
+ type: 'confirm',
780
+ name: 'emptyBranch',
781
+ message: '¿Activo la base vacía para el E2E de este repo?',
782
+ initial: false,
783
+ }));
784
+ if (typeof answer?.emptyBranch !== 'boolean')
785
+ return;
786
+ writeFileSync(path.join(rootDir, E2E_CONFIG_FILE), `${JSON.stringify({ emptyBranch: answer.emptyBranch }, null, 2)}\n`);
787
+ log(answer.emptyBranch
788
+ ? `Listo: escribí \`${E2E_CONFIG_FILE}\` con la base vacía activada. Corre \`pnpm test:e2e\` y commitea el archivo.`
789
+ : `Anotado en \`${E2E_CONFIG_FILE}\` (desactivada): no te lo vuelvo a preguntar. Commitea el archivo.`);
790
+ }
716
791
  /**
717
792
  * Run `factory update`. Orchestrates the full flow; `deps.acquire` is the only
718
793
  * network seam (tests inject a local fixture).
@@ -839,6 +914,10 @@ export async function runUpdate(flags, deps = {}) {
839
914
  if (decision === 'theirs')
840
915
  theirsConflicts.push(conflict.path);
841
916
  }
917
+ // 🔴 Classify CLAUDE.md BEFORE the swap (PROJ-006). Case A is "disk == lockfile",
918
+ // and `applyPlan` may write the incoming version over it — after that, a virgin
919
+ // CLAUDE.md is indistinguishable from an edited one.
920
+ const claudeMd = assessClaudeMd(rootDir, oldLock);
842
921
  const plan = buildPlan(diff, theirsConflicts);
843
922
  const backupDir = defaultBackupDir(tmpRoot);
844
923
  // Persist resume state BEFORE applying, so a crash mid-apply is recoverable
@@ -855,12 +934,17 @@ export async function runUpdate(flags, deps = {}) {
855
934
  // update uses `stampBirth` alone, so a prior `beta` is cleared by omission.
856
935
  writeLockfile(rootDir, flags.beta ? stampBeta(oldLock, manifest) : stampBirth(oldLock, manifest));
857
936
  clearUpdateState(rootDir);
937
+ // The retrofit runs BEFORE the brain commit, so the inserted line travels in the
938
+ // same commit as the rest of the update (and `CLAUDE.md` joins the staged paths —
939
+ // in case B it is in `ignoreLocal`, so the plan never names it).
940
+ const claudeMdInserted = applyClaudeMdRetrofit(rootDir, claudeMd);
858
941
  const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
859
- await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes], flags);
942
+ await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags);
860
943
  // `theirsConflicts` IS the overwritten-local list: same source whether the dev answered
861
944
  // the prompt, passed `--theirs-all`, or ran headless — there is no second path. The
862
945
  // package.json mutations ride the SAME closing summary, not three loose notices.
863
946
  reportSummary(diff, manifest, theirsConflicts, pkgMutations);
947
+ reportClaudeMd(claudeMd, 'applied', claudeMdInserted);
864
948
  // 🔴 The retrofit reminder, on EVERY applied update and regardless of how conflicts
865
949
  // were resolved — `--theirs-all`, `--mine-all`, per-file answers or none at all. A
866
950
  // retrofit is not a conflict: it is work this command never attempted, living outside
@@ -871,6 +955,7 @@ export async function runUpdate(flags, deps = {}) {
871
955
  // that just arrived from the ones that were already sitting there. A legacy repo carries
872
956
  // `0.0.0`, so every guide reads as new — correct: it has seen none of them.
873
957
  printRetrofitReminder({ rootDir, fromVersion: oldLock.version, guiSignal: flags.launcher });
958
+ await maybeOfferEmptyE2EBranch(rootDir, flags);
874
959
  // End-pointer to the next step (PVSKILL-002). Reached only after a clean apply;
875
960
  // `--verify`/`--resume` short-circuit above (verify NEVER prints), an error path
876
961
  // throws earlier, and headless `--yes` is suppressed (CI noise).