@timekast/factory 1.28.0 → 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 +5820 -768
- 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 +170 -35
- 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/backlog.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* parser/mapping (`backlog-parse.ts` / `backlog-mapping.ts`) — this file only
|
|
10
10
|
* orchestrates; it re-implements neither the HTTP nor the field mapping.
|
|
11
11
|
*
|
|
12
|
-
* READ-ONLY BY DEFAULT: with no `BACKLOG_API_KEY` / `BACKLOG_PROJECT_ID`
|
|
13
|
-
*
|
|
12
|
+
* READ-ONLY BY DEFAULT: with no `BACKLOG_API_KEY` / `BACKLOG_PROJECT_ID` — read from the
|
|
13
|
+
* `local` environment of the repo's vault when `.timekast/provision.json` carries a `vault`
|
|
14
|
+
* block, else from `.env.local` (VAULT-006) — it prints a note with the real cause and exits 0:
|
|
15
|
+
* the push NEVER blocks `/backlog`.
|
|
14
16
|
*
|
|
15
17
|
* IDEMPOTENCY (smoke Hallazgo 3): a re-POST of an existing client-supplied `id`
|
|
16
18
|
* returns `500` on the live API, NOT the documented `409` — so a status code is an
|
|
@@ -195,7 +197,7 @@ async function loadSyncState(apiKey, projectId, api, existing, items) {
|
|
|
195
197
|
* abort the run — §4).
|
|
196
198
|
*/
|
|
197
199
|
export async function runBacklogPush(flags, rootDir = process.cwd(), deps = {}) {
|
|
198
|
-
const creds = (deps.credentials ?? ((dir) => resolveBacklogCredentials(dir)))(rootDir);
|
|
200
|
+
const creds = await (deps.credentials ?? ((dir) => resolveBacklogCredentials(dir, deps.credentialDeps)))(rootDir);
|
|
199
201
|
if (creds.status === 'not-configured') {
|
|
200
202
|
console.log(`ℹ️ Push al backlog central omitido: ${creds.reason}`);
|
|
201
203
|
console.log(' (read-only por defecto — el push nunca bloquea `/backlog`.)');
|
|
@@ -666,22 +668,35 @@ async function runWithDeadline(ms, work, deadlineSleep) {
|
|
|
666
668
|
}
|
|
667
669
|
/**
|
|
668
670
|
* Orchestrate `factory backlog sync`. Read-only degradation: no credentials → note +
|
|
669
|
-
* return (exit 0 — the sync NEVER blocks the workflow). The
|
|
670
|
-
*
|
|
671
|
-
*
|
|
671
|
+
* return (exit 0 — the sync NEVER blocks the workflow). The credential resolution —
|
|
672
|
+
* which in a repo with a vault is a network read (VAULT-006) —, the point sync (unless
|
|
673
|
+
* `--heal-only`) and the self-healing heartbeat ALL run under a single 5s deadline: a
|
|
674
|
+
* slow vault cannot stretch the heartbeat. A cut before the credentials resolve is a
|
|
675
|
+
* `not-configured` note; a cut after is the usual clean cut. Either way the caller
|
|
676
|
+
* continues (fire-and-forget, §4).
|
|
672
677
|
*/
|
|
673
678
|
export async function runBacklogSync(flags, rootDir = process.cwd(), deps = {}) {
|
|
674
|
-
const
|
|
675
|
-
if (creds.status === 'not-configured') {
|
|
676
|
-
console.log(`ℹ️ Sync al backlog central omitido: ${creds.reason}`);
|
|
677
|
-
console.log(' (fire-and-forget — el sync nunca bloquea el workflow.)');
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
const { apiKey, projectId } = creds;
|
|
679
|
+
const resolveCredentials = deps.credentials ?? ((dir) => resolveBacklogCredentials(dir, deps.credentialDeps));
|
|
681
680
|
const api = deps.api ?? {};
|
|
682
|
-
|
|
681
|
+
let creds;
|
|
682
|
+
let collectFailure;
|
|
683
|
+
let cut = false;
|
|
683
684
|
let heal = { repaired: 0, failed: 0, skippedNoUuid: 0 };
|
|
684
685
|
const work = async () => {
|
|
686
|
+
const resolved = await resolveCredentials(rootDir);
|
|
687
|
+
creds = resolved;
|
|
688
|
+
// Resolved after the deadline already fired: the run was reported as cut — do nothing more.
|
|
689
|
+
if (cut || resolved.status === 'not-configured')
|
|
690
|
+
return;
|
|
691
|
+
const { apiKey, projectId } = resolved;
|
|
692
|
+
let collected;
|
|
693
|
+
try {
|
|
694
|
+
collected = (deps.collect ?? collectBacklog)(rootDir);
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
collectFailure = { error };
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
685
700
|
if (!flags.healOnly) {
|
|
686
701
|
await runPointSync(flags, collected, apiKey, projectId, api);
|
|
687
702
|
}
|
|
@@ -689,7 +704,29 @@ export async function runBacklogSync(flags, rootDir = process.cwd(), deps = {})
|
|
|
689
704
|
};
|
|
690
705
|
const completed = await runWithDeadline(SYNC_TIMEOUT_MS, work, deps.deadlineSleep);
|
|
691
706
|
if (!completed) {
|
|
692
|
-
|
|
707
|
+
cut = true;
|
|
708
|
+
if (creds === undefined) {
|
|
709
|
+
console.log(`ℹ️ Sync al backlog central omitido: la lectura de credenciales no terminó en ` +
|
|
710
|
+
`${SYNC_TIMEOUT_MS / 1000}s (la bóveda de secretos no respondió a tiempo).`);
|
|
711
|
+
console.log(' (fire-and-forget — el sync nunca bloquea el workflow.)');
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (creds.status === 'configured') {
|
|
715
|
+
console.warn(`⚠ Sync al backlog central cortado a los ${SYNC_TIMEOUT_MS / 1000}s — el workflow continúa sin esperar.`);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
// Resolved to `not-configured` right at the deadline: narrate its real reason below.
|
|
719
|
+
}
|
|
720
|
+
if (collectFailure)
|
|
721
|
+
throw collectFailure.error;
|
|
722
|
+
// `completed` with no credentials only happens if the resolver seam rejected: fail closed.
|
|
723
|
+
const resolved = creds ?? {
|
|
724
|
+
status: 'not-configured',
|
|
725
|
+
reason: 'no pude resolver las credenciales del backlog central.',
|
|
726
|
+
};
|
|
727
|
+
if (resolved.status === 'not-configured') {
|
|
728
|
+
console.log(`ℹ️ Sync al backlog central omitido: ${resolved.reason}`);
|
|
729
|
+
console.log(' (fire-and-forget — el sync nunca bloquea el workflow.)');
|
|
693
730
|
return;
|
|
694
731
|
}
|
|
695
732
|
if (heal.repaired || heal.failed) {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -13,9 +13,21 @@
|
|
|
13
13
|
* post-bootstrap, and that the patch channel for `src/` is EPIC 2 (agentic
|
|
14
14
|
* update, not yet available).
|
|
15
15
|
* - MISSING INITIAL MIGRATION: a derivative that ships a Drizzle schema and no
|
|
16
|
-
* generated `.sql`, so its `vercel-build`
|
|
17
|
-
*
|
|
16
|
+
* generated `.sql`, so the migration step of its deploy — `vercel-build`
|
|
17
|
+
* (`pnpm build && pnpm db:migrate`) on Vercel, the pre-deploy (`pnpm db:migrate`)
|
|
18
|
+
* on Railway — has nothing to apply and its first deploy never creates the tables.
|
|
18
19
|
* INFORMATIONAL — see below.
|
|
20
|
+
* - STALE RAIL FILE: a `~/.claude/.env` left on the machine from the file-based
|
|
21
|
+
* rail. The rail is read from the vault now (`fx-secrets-vault §3`), so that file
|
|
22
|
+
* is a dead copy of tokens nobody rotates: doctor names its absolute path and asks
|
|
23
|
+
* to delete it. It only checks EXISTENCE (`lstat`, no symlink follow) — it never
|
|
24
|
+
* opens, reads or deletes the file.
|
|
25
|
+
* - VAULT MODE (ADOPT-004): `bóveda` · `declinado` · `sin decidir` · `no aplica`, read from
|
|
26
|
+
* `.timekast/provision.json` (local, synchronous — part of `computeDoctor`). Only `sin decidir`
|
|
27
|
+
* suggests `factory vault adopt`: a declined repo is never asked again, `core` never. In
|
|
28
|
+
* `bóveda`, `runDoctor` adds the vault ↔ Vercel DRIFT by display hash as a SEPARATE, bounded
|
|
29
|
+
* and degradable step (`computeVaultDrift`): without a session or network it is "no
|
|
30
|
+
* verificable" with the cause — the offline part of the report never waits on it otherwise.
|
|
19
31
|
*
|
|
20
32
|
* No lockfile (pre-CLI repo) → delegate to the "run factory:update" advice, exit
|
|
21
33
|
* 0, never abort (same pattern as `status` / the §8 edge case).
|
|
@@ -27,18 +39,27 @@
|
|
|
27
39
|
* turn a notice into a blocked command through the back door. If it ever has to
|
|
28
40
|
* live there, it goes as `warn` (`LauncherCheckStatus`), which never trips the exit.
|
|
29
41
|
*/
|
|
30
|
-
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
42
|
+
import { existsSync, lstatSync, readFileSync, statSync } from 'node:fs';
|
|
43
|
+
import { homedir } from 'node:os';
|
|
31
44
|
import path from 'node:path';
|
|
32
45
|
import { CLIError } from '../lib/cli-error.js';
|
|
46
|
+
import { KIT_RULE_PATH_RE, RULES_DIR, bareImportPaths, resolveImportFrom, } from '../lib/claude-md.js';
|
|
33
47
|
import { collectClaudePaths } from '../lib/claude-paths.js';
|
|
34
48
|
import { CLAUDE_MD_FILE } from '../lib/constants.js';
|
|
35
49
|
import { MIGRATIONS_DIR, SCHEMA_DIR, needsInitialMigration, readMigrationFacts, } from '../lib/initial-migration.js';
|
|
36
50
|
import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
|
|
37
|
-
import { runLauncherPreflight, } from '../lib/preflight.js';
|
|
51
|
+
import { LAUNCHER_RAIL_TIMEOUT_MS, runLauncherPreflight, } from '../lib/preflight.js';
|
|
52
|
+
import { readNeonRevokePending } from '../lib/provision-state.js';
|
|
53
|
+
import { createRailResolver } from '../lib/rail.js';
|
|
38
54
|
import { detectRepo } from '../lib/repo-detection.js';
|
|
55
|
+
import { readVaultMode, renderRepoVault, repoVaultReportFor, } from '../lib/vault-adopt.js';
|
|
39
56
|
import { BETA_BANNER } from './status.js';
|
|
40
|
-
/**
|
|
41
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Matches a flat always-on rule file shipped by the kit (`.claude/rules/<name>.md`).
|
|
59
|
+
* Imported from the retrofit module so ONE definition governs both the check here and
|
|
60
|
+
* the A/B/C classification `update` runs over the same set.
|
|
61
|
+
*/
|
|
62
|
+
const RULE_PATH_RE = KIT_RULE_PATH_RE;
|
|
42
63
|
/**
|
|
43
64
|
* Matches ONLY the reserved on-demand rules heading (`Rules (on-demand reference)`
|
|
44
65
|
* and close variants), not any heading that merely contains "on-demand" (e.g.
|
|
@@ -66,10 +87,67 @@ export const A1_WARNING = 'Aviso de seguridad (A1): el boilerplate `src/` y sus
|
|
|
66
87
|
*/
|
|
67
88
|
export const MISSING_INITIAL_MIGRATION_NOTICE = `⚠ Falta la migración inicial: hay schema en \`${SCHEMA_DIR}/\` y ningún \`.sql\` en ` +
|
|
68
89
|
`\`${MIGRATIONS_DIR}/\`.\n` +
|
|
69
|
-
'
|
|
90
|
+
' Cada deploy migra (en Vercel, `vercel-build` corre `pnpm build && pnpm db:migrate`; en ' +
|
|
91
|
+
'Railway, el pre-deploy corre `pnpm db:migrate`), así que el primer deploy no crearía ' +
|
|
70
92
|
'ninguna tabla. Genera la migración con `pnpm db:generate` y commitéala antes del próximo ' +
|
|
71
93
|
'deploy.\n' +
|
|
72
94
|
' (Aviso informativo: `factory doctor` no bloquea — sale con éxito de todos modos.)';
|
|
95
|
+
/**
|
|
96
|
+
* Where the retired file-based rail lived, relative to the home directory. Only its
|
|
97
|
+
* EXISTENCE is ever checked (see {@link findStaleRailFile}).
|
|
98
|
+
*/
|
|
99
|
+
export const STALE_RAIL_FILE_REL = path.join('.claude', '.env');
|
|
100
|
+
/**
|
|
101
|
+
* The notice for a rail file left on the machine. Names the ABSOLUTE path and reads
|
|
102
|
+
* unambiguously as "delete it, it is no longer used" — and says doctor did not touch it.
|
|
103
|
+
* Exported for the tests.
|
|
104
|
+
*/
|
|
105
|
+
export function staleRailFileNotice(absPath) {
|
|
106
|
+
return (`⚠ Archivo del rail que ya no se usa: ${absPath}\n` +
|
|
107
|
+
' El rail ahora se lee de la bóveda con tu sesión (`infisical login`), no de ese archivo. ' +
|
|
108
|
+
'Lo que tenga es una copia de tokens que ya nadie rota: bórralo.\n' +
|
|
109
|
+
' (`factory doctor` no lo abrió, no lo leyó ni lo borró.)');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The absolute path of a stale `~/.claude/.env`, or `null` when there is none.
|
|
113
|
+
* `lstat` so a symlink is reported as itself (never followed) and a directory counts
|
|
114
|
+
* too; nothing is opened or read. Any stat failure → `null` (nothing to report).
|
|
115
|
+
*/
|
|
116
|
+
export function findStaleRailFile(homeDir) {
|
|
117
|
+
const abs = path.join(homeDir, STALE_RAIL_FILE_REL);
|
|
118
|
+
try {
|
|
119
|
+
lstatSync(abs);
|
|
120
|
+
return abs;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The line `doctor` prints while replaced Neon keys are still alive, waiting for the delivery
|
|
128
|
+
* proof: how many, and which run settles them. `null` when there are none.
|
|
129
|
+
*/
|
|
130
|
+
export function neonRevokePendingNotice(count) {
|
|
131
|
+
if (count <= 0)
|
|
132
|
+
return null;
|
|
133
|
+
return (`⚠ ${count === 1 ? '1 key de Neon anterior sigue viva' : `${count} keys de Neon anteriores siguen vivas`} ` +
|
|
134
|
+
'por revocar (bloque `vault` de `.timekast/provision.json`): esperan a que el sync ' +
|
|
135
|
+
'`develop:/ci → secrets de GitHub Actions` confirme que entregó la nueva. Las revoca la siguiente ' +
|
|
136
|
+
'corrida de `factory provision` con bóveda (p. ej. `--resume`).');
|
|
137
|
+
}
|
|
138
|
+
/** The count of {@link DoctorResult.neonRevokePending} — never throws (the mode reports errors). */
|
|
139
|
+
function countNeonRevokePending(rootDir) {
|
|
140
|
+
try {
|
|
141
|
+
return readNeonRevokePending(rootDir)?.keyIds.length ?? 0;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** The repo root `doctor` diagnoses: the root of the repo cwd is in, not cwd itself. */
|
|
148
|
+
function doctorRoot(rootDir) {
|
|
149
|
+
return rootDir ?? detectRepo(process.cwd()).repoRoot ?? process.cwd();
|
|
150
|
+
}
|
|
73
151
|
/** Parse `doctor`'s argv slice into flags. Unknown flags are ignored. */
|
|
74
152
|
export function parseDoctorFlags(argv) {
|
|
75
153
|
return {
|
|
@@ -111,18 +189,49 @@ function onDemandSectionText(claudeMd) {
|
|
|
111
189
|
}
|
|
112
190
|
return out.join('\n');
|
|
113
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* True when a path a CLAUDE.md imports is an INDEX of rules worth following: it must
|
|
194
|
+
* live under `.claude/rules/` and still be there after normalization (a `..` segment
|
|
195
|
+
* that climbs out of the dir is refused — nothing outside the rules tree is read).
|
|
196
|
+
*/
|
|
197
|
+
function isRulesIndexCandidate(rel) {
|
|
198
|
+
if (!rel.startsWith(`${RULES_DIR}/`) || !rel.endsWith('.md'))
|
|
199
|
+
return false;
|
|
200
|
+
return path.posix.normalize(rel).startsWith(`${RULES_DIR}/`);
|
|
201
|
+
}
|
|
114
202
|
/**
|
|
115
203
|
* Collect the rule paths a CLAUDE.md actually `@import`s — a line whose trimmed
|
|
116
|
-
* text is `@<path>` (the real import directive). A prose mention
|
|
117
|
-
*
|
|
118
|
-
* so
|
|
204
|
+
* text is `@<path>` (the real import directive). A prose mention is NOT a bare
|
|
205
|
+
* directive line, and neither is a line inside a fenced code block: the shared
|
|
206
|
+
* reader (`bareImportPaths`) tracks fence state, so a doc that SHOWS the directive
|
|
207
|
+
* in a ``` sample is never read as performing it.
|
|
208
|
+
*
|
|
209
|
+
* 🔴 Follows an INDEX **one hop**. The kit's CLAUDE.md now imports a single
|
|
210
|
+
* `@.claude/rules/INDEX.md` that fans out to the six always-on rules; reading only
|
|
211
|
+
* the top-level file would report all six as "not imported" on every healthy repo.
|
|
212
|
+
* So when an imported path lives under `.claude/rules/` AND exists on disk (the kit's
|
|
213
|
+
* INDEX, or any index the dev wrote), its own `@<path>` lines count as imported too.
|
|
214
|
+
*
|
|
215
|
+
* The nested paths are resolved RELATIVE TO THE FILE THAT CONTAINS THEM (the Claude
|
|
216
|
+
* Code rule for memory imports) and normalized back to repo-relative — that is why
|
|
217
|
+
* `INDEX.md` lists `@CORE.md`, not `@.claude/rules/CORE.md`.
|
|
218
|
+
*
|
|
219
|
+
* One hop only: no recursion, so there is no cycle to guard and no depth to bound. An
|
|
220
|
+
* INDEX that is referenced but NOT on disk (the `core` profile, which ships no INDEX)
|
|
221
|
+
* is simply not followed — never an error, never a crash.
|
|
119
222
|
*/
|
|
120
|
-
function importedRulePaths(claudeMd) {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
223
|
+
function importedRulePaths(claudeMd, rootDir) {
|
|
224
|
+
const direct = bareImportPaths(claudeMd);
|
|
225
|
+
const paths = new Set(direct);
|
|
226
|
+
for (const rel of direct) {
|
|
227
|
+
if (!isRulesIndexCandidate(rel))
|
|
228
|
+
continue;
|
|
229
|
+
const abs = path.join(rootDir, rel);
|
|
230
|
+
if (!existsSync(abs) || !statSync(abs).isFile())
|
|
231
|
+
continue;
|
|
232
|
+
for (const nested of bareImportPaths(readFileSync(abs, 'utf8'))) {
|
|
233
|
+
paths.add(resolveImportFrom(rel, nested));
|
|
234
|
+
}
|
|
126
235
|
}
|
|
127
236
|
return paths;
|
|
128
237
|
}
|
|
@@ -137,11 +246,16 @@ export function computeDoctor(deps = {}) {
|
|
|
137
246
|
// Diagnose at the REPO ROOT (where the brain + lockfile live), not cwd — so
|
|
138
247
|
// `doctor` from a subdir of a managed repo reports the real state instead of
|
|
139
248
|
// "no lockfile" (consistency with `add`/`update`). Tests inject `rootDir`.
|
|
140
|
-
const rootDir = deps.rootDir
|
|
249
|
+
const rootDir = doctorRoot(deps.rootDir);
|
|
250
|
+
// Vault mode (ADOPT-004) — local and synchronous; the drift is `runDoctor`'s separate step.
|
|
251
|
+
const vault = readVaultMode(rootDir);
|
|
252
|
+
const neonRevokePending = countNeonRevokePending(rootDir);
|
|
141
253
|
// Initial-migration gap (GAP-002). Delegated WHOLE to the provision predicate —
|
|
142
254
|
// facts read from disk, decision (Factory exception included) taken there. Doctor
|
|
143
255
|
// does not re-derive "is this the Factory": one copy of that rule, in one place.
|
|
144
256
|
const missingInitialMigration = needsInitialMigration(readMigrationFacts(rootDir));
|
|
257
|
+
// Stale file-based rail on this machine (RAIL-003) — existence only, never read.
|
|
258
|
+
const staleRailFile = findStaleRailFile(deps.homeDir ?? homedir());
|
|
145
259
|
if (!hasLockfile(rootDir)) {
|
|
146
260
|
return {
|
|
147
261
|
orphans: [],
|
|
@@ -152,8 +266,13 @@ export function computeDoctor(deps = {}) {
|
|
|
152
266
|
// Always false on this branch, and by the predicate rather than by a literal:
|
|
153
267
|
// no lockfile IS the Factory signal, which `needsInitialMigration` short-circuits.
|
|
154
268
|
missingInitialMigration,
|
|
269
|
+
staleRailFile,
|
|
155
270
|
noLockfile: true,
|
|
156
271
|
beta: false,
|
|
272
|
+
vault,
|
|
273
|
+
// `vault adopt` requires a lockfile: suggesting it here would send the dev to a refusal.
|
|
274
|
+
suggestAdopt: false,
|
|
275
|
+
neonRevokePending,
|
|
157
276
|
};
|
|
158
277
|
}
|
|
159
278
|
const lock = readLockfile(rootDir);
|
|
@@ -188,7 +307,8 @@ export function computeDoctor(deps = {}) {
|
|
|
188
307
|
const claudeMdMissing = !existsSync(claudeMdPath);
|
|
189
308
|
if (!claudeMdMissing) {
|
|
190
309
|
const claudeMd = readFileSync(claudeMdPath, 'utf8');
|
|
191
|
-
|
|
310
|
+
// Bare `@<path>` directive lines, plus one hop through an INDEX under `.claude/rules/`.
|
|
311
|
+
const imported = importedRulePaths(claudeMd, rootDir);
|
|
192
312
|
const onDemand = onDemandSectionText(claudeMd);
|
|
193
313
|
for (const entry of lock.files) {
|
|
194
314
|
if (!RULE_PATH_RE.test(entry.path))
|
|
@@ -206,17 +326,28 @@ export function computeDoctor(deps = {}) {
|
|
|
206
326
|
claudeMdMissing,
|
|
207
327
|
a1Warning: A1_WARNING,
|
|
208
328
|
missingInitialMigration,
|
|
329
|
+
staleRailFile,
|
|
209
330
|
noLockfile: false,
|
|
210
331
|
beta,
|
|
332
|
+
vault,
|
|
333
|
+
suggestAdopt: vault.mode === 'sin decidir',
|
|
334
|
+
neonRevokePending,
|
|
211
335
|
};
|
|
212
336
|
}
|
|
213
337
|
/** Render the doctor result to the console. The A1 notice is ALWAYS last. */
|
|
214
|
-
function renderDoctor(result) {
|
|
338
|
+
function renderDoctor(result, repoVault) {
|
|
339
|
+
const vaultLines = renderRepoVault(repoVault, { suggestAdopt: result.suggestAdopt });
|
|
340
|
+
const neonNotice = neonRevokePendingNotice(result.neonRevokePending);
|
|
341
|
+
if (neonNotice)
|
|
342
|
+
vaultLines.push(neonNotice);
|
|
215
343
|
if (result.noLockfile) {
|
|
216
344
|
// Nothing to say about migrations here: no lockfile IS the Factory signal, so
|
|
217
345
|
// `missingInitialMigration` is false by construction on this branch.
|
|
218
346
|
console.log('Sin lockfile detectado.');
|
|
219
347
|
console.log('Ejecuta `factory:update` para inicializar el tracking de versión.');
|
|
348
|
+
console.log(`\n${vaultLines.join('\n')}`);
|
|
349
|
+
if (result.staleRailFile)
|
|
350
|
+
console.log(`\n${staleRailFileNotice(result.staleRailFile)}`);
|
|
220
351
|
// The A1 notice is emitted even without a lockfile (always present).
|
|
221
352
|
console.log(`\n${result.a1Warning}`);
|
|
222
353
|
return;
|
|
@@ -263,6 +394,12 @@ function renderDoctor(result) {
|
|
|
263
394
|
if (result.missingInitialMigration) {
|
|
264
395
|
console.log(`\n${MISSING_INITIAL_MIGRATION_NOTICE}`);
|
|
265
396
|
}
|
|
397
|
+
// Vault mode + drift (ADOPT-004): informative, never a failure.
|
|
398
|
+
console.log(`\n${vaultLines.join('\n')}`);
|
|
399
|
+
// Stale rail file (RAIL-003): machine-level, advisory, never deleted by doctor.
|
|
400
|
+
if (result.staleRailFile) {
|
|
401
|
+
console.log(`\n${staleRailFileNotice(result.staleRailFile)}`);
|
|
402
|
+
}
|
|
266
403
|
// The A1 security notice is always emitted at the end (design §1 + §11).
|
|
267
404
|
console.log(`\n${result.a1Warning}`);
|
|
268
405
|
}
|
|
@@ -270,17 +407,51 @@ function renderDoctor(result) {
|
|
|
270
407
|
* Run `factory doctor`. Always exits 0 — a missing lockfile is advisory, not a
|
|
271
408
|
* failure (DIST-006 §8). The A1 notice is emitted on every run.
|
|
272
409
|
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
410
|
+
* The offline report (`computeDoctor`) is synchronous and has no network seam. Only a repo in
|
|
411
|
+
* `bóveda` mode adds the drift, a separate bounded step that degrades to "no verificable" (no
|
|
412
|
+
* session, no network, no Vercel project or `railway` block) and never throws for that. Outside `bóveda` nothing is
|
|
413
|
+
* awaited, so the report is rendered synchronously, exactly as before.
|
|
276
414
|
*/
|
|
277
|
-
export function runDoctor(deps = {}) {
|
|
278
|
-
const
|
|
279
|
-
|
|
415
|
+
export async function runDoctor(deps = {}) {
|
|
416
|
+
const rootDir = doctorRoot(deps.rootDir);
|
|
417
|
+
const result = computeDoctor({ ...deps, rootDir });
|
|
418
|
+
if (result.vault.mode !== 'bóveda') {
|
|
419
|
+
renderDoctor(result, {
|
|
420
|
+
mode: result.vault.mode,
|
|
421
|
+
modeError: result.vault.error,
|
|
422
|
+
drift: { status: 'no aplica' },
|
|
423
|
+
});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
renderDoctor(result, await repoVaultReportFor(rootDir, result.vault, deps.drift));
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The repo's vault report for the launcher document, within the launcher's budget. The document
|
|
430
|
+
* must ALWAYS come out (the launcher waits for it), so any failure here — even the Vitest seam
|
|
431
|
+
* refusal, which fires before any request — degrades to "no verificable" with its message.
|
|
432
|
+
*/
|
|
433
|
+
async function launcherRepoVault(deps, rail) {
|
|
434
|
+
const rootDir = doctorRoot(deps.rootDir);
|
|
435
|
+
const reading = readVaultMode(rootDir);
|
|
436
|
+
try {
|
|
437
|
+
return await repoVaultReportFor(rootDir, reading, {
|
|
438
|
+
rail,
|
|
439
|
+
timeoutMs: LAUNCHER_RAIL_TIMEOUT_MS,
|
|
440
|
+
...deps.drift,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
return {
|
|
445
|
+
mode: reading.mode,
|
|
446
|
+
modeError: reading.error,
|
|
447
|
+
drift: {
|
|
448
|
+
status: 'no verificable',
|
|
449
|
+
reason: error instanceof CLIError ? error.message : 'error interno al calcular el drift.',
|
|
450
|
+
railway: 'no verificado',
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
}
|
|
280
454
|
}
|
|
281
|
-
// ---------------------------------------------------------------------------
|
|
282
|
-
// `factory doctor --launcher` (LTUI-002) — env preflight, any-platform/any-shell
|
|
283
|
-
// ---------------------------------------------------------------------------
|
|
284
455
|
/** The glyph per launcher-check status. */
|
|
285
456
|
const STATUS_GLYPH = {
|
|
286
457
|
ok: '✅',
|
|
@@ -340,12 +511,22 @@ export function renderLauncherDoctor(checks) {
|
|
|
340
511
|
* Node version + the gh steps without spawning sub-processes).
|
|
341
512
|
*/
|
|
342
513
|
export async function runLauncherDoctor(deps = {}, json = false) {
|
|
343
|
-
|
|
514
|
+
// ONE rail resolver for the secrets check and the drift: the vault is read once per run.
|
|
515
|
+
const rail = deps.rail ?? createRailResolver({ timeoutMs: LAUNCHER_RAIL_TIMEOUT_MS });
|
|
516
|
+
const [preflight, repoVault] = await Promise.all([
|
|
517
|
+
runLauncherPreflight({ ...deps, rail }),
|
|
518
|
+
launcherRepoVault(deps, rail),
|
|
519
|
+
]);
|
|
520
|
+
// ADOPT-004: mode + drift ride ADDITIVELY on the `secrets` element — its id, status and message
|
|
521
|
+
// are untouched (RAIL-003: `warn` = keys missing / no session, never `fail`), so an installed
|
|
522
|
+
// launcher that predates the field ignores it (ADR-004 §3).
|
|
523
|
+
const checks = preflight.map((check) => check.id === 'secrets' ? { ...check, repoVault } : check);
|
|
344
524
|
if (json) {
|
|
345
525
|
process.stdout.write(`${JSON.stringify(checks)}\n`);
|
|
346
526
|
return;
|
|
347
527
|
}
|
|
348
528
|
renderLauncherDoctor(checks);
|
|
529
|
+
console.log(`\n${renderRepoVault(repoVault, { suggestAdopt: false }).join('\n')}`);
|
|
349
530
|
if (checks.some((c) => c.status === 'fail')) {
|
|
350
531
|
// Non-zero exit without a duplicate error dump: the per-check ❌ lines above
|
|
351
532
|
// already told the user what to fix. The empty message keeps the top-level
|