@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.
- package/dist/commands/backlog.js +52 -15
- package/dist/commands/doctor.js +210 -29
- package/dist/commands/env.js +154 -18
- package/dist/commands/invite-admin.js +62 -15
- package/dist/commands/provision.js +5815 -763
- 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 +145 -10
- 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 +1106 -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,67 @@ 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
|
+
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
|
-
|
|
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
|
-
}
|
|
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
|
|
321
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|