@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
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Reads `.env.local` and pushes ONLY the product vars (everything EXCEPT the
5
5
  * provision-owned, per-environment vars) to `production` + `preview` via the
6
- * Vercel REST API. Reuses `pushEnvPlan` + `readVercelToken` + the existing
6
+ * Vercel REST API. Reuses `pushEnvPlan` + `readVercelToken` (the vault rail, with the
7
+ * person's session — `lib/rail.ts`) + the existing
7
8
  * `.vercel/project.json` link — no `vercel link`, no re-implemented REST push,
8
9
  * no risk of clobbering the per-env DATABASE_URL/AUTH_SECRET that provision set.
9
10
  *
@@ -14,9 +15,10 @@ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
14
15
  import path from 'node:path';
15
16
  import { CLIError } from '../lib/cli-error.js';
16
17
  import { PER_ENV_MARKER, PER_ENV_REGISTRY_FILE, PROVISION_OWNED, addToPerEnvRegistry, findInlinePerEnvMarkers, findPerEnvMarkedKeys, listEnvKeys, readPerEnvRegistry, setEnvLine, } from '../lib/env-file.js';
17
- import { applyDefaultOverrides, applyWizardAnswers, buildEnvReview, buildQuestionPlan, checkEnvCompleteness, generateVapidKeys, isQuestionActive, parseEnvBody, parseEnvExample, provisionEmailDefaults, } from '../lib/env-wizard.js';
18
- import { defaultEnvPath, readVercelToken } from '../lib/provision-preflight.js';
19
- import { readProvisionState } from '../lib/provision-state.js';
18
+ import { applyDefaultOverrides, applyWizardAnswers, applyWizardAnswersToVault, buildEnvReview, buildEnvReviewFromEnv, buildQuestionPlan, checkEnvCompleteness, generateVapidKeys, isQuestionActive, parseEnvBody, parseEnvExample, provisionEmailDefaults, readVaultLocalEnv, readVaultMain, writeSharedToVault, } from '../lib/env-wizard.js';
19
+ import { defaultRail, railTokenRejectedHint, readVercelToken } from '../lib/provision-preflight.js';
20
+ import { readProvisionState, readVaultLink } from '../lib/provision-state.js';
21
+ import { createVaultClient } from '../lib/vault-api.js';
20
22
  import { pushEnvPlan, vercelProjectPath, VercelApiError, } from '../lib/vercel-env.js';
21
23
  // Re-exported so existing importers (and `cli/tests/unit/env-push.test.ts`) keep
22
24
  // resolving PROVISION_OWNED from this module after it moved to `lib/env-file.js`.
@@ -110,7 +112,67 @@ function readProjectLink(rootDir) {
110
112
  }
111
113
  return { projectId, teamId };
112
114
  }
