@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.
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 +146 -18
  4. package/dist/commands/invite-admin.js +62 -15
  5. package/dist/commands/provision.js +5798 -761
  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 +1101 -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,59 @@ 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
+ const syncs = state.syncs;
131
+ const recorded = VERCEL_SYNC_ROLES.every((role) => Boolean(syncs?.ids?.[role]));
132
+ const pending = VERCEL_SYNC_ROLES.some((role) => syncs?.pending?.includes(role));
133
+ return recorded && !pending ? 'active' : 'pending';
134
+ }
135
+ /**
136
+ * What `env push` says in a repo linked to the vault instead of writing (SYNC-002). The vault's
137
+ * syncs are the ONLY writer of Vercel there (`fx-secrets-vault §7`, "un solo escritor por
138
+ * destino"): a second writer would leave the destination with whichever value landed last. Names
139
+ * the vault project and the environments from the block — ids only, never a value. It only claims
140
+ * the sync carries the value to Vercel when the state records both Vercel syncs; otherwise it
141
+ * says they are not confirmed and points at `factory provision --resume`, which creates them.
142
+ */
143
+ export function vaultPushCessionMessage(link, syncs = 'active') {
144
+ const where = `(proyecto ${link.projectId}, entorno ${link.envs.main} → production, ` +
145
+ `${link.envs.develop} → preview)`;
146
+ const delivery = syncs === 'active'
147
+ ? 'el sync lo lleva a Vercel y aplica en el siguiente deploy. '
148
+ : syncs === 'pending'
149
+ ? 'pero los syncs de la bóveda hacia Vercel están pendientes o no están registrados en ' +
150
+ '`.timekast/provision.json`, así que el valor no llega a Vercel hasta que existan: corre ' +
151
+ '`factory provision --resume` para crearlos. '
152
+ : 'pero `.timekast/provision.json` no registra los syncs de la bóveda hacia Vercel, así que ' +
153
+ 'no puedo confirmar que el valor llegue: revisa los syncs del proyecto en la bóveda. ';
154
+ return ('Este repo usa la bóveda: sus variables llegan a Vercel por sincronización y env push no ' +
155
+ `escribe ahí. Cambia el valor en la bóveda ${where}; ${delivery}` +
156
+ 'No se envió nada a Vercel.');
157
+ }
158
+ export async function runEnvPush(flags, rootDir = process.cwd(), deps = {}) {
159
+ // SYNC-002: FIRST, before `.env.local`, the per-env registry or the inline-marker abort. In a
160
+ // vault repo none of those matter — nothing is written, `--dry-run` included — so a broken
161
+ // registry must not mask the real answer. Fail-closed: a malformed block throws (never "no
162
+ // vault", which would make this command the second writer it must not be).
163
+ const vaultLink = readVaultLink(rootDir);
164
+ if (vaultLink) {
165
+ console.log(vaultPushCessionMessage(vaultLink, readVaultVercelSyncStatus(rootDir)));
166
+ return;
167
+ }
114
168
  const body = readEnvBody(path.join(rootDir, '.env.local'));
115
169
  // Resolve the derivative's per-environment declaration BEFORE anything can reach
116
170
  // Vercel: a present-but-broken registry throws here and aborts the whole run
@@ -195,11 +249,8 @@ export async function runEnvPush(flags, rootDir = process.cwd()) {
195
249
  return;
196
250
  }
197
251
  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
- }
252
+ // Read only on a real push: `--dry-run` returned above without touching the vault.
253
+ const token = await readVercelToken(deps.rail ?? defaultRail());
203
254
  try {
204
255
  const result = await pushEnvPlan(projectId, token, teamId, plan);
205
256
  console.log(`✔ env push: ${result.created.length} creadas, ${result.updated.length} actualizadas ` +
@@ -210,6 +261,10 @@ export async function runEnvPush(flags, rootDir = process.cwd()) {
210
261
  }
211
262
  catch (error) {
212
263
  if (error instanceof VercelApiError) {
264
+ if (error.status === 401) {
265
+ throw new CLIError('El `VERCEL_TOKEN` del rail es inválido o expiró (Vercel respondió 401 al subir las variables).\n' +
266
+ railTokenRejectedHint('VERCEL_TOKEN'));
267
+ }
213
268
  throw new CLIError(`Vercel rechazó el push de env: ${error.message}`);
214
269
  }
215
270
  throw error;
@@ -272,6 +327,19 @@ async function readStdinJson() {
272
327
  chunks.push(chunk);
273
328
  return Buffer.concat(chunks).toString('utf8');
274
329
  }
330
+ /**
331
+ * The repo's vault project when `.timekast/provision.json` has a `vault` block, else `null` —
332
+ * the switch between the vault path and the historical `.env.local` path of every wizard
333
+ * command. Fail-closed: an unreadable state file throws instead of falling back to the file.
334
+ */
335
+ export function wizardVaultTarget(rootDir, deps = {}) {
336
+ const link = readVaultLink(rootDir);
337
+ if (!link)
338
+ return null;
339
+ return { client: deps.vaultClient ?? createVaultClient(), projectId: link.projectId, link };
340
+ }
341
+ /** Said after a wizard write in a vault repo, instead of offering `env:push`. */
342
+ 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
343
  /**
276
344
  * `factory env wizard --apply [--push]` — read `{ answers, push? }` JSON from
277
345
  * stdin, write the answers to `.env.local`, and optionally push product vars.
@@ -286,6 +354,25 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
286
354
  throw new CLIError('Esperaba JSON `{ "answers": { KEY: "value" }, "push"?: bool }` por stdin.');
287
355
  }
288
356
  const answers = parsed.answers ?? {};
357
+ const shouldPush = flags.push || parsed.push === true;
358
+ // VAULT-002: a repo in the vault gets the answers there (one copy in `main:/`, referenced
359
+ // from `develop:/` and `local`) — never a `.env.local`, and never `env:push`.
360
+ const vault = wizardVaultTarget(rootDir, deps);
361
+ if (vault) {
362
+ const { written, empty } = await applyWizardAnswersToVault(vault, answers);
363
+ console.log(`✔ Escribí ${written.length} variable(s) en la bóveda (main:/, referenciadas desde develop:/ y local)` +
364
+ (written.length > 0 ? `: ${written.join(', ')}.` : '.'));
365
+ if (empty.length > 0)
366
+ console.log(` (sin valor, no se guardan: ${empty.join(', ')})`);
367
+ for (const w of checkEnvCompleteness(await readVaultLocalEnv(vault)))
368
+ console.log(`⚠ ${w}`);
369
+ // SYNC-002: a requested push answers with the cession (the vault's sync is Vercel's only
370
+ // writer) instead of reaching `runEnvPush` — the wizard ends cleanly, nothing is sent.
371
+ console.log(shouldPush
372
+ ? vaultPushCessionMessage(vault.link, readVaultVercelSyncStatus(rootDir))
373
+ : VAULT_DEPLOYS_NOTICE);
374
+ return;
375
+ }
289
376
  const written = applyWizardAnswers(rootDir, answers);
290
377
  console.log(`✔ Escribí ${written.length} variable(s) en \`.env.local\`.`);
291
378
  // Completeness guards (OAuth creds, VAPID) — warn, never block the write. Read
@@ -293,7 +380,6 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
293
380
  const finalEnv = parseEnvBody(readFileSync(path.join(rootDir, '.env.local'), 'utf8'));
294
381
  for (const w of checkEnvCompleteness(finalEnv))
295
382
  console.log(`⚠ ${w}`);
296
- const shouldPush = flags.push || parsed.push === true;
297
383
  if (!shouldPush) {
298
384
  console.log('ℹ️ Para subirlas a Vercel cuando quieras: `pnpm env:push`.');
299
385
  return;
@@ -309,7 +395,12 @@ export async function runEnvWizardApply(flags, rootDir = process.cwd(), deps = {
309
395
  * the dev at the end (the agent NEVER echoes a raw secret) — the dev opens
310
396
  * `.env.local` to see the full values.
311
397
  */
312
- export function runEnvWizardReview(flags, rootDir = process.cwd()) {
398
+ export function runEnvWizardReview(flags, rootDir = process.cwd(), deps = {}) {
399
+ // VAULT-002: in a vault repo the review resolves the `local` environment (own values + the
400
+ // `develop:/ci` import + references) — there is no `.env.local` to read or to send the dev to.
401
+ const vault = wizardVaultTarget(rootDir, deps);
402
+ if (vault)
403
+ return runEnvWizardReviewFromVault(flags, rootDir, vault);
313
404
  const localPath = path.join(rootDir, '.env.local');
314
405
  if (!existsSync(localPath)) {
315
406
  throw new CLIError('No encontré `.env.local`. Corre el wizard (`factory env wizard`) primero.');
@@ -317,13 +408,18 @@ export function runEnvWizardReview(flags, rootDir = process.cwd()) {
317
408
  const body = readFileSync(localPath, 'utf8');
318
409
  const answers = parseEnvBody(body);
319
410
  const questions = planWithProvisionDefaults(rootDir).filter((q) => isQuestionActive(q, answers));
320
- const rows = buildEnvReview(questions, body);
321
- const warnings = checkEnvCompleteness(answers);
411
+ const printed = printEnvReview(flags, 'Configuración de `.env.local` (los secretos van enmascarados):\n', buildEnvReview(questions, body), checkEnvCompleteness(answers));
412
+ if (printed) {
413
+ console.log('\nAbre `.env.local` para ver los valores completos (de un secreto solo se ven sus últimos 4).');
414
+ }
415
+ }
416
+ /** Print the review rows + warnings (shared by the file path and the vault path). */
417
+ function printEnvReview(flags, title, rows, warnings) {
322
418
  if (flags.json) {
323
419
  process.stdout.write(`${JSON.stringify({ rows, warnings }, null, 2)}\n`);
324
- return;
420
+ return false;
325
421
  }
326
- console.log('Configuración de `.env.local` (los secretos van enmascarados):\n');
422
+ console.log(title);
327
423
  let section = '';
328
424
  for (const row of rows) {
329
425
  if (row.section !== section) {
@@ -338,14 +434,28 @@ export function runEnvWizardReview(flags, rootDir = process.cwd()) {
338
434
  for (const w of warnings)
339
435
  console.log(` ⚠ ${w}`);
340
436
  }
341
- console.log('\nAbre `.env.local` para ver los valores completos (de un secreto solo se ven sus últimos 4).');
437
+ return true;
438
+ }
439
+ /** `--review` over the vault's `local` environment (VAULT-002). Same masking rule. */
440
+ async function runEnvWizardReviewFromVault(flags, rootDir, vault) {
441
+ const env = await readVaultLocalEnv(vault);
442
+ const questions = planWithProvisionDefaults(rootDir).filter((q) => isQuestionActive(q, env));
443
+ const printed = printEnvReview(flags, 'Configuración del entorno `local` de la bóveda (los secretos van enmascarados):\n', buildEnvReviewFromEnv(questions, env), checkEnvCompleteness(env));
444
+ if (printed) {
445
+ console.log('\nLos valores completos viven en la bóveda (entorno `local`); de un secreto aquí solo se ven sus últimos 4.');
446
+ }
342
447
  }
343
448
  /**
344
449
  * `factory env wizard --gen-vapid` — generate a VAPID keypair and write it to
345
450
  * `.env.local`. The private key is a SECRET: it is written to the file (0600) and
346
451
  * NEVER printed. The public key is public by design, so it is safe to echo.
347
452
  */
348
- export function runEnvWizardGenVapid(rootDir = process.cwd()) {
453
+ export function runEnvWizardGenVapid(rootDir = process.cwd(), deps = {}) {
454
+ // VAULT-002: in a vault repo the three keys go to the vault (one copy in `main:/`, referenced
455
+ // from `develop:/` and `local`) — never to a file. The private key is never printed.
456
+ const vault = wizardVaultTarget(rootDir, deps);
457
+ if (vault)
458
+ return genVapidIntoVault(vault);
349
459
  const localPath = path.join(rootDir, '.env.local');
350
460
  const examplePath = path.join(rootDir, '.env.example');
351
461
  const basePath = existsSync(localPath) ? localPath : examplePath;
@@ -371,3 +481,21 @@ export function runEnvWizardGenVapid(rootDir = process.cwd()) {
371
481
  console.log(' ℹ️ Revisa VAPID_SUBJECT — puse un placeholder (correo de contacto para push).');
372
482
  }
373
483
  }
484
+ /**
485
+ * `--gen-vapid` in a vault repo (VAULT-002): the keypair is upserted (a new pair per run, as on
486
+ * the file path) and `VAPID_SUBJECT` is seeded only when missing, from `main:/EMAIL_FROM`.
487
+ */
488
+ async function genVapidIntoVault(vault) {
489
+ const { publicKey, privateKey } = generateVapidKeys();
490
+ await writeSharedToVault(vault, { NEXT_PUBLIC_VAPID_PUBLIC_KEY: publicKey, VAPID_PRIVATE_KEY: privateKey }, 'upsert');
491
+ const main = await readVaultMain(vault);
492
+ const subjectMissing = !main['VAPID_SUBJECT'];
493
+ const from = main['EMAIL_FROM'];
494
+ await writeSharedToVault(vault, { VAPID_SUBJECT: from ? `mailto:${from}` : 'mailto:admin@example.com' }, 'create-if-missing');
495
+ console.log('✔ Llaves VAPID generadas y escritas en la bóveda (main:/, referenciadas desde develop:/ y local; ' +
496
+ 'la privada queda oculta).');
497
+ console.log(` NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
498
+ if (subjectMissing && !from) {
499
+ console.log(' ℹ️ Revisa VAPID_SUBJECT — puse un placeholder (correo de contacto para push).');
500
+ }
501
+ }
@@ -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
+ }