@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
package/dist/index.js CHANGED
@@ -20,12 +20,14 @@ import { parseDoctorFlags, runDoctor, runLauncherDoctor } from './commands/docto
20
20
  import { parseNewFlags, runNew } from './commands/new.js';
21
21
  import { parseProvisionFlags, runProvision } from './commands/provision.js';
22
22
  import { parsePublishArgs, runPublish } from './commands/publish.js';
23
- import { secretsCommand } from './commands/secrets.js';
24
23
  import { parseStatusFlags, runStatus } from './commands/status.js';
25
24
  import { runTicketCommand } from './commands/ticket.js';
26
25
  import { parseUnpublishTargets, runUnpublish } from './commands/unpublish.js';
27
26
  import { maybeOfferAdopt, parseUpdateFlags, runUpdate } from './commands/update.js';
27
+ import { runVaultCommand } from './commands/vault.js';
28
+ import { loginCommand } from './lib/rail.js';
28
29
  import { detectRepo } from './lib/repo-detection.js';
30
+ import { VAULT_CONFIG } from './lib/vault-config.js';
29
31
  import { runTuiMenu } from './tui/index.js';
30
32
  const HELP = `
31
33
  @timekast/factory — bootstrap y mantenimiento de proyectos derivados del Factory.
@@ -43,12 +45,19 @@ Comandos:
43
45
  (--launcher: verifica el entorno — Node ≥ 22, gh, gh auth, membresía org)
44
46
  publish <qué> Publica el entregable (proposal|mockup|both) a proposals.timekast.mx
45
47
  unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
46
- provision Aprovisiona la infra de un derivado (Neon/Vercel/GitHub/DNS/backlog) — flag-driven, --json
48
+ provision Aprovisiona la infra de un derivado (Neon, GitHub, DNS, backlog y su destino de
49
+ despliegue: Vercel o Railway) — flag-driven, --json
47
50
  backlog <sub> push: refleja el backlog local en backlog.timekast.mx · sync: status + self-healing
48
51
  ticket <sub> push: entrega un factory-ticket de project/factory/ al repo del Factory
49
- secrets <file> Mergea un archivo KEY=value de secretos de metodología a ~/.claude/.env (chmod 600)
50
- env <sub> push: sube product vars a Vercel (day-2) · wizard: configura .env.local
52
+ env <sub> push: sube product vars a Vercel (day-2) · wizard: configura las variables
53
+ (en la bóveda si el repo la tiene; sin bóveda, en .env.local)
51
54
  invite-admin Crea una invitación de super_admin para un derivado (sin password en texto plano)
55
+ vault <sub> adopt: pasa a la bóveda un repo que nació antes de ella (sin --apply sólo
56
+ enseña el plan, por nombre y huella — nunca valores; con varios proyectos
57
+ de Vercel, --vercel-project <id> elige el principal) · sync railway: crea
58
+ el sync de un entorno de la bóveda a un servicio de Railway · deploys: siembra el
59
+ proyecto de cada despliegue de un repo multi-cliente desde la plantilla
60
+ (--apply, --yes para confirmar sin terminal los valores que cambia)
52
61
 
53
62
  Sobre \`new\`:
54
63
  Pregunta el perfil de forma interactiva. Los flags \`--full\` / \`--core\` lo
@@ -85,28 +94,50 @@ Sobre \`update\`:
85
94
  Sobre \`provision\`:
86
95
  Aprovisiona la infra de un proyecto derivado. Flag-driven (sin prompts) y
87
96
  headless-safe; la interacción la pone la skill \`tk-provision\`. Preflight
88
- fail-closed (gh + tokens del gate + \`VERCEL_TOKEN\` con acceso al team);
89
- \`neonctl\` es opcional (ausente → warning). Flags:
90
- --services=a,b Servicios a crear (neon,vercel,github,dns,backlog). \`backlog\`
91
- registra el proyecto en backlog.timekast.mx y escribe
92
- BACKLOG_API_KEY + BACKLOG_PROJECT_ID a .env.local (requiere
93
- BACKLOG_ADMIN_TOKEN en el rail; ausente en un bundle → se omite)
97
+ fail-closed (gh + tokens del gate + — con destino Vercel — \`VERCEL_TOKEN\` con
98
+ acceso al team);
99
+ \`neonctl\` es opcional (ausente → warning). Por default crea primero el proyecto
100
+ del repo en la bóveda (\`vault\`) y cada paso escribe ahí lo que acuña — sin
101
+ \`.env.local\`. No se ofrece en perfil \`core\`, ni se agrega sola a un repo ya
102
+ aprovisionado sin bóveda (para eso: \`factory vault adopt\`). Flags:
103
+ --services=a,b Servicios a crear (vault,neon,github,vercel,railway,dns,backlog). \`backlog\`
104
+ registra el proyecto en backlog.timekast.mx y guarda
105
+ BACKLOG_API_KEY + BACKLOG_PROJECT_ID en el entorno \`local\` de la
106
+ bóveda (sin bóveda: en .env.local) (requiere BACKLOG_ADMIN_TOKEN en
107
+ el rail; ausente en un bundle → se omite)
108
+ --target=<dest> Destino de despliegue del repo: vercel | railway. OBLIGATORIO en un
109
+ alta nueva con paso de despliegue (github, vercel, railway, dns o
110
+ --domain); neon/backlog solos no lo piden. Queda guardado en
111
+ .timekast/provision.json. Un repo sin destino guardado lo descubre
112
+ preguntando a Railway y a Vercel por el repo conectado (nunca por
113
+ vercel.json ni otro archivo). Cambiar un destino guardado: sólo
114
+ --adopt --force --target=<otro>. Con Railway, --adopt lo encuentra
115
+ por el repo conectado y --destroy borra con RAILWAY_TOKEN del rail
116
+ --resolve-target Sólo resuelve el destino y lo guarda en .timekast/provision.json; no
117
+ crea nada más. Salida: 0 = resuelto (guardado o ya estaba) ·
118
+ 10 = sin destino (el repo no está conectado en ninguna) ·
119
+ 11 = ambiguo (conectado en las dos, o project-config.md declara un
120
+ rail de cliente) · 12 = no se pudo leer un token del rail
121
+ --no-vault Omite la bóveda: ninguna llamada a ella, y todo como antes
122
+ (.env.local + escritura directa a los despliegues)
94
123
  --slug=<slug> Identificador del stack (nombre del proyecto + dominios interinos)
95
- --team=<id> Team de Vercel (override; default = \`VERCEL_TEAM_ID\` del rail ~/.claude/.env)
124
+ --team=<id> Team de Vercel (override; default = \`VERCEL_TEAM_ID\` de rail-timekast en la bóveda)
96
125
  --org-id=<id> Org de GitHub (override; default = org del Factory)
97
126
  --neon-org=<x> Organización de Neon donde CREAR el proyecto: nombre o id.
98
127
  Default = \`NEON_ORG\` del rail; si la key ve una sola org, esa.
99
128
  Varias orgs y sin selector → aborta (nunca adivina). Un proyecto
100
129
  que YA existe ignora este flag: su org se lee del proyecto
101
130
  --remint Reacuña la API key project-scoped de Neon aunque el paso esté
102
- completo (revoca la vigente, sube los 3 secrets de E2E y
103
- reescribe .env.local). Para una key revocada por fuera
131
+ completo (revoca la vigente, sube los 3 secrets de E2E y la
132
+ reescribe en develop:/ci de la bóveda — sin bóveda, en .env.local).
133
+ Para una key revocada por fuera
104
134
  --domain=<dom> Dominio definitivo del cliente (diferible; standalone o inicial)
105
135
  --dns=<ruta> Ruta DNS para --domain: org (default) | cliente-cf | registrar
106
136
  --verify-timeout=<min> Timeout del sondeo de verificación de Vercel (default 10)
107
137
  --confirm Pre-aprueba retirar el dominio interino tras verificar (headless-safe)
108
138
  --ephemeral Marca el stack \`ephemeral\` (vs \`live\`) para \`--destroy\`
109
- --dry-run Imprime el plan sin tocar ninguna API ni escribir estado
139
+ --dry-run Imprime el plan sin tocar ninguna API de plataforma (Railway, Vercel,
140
+ Neon, Cloudflare) ni escribir estado; sin destino resuelto lo dice
110
141
  --resume Retoma desde \`.timekast/provision.json\`, salta pasos hechos
111
142
  --adopt Reconstruye \`.timekast/provision.json\` de un derivado existente
112
143
  consultando los providers EN SOLO-LECTURA (nunca crea/borra)
@@ -128,16 +159,23 @@ Sobre \`status\` y \`doctor\`:
128
159
 
129
160
  Sobre \`env\`:
130
161
  \`env push\` sube las product vars de \`.env.local\` a Vercel (production+preview),
131
- sin tocar las provision-owned. \`env wizard\` configura \`.env.local\`:
162
+ sin tocar las provision-owned. En un repo con bóveda (bloque \`vault\`) no escribe:
163
+ lo dice, nombra el proyecto de la bóveda y termina sin error. \`env wizard\` configura
164
+ las variables — en un repo con bóveda las escribe ahí (una copia en main:/, referenciada desde develop:/ y local); sin
165
+ bóveda, en \`.env.local\`:
132
166
  (sin flags) Wizard interactivo en la terminal (deriva las preguntas de \`.env.example\`)
133
167
  --plan [--json] Emite el plan de preguntas (lo consume el agente / la skill tk-provision)
134
- --apply [--push] Lee \`{ answers, push? }\` JSON por stdin y escribe \`.env.local\`
168
+ --apply [--push] Lee \`{ answers, push? }\` JSON por stdin y las escribe (con bóveda,
169
+ \`--push\` no aplica: los despliegues las reciben de la bóveda)
170
+ --review [--json] Resumen enmascarado (con bóveda: del entorno \`local\`)
171
+ --gen-vapid Genera las llaves VAPID (con bóveda, se guardan ahí; la privada no se imprime)
135
172
 
136
173
  Sobre \`backlog\`:
137
174
  \`backlog push\` refleja el backlog local (\`project/backlog/*\`) en
138
175
  backlog.timekast.mx en cascada (épicas → features de los Refs \`FT-XX\` → issues
139
176
  story/task según el campo \`Board:\`). Read-only por defecto: sin \`BACKLOG_API_KEY\`
140
- en \`.env.local\` termina con exit 0 y una nota (nunca bloquea \`/backlog\`). Flags:
177
+ (en un repo sin bóveda, la de \`.env.local\`) termina con exit 0 y una nota (nunca
178
+ bloquea \`/backlog\`). Flags:
141
179
  --reconcile PATCH del status cuando el elemento ya existe (en vez de saltarlo)
142
180
  --stamp Incluye issues/épicas sin UUID y estampa su \`Backlog UUID\` de vuelta
143
181
  --backfill Campaña legacy: incluye + estampa un backlog pre-BSYNC-007 completo
@@ -166,10 +204,12 @@ Sobre \`ticket\`:
166
204
 
167
205
  Sobre \`invite-admin\`:
168
206
  Materializa el primer super_admin de un derivado por invitación (reemplaza el
169
- viejo seed con password en texto plano). Resuelve la connection string del
170
- target desde \`.env.local\` y spawnea el script del kit con ella:
207
+ viejo seed con password en texto plano). En un repo sin bóveda resuelve la
208
+ connection string del target desde \`.env.local\` y spawnea el script del kit con ella:
209
+ En un repo con bóveda lee el entorno del target directo de la bóveda, con tu sesión
210
+ (main → main:/ + main:/ci; develop → local) y no toca \`.env.local\`.
171
211
  --email=<addr> Email que recibe la invitación y será super_admin (requerido)
172
- --target=<t> develop (default, usa DATABASE_URL) | main (usa DATABASE_URL_MAIN)
212
+ --target=<t> develop (default, usa DATABASE_URL) | main (sin bóveda: DATABASE_URL_MAIN)
173
213
  --app-url=<url> Override del NEXT_PUBLIC_APP_URL del link de aceptación
174
214
  (úsalo con --target main para que apunte a producción)
175
215
  Alias en el derivado: \`pnpm invite:admin --email=...\`.
@@ -191,6 +231,24 @@ function readVersion() {
191
231
  return '0.0.0';
192
232
  }
193
233
  }
234
+ /**
235
+ * The tombstone of `factory secrets` (RETIRED — the rail lives in the vault; nothing reads a
236
+ * local rail file anymore). Kept instead of "unknown command" because installed desktop
237
+ * launchers still run `factory secrets <path>` and paint success on exit 0. Contract:
238
+ * · every shape (`secrets`, `secrets <path>`, `secrets pull|sync`, `secrets --help`) gets it;
239
+ * · non-zero exit (CLIError), so an old launcher card renders it as an error;
240
+ * · the LAST line is the actionable instruction — an old card shows only that line;
241
+ * · the arguments are never read: nothing is opened, validated or echoed (a path can name a
242
+ * person or a client).
243
+ */
244
+ function secretsRetiredError() {
245
+ const { rail } = VAULT_CONFIG;
246
+ return new CLIError([
247
+ `\`factory secrets\` se retiró: el rail de secretos ya no se importa a ~/.claude/.env, vive en la bóveda (proyecto ${rail.name}) y los comandos lo leen de ahí.`,
248
+ 'No se leyó ni se importó ningún archivo.',
249
+ `Para entrar: corre \`${loginCommand()}\` y pide a un admin acceso a ${rail.name}.`,
250
+ ].join('\n'));
251
+ }
194
252
  async function main(argv) {
195
253
  const [command, ...rest] = argv;
196
254
  // Explicit help flags always print the static HELP — never the TUI.
@@ -198,6 +256,11 @@ async function main(argv) {
198
256
  console.log(HELP);
199
257
  return;
200
258
  }
259
+ // `factory secrets` is retired: EVERY shape answers with the tombstone — `--help`/`-h`
260
+ // included, which otherwise the help gate below would turn into an exit 0 that an old
261
+ // launcher paints as success. Before that gate on purpose.
262
+ if (command === 'secrets')
263
+ throw secretsRetiredError();
201
264
  // A `--help`/`-h` flag ANYWHERE (e.g. `factory env push --help`) prints help and
202
265
  // NEVER runs the command. A help flag must never trigger a mutation: `env push --help`
203
266
  // used to fire a REAL Vercel push, because the check above only inspects the first arg.
@@ -259,7 +322,9 @@ async function main(argv) {
259
322
  await runLauncherDoctor({}, flags.json);
260
323
  }
261
324
  else {
262
- runDoctor();
325
+ // Awaited: in `bóveda` mode the report waits for the bounded drift step (ADOPT-004), and
326
+ // `main` exits the process as soon as it resolves.
327
+ await runDoctor();
263
328
  }
264
329
  return;
265
330
  }
