@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/env.js
CHANGED
|
@@ -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`
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
321
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
8
|
-
*
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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`).
|
|
116
|
-
// script reads .env.local for the rest of its config (email provider,
|
|
117
|
-
//
|
|
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
|
+
}
|