@timekast/factory 1.28.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/backlog.js +52 -15
- package/dist/commands/doctor.js +210 -29
- package/dist/commands/env.js +146 -18
- package/dist/commands/invite-admin.js +62 -15
- package/dist/commands/provision.js +5798 -761
- package/dist/commands/status.js +12 -2
- package/dist/commands/ticket.js +4 -4
- package/dist/commands/update.js +97 -12
- package/dist/commands/vault.js +2627 -0
- package/dist/index.js +97 -33
- package/dist/lib/backlog-mapping.js +111 -18
- package/dist/lib/backlog-parse.js +1 -1
- package/dist/lib/claude-md.js +320 -0
- package/dist/lib/cloudflare-api.js +213 -37
- package/dist/lib/constants.js +70 -6
- package/dist/lib/env-file.js +45 -7
- package/dist/lib/env-wizard.js +66 -2
- package/dist/lib/initial-migration.js +3 -2
- package/dist/lib/neon-api.js +145 -10
- package/dist/lib/package-json.js +37 -16
- package/dist/lib/preflight.js +92 -38
- package/dist/lib/provision-preflight.js +158 -100
- package/dist/lib/provision-state.js +652 -20
- package/dist/lib/publish-engine.js +1 -1
- package/dist/lib/rail.js +400 -0
- package/dist/lib/railway-api.js +851 -0
- package/dist/lib/vault-adopt.js +1495 -0
- package/dist/lib/vault-api.js +1101 -0
- package/dist/lib/vault-config.js +21 -0
- package/dist/lib/vault-deploys.js +898 -0
- package/dist/lib/vercel-env.js +251 -31
- package/dist/lib/wrapper-wiring.js +160 -0
- package/dist/tui/env-wizard-flow.js +17 -3
- package/dist/tui/menu.js +4 -22
- package/dist/tui/provision-flow.js +68 -13
- package/package.json +1 -1
- package/dist/commands/secrets.js +0 -71
- package/dist/lib/env-merge.js +0 -238
package/dist/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/commands/ticket.js
CHANGED
|
@@ -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
|
|
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
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
package/dist/commands/update.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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 (
|
|
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
|
-
' (
|
|
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).
|