@@ -300,16 +365,9 @@ async function main(argv) {
300
365
  return;
301
366
  }
302
367
  case 'secrets': {
303
- // Single shape: `factory secrets <file>` — merge a KEY=value file into the
304
- // rail (~/.claude/.env). The org gate (`secrets pull`/`sync`) was removed;
305
- // the future shared-secrets system is Infisical (a fresh branch).
306
- const file = rest.filter((a) => !a.startsWith('-'))[0];
307
- if (!file) {
308
- throw new CLIError('Falta el archivo de secretos. Uso: `factory secrets <file>` ' +
309
- '(un archivo `KEY=value`, p.ej. el master `secrets.env`) que se mergea a `~/.claude/.env`.');
310
- }
311
- await secretsCommand(file);
312
- return;
368
+ // Unreachable today (the tombstone fires before the help gate). Kept so that reordering
369
+ // the gates above can never turn `secrets` into "unknown command".
370
+ throw secretsRetiredError();
313
371
  }
314
372
  case 'env': {
315
373
  const sub = rest[0];
@@ -329,11 +387,11 @@ async function main(argv) {
329
387
  return;
330
388
  }
331
389
  if (wflags.review) {
332
- runEnvWizardReview(wflags);
390
+ await runEnvWizardReview(wflags);
333
391
  return;
334
392
  }
335
393
  if (wflags.genVapid) {
336
- runEnvWizardGenVapid();
394
+ await runEnvWizardGenVapid();
337
395
  return;
338
396
  }
339
397
  if (wflags.apply) {
@@ -346,6 +404,12 @@ async function main(argv) {
346
404
  throw new CLIError(`Subcomando de env desconocido: \`${sub ?? '(ninguno)'}\`. ` +
347
405
  'Uso: `factory env push` o `factory env wizard`.');
348
406
  }
407
+ case 'vault': {
408
+ // `factory vault adopt [--apply]` (ADOPT-001) — move a repo born before the vault into it.
409
+ // Without a subcommand it prints the vault help.
410
+ await runVaultCommand(rest);
411
+ return;
412
+ }
349
413
  case 'invite-admin': {
350
414
  // Mint a single-use super_admin invite against the target branch's DB.
351
415
  // Spawns the kit script `scripts/tools/invite-admin.ts` with the resolved
@@ -18,6 +18,9 @@
18
18
  * remotely, keeping the 6↔6 bijection intact.
19
19
  */
20
20
  import { existsSync, readFileSync } from 'node:fs';
21
+ import { readVaultLink, UNREADABLE_STATE_REMEDY, VaultLinkMalformedError, } from './provision-state.js';
22
+ import { INFISICAL_INSTALL_HINT, RailNoSessionError, RailNotInstalledError, loginCommand, } from './rail.js';
23
+ import { VAULT_ROOT_PATH, VaultApiError, VaultNoAccessError, createVaultClient, } from './vault-api.js';
21
24
  /**
22
25
  * LOCAL bucket → REMOTE `WorkStatus`. Half of the bijection; {@link STATUS_REMOTE_TO_LOCAL}
23
26
  * is its exact inverse. The 6 remote states were live-verified (smoke Hallazgo 3).
@@ -106,9 +109,9 @@ export function mapStoryPoints(sp) {
106
109
  return Number.isInteger(sp) && sp > 0 ? sp : null;
107
110
  }
108
111
  // ─────────────────────────────────────────────────────────────────────────────
109
- // Credential resolution — fail-closed, NEVER throws (issue §4)
112
+ // Credential resolution — fail-closed, NEVER throws (issue §4 · VAULT-006)
110
113
  // ─────────────────────────────────────────────────────────────────────────────
111
- /** The env keys the derivative's `.env.local` carries for the backlog integration. */
114
+ /** The env keys that carry the project-scoped backlog credentials. */
112
115
  export const BACKLOG_API_KEY_VAR = 'BACKLOG_API_KEY';
113
116
  export const BACKLOG_PROJECT_ID_VAR = 'BACKLOG_PROJECT_ID';
114
117
  /** Default FS reader — reads `.env.local` if present, swallowing any error to `null`. */
@@ -133,15 +136,14 @@ function readEnvValue(body, key) {
133
136
  }
134
137
  return value.length > 0 ? value : undefined;
135
138
  }
136
- /**
137
- * Resolve the per-project backlog credentials from `{rootDir}/.env.local`. FAIL-CLOSED
138
- * (issue §4): a missing file, an unreadable file, or a missing/empty `BACKLOG_API_KEY`
139
- * / `BACKLOG_PROJECT_ID` all return the distinctive `not-configured` result — this
140
- * function NEVER throws, so a derivative that never provisioned the backlog service
141
- * does not crash `push`/`sync`; they just skip. The `readFile` seam is injectable so
142
- * tests drive every branch without disk.
143
- */
144
- export function resolveBacklogCredentials(rootDir, deps = {}) {
139
+ /** The names of the two credential keys that are missing, joined for a reason. */
140
+ function missingKeys(apiKey, projectId) {
141
+ return [!apiKey ? BACKLOG_API_KEY_VAR : null, !projectId ? BACKLOG_PROJECT_ID_VAR : null]
142
+ .filter(Boolean)
143
+ .join(' + ');
144
+ }
145
+ /** The pre-vault source: `{rootDir}/.env.local` (behavior unchanged). */
146
+ function resolveFromEnvFile(rootDir, deps) {
145
147
  const read = deps.readFile ?? defaultReadFile;
146
148
  let body;
147
149
  try {
@@ -160,16 +162,107 @@ export function resolveBacklogCredentials(rootDir, deps = {}) {
160
162
  const apiKey = readEnvValue(body, BACKLOG_API_KEY_VAR);
161
163
  const projectId = readEnvValue(body, BACKLOG_PROJECT_ID_VAR);
162
164
  if (!apiKey || !projectId) {
163
- const missing = [
164
- !apiKey ? BACKLOG_API_KEY_VAR : null,
165
- !projectId ? BACKLOG_PROJECT_ID_VAR : null,
166
- ]
167
- .filter(Boolean)
168
- .join(' + ');
169
165
  return {
170
166
  status: 'not-configured',
171
- reason: `Falta ${missing} en \`.env.local\`; el backlog central no está configurado.`,
167
+ reason: `Falta ${missingKeys(apiKey, projectId)} en \`.env.local\`; el backlog central no está configurado.`,
172
168
  };
173
169
  }
174
170
  return { status: 'configured', apiKey, projectId };
175
171
  }
172
+ /**
173
+ * Translate a vault failure into the `reason` of a `not-configured` result. Branches on the
174
+ * error CLASS / `kind`, never on its text, and never quotes the error's message (it could carry a
175
+ * host or an operation, but never a value — still, the reason is built here, names only).
176
+ * 🔴 No reason mentions `.env.local`: in a repo with a vault that file is not the source, and
177
+ * naming it sends the developer after a file that no longer exists.
178
+ */
179
+ function vaultFailureReason(error, link) {
180
+ if (error instanceof RailNoSessionError) {
181
+ return ('No hay una sesión activa en la bóveda de secretos, o ya caducó; entra con ' +
182
+ `\`${loginCommand()}\` y vuelve a correr.`);
183
+ }
184
+ if (error instanceof RailNotInstalledError) {
185
+ return ('El CLI de la bóveda (`infisical`) no está instalado; instálalo con ' +
186
+ `\`${INFISICAL_INSTALL_HINT}\` y entra con \`${loginCommand()}\`.`);
187
+ }
188
+ if (error instanceof VaultNoAccessError) {
189
+ return (`Tu sesión de la bóveda no tiene acceso al proyecto \`${link.projectId}\` de este repo ` +
190
+ '(o ese proyecto no existe); pide acceso a un admin de la bóveda.');
191
+ }
192
+ if (error instanceof VaultApiError && error.kind === 'timeout') {
193
+ return 'La bóveda de secretos no respondió a tiempo; reintenta más tarde.';
194
+ }
195
+ return ('La bóveda de secretos no respondió (o respondió algo que no reconozco); reintenta más tarde ' +
196
+ 'y, si persiste, repórtalo al equipo del Factory.');
197
+ }
198
+ /**
199
+ * The vault source (VAULT-006): the `local` environment of the repo's vault project, read with
200
+ * the vault client (VAULT-001) and the PERSON's session — no wrapper, so it works through `npx`.
201
+ * Never touches `.env.local`, even when a leftover one exists: the vault is the only source, and
202
+ * an old file would point at a rotated key.
203
+ */
204
+ async function resolveFromVault(link, deps) {
205
+ let secrets;
206
+ try {
207
+ const client = createVaultClient(deps.vault);
208
+ secrets = await client.readSecrets(link.projectId, { env: link.envs.local, path: VAULT_ROOT_PATH }, { expandReferences: true });
209
+ }
210
+ catch (error) {
211
+ return { status: 'not-configured', reason: vaultFailureReason(error, link) };
212
+ }
213
+ const apiKey = secrets.get(BACKLOG_API_KEY_VAR)?.trim() || undefined;
214
+ const projectId = secrets.get(BACKLOG_PROJECT_ID_VAR)?.trim() || undefined;
215
+ if (!apiKey || !projectId) {
216
+ return {
217
+ status: 'not-configured',
218
+ reason: `Falta ${missingKeys(apiKey, projectId)} en el entorno \`${link.envs.local}\` de la bóveda ` +
219
+ 'de este repo; el backlog central no está conectado (se da de alta con ' +
220
+ '`factory provision --services=backlog`).',
221
+ };
222
+ }
223
+ return { status: 'configured', apiKey, projectId };
224
+ }
225
+ /**
226
+ * Resolve the per-project backlog credentials. The presence of the `vault` block in
227
+ * `.timekast/provision.json` decides the source:
228
+ * - WITH a vault block → the `local` environment of the repo's vault project (never
229
+ * `.env.local`). Every vault failure (no session, no access, missing keys, vault down,
230
+ * `infisical` missing, no seam under tests) becomes `not-configured` with a reason that names
231
+ * the real cause.
232
+ * - WITHOUT one → `{rootDir}/.env.local`, exactly as before.
233
+ *
234
+ * FAIL-CLOSED: this function NEVER throws (nor rejects), so a derivative that never provisioned
235
+ * the backlog service does not crash `push`/`sync`; they just skip. An unreadable
236
+ * `provision.json` is `not-configured` too — "I could not tell" never falls back to `.env.local`.
237
+ */
238
+ export async function resolveBacklogCredentials(rootDir, deps = {}) {
239
+ let link;
240
+ try {
241
+ link = (deps.vaultLink ?? readVaultLink)(rootDir);
242
+ }
243
+ catch (error) {
244
+ // Neither case is fixed by `--adopt`: a malformed `vault` block stops it on the same block,
245
+ // and over an unreadable file it rebuilds the state WITHOUT the `vault` block. Name the
246
+ // manual fix (the file is committed: git holds the last good copy).
247
+ return {
248
+ status: 'not-configured',
249
+ reason: error instanceof VaultLinkMalformedError
250
+ ? 'El bloque `vault` de `.timekast/provision.json` no tiene la forma esperada; ' +
251
+ 'corrígelo a mano (o bórralo si este repo no vive en la bóveda).'
252
+ : 'No pude leer `.timekast/provision.json` para saber si este repo vive en la bóveda: ' +
253
+ UNREADABLE_STATE_REMEDY,
254
+ };
255
+ }
256
+ if (link === null)
257
+ return resolveFromEnvFile(rootDir, deps);
258
+ try {
259
+ return await resolveFromVault(link, deps);
260
+ }
261
+ catch {
262
+ // Belt-and-suspenders: resolveFromVault already catches; the contract is "never throws".
263
+ return {
264
+ status: 'not-configured',
265
+ reason: 'La bóveda de secretos no respondió (o respondió algo que no reconozco); reintenta más tarde.',
266
+ };
267
+ }
268
+ }
@@ -95,7 +95,7 @@ export function parseTitle(content) {
95
95
  const match = content.match(/^# [\w-]+: (.*)$/m);
96
96
  return match ? match[1].trim() : undefined;
97
97
  }
98
- /** Parse `> **Skills:**` into its backtick-quoted tokens (`` `sk-api`, `kb-api` `` → [...]). */
98
+ /** Parse `> **Skills:**` into its backtick-quoted tokens (`` `sk-api`, `sk-db` `` → [...]). */
99
99
  export function parseSkills(content) {
100
100
  const line = matchBlockquoteField(content, 'Skills');
101
101
  if (!line)