113
- export async function runEnvPush(flags, rootDir = process.cwd()) {
115
+ const VERCEL_SYNC_ROLES = ['vercel-production', 'vercel-preview'];
116
+ /**
117
+ * The {@link VaultVercelSyncStatus} of the repo, read with the existing state reader and
118
+ * tolerating its absence or another shape (never throws — it only shapes a message).
119
+ */
120
+ export function readVaultVercelSyncStatus(rootDir) {
121
+ let state;
122
+ try {
123
+ state = readProvisionState(rootDir);
124
+ }
125
+ catch {
126
+ return 'unknown';
127
+ }
128
+ if (!state)
129
+ return 'unknown';
130
+ if (state.target === 'railway')
131
+ return 'railway';
132
+ const syncs = state.syncs;
133
+ const recorded = VERCEL_SYNC_ROLES.every((role) => Boolean(syncs?.ids?.[role]));
134
+ const pending = VERCEL_SYNC_ROLES.some((role) => syncs?.pending?.includes(role));
135
+ return recorded && !pending ? 'active' : 'pending';
136
+ }
137
+ /**
138
+ * What `env push` says in a repo linked to the vault instead of writing (SYNC-002). The vault's
139
+ * syncs are the ONLY writer of Vercel there (`fx-secrets-vault §7`, "un solo escritor por
140
+ * destino"): a second writer would leave the destination with whichever value landed last. Names
141
+ * the vault project and the environments from the block — ids only, never a value. It only claims
142
+ * the sync carries the value to Vercel when the state records both Vercel syncs; otherwise it
143
+ * says they are not confirmed and points at `factory provision --resume`, which creates them.
144
+ */
145
+ export function vaultPushCessionMessage(link, syncs = 'active') {
146
+ if (syncs === 'railway') {
147
+ return ('Este repo usa la bóveda y despliega en Railway: sus variables llegan ahí por los syncs de la ' +
148
+ 'bóveda y env push no escribe en ningún destino. Cambia el valor en la bóveda ' +
149
+ `(proyecto ${link.projectId}, entorno ${link.envs.main} → Railway \`main\`, ` +
150
+ `${link.envs.develop} → Railway \`develop\`); el sync lo lleva a Railway. No se envió nada.`);
151
+ }
152
+ const where = `(proyecto ${link.projectId}, entorno ${link.envs.main} → production, ` +
153
+ `${link.envs.develop} → preview)`;
154
+ const delivery = syncs === 'active'
155
+ ? 'el sync lo lleva a Vercel y aplica en el siguiente deploy. '
156
+ : syncs === 'pending'
157
+ ? 'pero los syncs de la bóveda hacia Vercel están pendientes o no están registrados en ' +
158
+ '`.timekast/provision.json`, así que el valor no llega a Vercel hasta que existan: corre ' +
159
+ '`factory provision --resume` para crearlos. '
160
+ : 'pero `.timekast/provision.json` no registra los syncs de la bóveda hacia Vercel, así que ' +
161
+ 'no puedo confirmar que el valor llegue: revisa los syncs del proyecto en la bóveda. ';
162
+ return ('Este repo usa la bóveda: sus variables llegan a Vercel por sincronización y env push no ' +
163
+ `escribe ahí. Cambia el valor en la bóveda ${where}; ${delivery}` +
164
+ 'No se envió nada a Vercel.');
165
+ }
166
+ export async function runEnvPush(flags, rootDir = process.cwd(), deps = {}) {
167
+ // SYNC-002: FIRST, before `.env.local`, the per-env registry or the inline-marker abort. In a
168
+ // vault repo none of those matter — nothing is written, `--dry-run` included — so a broken
169
+ // registry must not mask the real answer. Fail-closed: a malformed block throws (never "no
170
+ // vault", which would make this command the second writer it must not be).
171
+ const vaultLink = readVaultLink(rootDir);
172
+ if (vaultLink) {
173
+ console.log(vaultPushCessionMessage(vaultLink, readVaultVercelSyncStatus(rootDir)));
174
+ return;
175
+ }
114
176
  const body = readEnvBody(path.join(rootDir, '.env.local'));
115
177
  // Resolve the derivative's per-environment declaration BEFORE anything can reach
116
178
  // Vercel: a present-but-broken registry throws here and aborts the whole run
@@ -195,11 +257,8 @@ export async function runEnvPush(flags, rootDir = process.cwd()) {
195
257
  return;
196
258
  }
197
259
  const { projectId, teamId } = readProjectLink(rootDir);
198
- const token = readVercelToken(defaultEnvPath());
199
- if (!token) {
200
- throw new CLIError('No se pudo leer `VERCEL_TOKEN` de `~/.claude/.env`. ' +
201
- 'Corre `factory secrets <archivo>` para bajar el token y reintenta.');
202
- }
260
+ // Read only on a real push: `--dry-run` returned above without touching the vault.
261
+ const token = await readVercelToken(deps.rail ?? defaultRail());
203
262
  try {
204
263
  const result = await pushEnvPlan(projectId, token, teamId, plan);
205
264
  console.log(`✔ env push: ${result.created.length} creadas, ${result.updated.length} actualizadas ` +
@@ -210,6 +269,10 @@ export async function runEnvPush(flags, rootDir = process.cwd()) {
210
269
  }
211
270
  catch (error) {
212
271
  if (error instanceof VercelApiError) {
272
+ if (error.status === 401) {
273
+ throw new CLIError('El `VERCEL_TOKEN` del rail es inválido o expiró (Vercel respondió 401 al subir las variables).\n' +
274
+ railTokenRejectedHint('VERCEL_TOKEN'));
275
+ }
213
276
  throw new CLIError(`Vercel rechazó el push de env: ${error.message}`);
214
277
  }
215
278
  throw error;
@@ -272,6 +335,19 @@ async function readStdinJson() {
272
335
  chunks.push(chunk);
273
336
  return Buffer.concat(chunks).toString('utf8');
274
337
  }
338
+ /**
339
+ * The repo's vault project when `.timekast/provision.json` has a `vault` block, else `null` —
340
+ * the switch between the vault path and the historical `.env.local` path of every wizard
341
+ * command. Fail-closed: an unreadable state file throws instead of falling back to the file.
342
+ */
343
+ export function wizardVaultTarget(rootDir, deps = {}) {
344
+ const link = readVaultLink(rootDir);
345
+ if (!link)
346
+ return null;
347
+ return { client: deps.vaultClient ?? createVaultClient(), projectId: link.projectId, link };
348
+ }
349
+ /** Said after a wizard write in a vault repo, instead of offering `env:push`. */
350
+ export const VAULT_DEPLOYS_NOTICE = 'ℹ️ En un repo con bóveda, los despliegues reciben estos valores desde la bóveda: no uso `env:push`.';
275
351
  /**
276
352
  * `factory env wizard --apply [--push]` — read `{ answers, push? }` JSON from
277
353
  * stdin, write the answers to `.env.local`, and optionally push product vars.
@@ -286,6 +362,25 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
286
362
  throw new CLIError('Esperaba JSON `{ "answers": { KEY: "value" }, "push"?: bool }` por stdin.');
287
363
  }
288
364
  const answers = parsed.answers ?? {};
365
+ const shouldPush = flags.push || parsed.push === true;
366
+ // VAULT-002: a repo in the vault gets the answers there (one copy in `main:/`, referenced
367
+ // from `develop:/` and `local`) — never a `.env.local`, and never `env:push`.
368
+ const vault = wizardVaultTarget(rootDir, deps);
369
+ if (vault) {
370
+ const { written, empty } = await applyWizardAnswersToVault(vault, answers);
371
+ console.log(`✔ Escribí ${written.length} variable(s) en la bóveda (main:/, referenciadas desde develop:/ y local)` +
372
+ (written.length > 0 ? `: ${written.join(', ')}.` : '.'));
373
+ if (empty.length > 0)
374
+ console.log(` (sin valor, no se guardan: ${empty.join(', ')})`);
375
+ for (const w of checkEnvCompleteness(await readVaultLocalEnv(vault)))
376
+ console.log(`⚠ ${w}`);
377
+ // SYNC-002: a requested push answers with the cession (the vault's sync is Vercel's only
378
+ // writer) instead of reaching `runEnvPush` — the wizard ends cleanly, nothing is sent.
379
+ console.log(shouldPush
380
+ ? vaultPushCessionMessage(vault.link, readVaultVercelSyncStatus(rootDir))
381
+ : VAULT_DEPLOYS_NOTICE);
382
+ return;
383
+ }
289
384
  const written = applyWizardAnswers(rootDir, answers);
290
385
  console.log(`✔ Escribí ${written.length} variable(s) en \`.env.local\`.`);
291
386
  // Completeness guards (OAuth creds, VAPID) — warn, never block the write. Read
@@ -293,7 +388,6 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
293
388
  const finalEnv = parseEnvBody(readFileSync(path.join(rootDir, '.env.local'), 'utf8'));
294
389
  for (const w of checkEnvCompleteness(finalEnv))
295
390
  console.log(`⚠ ${w}`);
296
- const shouldPush = flags.push || parsed.push === true;
297
391
  if (!shouldPush) {
298
392
  console.log('ℹ️ Para subirlas a Vercel cuando quieras: `pnpm env:push`.');
299
393
  return;
@@ -309,7 +403,12 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
309
403
  * the dev at the end (the agent NEVER echoes a raw secret) — the dev opens
310
404
  * `.env.local` to see the full values.
311
405
  */
312
- export function runEnvWizardReview(flags, rootDir = process.cwd()) {
406
+ export function runEnvWizardReview(flags, rootDir = process.cwd(), deps = {}) {
407
+ // VAULT-002: in a vault repo the review resolves the `local` environment (own values + the
408
+ // `develop:/ci` import + references) — there is no `.env.local` to read or to send the dev to.
409
+ const vault = wizardVaultTarget(rootDir, deps);
410
+ if (vault)
411
+ return runEnvWizardReviewFromVault(flags, rootDir, vault);
313
412
  const localPath = path.join(rootDir, '.env.local');
314
413
  if (!existsSync(localPath)) {
315
414
  throw new CLIError('No encontré `.env.local`. Corre el wizard (`factory env wizard`) primero.');
@@ -317,13 +416,18 @@ export function runEnvWizardReview(flags, rootDir = process.cwd()) {
317
416
  const body = readFileSync(localPath, 'utf8');
318
417
  const answers = parseEnvBody(body);
319
418
  const questions = planWithProvisionDefaults(rootDir).filter((q) => isQuestionActive(q, answers));
320
- const rows = buildEnvReview(questions, body);
321
- const warnings = checkEnvCompleteness(answers);
419
+ const printed = printEnvReview(flags, 'Configuración de `.env.local` (los secretos van enmascarados):\n', buildEnvReview(questions, body), checkEnvCompleteness(answers));
420
+ if (printed) {
421
+ console.log('\nAbre `.env.local` para ver los valores completos (de un secreto solo se ven sus últimos 4).');
422
+ }
423
+ }
424
+ /** Print the review rows + warnings (shared by the file path and the vault path). */
425
+ function printEnvReview(flags, title, rows, warnings) {
322
426
  if (flags.json) {
323
427
  process.stdout.write(`${JSON.stringify({ rows, warnings }, null, 2)}\n`);
324
- return;
428
+ return false;
325
429
  }
326
- console.log('Configuración de `.env.local` (los secretos van enmascarados):\n');
430
+ console.log(title);
327
431
  let section = '';
328
432
  for (const row of rows) {
329
433
  if (row.section !== section) {
@@ -338,14 +442,28 @@ export function runEnvWizardReview(flags, rootDir = process.cwd()) {
338
442
  for (const w of warnings)
339
443
  console.log(` ⚠ ${w}`);
340
444
  }
341
- console.log('\nAbre `.env.local` para ver los valores completos (de un secreto solo se ven sus últimos 4).');
445
+ return true;
446
+ }
447
+ /** `--review` over the vault's `local` environment (VAULT-002). Same masking rule. */
448
+ async function runEnvWizardReviewFromVault(flags, rootDir, vault) {
449
+ const env = await readVaultLocalEnv(vault);
450
+ const questions = planWithProvisionDefaults(rootDir).filter((q) => isQuestionActive(q, env));
451
+ const printed = printEnvReview(flags, 'Configuración del entorno `local` de la bóveda (los secretos van enmascarados):\n', buildEnvReviewFromEnv(questions, env), checkEnvCompleteness(env));
452
+ if (printed) {
453
+ console.log('\nLos valores completos viven en la bóveda (entorno `local`); de un secreto aquí solo se ven sus últimos 4.');
454
+ }
342
455
  }
343
456
  /**
344
457
  * `factory env wizard --gen-vapid` — generate a VAPID keypair and write it to
345
458
  * `.env.local`. The private key is a SECRET: it is written to the file (0600) and
346
459
  * NEVER printed. The public key is public by design, so it is safe to echo.
347
460
  */
348
- export function runEnvWizardGenVapid(rootDir = process.cwd()) {
461
+ export function runEnvWizardGenVapid(rootDir = process.cwd(), deps = {}) {
462
+ // VAULT-002: in a vault repo the three keys go to the vault (one copy in `main:/`, referenced
463
+ // from `develop:/` and `local`) — never to a file. The private key is never printed.
464
+ const vault = wizardVaultTarget(rootDir, deps);
465
+ if (vault)
466
+ return genVapidIntoVault(vault);
349
467
  const localPath = path.join(rootDir, '.env.local');
350
468
  const examplePath = path.join(rootDir, '.env.example');
351
469
  const basePath = existsSync(localPath) ? localPath : examplePath;
@@ -371,3 +489,21 @@ export function runEnvWizardGenVapid(rootDir = process.cwd()) {
371
489
  console.log(' ℹ️ Revisa VAPID_SUBJECT — puse un placeholder (correo de contacto para push).');
372
490
  }
373
491
  }
492
+ /**
493
+ * `--gen-vapid` in a vault repo (VAULT-002): the keypair is upserted (a new pair per run, as on
494
+ * the file path) and `VAPID_SUBJECT` is seeded only when missing, from `main:/EMAIL_FROM`.
495
+ */
496
+ async function genVapidIntoVault(vault) {
497
+ const { publicKey, privateKey } = generateVapidKeys();
498
+ await writeSharedToVault(vault, { NEXT_PUBLIC_VAPID_PUBLIC_KEY: publicKey, VAPID_PRIVATE_KEY: privateKey }, 'upsert');
499
+ const main = await readVaultMain(vault);
500
+ const subjectMissing = !main['VAPID_SUBJECT'];
501
+ const from = main['EMAIL_FROM'];
502
+ await writeSharedToVault(vault, { VAPID_SUBJECT: from ? `mailto:${from}` : 'mailto:admin@example.com' }, 'create-if-missing');
503
+ console.log('✔ Llaves VAPID generadas y escritas en la bóveda (main:/, referenciadas desde develop:/ y local; ' +
504
+ 'la privada queda oculta).');
505
+ console.log(` NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
506
+ if (subjectMissing && !from) {
507
+ console.log(' ℹ️ Revisa VAPID_SUBJECT — puse un placeholder (correo de contacto para push).');
508
+ }
509
+ }
@@ -4,12 +4,23 @@
4
4
  * Replaces the old `SUPER_ADMIN_*` seed. The actual minting lives in the kit
5
5
  * script `scripts/tools/invite-admin.ts` (it needs the kit's `@/lib/invites` +
6
6
  * `@/lib/email`, which this CLI must not import). This command's only job is to
7
- * resolve the TARGET branch's connection string from `.env.local` and spawn that
8
- * script with it:
7
+ * resolve the TARGET branch's environment and spawn that script with it.
8
+ *
9
+ * Repo WITHOUT a vault (no `vault` block in `.timekast/provision.json`) → `.env.local`:
9
10
  *
10
11
  * - `--target develop` (default) → `DATABASE_URL` (the develop branch)
11
12
  * - `--target main` → `DATABASE_URL_MAIN` (production)
12
13
  *
14
+ * Repo WITH a vault (VAULT-003, Decision 20) → the vault, read directly with the client of
15
+ * `vault-api.ts` and the person's session (no local wrapper, so it also works through `npx`).
16
+ * The whole environment goes to the script in memory — not only `DATABASE_URL`, also the mail
17
+ * provider and the app URL it used to take from `.env.local`:
18
+ *
19
+ * - `--target main` → `main:/` with `main:/ci` on top (`/ci` wins: the DIRECT string of main)
20
+ * - `--target develop` → `local` (its own `DATABASE_URL` is the direct string of develop — ADR-005)
21
+ *
22
+ * With a vault, `.env.local` and `DATABASE_URL_MAIN` are never read.
23
+ *
13
24
  * `--target main` is consumed by SETUP-002 after the first deploy+migrate; the
14
25
  * accept link must point at production, so pass `--app-url=https://...` there.
15
26
  *
@@ -20,6 +31,8 @@ import { existsSync, readFileSync } from 'node:fs';
20
31
  import path from 'node:path';
21
32
  import { execa } from 'execa';
22
33
  import { CLIError } from '../lib/cli-error.js';
34
+ import { readVaultLink } from '../lib/provision-state.js';
35
+ import { VAULT_CI_PATH, VAULT_ROOT_PATH, createVaultClient, } from '../lib/vault-api.js';
23
36
  /** Map a target branch to the `.env.local` var holding its connection string. */
24
37
  export const TARGET_DB_VAR = {
25
38
  develop: 'DATABASE_URL',
@@ -88,6 +101,20 @@ export function readEnvLocalVar(rootDir, key) {
88
101
  }
89
102
  return null;
90
103
  }
104
+ /**
105
+ * Read the target's environment from the vault (ADR-005 layout). References are expanded and
106
+ * `local`'s import of `develop:/ci` is flattened (own values win) — the same view `infisical
107
+ * export` gives the local wrapper.
108
+ */
109
+ export async function readTargetFromVault(link, target, vault) {
110
+ const read = { expandReferences: true, includeImports: true };
111
+ if (target === 'main') {
112
+ const root = await vault.readSecrets(link.projectId, { env: link.envs.main, path: VAULT_ROOT_PATH }, read);
113
+ const ci = await vault.readSecrets(link.projectId, { env: link.envs.main, path: VAULT_CI_PATH }, read);
114
+ return new Map([...root, ...ci]);
115
+ }
116
+ return vault.readSecrets(link.projectId, { env: link.envs.local, path: VAULT_ROOT_PATH }, read);
117
+ }
91
118
  export async function runInviteAdmin(flags, rootDir = process.cwd(), deps = {}) {
92
119
  if (!flags.email) {
93
120
  throw new CLIError('Falta el email. Uso: `factory invite-admin --email=you@example.com [--target develop|main]`.');
@@ -96,25 +123,19 @@ export async function runInviteAdmin(flags, rootDir = process.cwd(), deps = {})
96
123
  throw new CLIError(`No encontré \`${INVITE_ADMIN_SCRIPT}\`. Corre \`factory update\` para traer el ` +
97
124
  'script del kit antes de invitar al admin.');
98
125
  }
99
- // Resolve the target branch's connection string. develop is the local default
100
- // (the script's own dotenv loads DATABASE_URL from .env.local), but we resolve
101
- // it anyway to fail early with a clear message; for main we MUST override.
102
- const dbVar = TARGET_DB_VAR[flags.target];
103
- const dbUrl = readEnvLocalVar(rootDir, dbVar);
104
- if (!dbUrl) {
105
- throw new CLIError(`No encontré \`${dbVar}\` en \`.env.local\` (target: ${flags.target}). ` +
106
- 'Corre `/provision` (o `factory provision`) para escribir las connection strings.');
107
- }
108
- const env = { ...process.env, DATABASE_URL: dbUrl };
126
+ const link = readVaultLink(rootDir);
127
+ const env = link
128
+ ? await vaultEnvironment(link, flags.target, deps.vault ?? createVaultClient())
129
+ : envLocalEnvironment(rootDir, flags.target);
109
130
  // For main the accept link must point at production, not localhost — the
110
131
  // caller (SETUP-002) passes the deployed domain via --app-url.
111
132
  if (flags.appUrl)
112
133
  env.NEXT_PUBLIC_APP_URL = flags.appUrl;
113
134
  console.log(`→ Creando invitación de super_admin (${flags.target}) para ${flags.email}…`);
114
135
  // `pnpm exec tsx` resolves the kit's local tsx (a devDependency) — same
115
- // package-manager assumption provision already makes (`pnpm install`). The
116
- // script reads .env.local for the rest of its config (email provider, app URL)
117
- // and uses the DATABASE_URL we pass for the chosen branch.
136
+ // package-manager assumption provision already makes (`pnpm install`). Without a
137
+ // vault the script reads .env.local for the rest of its config (email provider,
138
+ // app URL); with one, all of it arrives in `env` and there is no file to read.
118
139
  const run = deps.run ?? ((file, args, options) => execa(file, args, { ...options, stdio: 'inherit' }));
119
140
  try {
120
141
  await run('pnpm', ['exec', 'tsx', INVITE_ADMIN_SCRIPT, `--email=${flags.email}`], {
@@ -126,3 +147,29 @@ export async function runInviteAdmin(flags, rootDir = process.cwd(), deps = {})
126
147
  throw new CLIError('El script `invite-admin` falló. Revisa la salida de arriba.');
127
148
  }
128
149
  }
150
+ /** Without a vault: the target's connection string from `.env.local` (today's behavior). */
151
+ function envLocalEnvironment(rootDir, target) {
152
+ // develop is the local default (the script's own dotenv loads DATABASE_URL from
153
+ // .env.local), but we resolve it anyway to fail early with a clear message; for
154
+ // main we MUST override.
155
+ const dbVar = TARGET_DB_VAR[target];
156
+ const dbUrl = readEnvLocalVar(rootDir, dbVar);
157
+ if (!dbUrl) {
158
+ throw new CLIError(`No encontré \`${dbVar}\` en \`.env.local\` (target: ${target}). ` +
159
+ 'Corre `/provision` (o `factory provision`) para escribir las connection strings.');
160
+ }
161
+ return { ...process.env, DATABASE_URL: dbUrl };
162
+ }
163
+ /**
164
+ * With a vault: the whole environment of the target, in memory. The vault wins over the
165
+ * parent environment (a stale `DATABASE_URL` exported in the shell must not reach production).
166
+ */
167
+ async function vaultEnvironment(link, target, vault) {
168
+ const values = await readTargetFromVault(link, target, vault);
169
+ if (!values.get('DATABASE_URL')) {
170
+ const where = target === 'main' ? `${link.envs.main}:/ci` : `${link.envs.local}:/`;
171
+ throw new CLIError(`La bóveda no tiene \`DATABASE_URL\` en \`${where}\` (target: ${target}). ` +
172
+ 'Revisa el proyecto de la bóveda del repo (`fx-secrets-vault §7`).');
173
+ }
174
+ return { ...process.env, ...Object.fromEntries(values) };
175
+ }