@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
@@ -0,0 +1,2627 @@
1
+ /**
2
+ * `factory vault adopt [--apply]` (ADOPT-001) — move a repo born before the vault into it.
3
+ * Contract: `fx-secrets-vault §8`. The decisions live in `lib/vault-adopt.ts`; this module runs
4
+ * them against the real services, in the order the contract fixes:
5
+ *
6
+ * Preconditions (both modes) — `core` profile → says so and does nothing; the brain up to date
7
+ * (the comparison of `factory status`); no fork in the files the wrapper runs (the census of
8
+ * `factory update --verify`).
9
+ *
10
+ * Plan (no `--apply`) — inventory by NAME and display hash of `.env.local`, Vercel production +
11
+ * preview and Railway; the state of each key, where the apply would write it, the confirmations
12
+ * it will ask, the scripts of `package.json` it would rewrite or leave alone, and the GitHub
13
+ * secrets it compares with `develop:/ci`. WRITES NOTHING.
14
+ *
15
+ * Apply — (1) project + members, (2) the writes of the layout (ADR-005), (3) the syncs of
16
+ * SYNC-001 with its caller flag (`guardForeignKeys: false`), (4) the link in
17
+ * `.timekast/provision.json`, (5) the wrapper wiring (diff shown first), (6) `.env.local` →
18
+ * `.env.local.pre-vault.bak`. A failure in any step leaves `.env.local` intact.
19
+ *
20
+ * FIRST SYNC ON A LIVE DEPLOY — answered by the user (2026-09-24): "importar primero; hacia
21
+ * GitHub, listar los nombres y detenerse antes de crear el sync si falta alguno en develop:/ci".
22
+ * So every key a deploy holds is written to the vault BEFORE any sync exists (a sync then finds a
23
+ * superset of its destination and deletes nothing — checked again right before creating it), and
24
+ * when a secret of the repo is missing from `develop:/ci` the apply stops before creating ANY
25
+ * sync, naming it. A `sensitive` var cannot be imported by reading it: it is CLASSIFIED
26
+ * (ADOPT-003) — derivable (recomputed from its provider), rotable (generated, or re-issued at its
27
+ * provider) or sealed (the apply stops and explains the bridge deploy; never generated).
28
+ *
29
+ * 🔴 NO-ECHO: stdout, stderr, errors and the `--json` plan carry key names, locations and display
30
+ * hashes — never a value.
31
+ */
32
+ import { existsSync, readFileSync } from 'node:fs';
33
+ import path from 'node:path';
34
+ import { execa } from 'execa';
35
+ import prompts from 'prompts';
36
+ import { CLIError } from '../lib/cli-error.js';
37
+ import { WITH_VAULT_SCRIPT_FILE } from '../lib/constants.js';
38
+ import { parseEnvBody } from '../lib/env-wizard.js';
39
+ import { hasLockfile, readLockfile } from '../lib/lockfile.js';
40
+ import { getConnectionUri, getNeonProjectOrg, isNeonKeyFamily, listNeonApiKeys, listNeonBranches, NEON_DEVELOP_BRANCH, NeonApiKeyListError, } from '../lib/neon-api.js';
41
+ import { detectCoreProfile } from '../lib/provision-preflight.js';
42
+ import { readDestroyTarget, readNeonRevokePending, readVaultDeclined, readVaultLink, addNeonRevokePending, } from '../lib/provision-state.js';
43
+ import { createRailResolver, RAIL_TIMEKAST } from '../lib/rail.js';
44
+ import { createRailwayClient, RAILWAY_TOKEN_KEY, railwayFetchTransport, } from '../lib/railway-api.js';
45
+ import { buildPlanEntries, classifySensitive, createDisplayHasher, describeWrite, ENV_LOCAL_BACKUP_FILE, ENV_LOCAL_FILE, FACTORY_REPO, FACTORY_SLUG, folderOfTarget, isConnectedEmptyDeploy, isFactoryIdentity, manualVaultProjectSteps, isFactoryMode, isProjectScopedNeonKey, isRailKey, isRailwayProvided, isRailwaySyncTo, listRailwayProjects, MFA_KEY, NEON_API_KEY, planAdoptionLayout, planRailwayImport, proposeRailwaySyncs, RAILWAY_VAULT_ENVS, railwaySyncDifferences, railwaySyncLabel, railwaySyncName, readRailwayInventory, readRailwayVariables, recordedAdoptionVercelId, RESEND_KEY, retiredKeys, retireEnvLocal, sealedStopExplanation, sharedCandidatesOf, SHORTIO_KEY, wrapperDrift, writeAdoptionLink, writeDeclineMark, } from '../lib/vault-adopt.js';
46
+ import { createVaultClient, formatLocation, VAULT_CI_PATH, VAULT_ENV_DEVELOP, VAULT_ENV_LOCAL, VAULT_ENV_MAIN, VAULT_ROOT_PATH, VAULT_TEST_ISOLATION_KEY, VaultSeamMissingError, } from '../lib/vault-api.js';
47
+ import { LOCAL_ONLY_KEYS } from '../lib/env-file.js';
48
+ import { buildEnvPlan, deployHostCandidates, generateAuthSecret, listEnvVarNames, listProjectDomains, locateVercelProject, readEnvVarsWithValues, relatedOriginsFor, } from '../lib/vercel-env.js';
49
+ import { DEPLOY_ENVS, DEPLOYS_REGISTRY_FILE, planDeploys, readDeploysRegistry, recordDeployProjectId, resolveDeploy, } from '../lib/vault-deploys.js';
50
+ import { wireVaultWrapper } from '../lib/wrapper-wiring.js';
51
+ import { assertValidSubdomainSlug, ensureVaultSyncs, ghSecretNames, mintProjectResendKey, parseOriginRepoSlug, PROJECT_CONFIG_PATH, provisionNeon, PROVISION_EMAIL_FROM, readConfigSlug, readE2eParentBranch, readOriginUrl, reportDeleteProtection, resolveDevelopBranch, resolveMainBranch, revokeResendKey, settleNeonRevokes, syncTargetsDestination, VAULT_SYNC_PLAN, } from './provision.js';
52
+ import { computeStatus } from './status.js';
53
+ // ---------------------------------------------------------------------------
54
+ // Help + flags
55
+ // ---------------------------------------------------------------------------
56
+ /** What `factory vault` prints without a subcommand. */
57
+ export const VAULT_HELP = `
58
+ factory vault — la bóveda de secretos de la organización (fx-secrets-vault).
59
+
60
+ Uso:
61
+ factory vault adopt [--apply] [--json] [--team=<id>] [--vercel-project <id>]
62
+ factory vault adopt --decline
63
+ factory vault sync railway --project <id> --service <id> --env <main|develop> [--railway-env <nombre>]
64
+ factory vault deploys [--deploy <slug>] [--apply] [--yes]
65
+
66
+ Subcomandos:
67
+ adopt Pasa a la bóveda un repo que nació antes de ella (fx-secrets-vault §8).
68
+ Sin --apply sólo enseña el plan: inventario por nombre y huella (nunca por valor)
69
+ de .env.local, Vercel production/preview y Railway, y dónde iría cada clave.
70
+ Con --apply: crea el proyecto con sus miembros, importa (main y develop desde el
71
+ deploy, local desde .env.local), crea los syncs, guarda el vínculo en
72
+ .timekast/provision.json, cablea el wrapper y renombra .env.local a
73
+ .env.local.pre-vault.bak. Cada diferencia la confirmas tú; sin terminal, no se
74
+ confirma nada a ciegas.
75
+ Si el repo despliega a Railway (el proyecto con el repo conectado, se llame como se
76
+ llame), propone un sync por cada servicio cuyo entorno de Railway se llama como uno
77
+ de la bóveda (main, develop), y lo crea después de importar. Con dos o más proyectos
78
+ conectados se detiene sin escribir: son varios despliegues (vault deploys).
79
+ --apply Ejecuta el plan
80
+ --json (sólo sin --apply) el plan como JSON — nombres y huellas, nunca valores
81
+ --team=<id> Team de Vercel (default: VERCEL_TEAM_ID de rail-timekast)
82
+ --vercel-project <id>
83
+ Con varios proyectos de Vercel conectados al repo, elige el que queda como
84
+ proyecto principal (tiene que ser uno de ellos). Los demás despliegues se
85
+ operan después con \`factory vault deploys\`.
86
+ --decline Registra en .timekast/provision.json que este repo NO entra a la bóveda:
87
+ sigue con .env.local y env:push, y doctor deja de proponerlo. No toca nada
88
+ más ni consulta ningún servicio. Adoptar después sigue siendo posible.
89
+
90
+ sync railway
91
+ Crea el sync de un entorno de la bóveda del repo (su carpeta raíz) a un servicio de
92
+ Railway, con RAILWAY_TOKEN de rail-timekast. Antes importa a la bóveda lo que sólo
93
+ tiene Railway, así el primer sync no borra nada. Correrlo otra vez no duplica.
94
+ --project <id> Proyecto de Railway
95
+ --service <id> Servicio de Railway
96
+ --env <main|develop> Entorno de la bóveda (y, por default, el de Railway con ese nombre)
97
+ --railway-env <nombre> Entorno de Railway cuando se llama distinto (p. ej. production)
98
+
99
+ deploys Un repo que se despliega N veces (uno por cliente): lee .timekast/deploys.json y
100
+ siembra el proyecto de la bóveda de cada despliegue desde la plantilla, con esta
101
+ precedencia por clave: su valor propio → lo que deriva su registro → la plantilla.
102
+ Sin --apply sólo enseña el plan (por nombre de clave, nunca por valor). Con --apply
103
+ crea lo que falta (y anota en el registro el id de cada proyecto que crea), escribe
104
+ y crea el sync de cada despliegue a SU Vercel / Railway. Un proyecto que ya existe
105
+ se adopta sólo si su id está anotado en el registro. Antes de escribir comprueba que
106
+ ningún valor de un despliegue esté en otro ni en el principal: si lo encuentra, no
107
+ escribe nada (fx-secrets-vault §6). Si va a cambiar valores ya guardados, los lista
108
+ y pide confirmación.
109
+ --deploy <slug> Sólo ese despliegue (los demás se leen igual, para la comprobación)
110
+ --apply Ejecuta el plan
111
+ --yes (con --apply) confirma sin terminal los valores guardados que cambia
112
+
113
+ No confundir con \`factory provision --adopt\`, que reconstruye el registro de infraestructura
114
+ (.timekast/provision.json) y no toca secretos.
115
+ `;
116
+ /** Parse the argv AFTER `adopt`. Unknown flags stop (a typo must not run a real apply). */
117
+ export function parseVaultAdoptFlags(argv) {
118
+ const flags = { apply: false, json: false };
119
+ for (let i = 0; i < argv.length; i += 1) {
120
+ const arg = argv[i];
121
+ if (arg === '--apply')
122
+ flags.apply = true;
123
+ else if (arg === '--json')
124
+ flags.json = true;
125
+ else if (arg === '--decline')
126
+ flags.decline = true;
127
+ else if (arg.startsWith('--team=') && arg.length > '--team='.length) {
128
+ flags.team = arg.slice('--team='.length);
129
+ }
130
+ else if (arg === '--vercel-project' || arg.startsWith('--vercel-project=')) {
131
+ let value;
132
+ if (arg.startsWith('--vercel-project='))
133
+ value = arg.slice('--vercel-project='.length);
134
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) {
135
+ value = argv[i + 1];
136
+ i += 1;
137
+ }
138
+ if (value === undefined || value.trim() === '') {
139
+ throw new CLIError('`--vercel-project` necesita el id de un proyecto de Vercel.');
140
+ }
141
+ flags.vercelProject = value.trim();
142
+ }
143
+ else {
144
+ throw new CLIError(`Opción desconocida para \`factory vault adopt\`: \`${arg}\`. Uso: \`factory vault adopt [--apply] [--json] [--team=<id>] [--vercel-project <id>]\` o \`factory vault adopt --decline\`.`);
145
+ }
146
+ }
147
+ if (flags.apply && flags.json) {
148
+ throw new CLIError('`--json` es sólo para el plan (sin `--apply`).');
149
+ }
150
+ if (flags.decline &&
151
+ (flags.apply || flags.json || flags.team !== undefined || flags.vercelProject !== undefined)) {
152
+ throw new CLIError('`--decline` va solo: registra que el repo no entra a la bóveda, sin plan ni `--apply`.');
153
+ }
154
+ return flags;
155
+ }
156
+ /** `factory vault <sub>` — dispatch. Without a subcommand it prints {@link VAULT_HELP}. */
157
+ export async function runVaultCommand(argv, deps = {}) {
158
+ const [sub, ...rest] = argv;
159
+ if (sub === undefined) {
160
+ console.log(VAULT_HELP);
161
+ return;
162
+ }
163
+ if (sub === 'adopt') {
164
+ const result = await runVaultAdopt(parseVaultAdoptFlags(rest), deps);
165
+ // A sealed key is a RESULT (the stop and its explanation), reported with its own exit code.
166
+ if (result.status === 'sealed')
167
+ process.exitCode = VAULT_ADOPT_SEALED_EXIT_CODE;
168
+ return;
169
+ }
170
+ if (sub === 'deploys') {
171
+ await runVaultDeploys(parseVaultDeploysFlags(rest), deps);
172
+ return;
173
+ }
174
+ if (sub === 'sync') {
175
+ const [target, ...flags] = rest;
176
+ if (target !== 'railway') {
177
+ throw new CLIError(`Destino de \`factory vault sync\` desconocido: \`${target ?? ''}\`. Uso: ${VAULT_SYNC_RAILWAY_USAGE}.`);
178
+ }
179
+ await runVaultSyncRailway(parseVaultSyncRailwayFlags(flags), deps);
180
+ return;
181
+ }
182
+ throw new CLIError(`Subcomando de vault desconocido: \`${sub}\`. Uso: \`factory vault adopt\`, ${VAULT_DEPLOYS_USAGE} o ` +
183
+ `${VAULT_SYNC_RAILWAY_USAGE}.`);
184
+ }
185
+ // ---------------------------------------------------------------------------
186
+ // `factory vault sync railway` — flags (ADOPT-002)
187
+ // ---------------------------------------------------------------------------
188
+ const VAULT_SYNC_RAILWAY_USAGE = '`factory vault sync railway --project <id> --service <id> --env <main|develop> [--railway-env <nombre>]`';
189
+ const SYNC_RAILWAY_FLAGS = ['--project', '--service', '--env', '--railway-env'];
190
+ /**
191
+ * Parse the argv AFTER `sync railway`. `--flag value` and `--flag=value` both work. A missing
192
+ * required flag stops naming it (never a partial sync); an unknown one stops too.
193
+ */
194
+ export function parseVaultSyncRailwayFlags(argv) {
195
+ const values = new Map();
196
+ for (let i = 0; i < argv.length; i += 1) {
197
+ const arg = argv[i];
198
+ const eq = arg.indexOf('=');
199
+ const name = eq === -1 ? arg : arg.slice(0, eq);
200
+ if (!SYNC_RAILWAY_FLAGS.includes(name)) {
201
+ throw new CLIError(`Opción desconocida para \`factory vault sync railway\`: \`${arg}\`. Uso: ${VAULT_SYNC_RAILWAY_USAGE}.`);
202
+ }
203
+ let value;
204
+ if (eq !== -1)
205
+ value = arg.slice(eq + 1);
206
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) {
207
+ value = argv[i + 1];
208
+ i += 1;
209
+ }
210
+ if (value === undefined || value.trim() === '') {
211
+ throw new CLIError(`\`${name}\` necesita un valor. Uso: ${VAULT_SYNC_RAILWAY_USAGE}.`);
212
+ }
213
+ values.set(name, value.trim());
214
+ }
215
+ const missing = ['--project', '--service', '--env'].filter((flag) => !values.has(flag));
216
+ if (missing.length > 0) {
217
+ throw new CLIError(`Falta ${missing.map((flag) => `\`${flag}\``).join(', ')}: no creo un sync a medias. ` +
218
+ `Uso: ${VAULT_SYNC_RAILWAY_USAGE}.`);
219
+ }
220
+ const env = values.get('--env').toLowerCase();
221
+ if (!RAILWAY_VAULT_ENVS.includes(env)) {
222
+ throw new CLIError(`\`--env\` es un entorno de despliegue de la bóveda: ${RAILWAY_VAULT_ENVS.join(' o ')} ` +
223
+ `(\`${VAULT_ENV_LOCAL}\` nunca se sincroniza). Recibí \`${values.get('--env')}\`.`);
224
+ }
225
+ return {
226
+ project: values.get('--project'),
227
+ service: values.get('--service'),
228
+ env,
229
+ ...(values.has('--railway-env') ? { railwayEnv: values.get('--railway-env') } : {}),
230
+ };
231
+ }
232
+ /** The seams that reach the network, a session, git or `gh` — REQUIRED under Vitest. */
233
+ const REQUIRED_TEST_SEAMS = [
234
+ 'vaultClient',
235
+ 'rail',
236
+ 'fetchImpl',
237
+ 'fetchLatest',
238
+ 'listGithubSecretNames',
239
+ 'resolveOriginRepo',
240
+ 'isGitIgnored',
241
+ ];
242
+ /**
243
+ * 🔴 Fail-closed under the CLI's Vitest setup (the vault client's own marker): a run without every
244
+ * seam that reaches a real service refuses BEFORE touching anything — no request to the vault,
245
+ * Vercel, Railway or GitHub, and no read or write of `.timekast/provision.json`, `package.json` or
246
+ * `.env.local`. Outside Vitest the marker does not exist and the real seams are used.
247
+ */
248
+ function assertAdoptSeams(deps) {
249
+ if (globalThis[VAULT_TEST_ISOLATION_KEY] === undefined)
250
+ return;
251
+ for (const seam of REQUIRED_TEST_SEAMS) {
252
+ if (deps[seam] === undefined)
253
+ throw new VaultSeamMissingError(seam);
254
+ }
255
+ }
256
+ /**
257
+ * `owner/repo` of the `origin` remote — the same reader `provision` uses (`readOriginUrl`), over a
258
+ * real `git`. The default of the `resolveOriginRepo` seam; `null` without an `origin`.
259
+ */
260
+ export async function originRepoOf(rootDir) {
261
+ const url = await readOriginUrl(async (args, cwd) => {
262
+ const { stdout } = await execa('git', args, { cwd });
263
+ return { stdout };
264
+ }, rootDir);
265
+ return url === null ? null : parseOriginRepoSlug(url);
266
+ }
267
+ async function defaultAskHost(question) {
268
+ const answer = (await prompts({ type: 'text', name: 'host', message: question }));
269
+ return typeof answer?.host === 'string' ? answer.host : null;
270
+ }
271
+ function defaultIsGitIgnored(rootDir) {
272
+ return async (relPath) => {
273
+ const result = await execa('git', ['check-ignore', '-q', relPath], {
274
+ cwd: rootDir,
275
+ reject: false,
276
+ });
277
+ return result.exitCode === 0;
278
+ };
279
+ }
280
+ async function defaultConfirm(question) {
281
+ const answer = (await prompts({
282
+ type: 'confirm',
283
+ name: 'ok',
284
+ message: question,
285
+ initial: false,
286
+ }));
287
+ return answer?.ok === true;
288
+ }
289
+ // ---------------------------------------------------------------------------
290
+ // The plan
291
+ // ---------------------------------------------------------------------------
292
+ /** `owner/name` of a GitHub repo — checked before it reaches `gh` or a sync. */
293
+ const REPO_RE = /^[A-Za-z0-9_.][A-Za-z0-9_.-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/;
294
+ const UNKNOWN = 'unknown';
295
+ /**
296
+ * Exit code of `factory vault adopt --apply` when it stops on a sealed key: a RESULT the command
297
+ * reports (`resolveSuccessExitCode`), distinct from 0 so no one — person or agent — reads it as done.
298
+ */
299
+ export const VAULT_ADOPT_SEALED_EXIT_CODE = 3;
300
+ function realField(value) {
301
+ return value && value !== UNKNOWN ? value : null;
302
+ }
303
+ /** Split Vercel vars per target (the inventory's production/preview maps). */
304
+ function vercelMaps(vars) {
305
+ const production = new Map();
306
+ const preview = new Map();
307
+ const branchScoped = [];
308
+ const sensitive = [];
309
+ const unreadableOther = new Set();
310
+ const note = (key, type, value, target) => {
311
+ if (value !== null)
312
+ return;
313
+ if (type === 'sensitive')
314
+ sensitive.push({ key, target });
315
+ else
316
+ unreadableOther.add(key);
317
+ };
318
+ for (const env of vars) {
319
+ if (env.target.includes('production')) {
320
+ production.set(env.key, env.value);
321
+ note(env.key, env.type, env.value, 'production');
322
+ }
323
+ if (env.target.includes('preview')) {
324
+ if (env.gitBranch)
325
+ branchScoped.push({ name: env.key, branch: env.gitBranch });
326
+ else {
327
+ preview.set(env.key, env.value);
328
+ note(env.key, env.type, env.value, 'preview');
329
+ }
330
+ }
331
+ }
332
+ return {
333
+ production,
334
+ preview,
335
+ branchScoped,
336
+ sensitive,
337
+ unreadableOther: [...unreadableOther].sort(),
338
+ };
339
+ }
340
+ /** Why a derivation needs a host, and which one. */
341
+ function hostFor(item) {
342
+ if (item.verdict.class !== 'derivable')
343
+ return null;
344
+ switch (item.verdict.derive) {
345
+ case 'app-url':
346
+ return item.target === 'production' ? 'prod' : 'dev';
347
+ case 'rp-id':
348
+ return 'prod';
349
+ case 'related-origins':
350
+ return 'dev';
351
+ default:
352
+ return null;
353
+ }
354
+ }
355
+ /** How the plan describes one sensitive var — its source, its effect or its reason; no value. */
356
+ function sensitiveDetail(item, hosts, neonProjectId) {
357
+ const v = item.verdict;
358
+ const where = formatLocation(folderOfTarget(item.target));
359
+ if (v.class === 'derivable') {
360
+ const host = hostFor(item);
361
+ const domain = host && hosts[host] ? ` (\`${hosts[host]}\`)` : '';
362
+ const neon = v.derive === 'neon-pooled' && neonProjectId
363
+ ? ` — proyecto \`${neonProjectId}\`, ${item.target === 'production' ? 'su branch default (producción)' : `branch \`${NEON_DEVELOP_BRANCH}\` (o, sin él, el de E2E)`}`
364
+ : '';
365
+ return `sale de ${v.source}${domain}${neon} → ${where}`;
366
+ }
367
+ if (v.class === 'rotable') {
368
+ const effect = v.effect.replace('de ese entorno', item.target === 'production' ? 'de producción' : 'de preview');
369
+ return v.rotate === 'auth-secret'
370
+ ? `genero uno nuevo tras tu confirmación — ${effect} → ${where}`
371
+ : `no la genero: ${effect}. La adopción espera a que esté en ${where}`;
372
+ }
373
+ if (v.class === 'sellada')
374
+ return `${v.reason}; se recupera con un deploy puente → ${where}`;
375
+ return 'el kit no la conoce: te pregunto si cifra datos guardados; sin terminal o sin respuesta, se trata como sellada';
376
+ }
377
+ /** Write the sensitive part of the plan from the items (again after the unknowns are resolved). */
378
+ function refreshSensitivePlan(plan, items, hosts, neonProjectId, vaultProjectExists) {
379
+ plan.sensitive = items.map((item) => ({
380
+ name: item.key,
381
+ target: item.target,
382
+ class: item.verdict.class,
383
+ detail: sensitiveDetail(item, hosts, neonProjectId),
384
+ inVault: item.inVault,
385
+ ...(item.blocked && !item.inVault ? { blocked: item.blocked } : {}),
386
+ }));
387
+ plan.sealed = items
388
+ .filter((item) => item.verdict.class === 'sellada' && !item.inVault)
389
+ .map((item) => ({ name: item.key, target: item.target }));
390
+ plan.sealedExplanation =
391
+ plan.sealed.length > 0
392
+ ? sealedStopExplanation(plan.sealed, plan.slug, vaultProjectExists)
393
+ : null;
394
+ }
395
+ /**
396
+ * Classify every `sensitive` var of the inventory (a retired key is never imported, so it is left
397
+ * out), check whether each is ALREADY in its vault folder (after a bridge deploy, or a key loaded by
398
+ * hand), and read — only when a derivation needs it — the project's domains in Vercel. Reads only.
399
+ */
400
+ async function inspectSensitive(input) {
401
+ const hosts = { prod: null, dev: null };
402
+ const hostCandidates = { prod: [], dev: [] };
403
+ const items = input.found
404
+ .filter((f) => !input.retired.has(f.key))
405
+ .map((f) => ({
406
+ key: f.key,
407
+ target: f.target,
408
+ verdict: classifySensitive({
409
+ key: f.key,
410
+ type: 'sensitive',
411
+ target: f.target,
412
+ }),
413
+ inVault: false,
414
+ }));
415
+ if (items.length === 0)
416
+ return { items, hosts, hostCandidates, vaultProjectExists: true };
417
+ const projectId = input.vaultProjectId ?? (await input.client.findProjectBySlug(input.slug))?.id ?? null;
418
+ if (projectId) {
419
+ const keysAt = new Map();
420
+ for (const item of items) {
421
+ const folder = folderOfTarget(item.target);
422
+ const where = formatLocation(folder);
423
+ if (!keysAt.has(where)) {
424
+ keysAt.set(where, new Set(await input.client.listSecretKeys(projectId, folder)));
425
+ }
426
+ item.inVault = keysAt.get(where)?.has(item.key) === true;
427
+ }
428
+ }
429
+ const problems = {};
430
+ if (items.some((item) => !item.inVault && hostFor(item) !== null)) {
431
+ if (!input.vercelProject) {
432
+ problems.prod = problems.dev = 'no hay proyecto de Vercel del que leer el dominio';
433
+ }
434
+ else {
435
+ let domains;
436
+ try {
437
+ domains = await listProjectDomains(input.vercelProject.id, input.vercelToken, input.teamId, {
438
+ fetchImpl: input.fetchImpl,
439
+ });
440
+ }
441
+ catch {
442
+ throw new CLIError('Vercel no respondió bien al leer los dominios del proyecto. No hice nada.');
443
+ }
444
+ // ONE rule for the production host (the same as the connected-and-empty deploy's): the
445
+ // domains not bound to a branch; exactly one → it; zero or several → the `--apply` ASKS,
446
+ // never prefers one (an own domain over `*.vercel.app` was a guess, too).
447
+ const candidates = deployHostCandidates(domains);
448
+ hostCandidates.prod = candidates.production;
449
+ hostCandidates.dev = candidates.preview;
450
+ const pick = (names, what) => names.length === 1
451
+ ? names[0]
452
+ : {
453
+ problem: (names.length === 0
454
+ ? `Vercel no tiene un dominio de ${what}`
455
+ : `Vercel tiene ${names.length} dominios de ${what} (${names.join(', ')}) y no elijo uno a ciegas`) +
456
+ ': el --apply te pregunta cuál es (sin terminal se detiene)',
457
+ };
458
+ const p = pick(hostCandidates.prod, 'producción');
459
+ const d = pick(hostCandidates.dev, 'develop (rama `develop`)');
460
+ if (typeof p === 'string')
461
+ hosts.prod = p;
462
+ else
463
+ problems.prod = p.problem;
464
+ if (typeof d === 'string')
465
+ hosts.dev = d;
466
+ else
467
+ problems.dev = d.problem;
468
+ }
469
+ }
470
+ for (const item of items) {
471
+ if (item.inVault || item.verdict.class !== 'derivable')
472
+ continue;
473
+ const host = hostFor(item);
474
+ if (host && !hosts[host]) {
475
+ item.blocked = problems[host] ?? 'sin dominio del que derivarla';
476
+ if (input.vercelProject)
477
+ item.hostPending = host;
478
+ }
479
+ if (item.verdict.derive === 'neon-pooled' && !input.neonProjectId) {
480
+ item.blocked =
481
+ 'no sé cuál es el proyecto de Neon: ni `.timekast/provision.json` ni `NEON_PROJECT_ID` de `.env.local` lo dicen';
482
+ }
483
+ }
484
+ return { items, hosts, hostCandidates, vaultProjectExists: projectId !== null };
485
+ }
486
+ /**
487
+ * The hosts a `sensitive` derivation needs and Vercel's domains did not settle (zero or several
488
+ * candidates): asked here, with the same rule and the same question as the connected-and-empty
489
+ * deploy ({@link resolveEmptyDeployHost}) — no terminal → stops naming them. Never picked.
490
+ */
491
+ async function resolveSensitiveHosts(prepared, deps) {
492
+ const roles = [
493
+ { role: 'prod', what: 'producción' },
494
+ { role: 'dev', what: 'preview (rama `develop`)' },
495
+ ];
496
+ let changed = false;
497
+ for (const { role, what } of roles) {
498
+ const waiting = prepared.sensitive.filter((item) => !item.inVault && item.hostPending === role);
499
+ if (waiting.length === 0)
500
+ continue;
501
+ const host = await resolveEmptyDeployHost(what, null, prepared.hostCandidates[role], deps);
502
+ prepared.hosts[role] = host;
503
+ for (const item of waiting) {
504
+ delete item.hostPending;
505
+ delete item.blocked;
506
+ }
507
+ changed = true;
508
+ }
509
+ if (changed) {
510
+ refreshSensitivePlan(prepared.plan, prepared.sensitive, prepared.hosts, prepared.neonProjectId, prepared.vaultProjectExists);
511
+ }
512
+ }
513
+ /**
514
+ * Resolve the unknown `sensitive` vars: the person answers whether each one encrypts stored data.
515
+ * Only an explicit "it does NOT" makes it a provider-rotable credential; no terminal, no answer or
516
+ * a "no" → sealed (rotating a sealed key by mistake is the irreversible damage). Nothing is
517
+ * generated for an unknown key, ever.
518
+ */
519
+ async function resolveUnknownSensitive(prepared, deps) {
520
+ const unknown = prepared.sensitive.filter((item) => item.verdict.class === 'desconocida' && !item.inVault);
521
+ if (unknown.length === 0)
522
+ return;
523
+ const confirm = resolveConfirm(deps);
524
+ for (const item of unknown) {
525
+ const notSealed = confirm
526
+ ? await confirm(`\`${item.key}\` (Vercel ${item.target}) es sensitive y el kit no la conoce. ¿Confirmas que NO cifra ` +
527
+ 'datos guardados (es una credencial que se reemite en su proveedor)? Si dudas, responde que no: ' +
528
+ 'se trata como sellada.')
529
+ : false;
530
+ item.verdict = notSealed
531
+ ? {
532
+ class: 'rotable',
533
+ rotate: 'provider',
534
+ effect: 'se reemite en la consola de su proveedor; la vieja deja de servir',
535
+ }
536
+ : { class: 'sellada', reason: 'cifra datos guardados (o no hubo quién lo confirmara)' };
537
+ }
538
+ refreshSensitivePlan(prepared.plan, prepared.sensitive, prepared.hosts, prepared.neonProjectId, prepared.vaultProjectExists);
539
+ }
540
+ /**
541
+ * Before writing anything: a provider-rotable credential that is not in the vault yet, or a
542
+ * derivable whose source is missing, stops the apply naming it. Then the person confirms each
543
+ * derivation (by its source — the domain is not a secret) and each `AUTH_SECRET` to generate (by
544
+ * its effect). Declined → nothing is written.
545
+ */
546
+ async function confirmSensitive(prepared, deps) {
547
+ const pending = prepared.sensitive.filter((item) => !item.inVault);
548
+ const waiting = pending.filter((item) => item.verdict.class === 'rotable' && item.verdict.rotate === 'provider');
549
+ if (waiting.length > 0) {
550
+ throw new CLIError('Estas credenciales son sensitive (Vercel no devuelve su valor) y no las genero: se reemiten en la ' +
551
+ 'consola de su proveedor y se cargan en la bóveda:\n' +
552
+ waiting
553
+ .map((item) => ` · ${item.key} (Vercel ${item.target}) → \`${formatLocation(folderOfTarget(item.target))}\` del proyecto \`${prepared.plan.slug}\``)
554
+ .join('\n') +
555
+ (prepared.vaultProjectExists
556
+ ? ''
557
+ : `\n${manualVaultProjectSteps(prepared.plan.slug, waiting.map((item) => formatLocation(folderOfTarget(item.target))))}`) +
558
+ '\nCuando estén ahí, repite `factory vault adopt --apply`. No se escribió nada.');
559
+ }
560
+ const blocked = pending.filter((item) => item.blocked);
561
+ if (blocked.length > 0) {
562
+ throw new CLIError('No puedo derivar estas variables sensitive:\n' +
563
+ blocked
564
+ .map((item) => ` · ${item.key} (Vercel ${item.target}): ${item.blocked}`)
565
+ .join('\n') +
566
+ '\nResuélvelo (o carga el valor en la bóveda a mano) y repite. No se escribió nada.');
567
+ }
568
+ const questions = pending.flatMap((item) => {
569
+ const detail = sensitiveDetail(item, prepared.hosts, prepared.neonProjectId);
570
+ if (item.verdict.class === 'derivable') {
571
+ const passkeys = item.verdict.derive === 'rp-id'
572
+ ? ' Si la app usa hoy otro rpID, todas las passkeys existentes quedan inservibles ' +
573
+ '(retrofit passkey-domain-binding).'
574
+ : '';
575
+ return [
576
+ {
577
+ name: item.key,
578
+ question: `\`${item.key}\` (Vercel ${item.target}) es sensitive: la derivo — ${detail}.${passkeys} ¿Confirmas?`,
579
+ },
580
+ ];
581
+ }
582
+ if (item.verdict.class === 'rotable' && item.verdict.rotate === 'auth-secret') {
583
+ return [
584
+ {
585
+ name: item.key,
586
+ question: `\`${item.key}\` (Vercel ${item.target}) es sensitive: ${detail}. ¿Confirmas?`,
587
+ },
588
+ ];
589
+ }
590
+ return [];
591
+ });
592
+ await confirmQuestions(questions, deps, '`factory vault adopt --apply`');
593
+ }
594
+ /**
595
+ * After the confirmations: derive or generate each pending value, IN MEMORY, into the inventory —
596
+ * the layout then writes it like any other. Never a sealed or an unknown key: those have no
597
+ * generator here (`generateMfaEncryptionKey` is `provision`'s, for a repo with no data yet).
598
+ */
599
+ async function materializeSensitive(prepared, deps) {
600
+ const fetchImpl = deps.fetchImpl ?? fetch;
601
+ let neon = null;
602
+ let count = 0;
603
+ for (const item of prepared.sensitive) {
604
+ if (item.inVault)
605
+ continue;
606
+ const v = item.verdict;
607
+ let value = null;
608
+ if (v.class === 'rotable' && v.rotate === 'auth-secret')
609
+ value = generateAuthSecret();
610
+ else if (v.class === 'derivable') {
611
+ if (v.derive === 'neon-pooled') {
612
+ const projectId = prepared.neonProjectId;
613
+ try {
614
+ if (!neon) {
615
+ const apiKey = await prepared.rail.get('NEON_API_KEY');
616
+ neon = { apiKey, branches: await listNeonBranches(apiKey, projectId, { fetchImpl }) };
617
+ }
618
+ // ADOPT-008's resolvers — the same ones `provision` and the empty-deploy route use.
619
+ const branch = item.target === 'production'
620
+ ? resolveMainBranch(neon.branches, projectId)
621
+ : resolveDevelopBranch(neon.branches, readE2eParentBranch(prepared.rootDir));
622
+ if (!branch) {
623
+ throw new CLIError(`El proyecto \`${projectId}\` de Neon no reporta branches: no derivo ` +
624
+ `\`${item.key}\` (Vercel ${item.target}). No se escribió nada.`);
625
+ }
626
+ value = await getConnectionUri(neon.apiKey, projectId, branch.id, true, { fetchImpl });
627
+ }
628
+ catch (error) {
629
+ if (error instanceof CLIError)
630
+ throw error;
631
+ // Rebuilt without the provider's message: this path handles a connection string.
632
+ throw new CLIError(`Neon no respondió bien al derivar \`${item.key}\` (Vercel ${item.target}). No se escribió nada.`);
633
+ }
634
+ }
635
+ else {
636
+ const prod = prepared.hosts.prod;
637
+ const dev = prepared.hosts.dev;
638
+ if (v.derive === 'app-url')
639
+ value = `https://${item.target === 'production' ? prod : dev}`;
640
+ else if (v.derive === 'rp-id')
641
+ value = prod;
642
+ else
643
+ value = relatedOriginsFor([dev]);
644
+ }
645
+ }
646
+ if (value === null)
647
+ continue;
648
+ (item.target === 'production' ? prepared.values.production : prepared.values.preview).set(item.key, value);
649
+ count += 1;
650
+ }
651
+ return count;
652
+ }
653
+ const MAIN_ROOT = { env: VAULT_ENV_MAIN, path: VAULT_ROOT_PATH };
654
+ const DEVELOP_CI = { env: VAULT_ENV_DEVELOP, path: VAULT_CI_PATH };
655
+ /** Order in which the apply writes the folders — a reference always after its target. */
656
+ const LOCATION_ORDER = ['main:/', 'main:/ci', 'develop:/', 'develop:/ci', 'local:/'];
657
+ /** Stable sort of the writes by {@link LOCATION_ORDER} (Railway's are appended after the layout). */
658
+ function sortByLocation(writes) {
659
+ const rank = (w) => {
660
+ const index = LOCATION_ORDER.indexOf(formatLocation(w.location));
661
+ return index === -1 ? LOCATION_ORDER.length : index;
662
+ };
663
+ return writes
664
+ .map((w, i) => ({ w, i }))
665
+ .sort((a, b) => rank(a.w) - rank(b.w) || a.i - b.i)
666
+ .map((x) => x.w);
667
+ }
668
+ /**
669
+ * Stand-in for a value the apply mints (the project's Resend key): present, and — with its NUL
670
+ * bytes — never equal to a value read from a deploy. In memory only.
671
+ */
672
+ const MINTED_PLACEHOLDER = '\u0000minted-by-the-apply\u0000';
673
+ /**
674
+ * What a folder WILL hold after `writes`, references resolved one level — in memory only, to
675
+ * compare with what Railway holds. A minted value is present and unequal to every read value.
676
+ */
677
+ function resolvedFolder(writes, location) {
678
+ const where = formatLocation(location);
679
+ const valueAt = (loc, key) => {
680
+ const w = writes.find((x) => formatLocation(x.location) === formatLocation(loc) && x.key === key);
681
+ if (!w)
682
+ return undefined;
683
+ if (w.kind === 'value')
684
+ return w.value;
685
+ if (w.kind === 'minted-resend')
686
+ return MINTED_PLACEHOLDER;
687
+ return valueAt(w.target, w.target.key);
688
+ };
689
+ const folder = new Map();
690
+ for (const w of writes) {
691
+ if (formatLocation(w.location) !== where)
692
+ continue;
693
+ const value = valueAt(w.location, w.key);
694
+ if (value !== undefined)
695
+ folder.set(w.key, value);
696
+ }
697
+ return folder;
698
+ }
699
+ /**
700
+ * The writes of the apply — the ADR-005 layout, plus the import of Railway into the root of each
701
+ * environment a selected Railway sync reads (ADOPT-002, "importar primero") — with the plan's
702
+ * entries and confirmations. Pure: values in, writes (in memory) and names out.
703
+ */
704
+ function planWrites(input) {
705
+ const { values, retired, replaceResend, proposals } = input;
706
+ const layout = planAdoptionLayout(values, {
707
+ retired,
708
+ replaceResend,
709
+ importLocalNeonKey: input.importLocalNeonKey,
710
+ extra: input.extra,
711
+ });
712
+ const railwayWrites = [];
713
+ const differing = [];
714
+ const railwayConflicts = [];
715
+ for (const vaultEnv of RAILWAY_VAULT_ENVS) {
716
+ const here = proposals.filter((p) => p.vaultEnv === vaultEnv);
717
+ if (here.length === 0)
718
+ continue;
719
+ const location = { env: vaultEnv, path: VAULT_ROOT_PATH };
720
+ const all = [...layout, ...railwayWrites];
721
+ const imported = planRailwayImport({
722
+ location,
723
+ sources: here.map((p) => p.source.values),
724
+ folder: resolvedFolder(all, location),
725
+ mainFolder: resolvedFolder(all, MAIN_ROOT),
726
+ retired,
727
+ });
728
+ railwayWrites.push(...imported.writes);
729
+ for (const key of imported.differing) {
730
+ differing.push({
731
+ key,
732
+ labels: here.filter((p) => p.source.values.has(key)).map((p) => p.source.label),
733
+ });
734
+ }
735
+ railwayConflicts.push(...imported.conflicting.map((key) => `${key} (${formatLocation(location)})`));
736
+ }
737
+ const writes = sortByLocation([...layout, ...railwayWrites]);
738
+ const { entries, confirmations, unreadable } = buildPlanEntries({
739
+ values,
740
+ writes,
741
+ retired,
742
+ replaceResend,
743
+ hash: input.hash,
744
+ notes: input.notes,
745
+ });
746
+ for (const { key, labels } of differing) {
747
+ confirmations.push({
748
+ name: key,
749
+ question: `\`${key}\` tiene en Railway (${labels.join(', ')}) otro valor que el que queda en la bóveda: ` +
750
+ 'el sync pondrá en Railway el de la bóveda. ¿Confirmas?',
751
+ });
752
+ }
753
+ return { writes, entries, confirmations, unreadable, railwayConflicts };
754
+ }
755
+ /** How the plan names a precondition that does not apply on the kit's origin. */
756
+ const FACTORY_NOT_APPLICABLE = 'no aplica — es el origen del kit';
757
+ /** Preconditions 1 and 2 of a derivative (ADOPT-001). Throw naming what to fix. */
758
+ async function assertDerivativePreconditions(rootDir, deps) {
759
+ // Precondition 1 — the brain up to date (the same comparison as `factory status`).
760
+ if (!hasLockfile(rootDir)) {
761
+ throw new CLIError('Este repo no tiene `.timekast/lockfile.json`: no sé qué cerebro tiene. Corre `factory update` y repite.');
762
+ }
763
+ const status = await computeStatus({
764
+ rootDir,
765
+ ...(deps.fetchLatest ? { fetchLatest: deps.fetchLatest } : {}),
766
+ });
767
+ if (status.networkError) {
768
+ throw new CLIError('No pude confirmar cuál es la última versión del cerebro (sin conexión o sin `gh`). La adopción ' +
769
+ 'exige el cerebro al día: revisa tu conexión y repite.');
770
+ }
771
+ if (status.hasUpdate) {
772
+ throw new CLIError(`El cerebro de este repo está en v${status.installed} y la última es v${status.latest}. ` +
773
+ 'Corre `factory update` y repite `factory vault adopt`.');
774
+ }
775
+ // Precondition 2 — no fork in what the wrapper runs.
776
+ const drift = wrapperDrift(rootDir, readLockfile(rootDir));
777
+ if (drift.length > 0) {
778
+ throw new CLIError('Estos archivos que corre el wrapper de la bóveda difieren de lo que instaló el kit (o faltan):\n' +
779
+ drift.map((file) => ` · ${file}`).join('\n') +
780
+ '\nResuélvelos antes de adoptar (`factory update --verify` los reporta; `factory update` trae los del kit). ' +
781
+ 'No hice nada.');
782
+ }
783
+ }
784
+ /** Neon's failures in this path are rebuilt without the provider's body. */
785
+ function neonFailure(what) {
786
+ return new CLIError(`Neon no respondió bien al ${what}. No hice nada.`);
787
+ }
788
+ /**
789
+ * The branches of the Neon project for `main` and `develop`, from ADOPT-008's resolvers — the
790
+ * same ones the `--apply` uses (it reuses this result). Reads only.
791
+ */
792
+ async function resolveAdoptBranches(rootDir, projectId, rail, fetchImpl) {
793
+ const apiKey = await rail.get('NEON_API_KEY');
794
+ let branches;
795
+ try {
796
+ branches = await listNeonBranches(apiKey, projectId, { fetchImpl });
797
+ }
798
+ catch {
799
+ throw neonFailure(`listar los branches del proyecto \`${projectId}\``);
800
+ }
801
+ const main = resolveMainBranch(branches, projectId);
802
+ const develop = resolveDevelopBranch(branches, readE2eParentBranch(rootDir)) ?? main;
803
+ return { main, develop };
804
+ }
805
+ /**
806
+ * The project's Neon key for CI (ADOPT-009): the one already in `develop:/ci` if it is PROVEN the
807
+ * project's, else the one of `.env.local` if proven, else a new one is minted — whose plan lists
808
+ * the live keys of its family (`<slug>-e2e`, by `isNeonKeyFamily`). A listing that fails stops the
809
+ * run naming it, before anything is minted. `null` when no CI key is at stake (no GitHub repo, no
810
+ * Neon project, or the repo's CI does not carry one).
811
+ */
812
+ async function inspectNeonKey(input) {
813
+ const localKey = input.local.get(NEON_API_KEY);
814
+ const proofDeps = {
815
+ railKey: () => input.rail.get('NEON_API_KEY'),
816
+ fetchImpl: input.fetchImpl,
817
+ };
818
+ const localProven = localKey !== undefined && input.neonProjectId !== null
819
+ ? await isProjectScopedNeonKey(localKey, input.neonProjectId, proofDeps)
820
+ : false;
821
+ const family = `${input.slug}-e2e`;
822
+ const ciCarriesKey = input.githubSecretNames?.includes(NEON_API_KEY) === true || localKey !== undefined;
823
+ if (!input.repo || !input.neonProjectId || !ciCarriesKey) {
824
+ return { plan: null, localProven, orgId: null };
825
+ }
826
+ if (input.vaultProjectId) {
827
+ const inCi = await input.client.readSecrets(input.vaultProjectId, DEVELOP_CI);
828
+ const current = inCi.get(NEON_API_KEY);
829
+ if (current !== undefined &&
830
+ (await isProjectScopedNeonKey(current, input.neonProjectId, proofDeps))) {
831
+ return {
832
+ plan: { action: 'ya está en la bóveda', family, oldKeys: [] },
833
+ localProven,
834
+ orgId: null,
835
+ };
836
+ }
837
+ }
838
+ if (localProven) {
839
+ return {
840
+ plan: { action: 'importar de .env.local', family, oldKeys: [] },
841
+ localProven,
842
+ orgId: null,
843
+ };
844
+ }
845
+ const railKey = await input.rail.get('NEON_API_KEY');
846
+ let orgId;
847
+ try {
848
+ orgId = await getNeonProjectOrg(railKey, input.neonProjectId, { fetchImpl: input.fetchImpl });
849
+ }
850
+ catch {
851
+ throw neonFailure(`leer el proyecto \`${input.neonProjectId}\``);
852
+ }
853
+ if (!orgId) {
854
+ throw new CLIError(`El proyecto Neon \`${input.neonProjectId}\` no existe o la \`NEON_API_KEY\` del rail no lo alcanza: ` +
855
+ 'no puedo acuñar la key de CI del proyecto. No hice nada.');
856
+ }
857
+ let oldKeys;
858
+ try {
859
+ oldKeys = (await listNeonApiKeys(railKey, orgId, { fetchImpl: input.fetchImpl }))
860
+ .filter((key) => isNeonKeyFamily(key.name, family))
861
+ .map((key) => key.name)
862
+ .sort();
863
+ }
864
+ catch (error) {
865
+ if (error instanceof NeonApiKeyListError) {
866
+ throw new CLIError(`${error.message} No acuñé ninguna key: sin la lista no sé qué key reemplazo. No hice nada.`);
867
+ }
868
+ throw error;
869
+ }
870
+ return { plan: { action: 'acuñar', family, oldKeys }, localProven, orgId };
871
+ }
872
+ /** Preconditions + inventory + plan. Reads only: nothing here writes anywhere. */
873
+ async function prepareAdoption(rootDir, flags, deps) {
874
+ // Precondition — the files the vault needs locally.
875
+ if (!existsSync(path.join(rootDir, WITH_VAULT_SCRIPT_FILE))) {
876
+ throw new CLIError(`Este repo no trae \`${WITH_VAULT_SCRIPT_FILE}\`, el wrapper que inyecta la bóveda a tus scripts. ` +
877
+ 'Corre `factory update` para traer el cerebro al día y repite `factory vault adopt`.');
878
+ }
879
+ // ADOPT-009 — Factory mode: two signals at once (no lockfile AND origin = the Factory's repo).
880
+ const readOrigin = deps.resolveOriginRepo ?? originRepoOf;
881
+ const factoryMode = await isFactoryMode(rootDir, readOrigin);
882
+ if (!factoryMode)
883
+ await assertDerivativePreconditions(rootDir, deps);
884
+ const preconditions = factoryMode
885
+ ? { brain: FACTORY_NOT_APPLICABLE, forks: FACTORY_NOT_APPLICABLE }
886
+ : { brain: 'pasada', forks: 'pasada' };
887
+ // The state on disk (both shapes; fail-closed when unreadable or with a malformed `vault`).
888
+ const target = readDestroyTarget(rootDir);
889
+ const link = readVaultLink(rootDir);
890
+ const declined = readVaultDeclined(rootDir);
891
+ const rail = deps.rail ?? createRailResolver();
892
+ const fetchImpl = deps.fetchImpl ?? fetch;
893
+ const vercelToken = await rail.get('VERCEL_TOKEN');
894
+ const teamId = flags.team ?? (await rail.getOptional('VERCEL_TEAM_ID'));
895
+ if (!teamId) {
896
+ throw new CLIError('No sé en qué team de Vercel buscar: falta `VERCEL_TEAM_ID` en `rail-timekast`. Pásalo con `--team=<id>`.');
897
+ }
898
+ const originRepo = target?.githubRepo ? null : await readOrigin(rootDir);
899
+ const repoCandidate = target?.githubRepo ?? originRepo;
900
+ const repo = repoCandidate && REPO_RE.test(repoCandidate) ? repoCandidate : null;
901
+ const location = await locateVercelProject({
902
+ recordedId: recordedAdoptionVercelId(link) ?? target?.vercelProjectId ?? null,
903
+ repo,
904
+ token: vercelToken,
905
+ teamId,
906
+ deps: { fetchImpl },
907
+ });
908
+ const candidates = location.kind === 'ambiguous'
909
+ ? location.candidates
910
+ : location.kind === 'found'
911
+ ? [location.project]
912
+ : [];
913
+ const named = (list) => list.map((p) => `\`${p.name}\` (${p.id})`).join(', ');
914
+ let vercelProject = location.kind === 'found' ? location.project : null;
915
+ if (flags.vercelProject !== undefined) {
916
+ const chosen = candidates.find((p) => p.id === flags.vercelProject);
917
+ if (!chosen) {
918
+ throw new CLIError(`\`--vercel-project ${flags.vercelProject}\` no es ${location.kind === 'found' && location.source === 'recorded' ? 'el proyecto que registra `.timekast/provision.json`' : 'uno de los proyectos de Vercel conectados al repo'}: ` +
919
+ `${candidates.length > 0 ? `los candidatos son ${named(candidates)}` : 'no hay ninguno'}. No se escribió nada.`);
920
+ }
921
+ vercelProject = chosen;
922
+ }
923
+ else if (location.kind === 'ambiguous') {
924
+ throw new CLIError(`Hay ${location.candidates.length} proyectos de Vercel conectados a \`${repo}\`: ` +
925
+ `${named(location.candidates)}. No elijo uno a ciegas.\n` +
926
+ ' · Elige el que queda como proyecto principal con `factory vault adopt --vercel-project <id>`.\n' +
927
+ ' · Los demás despliegues se operan después con `factory vault deploys` (fx-secrets-vault §6).');
928
+ }
929
+ let slug;
930
+ if (factoryMode) {
931
+ // The Factory's directory name is not a valid slug; its identity doc is the source.
932
+ const fromConfig = readConfigSlug(rootDir);
933
+ if (!fromConfig) {
934
+ throw new CLIError(`Modo Factory: no encontré un \`Slug\` válido en \`${PROJECT_CONFIG_PATH}\` §1. El slug del ` +
935
+ 'proyecto de la bóveda sale de ahí, nunca del nombre del directorio. No hice nada.');
936
+ }
937
+ slug = fromConfig;
938
+ }
939
+ else {
940
+ slug = (realField(target?.slug) ?? vercelProject?.name ?? path.basename(rootDir)).toLowerCase();
941
+ }
942
+ assertValidSubdomainSlug(slug);
943
+ const railwayToken = await rail.getOptional(RAILWAY_TOKEN_KEY);
944
+ if (!railwayToken) {
945
+ console.warn('⚠ No hay `RAILWAY_TOKEN` en `rail-timekast`: no inventarié Railway. Si este repo despliega ahí, ' +
946
+ 'pide a un admin que lo cargue y repite.');
947
+ }
948
+ else if (!repo) {
949
+ console.warn('⚠ Sin remote `origin` de GitHub no sé qué repo buscar en Railway (se busca por el repo conectado): ' +
950
+ 'no inventarié Railway.');
951
+ }
952
+ // Railway is searched by the repo its services have CONNECTED (Decision 13), never by a name:
953
+ // two or more connected projects stop here, before anything is written.
954
+ const railway = railwayToken && repo ? await readRailwayInventory(repo, railwayToken, fetchImpl) : null;
955
+ // 🔴 Not consulted is never "absent": it can never make the deploy count as empty.
956
+ const railwayPresence = !railwayToken || !repo ? 'no consultado' : railway ? 'presente' : 'ausente';
957
+ if (!vercelProject && !railway) {
958
+ throw new CLIError(`No encontré ningún despliegue de este repo: ni un proyecto de Vercel ni uno de Railway conectados a ` +
959
+ `\`${repo ?? '(sin remote origin de GitHub)'}\`. ` +
960
+ 'No hay nada que adoptar desde un deploy. No hice nada.');
961
+ }
962
+ // Inventory — values in memory only. `null` = Vercel answered WITHOUT the list.
963
+ const vercelEnvs = vercelProject
964
+ ? await readEnvVarsWithValues(vercelProject.id, vercelToken, teamId, { fetchImpl })
965
+ : null;
966
+ const vercel = vercelMaps(vercelEnvs ?? []);
967
+ const envLocalPath = path.join(rootDir, ENV_LOCAL_FILE);
968
+ const hasEnvLocal = existsSync(envLocalPath);
969
+ const values = {
970
+ local: new Map(Object.entries(hasEnvLocal ? parseEnvBody(readFileSync(envLocalPath, 'utf8')) : {})),
971
+ production: vercel.production,
972
+ preview: vercel.preview,
973
+ railway: new Map((railway?.sources ?? []).map((s) => [s.label, s.values])),
974
+ };
975
+ // The rail's TEAM Resend key, compared in memory (never printed, never stored).
976
+ const hasResend = [values.local, values.production, values.preview].some((m) => m.has(RESEND_KEY));
977
+ const resendTeamKey = hasResend ? await rail.getOptional(RESEND_KEY) : null;
978
+ const replaceResend = resendTeamKey !== null &&
979
+ [values.production, values.preview, values.local].some((m) => m.get(RESEND_KEY) === resendTeamKey);
980
+ const sender = values.production.get('EMAIL_FROM') ??
981
+ values.preview.get('EMAIL_FROM') ??
982
+ values.local.get('EMAIL_FROM') ??
983
+ PROVISION_EMAIL_FROM;
984
+ const resendDomain = sender.slice(sender.indexOf('@') + 1);
985
+ const client = deps.vaultClient ?? createVaultClient();
986
+ const githubSecretNames = repo
987
+ ? [...(await (deps.listGithubSecretNames ?? ghSecretNames)(repo))].sort()
988
+ : null;
989
+ const neonProjectId = realField(target?.neonProjectId) ?? values.local.get('NEON_PROJECT_ID') ?? null;
990
+ // ADOPT-009 — the project's Neon key for CI (imported only when proven; else minted).
991
+ const neonKey = await inspectNeonKey({
992
+ slug,
993
+ repo,
994
+ neonProjectId,
995
+ githubSecretNames,
996
+ local: values.local,
997
+ vaultProjectId: link?.projectId ?? null,
998
+ client,
999
+ rail,
1000
+ fetchImpl,
1001
+ });
1002
+ const notes = new Map();
1003
+ if (values.local.has(NEON_API_KEY) && !neonKey.localProven) {
1004
+ notes.set(NEON_API_KEY, 'no se prueba como la key del proyecto (del rail, personal, vieja o muerta): no se importa' +
1005
+ (neonKey.plan?.action === 'acuñar' ? '; se acuña una propia del proyecto' : ''));
1006
+ }
1007
+ // ADOPT-009 — Factory mode: `SHORTIO_API_KEY` from the rail (in memory) to `develop:/ci`, and
1008
+ // any sync of `rail-timekast` that writes to this repo — found BY DESTINATION, not by name.
1009
+ let factory = null;
1010
+ const extra = [];
1011
+ const factoryIdentity = factoryMode && isFactoryIdentity(slug, repo);
1012
+ if (factoryMode && !factoryIdentity) {
1013
+ console.warn(`⚠ Modo Factory, pero el slug resuelto (\`${slug}\`) o el repo (\`${repo ?? '(sin remote)'}\`) no ` +
1014
+ `son los del Factory (\`${FACTORY_SLUG}\`, \`${FACTORY_REPO}\`): no propongo la copia de ` +
1015
+ `\`${SHORTIO_KEY}\` del rail. Un token del rail sólo sale hacia el CI del propio Factory.`);
1016
+ }
1017
+ if (factoryIdentity) {
1018
+ const shortio = await rail.get(SHORTIO_KEY);
1019
+ extra.push({
1020
+ kind: 'value',
1021
+ location: DEVELOP_CI,
1022
+ key: SHORTIO_KEY,
1023
+ origin: SHORTIO_KEY,
1024
+ value: shortio,
1025
+ });
1026
+ notes.set(SHORTIO_KEY, 'modo Factory: va a develop:/ci con el valor del rail (una copia que se rota a mano), nunca el de este archivo');
1027
+ const github = VAULT_SYNC_PLAN.find((spec) => spec.role === 'github-ci');
1028
+ let railSyncs;
1029
+ try {
1030
+ railSyncs = await client.listSecretSyncs(RAIL_TIMEKAST.projectId);
1031
+ }
1032
+ catch {
1033
+ throw new CLIError('No pude listar los syncs de `rail-timekast`: sin esa lista no sé si otro sync escribe en el repo ' +
1034
+ 'del Factory (dos escritores de la misma clave). No hice nada.');
1035
+ }
1036
+ factory = {
1037
+ shortio: 'develop:/ci (valor del rail, leído con tu sesión)',
1038
+ railSyncsToRepo: railSyncs
1039
+ .filter((sync) => syncTargetsDestination(sync, github, { repo: repo ?? FACTORY_REPO }))
1040
+ .map((sync) => sync.name)
1041
+ .sort(),
1042
+ };
1043
+ }
1044
+ const retired = retiredKeys(values);
1045
+ const { proposals, unmapped } = railway
1046
+ ? proposeRailwaySyncs(railway)
1047
+ : { proposals: [], unmapped: [] };
1048
+ const railwayProposals = proposals.map((p) => ({
1049
+ ...p,
1050
+ project: railway.project,
1051
+ label: railwaySyncLabel(p.vaultEnv, p.source),
1052
+ }));
1053
+ const hash = createDisplayHasher();
1054
+ const replan = (selected) => planWrites({
1055
+ values,
1056
+ retired,
1057
+ replaceResend,
1058
+ proposals: selected,
1059
+ hash,
1060
+ importLocalNeonKey: neonKey.localProven,
1061
+ extra,
1062
+ notes,
1063
+ });
1064
+ const { writes, entries, confirmations, railwayConflicts } = replan(railwayProposals);
1065
+ // ADOPT-003 — the `sensitive` vars: classified by name, never read.
1066
+ const { items: sensitive, hosts, hostCandidates, vaultProjectExists, } = await inspectSensitive({
1067
+ found: vercel.sensitive,
1068
+ retired,
1069
+ client,
1070
+ vaultProjectId: link?.projectId ?? null,
1071
+ slug,
1072
+ vercelProject,
1073
+ vercelToken,
1074
+ teamId,
1075
+ fetchImpl,
1076
+ neonProjectId,
1077
+ });
1078
+ // ADOPT-009 — the connected-and-empty deploy (fail-closed) and its hosts, from Vercel's domains.
1079
+ let emptyDeploy = null;
1080
+ let neonBranches = null;
1081
+ if (isConnectedEmptyDeploy({
1082
+ hasVercelProject: vercelProject !== null,
1083
+ vercelEnvs,
1084
+ railway: railwayPresence,
1085
+ })) {
1086
+ let domains;
1087
+ try {
1088
+ domains = await listProjectDomains(vercelProject.id, vercelToken, teamId, {
1089
+ fetchImpl,
1090
+ });
1091
+ }
1092
+ catch {
1093
+ throw new CLIError('Vercel no respondió bien al leer los dominios del proyecto. No hice nada.');
1094
+ }
1095
+ const candidates = deployHostCandidates(domains);
1096
+ if (neonProjectId) {
1097
+ neonBranches = await resolveAdoptBranches(rootDir, neonProjectId, rail, fetchImpl);
1098
+ }
1099
+ emptyDeploy = {
1100
+ prodHost: candidates.production.length === 1 ? candidates.production[0] : null,
1101
+ previewHost: candidates.preview.length === 1 ? candidates.preview[0] : null,
1102
+ prodCandidates: candidates.production,
1103
+ previewCandidates: candidates.preview,
1104
+ shared: sharedCandidatesOf(values.local),
1105
+ blocked: neonProjectId
1106
+ ? null
1107
+ : 'no sé cuál es el proyecto de Neon (ni `.timekast/provision.json` ni `NEON_PROJECT_ID` de `.env.local`): sin él no hay `DATABASE_URL` por branch',
1108
+ };
1109
+ }
1110
+ let github = null;
1111
+ if (repo && githubSecretNames) {
1112
+ const planned = new Set(writes.filter((w) => isCiFolder(w.location)).map((w) => w.key));
1113
+ // A key minted in the `--apply` lands in `develop:/ci` before the syncs exist; on an empty
1114
+ // deploy the `DATABASE_URL` reference follows the generated `develop:/DATABASE_URL`.
1115
+ if (neonKey.plan?.action === 'acuñar')
1116
+ planned.add(NEON_API_KEY);
1117
+ if (emptyDeploy && !emptyDeploy.blocked)
1118
+ planned.add('DATABASE_URL');
1119
+ const existing = link
1120
+ ? new Set(await client.listSecretKeys(link.projectId, DEVELOP_CI))
1121
+ : new Set();
1122
+ github = {
1123
+ repo,
1124
+ secretNames: githubSecretNames,
1125
+ missingInCi: githubSecretNames.filter((name) => !planned.has(name) && !existing.has(name)),
1126
+ };
1127
+ }
1128
+ let wiring;
1129
+ try {
1130
+ wiring = wireVaultWrapper(rootDir, { dryRun: true });
1131
+ }
1132
+ catch (error) {
1133
+ throw new CLIError(`No puedo leer \`package.json\` para cablear el wrapper: ${error.message}`);
1134
+ }
1135
+ const plan = {
1136
+ slug,
1137
+ factoryMode,
1138
+ preconditions,
1139
+ emptyDeploy,
1140
+ neon: neonBranches && neonProjectId
1141
+ ? {
1142
+ projectId: neonProjectId,
1143
+ main: neonBranches.main.name,
1144
+ develop: neonBranches.develop.name,
1145
+ sameBase: neonBranches.main.id === neonBranches.develop.id,
1146
+ }
1147
+ : null,
1148
+ neonKey: neonKey.plan,
1149
+ factory,
1150
+ vaultProjectId: link?.projectId ?? null,
1151
+ declined,
1152
+ vercelProject: vercelProject
1153
+ ? {
1154
+ id: vercelProject.id,
1155
+ name: vercelProject.name,
1156
+ source: location.kind === 'found' ? location.source : 'connected',
1157
+ }
1158
+ : null,
1159
+ railway: railway
1160
+ ? {
1161
+ project: railway.project.name,
1162
+ projectId: railway.project.id,
1163
+ sources: railway.sources.map((s) => s.label),
1164
+ }
1165
+ : null,
1166
+ railwaySyncs: railwayProposals.map((p) => p.label),
1167
+ railwayUnmapped: unmapped.map((s) => s.label),
1168
+ railwayConflicts,
1169
+ repo,
1170
+ entries,
1171
+ unreadable: vercel.unreadableOther,
1172
+ sensitive: [],
1173
+ sealed: [],
1174
+ sealedExplanation: null,
1175
+ branchScoped: vercel.branchScoped,
1176
+ confirmations: confirmations.map((c) => c.name),
1177
+ github,
1178
+ wrapper: {
1179
+ rewritten: wiring.rewritten,
1180
+ customized: wiring.customized.map((c) => c.name),
1181
+ },
1182
+ envLocal: hasEnvLocal ? 'presente' : 'ausente',
1183
+ syncs: [
1184
+ ...VAULT_SYNC_PLAN.filter((spec) => spec.destination === 'vercel' ? Boolean(vercelProject) : Boolean(repo)).map((spec) => spec.label),
1185
+ ...railwayProposals.map((p) => p.label),
1186
+ ],
1187
+ };
1188
+ refreshSensitivePlan(plan, sensitive, hosts, neonProjectId, vaultProjectExists);
1189
+ return {
1190
+ plan,
1191
+ rootDir,
1192
+ writes,
1193
+ questions: confirmations,
1194
+ railwayToken,
1195
+ railwayProposals,
1196
+ replan,
1197
+ values,
1198
+ rail,
1199
+ sensitive,
1200
+ neonProjectId,
1201
+ hosts,
1202
+ hostCandidates,
1203
+ vaultProjectExists,
1204
+ vercelToken,
1205
+ teamId,
1206
+ resendTeamKey: replaceResend ? resendTeamKey : null,
1207
+ resendDomain,
1208
+ client,
1209
+ neonBranches,
1210
+ neonOrgId: neonKey.orgId,
1211
+ };
1212
+ }
1213
+ function isCiFolder(location) {
1214
+ return location.env === DEVELOP_CI.env && location.path === DEVELOP_CI.path;
1215
+ }
1216
+ /** Print the plan for a person — names and hashes only. */
1217
+ function printPlan(plan, apply) {
1218
+ const out = (line = '') => console.log(line);
1219
+ out(`\nAdopción de la bóveda — \`${plan.slug}\`${apply ? '' : ' (plan: no se escribe nada sin `--apply`)'}`);
1220
+ if (plan.declined) {
1221
+ out(' ⚠ Este repo había declinado la bóveda (`factory vault adopt --decline`). ' +
1222
+ (apply
1223
+ ? 'Al guardar el vínculo borro esa marca: adoptar es la decisión más nueva.'
1224
+ : 'Si lo adoptas, el `--apply` borra esa marca al guardar el vínculo.'));
1225
+ }
1226
+ if (plan.vercelProject) {
1227
+ const how = plan.vercelProject.source === 'recorded'
1228
+ ? 'registrado en provision.json'
1229
+ : `conectado a ${plan.repo}`;
1230
+ out(` Vercel: \`${plan.vercelProject.name}\` (${plan.vercelProject.id}, ${how})`);
1231
+ }
1232
+ else {
1233
+ out(' Vercel: sin proyecto conectado a este repo');
1234
+ }
1235
+ out(plan.railway
1236
+ ? ` Railway: \`${plan.railway.project}\` (${plan.railway.projectId}) — ${plan.railway.sources.join(', ') || '(sin servicios)'}`
1237
+ : ' Railway: sin proyecto conectado al repo');
1238
+ out(` Repo: ${plan.repo ?? '(sin remote de GitHub)'}`);
1239
+ out(plan.vaultProjectId
1240
+ ? ` Bóveda: el repo ya tiene bloque \`vault\` (${plan.vaultProjectId}): el --apply sólo completa lo que falte`
1241
+ : ` Bóveda: proyecto \`${plan.slug}\` (entornos main/develop/local; miembros: quien corre + los admins)`);
1242
+ if (plan.factoryMode) {
1243
+ out(' Modo Factory: es el checkout del origen del kit (sin lockfile y con origin del Factory); el slug ' +
1244
+ 'sale de project-config.md §1 y el vínculo se escribe sin `provision --adopt`.');
1245
+ }
1246
+ out(` Precondición cerebro al día: ${plan.preconditions.brain}`);
1247
+ out(` Precondición sin forks en el wrapper: ${plan.preconditions.forks}`);
1248
+ if (plan.neon) {
1249
+ out(` Neon: proyecto \`${plan.neon.projectId}\` — main ← branch \`${plan.neon.main}\`, develop ← branch \`${plan.neon.develop}\``);
1250
+ if (plan.neon.sameBase) {
1251
+ out(' ⚠ El proyecto de Neon tiene un solo branch: main y develop quedan en la MISMA base. El --apply te pide confirmarlo.');
1252
+ }
1253
+ }
1254
+ if (plan.neonKey) {
1255
+ const key = plan.neonKey;
1256
+ out(key.action === 'acuñar'
1257
+ ? ` Key de Neon de CI: se acuña una del proyecto (sólo-key, a develop:/ci, antes de los syncs). ` +
1258
+ (key.oldKeys.length > 0
1259
+ ? `Key vieja encontrada en la familia \`${key.family}\`: ${key.oldKeys.join(', ')} — se revoca cuando el sync entregue la nueva a GitHub.`
1260
+ : `No encontré ninguna key vieja en la familia \`${key.family}\`.`)
1261
+ : ` Key de Neon de CI: ${key.action} (probada como del proyecto).`);
1262
+ }
1263
+ if (plan.emptyDeploy) {
1264
+ const e = plan.emptyDeploy;
1265
+ const host = (value, candidates) => value ??
1266
+ (candidates.length === 0
1267
+ ? 'sin candidato — te lo pregunto en el --apply'
1268
+ : `${candidates.join(' | ')} — eliges en el --apply`);
1269
+ out('\nDeploy conectado y VACÍO (Vercel sin variables, Railway consultado y sin proyecto): los valores por ' +
1270
+ 'entorno se generan o derivan como en un deploy nuevo, nunca de .env.local.');
1271
+ out(` Host de producción: ${host(e.prodHost, e.prodCandidates)}`);
1272
+ out(` Host de preview: ${host(e.previewHost, e.previewCandidates)}`);
1273
+ out(' Por entorno: AUTH_SECRET (uno nuevo por entorno), NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_APP_ENV, ' +
1274
+ 'WEBAUTHN_RP_ID/WEBAUTHN_RELATED_ORIGINS (de los hosts), DATABASE_URL (por branch de Neon).');
1275
+ if (e.shared.length > 0) {
1276
+ out(` Compartidos candidatos de .env.local (confirmas cada uno): ${e.shared.join(', ')}`);
1277
+ }
1278
+ if (e.blocked)
1279
+ out(` 🔴 Bloquea el --apply: ${e.blocked}`);
1280
+ }
1281
+ if (plan.factory) {
1282
+ out(` SHORTIO_API_KEY → ${plan.factory.shortio}`);
1283
+ if (plan.factory.railSyncsToRepo.length > 0) {
1284
+ out(` 🔴 Syncs de rail-timekast hacia este repo: ${plan.factory.railSyncsToRepo.join(', ')}. ` +
1285
+ 'Serían un segundo escritor: el --apply se detiene hasta que se borren.');
1286
+ }
1287
+ }
1288
+ out('\nVariables — nombre · estado · huellas (HMAC con llave de esta corrida; no se comparan entre corridas) · destino:');
1289
+ for (const entry of plan.entries) {
1290
+ const hashes = [
1291
+ entry.sources.local ? `local=${entry.sources.local}` : null,
1292
+ entry.sources.production ? `production=${entry.sources.production}` : null,
1293
+ entry.sources.preview ? `preview=${entry.sources.preview}` : null,
1294
+ ...Object.entries(entry.sources.railway ?? {}).map(([label, h]) => `railway(${label})=${h}`),
1295
+ ].filter(Boolean);
1296
+ const destination = entry.targets.length > 0 ? entry.targets.join(', ') : '(no se escribe)';
1297
+ out(` · ${entry.name} [${entry.state}] ${hashes.join(' ')} → ${destination}`);
1298
+ if (entry.note)
1299
+ out(` ↳ ${entry.action ? `${entry.action}: ` : ''}${entry.note}`);
1300
+ }
1301
+ if (plan.sensitive.length > 0) {
1302
+ out('\nVariables sensitive de Vercel (no devuelve su valor; nunca lo leo ni lo imprimo):');
1303
+ for (const entry of plan.sensitive) {
1304
+ const state = entry.inVault
1305
+ ? 'ya está en la bóveda: la uso, no la toco'
1306
+ : `${entry.class}: ${entry.detail}`;
1307
+ out(` · ${entry.name} (${entry.target}) — ${state}`);
1308
+ if (entry.blocked)
1309
+ out(` ↳ 🔴 no se puede derivar: ${entry.blocked}`);
1310
+ }
1311
+ }
1312
+ if (plan.sealedExplanation)
1313
+ out(`\n${plan.sealedExplanation}`);
1314
+ if (plan.unreadable.length > 0) {
1315
+ out(`\n🔴 Ilegibles sin ser sensitive (un error de lectura u otro tipo de variable): ${plan.unreadable.join(', ')}. ` +
1316
+ 'Bloquean el --apply.');
1317
+ }
1318
+ if (plan.branchScoped.length > 0) {
1319
+ out(`\n🔴 Variables de preview atadas a una rama (no las mapeo solas a develop): ` +
1320
+ `${plan.branchScoped.map((v) => `${v.name} (rama ${v.branch})`).join(', ')}. ` +
1321
+ 'Decide en Vercel (quítales la rama o bórralas) y repite. Bloquean el --apply.');
1322
+ }
1323
+ if (plan.railwaySyncs.length > 0) {
1324
+ out(`\nRailway${plan.railway ? ` (proyecto \`${plan.railway.project}\`, id ${plan.railway.projectId})` : ''} — ` +
1325
+ 'syncs propuestos (confirmas cada uno; el que no confirmes no se importa ni se crea): ' +
1326
+ plan.railwaySyncs.join(' · '));
1327
+ }
1328
+ if (plan.railwayUnmapped.length > 0) {
1329
+ out(` Sin sync (su entorno de Railway no se llama como uno de la bóveda: ${RAILWAY_VAULT_ENVS.join(', ')}): ` +
1330
+ `${plan.railwayUnmapped.join(', ')}. Después de adoptar: \`factory vault sync railway --project <id> ` +
1331
+ '--service <id> --env <main|develop> --railway-env <nombre>`.');
1332
+ }
1333
+ if (plan.railwayConflicts.length > 0) {
1334
+ out(`\n🔴 Dos servicios de Railway que leerían la misma carpeta tienen otro valor: ${plan.railwayConflicts.join(', ')}. ` +
1335
+ 'Una carpeta tiene un solo valor por clave. Bloquean el --apply (o no confirmes uno de esos syncs).');
1336
+ }
1337
+ if (plan.github) {
1338
+ out(`\nGitHub (${plan.github.repo}) — secrets del repo: ${plan.github.secretNames.join(', ') || '(ninguno)'}`);
1339
+ if (plan.github.missingInCi.length > 0) {
1340
+ out(` ⚠ No estarían en develop:/ci: ${plan.github.missingInCi.join(', ')}. El sync a GitHub los borraría: ` +
1341
+ 'el --apply importa todo y se detiene ANTES de crear los syncs hasta que estén en develop:/ci ' +
1342
+ '(agrégalos en la bóveda, o bórralos del repo si ya no se usan) y lo repitas.');
1343
+ }
1344
+ }
1345
+ if (plan.wrapper.rewritten.length > 0) {
1346
+ out(`\nWrapper: se envuelven los scripts ${plan.wrapper.rewritten.join(', ')} (el diff se muestra antes de escribir).`);
1347
+ }
1348
+ if (plan.wrapper.customized.length > 0) {
1349
+ out(`Scripts customizados de package.json (no se tocan; corren sin la bóveda hasta que los adapten): ` +
1350
+ plan.wrapper.customized.join(', '));
1351
+ }
1352
+ if (plan.syncs.length > 0) {
1353
+ out(`\nSyncs (se crean DESPUÉS de importar, así el primero no borra nada): ${plan.syncs.join(' · ')}`);
1354
+ }
1355
+ out(plan.envLocal === 'presente'
1356
+ ? `\n.env.local → se renombra a ${ENV_LOCAL_BACKUP_FILE} al final (si git lo ignora y no existe ya).`
1357
+ : '\nSin .env.local: local queda con lo que el layout le pone por referencia.');
1358
+ if (!apply)
1359
+ out('\nPara ejecutarlo: `factory vault adopt --apply`.');
1360
+ }
1361
+ // ---------------------------------------------------------------------------
1362
+ // The apply
1363
+ // ---------------------------------------------------------------------------
1364
+ /** The confirm seam, or `undefined` when nobody can answer (no terminal, none injected). */
1365
+ function resolveConfirm(deps) {
1366
+ const tty = deps.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
1367
+ return deps.confirm ?? (tty ? defaultConfirm : undefined);
1368
+ }
1369
+ /** Ask every confirmation BEFORE writing anything. Unconfirmed → nothing is written. */
1370
+ async function confirmAll(prepared, deps) {
1371
+ await confirmQuestions(prepared.questions, deps, '`factory vault adopt --apply`');
1372
+ }
1373
+ /** Ask `questions` (all must be `yes`); without a terminal nothing is confirmed blindly. */
1374
+ async function confirmQuestions(questions, deps, command) {
1375
+ if (questions.length === 0)
1376
+ return;
1377
+ const confirm = resolveConfirm(deps);
1378
+ if (!confirm) {
1379
+ throw new CLIError('Estas diferencias necesitan tu confirmación y no hay una terminal para dártela: ' +
1380
+ `${questions.map((q) => q.name).join(', ')}. No hay modo que las confirme a ciegas: ` +
1381
+ `corre ${command} en una terminal. No se escribió nada.`);
1382
+ }
1383
+ const declined = [];
1384
+ for (const question of questions) {
1385
+ if (!(await confirm(question.question)))
1386
+ declined.push(question.name);
1387
+ }
1388
+ if (declined.length > 0) {
1389
+ throw new CLIError(`No confirmaste: ${declined.join(', ')}. No se escribió nada; resuélvelo y repite.`);
1390
+ }
1391
+ }
1392
+ /** Step (2): the writes of the layout, location by location (values, then references). */
1393
+ async function writeLayout(prepared, projectId, deps) {
1394
+ const { client, writes } = prepared;
1395
+ const fetchImpl = deps.fetchImpl ?? fetch;
1396
+ let resend = null;
1397
+ const order = [];
1398
+ for (const write of writes) {
1399
+ const where = formatLocation(write.location);
1400
+ if (!order.includes(where))
1401
+ order.push(where);
1402
+ }
1403
+ for (const where of order) {
1404
+ const here = writes.filter((w) => formatLocation(w.location) === where);
1405
+ const location = here[0].location;
1406
+ for (const write of here.filter((w) => w.kind === 'minted-resend')) {
1407
+ const existing = await client.readSecrets(projectId, location);
1408
+ if (existing.has(write.key)) {
1409
+ console.log(` = bóveda ${where} ${write.key} (ya existía)`);
1410
+ continue;
1411
+ }
1412
+ const teamKey = prepared.resendTeamKey;
1413
+ const minted = await mintProjectResendKey(prepared.plan.slug, prepared.resendDomain, teamKey, fetchImpl, 'repite `factory vault adopt --apply`');
1414
+ if (!minted) {
1415
+ throw new CLIError('No pude acuñar la key de Resend del proyecto. No creé ningún sync: el despliegue sigue con su ' +
1416
+ 'key actual. Resuélvelo y repite `factory vault adopt --apply`.');
1417
+ }
1418
+ try {
1419
+ await client.writeSecrets(projectId, location, { [write.key]: minted.token }, 'create-if-missing');
1420
+ }
1421
+ catch (error) {
1422
+ const revoked = await revokeResendKey(minted.id, teamKey, fetchImpl);
1423
+ console.warn(revoked
1424
+ ? '⚠ No pude guardar la key de Resend del proyecto en la bóveda; la revoqué para no dejarla huérfana.'
1425
+ : `⚠ No pude guardar la key de Resend del proyecto NI revocarla (id \`${minted.id}\`): revócala en Resend.`);
1426
+ throw error;
1427
+ }
1428
+ resend = { apiKeyId: minted.id, domain: prepared.resendDomain };
1429
+ console.log(` + bóveda ${where} ${write.key} (key propia del proyecto)`);
1430
+ }
1431
+ const entries = {};
1432
+ for (const write of here)
1433
+ if (write.kind === 'value')
1434
+ entries[write.key] = write.value;
1435
+ if (Object.keys(entries).length > 0) {
1436
+ const result = await client.writeSecrets(projectId, location, entries, 'create-if-missing');
1437
+ for (const key of result.created)
1438
+ console.log(` + bóveda ${where} ${key}`);
1439
+ for (const key of result.unchanged)
1440
+ console.log(` = bóveda ${where} ${key} (ya existía)`);
1441
+ }
1442
+ for (const write of here) {
1443
+ if (write.kind !== 'reference')
1444
+ continue;
1445
+ const result = await client.writeReference(projectId, location, write.key, write.target, 'create-if-missing');
1446
+ const mark = result.created.length > 0 ? '+' : '=';
1447
+ console.log(` ${mark} bóveda ${describeWrite(write)} ${write.key}`);
1448
+ }
1449
+ }
1450
+ return resend;
1451
+ }
1452
+ /**
1453
+ * Right before creating the syncs: the vault folder must hold every key its Vercel target holds
1454
+ * (import first — the first sync deletes nothing), except the retirements already confirmed; and
1455
+ * every secret of the repo must be in `develop:/ci` (the user's answer for GitHub). Any gap stops
1456
+ * here, before ANY sync exists.
1457
+ */
1458
+ async function assertNothingToDelete(prepared, projectId, deps, railwaySelected = []) {
1459
+ const { client, plan } = prepared;
1460
+ const retired = new Set(plan.entries.filter((e) => e.action === 'retirar').map((e) => e.name));
1461
+ const gaps = [];
1462
+ if (plan.vercelProject) {
1463
+ const names = await listEnvVarNames(plan.vercelProject.id, prepared.vercelToken, prepared.teamId, {
1464
+ fetchImpl: deps.fetchImpl ?? fetch,
1465
+ });
1466
+ for (const spec of VAULT_SYNC_PLAN.filter((s) => s.destination === 'vercel')) {
1467
+ const inVault = new Set(await client.listSecretKeys(projectId, spec.source));
1468
+ const missing = [
1469
+ ...new Set(names
1470
+ .filter((env) => env.target.includes(spec.vercelTarget))
1471
+ .map((env) => env.key)),
1472
+ ]
1473
+ .filter((key) => !inVault.has(key) && !retired.has(key))
1474
+ .sort();
1475
+ if (missing.length > 0)
1476
+ gaps.push(`\`${spec.label}\`: ${missing.join(', ')}`);
1477
+ }
1478
+ }
1479
+ // ADOPT-002: each selected Railway service, re-read now — its keys must be in the folder.
1480
+ for (const proposal of railwaySelected) {
1481
+ const now = await readRailwayVariables(prepared.railwayToken, {
1482
+ projectId: proposal.project.id,
1483
+ environmentId: proposal.source.environment.id,
1484
+ serviceId: proposal.source.service.id,
1485
+ serviceName: proposal.source.service.name,
1486
+ }, deps.fetchImpl ?? fetch);
1487
+ const inVault = new Set(await client.listSecretKeys(projectId, { env: proposal.vaultEnv, path: VAULT_ROOT_PATH }));
1488
+ const missing = [...now.keys()]
1489
+ .filter((key) => !isRailwayProvided(key) && !inVault.has(key) && !retired.has(key))
1490
+ .sort();
1491
+ if (missing.length > 0)
1492
+ gaps.push(`\`${proposal.label}\`: ${missing.join(', ')}`);
1493
+ }
1494
+ if (plan.github) {
1495
+ const inCi = new Set(await client.listSecretKeys(projectId, DEVELOP_CI));
1496
+ const names = await (deps.listGithubSecretNames ?? ghSecretNames)(plan.github.repo);
1497
+ const missing = [...new Set(names)].filter((name) => !inCi.has(name)).sort();
1498
+ if (missing.length > 0) {
1499
+ gaps.push(`\`develop:/ci → secrets de GitHub Actions\`: ${missing.join(', ')}`);
1500
+ }
1501
+ }
1502
+ if (gaps.length > 0) {
1503
+ throw new CLIError('Importé a la bóveda, pero me detengo ANTES de crear los syncs: estas claves del destino no están en ' +
1504
+ 'su carpeta y el primer sync las borraría.\n' +
1505
+ gaps.map((line) => ` · ${line}`).join('\n') +
1506
+ '\nAgrégalas en esa carpeta de la bóveda (o bórralas del destino si ya no se usan) y repite ' +
1507
+ '`factory vault adopt --apply`: adopta lo que ya existe. No se creó ningún sync; `.env.local` sigue intacto.');
1508
+ }
1509
+ }
1510
+ /** A bare host (`app.example.com`, `slug.vercel.app`) — no scheme, no path, no port. */
1511
+ const HOST_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
1512
+ /**
1513
+ * The host of one deploy on a connected-and-empty deploy: the one Vercel gives when it gives
1514
+ * exactly one; otherwise whoever runs answers (the candidates are shown, none is picked). No
1515
+ * terminal → stop naming it. Never invented.
1516
+ */
1517
+ async function resolveEmptyDeployHost(what, resolved, candidates, deps) {
1518
+ if (resolved)
1519
+ return resolved;
1520
+ const tty = deps.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
1521
+ const ask = deps.askHost ?? (tty ? defaultAskHost : undefined);
1522
+ const seen = candidates.length === 0
1523
+ ? `Vercel no tiene un dominio de ${what}`
1524
+ : `Vercel tiene ${candidates.length} dominios de ${what} (${candidates.join(', ')}) y no elijo uno`;
1525
+ if (!ask) {
1526
+ throw new CLIError(`No sé el host de ${what}: ${seen}. No lo invento y no hay una terminal para preguntártelo: ` +
1527
+ 'corre `factory vault adopt --apply` en una terminal. No se escribió nada.');
1528
+ }
1529
+ const answer = (await ask(`${seen}. ¿Cuál es el host de ${what}? (sólo el host, sin https://)`))
1530
+ ?.trim()
1531
+ .toLowerCase();
1532
+ if (!answer || !HOST_RE.test(answer)) {
1533
+ throw new CLIError(`No recibí un host válido para ${what}. No lo invento: repite \`factory vault adopt --apply\`. ` +
1534
+ 'No se escribió nada.');
1535
+ }
1536
+ return answer;
1537
+ }
1538
+ /**
1539
+ * ADOPT-009 — a connected-and-EMPTY deploy: the per-environment values are generated or derived
1540
+ * exactly like `provision` does for a new deploy (`buildEnvPlan`): `AUTH_SECRET` distinct per
1541
+ * environment, the origins and the rpID from the hosts, `DATABASE_URL` per branch from ADOPT-008's
1542
+ * resolvers. Never from `.env.local` — only the product's SHARED values come from it, each one
1543
+ * confirmed by whoever runs (a "no" keeps it in `local` only). In memory, into the inventory; the
1544
+ * layout then writes it like any other value.
1545
+ */
1546
+ async function materializeEmptyDeploy(prepared, deps) {
1547
+ const empty = prepared.plan.emptyDeploy;
1548
+ if (empty.blocked || !prepared.neonProjectId || !prepared.neonBranches) {
1549
+ throw new CLIError(`El deploy está conectado y vacío, pero ${empty.blocked ?? 'no pude resolver los branches de Neon'}. ` +
1550
+ 'No se escribió nada.');
1551
+ }
1552
+ const prodHost = await resolveEmptyDeployHost('producción', empty.prodHost, empty.prodCandidates, deps);
1553
+ const previewHost = await resolveEmptyDeployHost('preview (rama `develop`)', empty.previewHost, empty.previewCandidates, deps);
1554
+ empty.prodHost = prodHost;
1555
+ empty.previewHost = previewHost;
1556
+ const confirm = resolveConfirm(deps);
1557
+ const sameBase = prepared.plan.neon?.sameBase === true;
1558
+ if (empty.shared.length > 0 || sameBase) {
1559
+ if (!confirm) {
1560
+ throw new CLIError('El deploy está vacío y estas decisiones necesitan tu confirmación, sin una terminal para dártela: ' +
1561
+ `${[...empty.shared, ...(sameBase ? ['misma base para main y develop'] : [])].join(', ')}. ` +
1562
+ 'Corre `factory vault adopt --apply` en una terminal. No se escribió nada.');
1563
+ }
1564
+ }
1565
+ if (sameBase && confirm) {
1566
+ const neon = prepared.plan.neon;
1567
+ const ok = await confirm(`El proyecto de Neon \`${neon.projectId}\` tiene un solo branch (\`${neon.main}\`): main y develop ` +
1568
+ 'quedan en la MISMA base (los previews escriben en la de producción). ¿Confirmas?');
1569
+ if (!ok) {
1570
+ throw new CLIError('No confirmaste que main y develop compartan la base. Crea un branch `develop` en Neon y repite. ' +
1571
+ 'No se escribió nada.');
1572
+ }
1573
+ }
1574
+ const shared = {};
1575
+ for (const key of empty.shared) {
1576
+ const yes = await confirm(`\`${key}\` de .env.local: ¿es un valor compartido del producto y va a los despliegues ` +
1577
+ '(main:/, y develop:/ lo referencia)? Si no, queda sólo en local.');
1578
+ if (yes)
1579
+ shared[key] = prepared.values.local.get(key);
1580
+ else
1581
+ console.log(` · ${key}: sólo en local (no lo confirmaste para los despliegues)`);
1582
+ }
1583
+ const mfa = shared[MFA_KEY] ?? '';
1584
+ delete shared[MFA_KEY];
1585
+ const fetchImpl = deps.fetchImpl ?? fetch;
1586
+ const apiKey = await prepared.rail.get('NEON_API_KEY');
1587
+ const uri = async (branch) => {
1588
+ try {
1589
+ return await getConnectionUri(apiKey, prepared.neonProjectId, branch.id, true, {
1590
+ fetchImpl,
1591
+ });
1592
+ }
1593
+ catch {
1594
+ throw new CLIError(`Neon no respondió bien al leer la cadena del branch \`${branch.name}\`. No se escribió nada.`);
1595
+ }
1596
+ };
1597
+ const mainDatabaseUrl = await uri(prepared.neonBranches.main);
1598
+ const developDatabaseUrl = sameBase ? mainDatabaseUrl : await uri(prepared.neonBranches.develop);
1599
+ const authSecretProd = generateAuthSecret();
1600
+ const authSecretPreview = generateAuthSecret();
1601
+ if (authSecretProd === authSecretPreview) {
1602
+ throw new CLIError('Los dos AUTH_SECRET generados salieron iguales. No se escribió nada.');
1603
+ }
1604
+ const envPlan = buildEnvPlan({
1605
+ mainDatabaseUrl,
1606
+ developDatabaseUrl,
1607
+ authSecretProd,
1608
+ authSecretPreview,
1609
+ mfaEncryptionKeyProd: mfa,
1610
+ mfaEncryptionKeyPreview: mfa,
1611
+ prodHost,
1612
+ previewHost,
1613
+ shared,
1614
+ });
1615
+ for (const entry of envPlan) {
1616
+ if (entry.value === '')
1617
+ continue; // no MFA key confirmed: never generated here (it is sealed)
1618
+ if (entry.target.includes('production'))
1619
+ prepared.values.production.set(entry.key, entry.value);
1620
+ if (entry.target.includes('preview'))
1621
+ prepared.values.preview.set(entry.key, entry.value);
1622
+ }
1623
+ }
1624
+ /** A `gh secret set` the key-only mint must never reach (the vault is the single writer). */
1625
+ async function refuseGhSecretSet() {
1626
+ throw new CLIError('vault adopt no escribe secrets de GitHub: el sync de develop:/ci es su escritor.');
1627
+ }
1628
+ /**
1629
+ * ADOPT-009 — step (2b): the project's Neon key, only when the plan said so (no proven key). Minted
1630
+ * with `provisionNeon`'s key-only option (ADOPT-008) over an IN-MEMORY state (slug, Neon project,
1631
+ * repo; `readRemintState` is never read): lists the family and the delivery marker, settles what a
1632
+ * previous run left, mints, writes ONLY `NEON_API_KEY` + `NEON_PROJECT_ID` into `develop:/ci`. The
1633
+ * revocations it leaves are RETURNED — persisted at step (4), settled after the syncs.
1634
+ */
1635
+ async function mintNeonKey(prepared, projectId, deps) {
1636
+ const { plan } = prepared;
1637
+ if (plan.neonKey?.action !== 'acuñar')
1638
+ return null;
1639
+ const now = deps.now ?? (() => new Date());
1640
+ const state = {
1641
+ projectName: plan.slug,
1642
+ lifecycle: 'live',
1643
+ scope: prepared.teamId,
1644
+ createdAt: now().toISOString(),
1645
+ neon: {
1646
+ projectId: prepared.neonProjectId,
1647
+ ...(prepared.neonOrgId ? { orgId: prepared.neonOrgId } : {}),
1648
+ mainBranchId: '',
1649
+ developBranchId: '',
1650
+ },
1651
+ ...(plan.repo ? { github: { repo: plan.repo, done: true } } : {}),
1652
+ };
1653
+ let pending = null;
1654
+ await provisionNeon(prepared.rootDir, state, {
1655
+ keyOnly: true,
1656
+ remint: true,
1657
+ persistState: false,
1658
+ vault: { client: prepared.client, projectId },
1659
+ rail: prepared.rail,
1660
+ fetchImpl: deps.fetchImpl ?? fetch,
1661
+ setSecret: refuseGhSecretSet,
1662
+ ...(plan.repo ? { ciRepo: plan.repo } : {}),
1663
+ now,
1664
+ onRevokePending: (p) => {
1665
+ pending = p;
1666
+ },
1667
+ });
1668
+ console.log(` + bóveda develop:/ci ${NEON_API_KEY} (key propia del proyecto, nueva)`);
1669
+ return pending;
1670
+ }
1671
+ async function applyAdoption(prepared, deps) {
1672
+ const { plan, client, rootDir } = prepared;
1673
+ // ADOPT-009 — a sync of `rail-timekast` toward the Factory's repo is a second writer of the
1674
+ // same secrets (`SHORTIO_API_KEY`): stop before anything, naming it.
1675
+ if (plan.factory && plan.factory.railSyncsToRepo.length > 0) {
1676
+ throw new CLIError(`\`rail-timekast\` tiene ${plan.factory.railSyncsToRepo.length === 1 ? 'un sync' : 'syncs'} hacia ` +
1677
+ `\`${plan.repo ?? FACTORY_REPO}\`: ${plan.factory.railSyncsToRepo.map((n) => `\`${n}\``).join(', ')}. ` +
1678
+ 'Con el sync de `develop:/ci` serían dos escritores de los mismos secrets de GitHub y se pisarían. ' +
1679
+ 'Bórralo en la bóveda (el rail no tiene syncs, fx-secrets-vault §2) y repite. No se escribió nada.');
1680
+ }
1681
+ if (plan.unreadable.length > 0) {
1682
+ throw new CLIError(`No puedo importar estas variables: el deploy no devolvió su valor y no son sensitive (un error de ` +
1683
+ `lectura u otro tipo): ${plan.unreadable.join(', ')}. Mientras falte una, no creo el sync (borraría ` +
1684
+ 'su valor del deploy). No se escribió nada.');
1685
+ }
1686
+ if (plan.branchScoped.length > 0) {
1687
+ throw new CLIError(`Hay variables de preview atadas a una rama: ${plan.branchScoped
1688
+ .map((v) => `${v.name} (rama ${v.branch})`)
1689
+ .join(', ')}. No las mapeo solas a develop: decide en Vercel y repite. No se escribió nada.`);
1690
+ }
1691
+ // ADOPT-002: which Railway syncs go ahead — the person confirms each; the rest is replanned.
1692
+ const railwaySelected = await selectRailwaySyncs(prepared, deps);
1693
+ if (railwaySelected.length !== prepared.railwayProposals.length) {
1694
+ const replanned = prepared.replan(railwaySelected);
1695
+ prepared.writes = replanned.writes;
1696
+ prepared.questions = replanned.confirmations;
1697
+ plan.railwayConflicts = replanned.railwayConflicts;
1698
+ }
1699
+ // ADOPT-003: the sensitive vars — hosts asked, confirmed, then derived/generated in memory, then
1700
+ // replanned.
1701
+ await resolveSensitiveHosts(prepared, deps);
1702
+ await confirmSensitive(prepared, deps);
1703
+ if ((await materializeSensitive(prepared, deps)) > 0) {
1704
+ const replanned = prepared.replan(railwaySelected);
1705
+ prepared.writes = replanned.writes;
1706
+ prepared.questions = replanned.confirmations;
1707
+ plan.railwayConflicts = replanned.railwayConflicts;
1708
+ }
1709
+ // ADOPT-009: a connected-and-empty deploy — hosts, shared values, then generated per env.
1710
+ if (plan.emptyDeploy) {
1711
+ await materializeEmptyDeploy(prepared, deps);
1712
+ const replanned = prepared.replan(railwaySelected);
1713
+ prepared.writes = replanned.writes;
1714
+ prepared.questions = replanned.confirmations;
1715
+ plan.railwayConflicts = replanned.railwayConflicts;
1716
+ }
1717
+ if (plan.railwayConflicts.length > 0) {
1718
+ throw new CLIError(`Dos servicios de Railway que leerían la misma carpeta de la bóveda tienen otro valor: ` +
1719
+ `${plan.railwayConflicts.join(', ')}. Una carpeta tiene un solo valor por clave y no elijo uno a ` +
1720
+ 'ciegas: iguálalos en Railway, o no confirmes uno de esos syncs. No se escribió nada.');
1721
+ }
1722
+ await confirmAll(prepared, deps);
1723
+ // Resolved BEFORE writing anything: without it no Railway sync can exist, so nothing is imported.
1724
+ const railwayConnection = railwaySelected.length > 0 ? await requireRailwayConnection(client) : null;
1725
+ // (1) project + members (idempotent: adopts what exists).
1726
+ const layout = await client.ensureProjectLayout({ name: plan.slug, slug: plan.slug });
1727
+ if (plan.vaultProjectId && plan.vaultProjectId !== layout.project.id) {
1728
+ throw new CLIError(`El bloque \`vault\` de este repo apunta al proyecto ${plan.vaultProjectId}, pero el proyecto ` +
1729
+ `\`${plan.slug}\` de la bóveda es ${layout.project.id}. No escribo en ninguno: corrige el vínculo y repite.`);
1730
+ }
1731
+ const projectId = layout.project.id;
1732
+ console.log(` ${layout.projectCreated ? '+' : '↻ (ya existía)'} bóveda: proyecto \`${layout.project.slug}\``);
1733
+ // ADOPT-006: born protected, or turned on here when a pre-existing project lacked it.
1734
+ reportDeleteProtection(layout.project.slug, layout.deleteProtection);
1735
+ for (const member of layout.members.added)
1736
+ console.log(` + bóveda: miembro ${member}`);
1737
+ // (2) the writes — import first.
1738
+ const resend = await writeLayout(prepared, projectId, deps);
1739
+ // (2b) ADOPT-009 — the project's Neon key, minted key-only BEFORE any sync exists (so the GitHub
1740
+ // names check finds it in `develop:/ci`). Its pending revocations stay in memory until (4).
1741
+ const neonPending = await mintNeonKey(prepared, projectId, deps);
1742
+ // (3) the syncs, with the caller flag of `adopt`.
1743
+ await assertNothingToDelete(prepared, projectId, deps, railwaySelected);
1744
+ const roles = [
1745
+ ...(plan.vercelProject ? ['vercel-production', 'vercel-preview'] : []),
1746
+ ...(plan.repo ? ['github-ci'] : []),
1747
+ ];
1748
+ if (roles.length > 0) {
1749
+ await ensureVaultSyncs({ client, projectId }, {
1750
+ roles,
1751
+ guardForeignKeys: false,
1752
+ projectName: plan.slug,
1753
+ destinations: {
1754
+ ...(plan.vercelProject ? { vercelProjectId: plan.vercelProject.id } : {}),
1755
+ teamId: prepared.teamId,
1756
+ ...(plan.repo ? { repo: plan.repo, repoExists: true } : {}),
1757
+ },
1758
+ vercelToken: async () => prepared.vercelToken,
1759
+ }, {
1760
+ ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
1761
+ ...(deps.listGithubSecretNames
1762
+ ? { listGithubSecretNames: deps.listGithubSecretNames }
1763
+ : {}),
1764
+ });
1765
+ }
1766
+ // (3b) ADOPT-002: the Railway syncs — after the import (each is adopted when it already exists).
1767
+ for (const proposal of railwaySelected) {
1768
+ const destination = railwayDestinationOf(proposal);
1769
+ const source = { env: proposal.vaultEnv, path: VAULT_ROOT_PATH };
1770
+ const found = await findRailwaySync(client, projectId, destination, source, proposal.label);
1771
+ if (found) {
1772
+ console.log(` ↻ (ya existía) sync ${proposal.label}`);
1773
+ continue;
1774
+ }
1775
+ await createRailwaySyncFor(client, projectId, {
1776
+ name: railwaySyncName(plan.slug, proposal.vaultEnv, proposal.source.service.name),
1777
+ source,
1778
+ destination,
1779
+ connection: railwayConnection,
1780
+ label: proposal.label,
1781
+ });
1782
+ }
1783
+ // (4) the link — it CARRIES the block's other fields (a pending revocation of a previous run).
1784
+ writeAdoptionLink(rootDir, {
1785
+ projectId,
1786
+ slug: plan.slug,
1787
+ vercelProjectId: plan.vercelProject?.id ?? null,
1788
+ githubRepo: plan.repo,
1789
+ neonProjectId: prepared.neonProjectId,
1790
+ ...(resend ? { resend } : {}),
1791
+ ...(deps.now ? { now: deps.now } : {}),
1792
+ });
1793
+ console.log(` + vínculo con la bóveda en .timekast/provision.json`);
1794
+ // (4b) ADOPT-008's narrow writer persists the revocations this run left, then its settle —
1795
+ // AFTER the `develop:/ci` sync exists: the old key is revoked only once the new one reached GitHub.
1796
+ if (neonPending)
1797
+ addNeonRevokePending(rootDir, neonPending);
1798
+ if (readNeonRevokePending(rootDir)) {
1799
+ await settleNeonRevokes(rootDir, { client, projectId }, {
1800
+ repo: plan.repo ?? undefined,
1801
+ rail: prepared.rail,
1802
+ fetchImpl: deps.fetchImpl ?? fetch,
1803
+ wait: neonPending !== null,
1804
+ recordedThisRun: neonPending !== null,
1805
+ neon: { projectId: prepared.neonProjectId, slug: plan.slug },
1806
+ ...(deps.vaultSync ? { sync: deps.vaultSync } : {}),
1807
+ });
1808
+ }
1809
+ // (5) the wrapper — the diff first, then the write; customized scripts are never touched.
1810
+ const preview = wireVaultWrapper(rootDir, { dryRun: true });
1811
+ if (preview.diff.length > 0) {
1812
+ console.log(`\nCableo el wrapper de la bóveda en \`package.json\` (${preview.rewritten.join(', ')}):`);
1813
+ for (const line of preview.diff)
1814
+ console.log(` ${line}`);
1815
+ wireVaultWrapper(rootDir);
1816
+ }
1817
+ if (preview.customized.length > 0) {
1818
+ console.warn('\n⚠ Estos scripts de `package.json` son de tu equipo y no los toqué: corren SIN la bóveda hasta que ' +
1819
+ 'los adapten (por ejemplo, anteponiendo `node scripts/tools/with-vault.mjs`):\n' +
1820
+ preview.customized.map((c) => ` · ${c.name}`).join('\n'));
1821
+ }
1822
+ // (6) `.env.local` — last.
1823
+ const retirement = await retireEnvLocal(rootDir, deps.isGitIgnored ?? defaultIsGitIgnored(rootDir));
1824
+ if (retirement === 'renamed')
1825
+ console.log(` ↻ .env.local → ${ENV_LOCAL_BACKUP_FILE}`);
1826
+ else if (retirement === 'backup-exists') {
1827
+ console.warn(`⚠ Ya existe \`${ENV_LOCAL_BACKUP_FILE}\`: no lo sobrescribo, así que \`.env.local\` se queda. ` +
1828
+ 'Next.js lo carga encima de la bóveda: retíralo tú cuando revises el respaldo viejo.');
1829
+ }
1830
+ else if (retirement === 'backup-not-ignored') {
1831
+ console.warn(`⚠ git no ignora \`${ENV_LOCAL_BACKUP_FILE}\`: no renombro \`.env.local\` (un secreto en el historial es ` +
1832
+ 'permanente). Agrega `.env*` a tu `.gitignore` y renómbralo tú.');
1833
+ }
1834
+ console.log(`\n✔ Repo adoptado en la bóveda: \`${plan.slug}\` (${projectId}).`);
1835
+ return retirement;
1836
+ }
1837
+ // ---------------------------------------------------------------------------
1838
+ // Railway syncs (ADOPT-002) — shared by `vault adopt --apply` and `vault sync railway`
1839
+ // ---------------------------------------------------------------------------
1840
+ /** Ask which proposed Railway syncs go ahead; without a terminal, nothing is chosen blindly. */
1841
+ async function selectRailwaySyncs(prepared, deps) {
1842
+ if (prepared.railwayProposals.length === 0)
1843
+ return [];
1844
+ const confirm = resolveConfirm(deps);
1845
+ if (!confirm) {
1846
+ throw new CLIError('Los syncs a Railway necesitan tu confirmación y no hay una terminal para dártela: ' +
1847
+ `${prepared.railwayProposals.map((p) => p.label).join(', ')}. No los elijo a ciegas: corre ` +
1848
+ '`factory vault adopt --apply` en una terminal. No se escribió nada.');
1849
+ }
1850
+ const selected = [];
1851
+ for (const proposal of prepared.railwayProposals) {
1852
+ const yes = await confirm(`¿Creo el sync ${proposal.label} (proyecto de Railway \`${proposal.project.name}\`, id ` +
1853
+ `${proposal.project.id})? Importo antes a la bóveda lo que sólo tiene Railway; desde ahí ` +
1854
+ 'la bóveda es el único escritor de sus variables y lo que borres en la bóveda se borra en Railway.');
1855
+ if (yes)
1856
+ selected.push(proposal);
1857
+ else
1858
+ console.log(` · sin sync ${proposal.label} (no lo confirmaste): no importo sus variables`);
1859
+ }
1860
+ return selected;
1861
+ }
1862
+ function railwayDestinationOf(proposal) {
1863
+ return {
1864
+ projectId: proposal.project.id,
1865
+ projectName: proposal.project.name,
1866
+ environmentId: proposal.source.environment.id,
1867
+ environmentName: proposal.source.environment.name,
1868
+ serviceId: proposal.source.service.id,
1869
+ serviceName: proposal.source.service.name,
1870
+ };
1871
+ }
1872
+ /**
1873
+ * The organization's app connection with Railway — REUSED, never created: creating one would store
1874
+ * a copy of the rail's team token in the vault that nobody rotates (`fx-secrets-vault §2`).
1875
+ */
1876
+ async function requireRailwayConnection(client) {
1877
+ const connection = await client.findAppConnection('railway');
1878
+ if (!connection) {
1879
+ throw new CLIError('La organización no tiene en la bóveda una conexión con Railway (app connection de tipo `railway`). ' +
1880
+ 'No la creo yo: guardaría en la bóveda una copia del token del rail que nadie rotaría ' +
1881
+ '(fx-secrets-vault §2). Pide a un admin de la bóveda que la cree y repite. No se escribió nada ' +
1882
+ 'ni se creó ningún sync.');
1883
+ }
1884
+ return connection;
1885
+ }
1886
+ /**
1887
+ * The sync the vault already has toward this Railway service × environment, or `null`. Adopted
1888
+ * only if it is the expected one (source, deletion ON, auto-sync ON, no key schema); two syncs
1889
+ * toward one destination, or one that differs, stop the run naming why — never patched.
1890
+ */
1891
+ async function findRailwaySync(client, projectId, destination, source, label) {
1892
+ const matches = (await client.listSecretSyncs(projectId)).filter((sync) => isRailwaySyncTo(sync, destination));
1893
+ if (matches.length > 1) {
1894
+ throw new CLIError(`La bóveda tiene ${matches.length} syncs hacia el destino de \`${label}\` ` +
1895
+ `(${matches.map((sync) => `\`${sync.name}\``).join(', ')}): dos escritores en un destino se pisan. ` +
1896
+ 'Deja uno solo en la bóveda y repite.');
1897
+ }
1898
+ const [found] = matches;
1899
+ if (!found)
1900
+ return null;
1901
+ const differences = railwaySyncDifferences(found, source);
1902
+ if (differences.length > 0) {
1903
+ throw new CLIError(`Ya existe en la bóveda un sync hacia el destino de \`${label}\` (\`${found.name}\`), pero no es el ` +
1904
+ `esperado: ${differences.join('; ')}.\nNo lo adopto ni lo modifico: corrígelo o bórralo en la bóveda y repite.`);
1905
+ }
1906
+ return found;
1907
+ }
1908
+ async function createRailwaySyncFor(client, projectId, input) {
1909
+ const sync = await client.createRailwaySync(projectId, {
1910
+ name: input.name,
1911
+ source: input.source,
1912
+ connectionId: input.connection.id,
1913
+ railway: input.destination,
1914
+ });
1915
+ console.log(` + sync ${input.label} (borra en destino · primer sync: sobrescribe, ya importado)`);
1916
+ return sync;
1917
+ }
1918
+ /** The seams `factory vault sync railway` needs — REQUIRED under Vitest. */
1919
+ const REQUIRED_SYNC_RAILWAY_SEAMS = [
1920
+ 'vaultClient',
1921
+ 'rail',
1922
+ 'fetchImpl',
1923
+ ];
1924
+ /**
1925
+ * `factory vault sync railway --project <id> --service <id> --env <main|develop>
1926
+ * [--railway-env <nombre>]` (ADOPT-002) — the sync of a vault deploy root to one Railway service.
1927
+ *
1928
+ * 1. The vault project comes from the `vault` block of `.timekast/provision.json`; without it,
1929
+ * abort pointing at `factory vault adopt` (or `factory provision`). Nothing is created.
1930
+ * 2. `RAILWAY_TOKEN` from the rail (`rail.ts`), never from `process.env` nor a file; the ids are
1931
+ * checked with a GraphQL query over projects — never `me` (`fx-secrets-vault §5`).
1932
+ * 3. The Railway environment is the one named like `--env`, or `--railway-env` when it differs
1933
+ * (the user's answer, 2026-09-24); a name Railway does not have aborts naming the ones it has.
1934
+ * 4. An existing sync toward that service × environment is adopted (a second run creates none).
1935
+ * 5. The org's `railway` app connection is reused; none → abort (never created).
1936
+ * 6. Import first: what only Railway holds is written to the vault BEFORE the sync (its first
1937
+ * run deletes nothing); a different value, or a key that cannot live in a deploy root, is
1938
+ * confirmed by the person (no terminal → nothing is written).
1939
+ * 7. Checked again by names, then created: deletion in the destination ON (ADR-005 §4).
1940
+ *
1941
+ * 🔴 NO-ECHO: the token and the values never reach stdout, stderr, an error or a URL.
1942
+ */
1943
+ export async function runVaultSyncRailway(flags, deps = {}) {
1944
+ if (globalThis[VAULT_TEST_ISOLATION_KEY] !== undefined) {
1945
+ for (const seam of REQUIRED_SYNC_RAILWAY_SEAMS) {
1946
+ if (deps[seam] === undefined)
1947
+ throw new VaultSeamMissingError(seam);
1948
+ }
1949
+ }
1950
+ const rootDir = deps.rootDir ?? process.cwd();
1951
+ const link = readVaultLink(rootDir);
1952
+ if (!link) {
1953
+ throw new CLIError('Este repo no tiene bloque `vault` en `.timekast/provision.json`: no sé de qué proyecto de la bóveda ' +
1954
+ 'sale el sync. Si el repo nació antes de la bóveda, corre `factory vault adopt`; si es un repo nuevo, ' +
1955
+ '`factory provision`. No creé nada.');
1956
+ }
1957
+ const projectId = link.projectId;
1958
+ const rail = deps.rail ?? createRailResolver();
1959
+ const client = deps.vaultClient ?? createVaultClient();
1960
+ const token = await rail.get(RAILWAY_TOKEN_KEY);
1961
+ // Validated with the Railway client: a query over projects (never `me`), by the ids given.
1962
+ const railwayClient = createRailwayClient({
1963
+ token,
1964
+ ...(deps.fetchImpl ? { transport: railwayFetchTransport(deps.fetchImpl) } : {}),
1965
+ });
1966
+ const project = (await railwayClient.listProjects()).find((p) => p.id === flags.project);
1967
+ if (!project) {
1968
+ throw new CLIError(`No encontré en Railway el proyecto \`${flags.project}\` con \`RAILWAY_TOKEN\` (el token de equipo de ` +
1969
+ 'la organización TimeKast): no existe, o vive en la cuenta propia de un cliente, que usa otro token ' +
1970
+ 'y este comando no maneja (fx-secrets-vault §5). No creé nada.');
1971
+ }
1972
+ const service = project.services.find((s) => s.id === flags.service);
1973
+ if (!service) {
1974
+ throw new CLIError(`El proyecto \`${project.name}\` de Railway no tiene el servicio \`${flags.service}\`. Sus servicios: ` +
1975
+ `${project.services.map((s) => `\`${s.name}\` (${s.id})`).join(', ') || '(ninguno)'}. No creé nada.`);
1976
+ }
1977
+ const railwayEnvName = flags.railwayEnv ?? flags.env;
1978
+ const environment = project.environments.find((e) => e.name === railwayEnvName);
1979
+ if (!environment) {
1980
+ throw new CLIError(`El proyecto \`${project.name}\` de Railway no tiene un entorno \`${railwayEnvName}\`. Tiene: ` +
1981
+ `${project.environments.map((e) => `\`${e.name}\``).join(', ') || '(ninguno)'}.` +
1982
+ (flags.railwayEnv
1983
+ ? ''
1984
+ : ` Si el entorno de Railway se llama distinto, dilo con \`--railway-env <nombre>\`.`) +
1985
+ ' No creé nada.');
1986
+ }
1987
+ const source = { env: flags.env, path: VAULT_ROOT_PATH };
1988
+ const destination = {
1989
+ projectId: project.id,
1990
+ projectName: project.name,
1991
+ environmentId: environment.id,
1992
+ environmentName: environment.name,
1993
+ serviceId: service.id,
1994
+ serviceName: service.name,
1995
+ };
1996
+ const label = railwaySyncLabel(flags.env, { label: `${service.name}@${environment.name}` });
1997
+ const existing = await findRailwaySync(client, projectId, destination, source, label);
1998
+ if (existing) {
1999
+ console.log(`✔ Ya existe el sync ${label} (${existing.id}): no creo otro.`);
2000
+ return { syncId: existing.id, created: false, imported: [] };
2001
+ }
2002
+ const connection = await requireRailwayConnection(client);
2003
+ // Import first — values in memory only.
2004
+ const railwayValues = await railwayClient.readVariableValues({
2005
+ projectId: project.id,
2006
+ environmentId: environment.id,
2007
+ serviceId: service.id,
2008
+ serviceName: service.name,
2009
+ });
2010
+ const folder = await client.readSecrets(projectId, source, { expandReferences: true });
2011
+ const mainFolder = flags.env === VAULT_ENV_DEVELOP
2012
+ ? await client.readSecrets(projectId, MAIN_ROOT, { expandReferences: true })
2013
+ : undefined;
2014
+ const retired = new Set([...railwayValues.keys()].filter((key) => isRailKey(key) || LOCAL_ONLY_KEYS.has(key)));
2015
+ const imported = planRailwayImport({
2016
+ location: source,
2017
+ sources: [railwayValues],
2018
+ folder,
2019
+ ...(mainFolder ? { mainFolder } : {}),
2020
+ retired,
2021
+ });
2022
+ const where = formatLocation(source);
2023
+ await confirmQuestions([
2024
+ ...imported.differing.map((key) => ({
2025
+ name: key,
2026
+ question: `\`${key}\` tiene en Railway otro valor que en ${where}: el sync pondrá en Railway el de la bóveda. ` +
2027
+ '¿Confirmas?',
2028
+ })),
2029
+ ...imported.removed.map((key) => ({
2030
+ name: key,
2031
+ question: `\`${key}\` está en Railway y no puede vivir en ${where} ` +
2032
+ `(${isRailKey(key) ? 'token del rail' : 'clave sólo-local'}): el sync la quitará de ` +
2033
+ 'Railway. ¿Confirmas?',
2034
+ })),
2035
+ ], deps, `\`factory vault sync railway\``);
2036
+ const values = {};
2037
+ for (const write of imported.writes)
2038
+ if (write.kind === 'value')
2039
+ values[write.key] = write.value;
2040
+ if (Object.keys(values).length > 0) {
2041
+ const result = await client.writeSecrets(projectId, source, values, 'create-if-missing');
2042
+ for (const key of result.created)
2043
+ console.log(` + bóveda ${where} ${key} (importada de Railway)`);
2044
+ }
2045
+ for (const write of imported.writes) {
2046
+ if (write.kind !== 'reference')
2047
+ continue;
2048
+ await client.writeReference(projectId, source, write.key, write.target, 'create-if-missing');
2049
+ console.log(` + bóveda ${describeWrite(write)} ${write.key}`);
2050
+ }
2051
+ // Right before creating it: every key Railway holds is in the folder (the first sync deletes nothing).
2052
+ const inVault = new Set(await client.listSecretKeys(projectId, source));
2053
+ const missing = [...railwayValues.keys()]
2054
+ .filter((key) => !isRailwayProvided(key) && !inVault.has(key) && !retired.has(key))
2055
+ .sort();
2056
+ if (missing.length > 0) {
2057
+ throw new CLIError(`Importé a la bóveda, pero me detengo ANTES de crear el sync: ${missing.join(', ')} no están en ` +
2058
+ `${where} y el primer sync las borraría de Railway. Agrégalas ahí y repite. No se creó ningún sync.`);
2059
+ }
2060
+ const sync = await createRailwaySyncFor(client, projectId, {
2061
+ name: railwaySyncName(
2062
+ // The vault project's slug IS the repo's (`fx-secrets-vault §6`) — whatever shape the state has.
2063
+ (await client.getProject(projectId)).slug, flags.env, service.name),
2064
+ source,
2065
+ destination,
2066
+ connection,
2067
+ label,
2068
+ });
2069
+ console.log(`✔ Sync ${label} creado (${sync.id}) — proyecto de Railway \`${project.name}\` (${project.id}).`);
2070
+ return {
2071
+ syncId: sync.id,
2072
+ created: true,
2073
+ imported: imported.writes.map((w) => w.key),
2074
+ };
2075
+ }
2076
+ /**
2077
+ * `factory vault adopt --decline` (ADOPT-004) — record the repo's "no" and nothing else: no
2078
+ * `.env.local`, no `package.json`, no Vercel, no vault (not even a read). The shape of the record
2079
+ * is ADOPT-001's ({@link writeDeclineMark}); a repo already in the vault refuses; twice is a no-op.
2080
+ */
2081
+ function declineVault(rootDir, deps) {
2082
+ const slug = path.basename(rootDir).toLowerCase();
2083
+ assertValidSubdomainSlug(slug);
2084
+ const outcome = writeDeclineMark(rootDir, { slug, ...(deps.now ? { now: deps.now } : {}) });
2085
+ if (outcome === 'already-declined') {
2086
+ console.log('ℹ️ Este repo ya había declinado la bóveda: no cambié nada. Sigue con `.env.local` y `env:push`.');
2087
+ return { status: 'already-declined' };
2088
+ }
2089
+ console.log('✔ Registré en `.timekast/provision.json` que este repo no entra a la bóveda. Sigue con ' +
2090
+ '`.env.local` y `env:push` como hasta hoy, y `factory doctor` deja de proponerlo. No toqué nada ' +
2091
+ 'más. Si algún día cambias de opinión, `factory vault adopt` sigue disponible.');
2092
+ return { status: 'declined' };
2093
+ }
2094
+ /** `factory vault adopt [--apply | --decline]`. */
2095
+ export async function runVaultAdopt(flags, deps = {}) {
2096
+ assertAdoptSeams(deps);
2097
+ const rootDir = deps.rootDir ?? process.cwd();
2098
+ const core = detectCoreProfile(rootDir);
2099
+ if (core !== null) {
2100
+ const why = core === 'lockfile'
2101
+ ? 'el lockfile declara el perfil `core`'
2102
+ : `no hay lockfile y falta \`${WITH_VAULT_SCRIPT_FILE}\``;
2103
+ console.log(`ℹ️ La bóveda no se ofrece en perfil \`core\` (${why}): ese perfil no trae el wrapper que la inyecta ` +
2104
+ 'a los scripts. No hice nada.');
2105
+ return { status: 'core' };
2106
+ }
2107
+ if (flags.decline)
2108
+ return declineVault(rootDir, deps);
2109
+ const prepared = await prepareAdoption(rootDir, flags, deps);
2110
+ if (!flags.apply) {
2111
+ if (flags.json)
2112
+ console.log(JSON.stringify(prepared.plan, null, 2));
2113
+ else
2114
+ printPlan(prepared.plan, false);
2115
+ return { status: 'planned', plan: prepared.plan };
2116
+ }
2117
+ printPlan(prepared.plan, true);
2118
+ // ADOPT-003: a sealed key not in the vault stops HERE — before any question or write.
2119
+ await resolveUnknownSensitive(prepared, deps);
2120
+ if (prepared.plan.sealedExplanation) {
2121
+ console.log(`\n${prepared.plan.sealedExplanation}`);
2122
+ return { status: 'sealed', plan: prepared.plan, explanation: prepared.plan.sealedExplanation };
2123
+ }
2124
+ const envLocal = await applyAdoption(prepared, deps);
2125
+ return { status: 'applied', plan: prepared.plan, envLocal };
2126
+ }
2127
+ // ---------------------------------------------------------------------------
2128
+ // `factory vault deploys` (MDEP-001) — a repo deployed once per client
2129
+ // ---------------------------------------------------------------------------
2130
+ const VAULT_DEPLOYS_USAGE = '`factory vault deploys [--deploy <slug>] [--apply] [--yes]`';
2131
+ /** Parse the argv AFTER `deploys`. `--deploy value` and `--deploy=value` both work; unknown stops. */
2132
+ export function parseVaultDeploysFlags(argv) {
2133
+ const flags = { apply: false };
2134
+ for (let i = 0; i < argv.length; i += 1) {
2135
+ const arg = argv[i];
2136
+ if (arg === '--apply') {
2137
+ flags.apply = true;
2138
+ continue;
2139
+ }
2140
+ if (arg === '--yes') {
2141
+ flags.yes = true;
2142
+ continue;
2143
+ }
2144
+ if (arg === '--deploy' || arg.startsWith('--deploy=')) {
2145
+ let value;
2146
+ if (arg.startsWith('--deploy='))
2147
+ value = arg.slice('--deploy='.length);
2148
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) {
2149
+ value = argv[i + 1];
2150
+ i += 1;
2151
+ }
2152
+ if (value === undefined || value.trim() === '') {
2153
+ throw new CLIError(`\`--deploy\` necesita el slug de un despliegue. Uso: ${VAULT_DEPLOYS_USAGE}.`);
2154
+ }
2155
+ flags.deploy = value.trim();
2156
+ continue;
2157
+ }
2158
+ throw new CLIError(`Opción desconocida para \`factory vault deploys\`: \`${arg}\`. Uso: ${VAULT_DEPLOYS_USAGE}.`);
2159
+ }
2160
+ if (flags.yes && !flags.apply) {
2161
+ throw new CLIError('`--yes` sólo va con `--apply`: el plan no escribe nada que confirmar.');
2162
+ }
2163
+ return flags;
2164
+ }
2165
+ /** The seams `factory vault deploys` needs — REQUIRED under Vitest. */
2166
+ const REQUIRED_DEPLOYS_SEAMS = [
2167
+ 'vaultClient',
2168
+ 'rail',
2169
+ 'fetchImpl',
2170
+ 'resolveOriginRepo',
2171
+ ];
2172
+ /** The two Vercel syncs of a client: `main:/` → production, `develop:/` → preview. Never `/ci`. */
2173
+ const DEPLOY_VERCEL_SYNCS = VAULT_SYNC_PLAN.filter((spec) => spec.destination === 'vercel');
2174
+ /** Keys of a destination the client's folder will not hold — the first sync would delete them. */
2175
+ function foreignDestinationKeys(destination, folder) {
2176
+ const managed = new Set(folder);
2177
+ return [...new Set(destination)].filter((key) => !managed.has(key)).sort();
2178
+ }
2179
+ /** How a message names a project of the run. */
2180
+ function describeRunProject(project) {
2181
+ if (project.role === 'principal')
2182
+ return `el proyecto principal \`${project.slug}\``;
2183
+ if (project.role === 'plantilla')
2184
+ return `la plantilla \`${project.slug}\``;
2185
+ return `el proyecto \`${project.slug}\` (despliegue \`${project.role}\`)`;
2186
+ }
2187
+ /** The "pending" reason of a sync whose environment still misses own values. */
2188
+ function pendingReason(keys) {
2189
+ return `pendiente — faltan valores propios: ${keys.join(', ')}`;
2190
+ }
2191
+ /**
2192
+ * BEFORE the first write: where each client syncs and whether each sync can be created or adopted.
2193
+ * Reads only. Stops (all collected, then one error):
2194
+ *
2195
+ * - a destination that ANOTHER project of the run (principal, template, another client) already
2196
+ * syncs to — two writers on one destination;
2197
+ * - an existing sync of the client that is not the expected one (source, deletion ON, auto-sync
2198
+ * ON, no key schema) — never adopted, never patched;
2199
+ * - a destination with no sync yet that holds keys the client's folder will not have (the first
2200
+ * sync would delete them);
2201
+ * - a missing app connection for a sync that has to be created.
2202
+ *
2203
+ * A sync whose environment still misses the client's own values is NOT a stop: it stays pending
2204
+ * (named), is not created, and the destination is not compared — the next run, with the values
2205
+ * loaded, decides.
2206
+ */
2207
+ async function inspectDeployDestinations(input) {
2208
+ const { plan, registry, client, rail, fetchImpl, repo } = input;
2209
+ const tasks = [];
2210
+ const problems = [];
2211
+ /** Per team: ids of the Vercel projects connected to THIS repo (the reader `vault adopt` uses). */
2212
+ const connectedByTeam = new Map();
2213
+ /**
2214
+ * A client's Vercel project must be connected to THIS repo before a sync toward it is created or
2215
+ * adopted: an id copied from another repo's project would pour this client's values into a
2216
+ * deploy of other code. Returns the team, or `null` after naming the problem.
2217
+ */
2218
+ const connectedHere = async (deploy, vercel) => {
2219
+ const teamId = vercel.teamId ?? (await rail.get('VERCEL_TEAM_ID'));
2220
+ const field = `deploys[${registry.deploys.findIndex((d) => d.slug === deploy.slug)}].vercel.projectId`;
2221
+ if (!repo) {
2222
+ problems.push(`\`${deploy.slug}\`: no sé de qué repo de GitHub es este checkout (no tiene remote \`origin\` de ` +
2223
+ 'GitHub), así que no puedo comprobar que el proyecto de Vercel ' +
2224
+ `\`${vercel.projectId}\` esté conectado a él — configura el remote \`origin\` y repite`);
2225
+ return null;
2226
+ }
2227
+ let ids = connectedByTeam.get(teamId);
2228
+ if (!ids) {
2229
+ const location = await locateVercelProject({
2230
+ repo,
2231
+ token: await rail.get('VERCEL_TOKEN'),
2232
+ teamId,
2233
+ deps: { fetchImpl },
2234
+ });
2235
+ ids = new Set(location.kind === 'found'
2236
+ ? [location.project.id]
2237
+ : location.kind === 'ambiguous'
2238
+ ? location.candidates.map((p) => p.id)
2239
+ : []);
2240
+ connectedByTeam.set(teamId, ids);
2241
+ }
2242
+ if (!ids.has(vercel.projectId)) {
2243
+ problems.push(`\`${deploy.slug}\`: el proyecto de Vercel \`${vercel.projectId}\` no está conectado a este repo ` +
2244
+ `(\`${repo}\`, team \`${teamId}\`) — un sync hacia él escribiría los valores de este despliegue en el ` +
2245
+ `de otro repo. Corrige \`${field}\` (o conecta ese proyecto a este repo en Vercel)`);
2246
+ return null;
2247
+ }
2248
+ return teamId;
2249
+ };
2250
+ const syncsOf = new Map();
2251
+ for (const project of plan.projects) {
2252
+ if (!syncsOf.has(project.id))
2253
+ syncsOf.set(project.id, await client.listSecretSyncs(project.id));
2254
+ }
2255
+ /** The OTHER projects' syncs that write to a destination. */
2256
+ const otherWriters = (own, matches) => plan.projects
2257
+ .filter((project) => project.id !== own)
2258
+ .flatMap((project) => (syncsOf.get(project.id) ?? []).filter(matches).map((sync) => ({ project, sync })));
2259
+ /** An existing sync of the client toward this destination: adopted only if it is the expected one. */
2260
+ const checkOwn = (deploy, found, label, source) => {
2261
+ if (found.length > 1) {
2262
+ problems.push(`\`${deploy.slug}\` → \`${label}\`: la bóveda tiene ${found.length} syncs hacia ese destino ` +
2263
+ `(${found.map((sync) => `\`${sync.name}\``).join(', ')}); deja uno solo`);
2264
+ return true;
2265
+ }
2266
+ const [sync] = found;
2267
+ if (!sync)
2268
+ return false;
2269
+ const differences = railwaySyncDifferences(sync, source);
2270
+ if (differences.length > 0) {
2271
+ problems.push(`\`${deploy.slug}\` → \`${label}\`: el sync existente \`${sync.name}\` no es el esperado — ` +
2272
+ `${differences.join('; ')}. No lo adopto ni lo modifico: corrígelo o bórralo en la bóveda`);
2273
+ }
2274
+ return true;
2275
+ };
2276
+ let railwayProjects;
2277
+ let railwayToken;
2278
+ const needs = { vercel: false, railway: false };
2279
+ for (const deploy of plan.deploys) {
2280
+ const entry = registry.deploys.find((d) => d.slug === deploy.slug);
2281
+ if (!entry.vercel && !entry.railway)
2282
+ continue;
2283
+ const clientSyncs = deploy.projectId ? (syncsOf.get(deploy.projectId) ?? []) : [];
2284
+ const teamId = entry.vercel ? await connectedHere(deploy, entry.vercel) : null;
2285
+ if (entry.vercel && teamId !== null) {
2286
+ const vercelProjectId = entry.vercel.projectId;
2287
+ const missing = [];
2288
+ const pending = [];
2289
+ let names;
2290
+ for (const spec of DEPLOY_VERCEL_SYNCS) {
2291
+ const where = { vercelProjectId };
2292
+ const label = `${spec.label} (Vercel \`${vercelProjectId}\`)`;
2293
+ const writers = otherWriters(deploy.projectId, (sync) => syncTargetsDestination(sync, spec, where));
2294
+ if (writers.length > 0) {
2295
+ for (const { project, sync } of writers) {
2296
+ problems.push(`\`${deploy.slug}\`: ${describeRunProject(project)} ya tiene un sync (\`${sync.name}\`) hacia ` +
2297
+ `\`${spec.label}\` del proyecto de Vercel \`${vercelProjectId}\` (dos escritores se pisan): ` +
2298
+ 'quita uno de los dos');
2299
+ }
2300
+ continue;
2301
+ }
2302
+ const own = clientSyncs.filter((sync) => syncTargetsDestination(sync, spec, where));
2303
+ if (checkOwn(deploy, own, label, spec.source))
2304
+ continue;
2305
+ const env = spec.source.env;
2306
+ if (deploy.ownMissing[env].length > 0) {
2307
+ pending.push(spec.role);
2308
+ continue;
2309
+ }
2310
+ missing.push(spec.role);
2311
+ names ??= await listEnvVarNames(vercelProjectId, await rail.get('VERCEL_TOKEN'), teamId, {
2312
+ fetchImpl,
2313
+ });
2314
+ const foreign = foreignDestinationKeys(names.filter((v) => v.target.includes(spec.vercelTarget)).map((v) => v.key), deploy.folderKeys[env]);
2315
+ if (foreign.length > 0) {
2316
+ problems.push(`\`${deploy.slug}\` → \`${spec.label}\`: ${foreign.join(', ')} están en el destino y no en la ` +
2317
+ 'bóveda (el primer sync las borraría) — decláralas propias (`ownKeys` + su valor) o ponlas en la plantilla');
2318
+ }
2319
+ }
2320
+ if (missing.length > 0)
2321
+ needs.vercel = true;
2322
+ tasks.push({
2323
+ kind: 'vercel',
2324
+ deploy,
2325
+ label: `Vercel \`${vercelProjectId}\``,
2326
+ vercelProjectId,
2327
+ teamId,
2328
+ missing,
2329
+ pending,
2330
+ });
2331
+ }
2332
+ for (const service of entry.railway?.services ?? []) {
2333
+ railwayToken ??= await rail.get(RAILWAY_TOKEN_KEY);
2334
+ railwayProjects ??= await listRailwayProjects(railwayToken, fetchImpl);
2335
+ const railwayProjectId = entry.railway.projectId;
2336
+ const project = railwayProjects.find((p) => p.id === railwayProjectId);
2337
+ const serviceInfo = project?.services.find((s) => s.id === service.serviceId);
2338
+ const envName = service.railwayEnv ?? service.env;
2339
+ const environment = project?.environments.find((e) => e.name === envName);
2340
+ if (!project || !serviceInfo || !environment) {
2341
+ problems.push(`\`${deploy.slug}\`: Railway no tiene ${!project ? `el proyecto \`${railwayProjectId}\`` : !serviceInfo ? `el servicio \`${service.serviceId}\`` : `el entorno \`${envName}\``} ` +
2342
+ 'con `RAILWAY_TOKEN` (o vive en la cuenta propia de un cliente, que usa otro token) — corrige el registro');
2343
+ continue;
2344
+ }
2345
+ const destination = {
2346
+ projectId: project.id,
2347
+ projectName: project.name,
2348
+ environmentId: environment.id,
2349
+ environmentName: environment.name,
2350
+ serviceId: serviceInfo.id,
2351
+ serviceName: serviceInfo.name,
2352
+ };
2353
+ const label = railwaySyncLabel(service.env, {
2354
+ label: `${serviceInfo.name}@${environment.name}`,
2355
+ });
2356
+ const writers = otherWriters(deploy.projectId, (sync) => isRailwaySyncTo(sync, destination));
2357
+ if (writers.length > 0) {
2358
+ for (const { project: other, sync } of writers) {
2359
+ problems.push(`\`${deploy.slug}\`: ${describeRunProject(other)} ya tiene un sync (\`${sync.name}\`) hacia ` +
2360
+ `\`${label}\` (dos escritores se pisan): quita uno de los dos`);
2361
+ }
2362
+ continue;
2363
+ }
2364
+ const source = { env: service.env, path: VAULT_ROOT_PATH };
2365
+ const own = clientSyncs.filter((sync) => isRailwaySyncTo(sync, destination));
2366
+ const exists = checkOwn(deploy, own, label, source);
2367
+ const pending = !exists && deploy.ownMissing[service.env].length > 0;
2368
+ if (!exists && !pending) {
2369
+ needs.railway = true;
2370
+ const vars = await readRailwayVariables(railwayToken, {
2371
+ projectId: project.id,
2372
+ environmentId: environment.id,
2373
+ serviceId: serviceInfo.id,
2374
+ serviceName: serviceInfo.name,
2375
+ }, fetchImpl);
2376
+ const foreign = foreignDestinationKeys([...vars.keys()].filter((key) => !isRailwayProvided(key)), deploy.folderKeys[service.env]);
2377
+ if (foreign.length > 0)
2378
+ problems.push(`\`${deploy.slug}\` → \`${label}\`: ${foreign.join(', ')} están en el destino y no en la bóveda ` +
2379
+ '(el primer sync las borraría) — decláralas propias (`ownKeys` + su valor) o ponlas en la plantilla');
2380
+ }
2381
+ tasks.push({
2382
+ kind: 'railway',
2383
+ deploy,
2384
+ label,
2385
+ env: service.env,
2386
+ destination,
2387
+ exists,
2388
+ pending,
2389
+ });
2390
+ }
2391
+ }
2392
+ for (const app of ['vercel', 'railway']) {
2393
+ if (needs[app] && !(await client.findAppConnection(app))) {
2394
+ problems.push(`la organización no tiene en la bóveda una conexión con ${app === 'vercel' ? 'Vercel' : 'Railway'} ` +
2395
+ `(app connection de tipo \`${app}\`): pide a un admin de la bóveda que la cree`);
2396
+ }
2397
+ }
2398
+ if (problems.length > 0) {
2399
+ throw new CLIError('No escribo nada: los syncs de los despliegues no se pueden crear o adoptar así.\n' +
2400
+ problems.map((line) => ` · ${line}.`).join('\n') +
2401
+ '\nResuélvelo y repite. No se creó ningún proyecto ni se escribió ningún valor.');
2402
+ }
2403
+ return tasks;
2404
+ }
2405
+ /** How the plan names a change. */
2406
+ function describeDeployChange(change) {
2407
+ const mark = change.action === 'añade' ? '+ añade' : '~ cambia';
2408
+ return `${mark} ${change.key}${change.as === 'referencia' ? ' (referencia a main)' : ''}`;
2409
+ }
2410
+ function printDeploysPlan(plan, tasks, apply) {
2411
+ console.log(`\nDespliegues — sesión de ${plan.person} · principal \`${plan.principal.slug}\` · plantilla ` +
2412
+ `\`${plan.template.slug}\`${apply ? '' : ' (plan: no escribo nada)'}`);
2413
+ for (const deploy of plan.deploys) {
2414
+ console.log(`\n▸ ${deploy.slug} (${deploy.name}) → proyecto \`${deploy.project}\` ` +
2415
+ `${deploy.create ? '[se crea, con sus miembros y protegido contra borrado; su id se anota en el registro]' : '[existe]'}`);
2416
+ for (const env of DEPLOY_ENVS) {
2417
+ const changes = deploy.changes.filter((c) => c.environment === env);
2418
+ console.log(changes.length === 0
2419
+ ? ` ${env}:/ sin cambios`
2420
+ : ` ${env}:/\n${changes.map((c) => ` ${describeDeployChange(c)}`).join('\n')}`);
2421
+ if (deploy.extra[env].length > 0) {
2422
+ console.log(` · en la bóveda y fuera del cálculo (no se borran): ${deploy.extra[env].join(', ')}`);
2423
+ }
2424
+ if (deploy.ownMissing[env].length > 0) {
2425
+ console.log(` · propias sin valor todavía (cárgalas en la bóveda): ${deploy.ownMissing[env].join(', ')}`);
2426
+ }
2427
+ }
2428
+ for (const task of tasks.filter((t) => t.deploy === deploy)) {
2429
+ if (task.kind === 'vercel') {
2430
+ for (const spec of DEPLOY_VERCEL_SYNCS) {
2431
+ const env = spec.source.env;
2432
+ const state = task.pending.includes(spec.role)
2433
+ ? pendingReason(deploy.ownMissing[env])
2434
+ : task.missing.includes(spec.role)
2435
+ ? 'se crea'
2436
+ : 'ya existe';
2437
+ console.log(` sync ${spec.label} (${task.label}): ${state}`);
2438
+ }
2439
+ }
2440
+ else {
2441
+ const state = task.exists
2442
+ ? 'ya existe'
2443
+ : task.pending
2444
+ ? pendingReason(deploy.ownMissing[task.env])
2445
+ : 'se crea';
2446
+ console.log(` sync ${task.label}: ${state}`);
2447
+ }
2448
+ }
2449
+ }
2450
+ const total = plan.deploys.reduce((n, d) => n + d.changes.length, 0);
2451
+ const creates = plan.deploys.filter((d) => d.create).length;
2452
+ if (total === 0 && creates === 0)
2453
+ console.log('\nNada que cambiar en la bóveda.');
2454
+ if (!apply)
2455
+ console.log(`\nPara ejecutarlo: ${VAULT_DEPLOYS_USAGE.replace('[--apply] [--yes]', '--apply')}`);
2456
+ }
2457
+ /**
2458
+ * D1 — values already stored in a client that the apply CHANGES (additions are not asked). Listed
2459
+ * by name and environment, then one confirmation; without a terminal, only `--yes` confirms.
2460
+ * Runs before the first write: a "no" or a missing terminal leaves the vault untouched.
2461
+ */
2462
+ async function confirmOverwrites(plan, flags, deps) {
2463
+ const overwrites = plan.deploys.flatMap((deploy) => deploy.changes
2464
+ .filter((change) => change.action === 'cambia')
2465
+ .map((change) => `${deploy.slug} ${change.environment}:/${change.key}`));
2466
+ if (overwrites.length === 0)
2467
+ return;
2468
+ console.log(`\nEl --apply CAMBIA ${overwrites.length} valor(es) ya guardado(s) en la bóveda:\n` +
2469
+ overwrites.map((line) => ` ~ ${line}`).join('\n'));
2470
+ if (flags.yes) {
2471
+ console.log(' (confirmado con --yes)');
2472
+ return;
2473
+ }
2474
+ const confirm = resolveConfirm(deps);
2475
+ if (!confirm) {
2476
+ throw new CLIError('Pisar esos valores necesita tu confirmación y no hay una terminal para dártela. Corre el comando en ' +
2477
+ 'una terminal, o agrega `--yes` si ya revisaste la lista. No se escribió nada.');
2478
+ }
2479
+ if (!(await confirm(`¿Sobrescribo esos ${overwrites.length} valor(es) guardado(s)?`))) {
2480
+ throw new CLIError('No confirmaste sobrescribir esos valores. No se escribió nada.');
2481
+ }
2482
+ }
2483
+ /**
2484
+ * `factory vault deploys [--deploy <slug>] [--apply] [--yes]` (MDEP-001) — seed the vault project of
2485
+ * each deploy of `.timekast/deploys.json` from the template, and sync each one to ITS destinations.
2486
+ *
2487
+ * 1. The registry, the principal link (`.timekast/provision.json`) and a PERSON behind the session.
2488
+ * 2. Every client is read, computed and checked (annotated ids, template, foreign values against
2489
+ * the other clients and the principal, write guard), every destination inspected, and the
2490
+ * overwrite of stored values confirmed — all BEFORE the first write. A stop up to here leaves
2491
+ * the vault untouched.
2492
+ * 3. Without `--apply`: the plan, by key name. With it, in this order: projects (layout + members
2493
+ * + delete protection; the id of each one created is annotated in the registry at once),
2494
+ * values, then the syncs — `main:/` → Vercel production, `develop:/` → preview, and each
2495
+ * declared Railway service; a sync whose environment misses own values stays pending. Never
2496
+ * `/ci`: CI syncs only from the principal. A failure in this phase leaves what was already
2497
+ * written, and names it; a re-run adopts it and completes the rest.
2498
+ *
2499
+ * 🔴 NO-ECHO: values never reach stdout, stderr or an error — only key names, slugs and ids.
2500
+ */
2501
+ export async function runVaultDeploys(flags, deps = {}) {
2502
+ if (globalThis[VAULT_TEST_ISOLATION_KEY] !== undefined) {
2503
+ for (const seam of REQUIRED_DEPLOYS_SEAMS) {
2504
+ if (deps[seam] === undefined)
2505
+ throw new VaultSeamMissingError(seam);
2506
+ }
2507
+ }
2508
+ const rootDir = deps.rootDir ?? process.cwd();
2509
+ const registry = readDeploysRegistry(rootDir);
2510
+ if (flags.deploy !== undefined)
2511
+ resolveDeploy(registry, flags.deploy);
2512
+ const link = readVaultLink(rootDir);
2513
+ if (!link) {
2514
+ throw new CLIError('Este repo no tiene bloque `vault` en `.timekast/provision.json`: el proyecto principal de la bóveda es ' +
2515
+ 'el que da nombre a los de cada despliegue. Pásalo primero a la bóveda (`factory vault adopt`, o ' +
2516
+ '`factory provision` si es nuevo). No se escribió nada.');
2517
+ }
2518
+ const client = deps.vaultClient ?? createVaultClient();
2519
+ const rail = deps.rail ?? createRailResolver();
2520
+ const fetchImpl = deps.fetchImpl ?? fetch;
2521
+ const { plan, apply } = await planDeploys({
2522
+ client,
2523
+ registry,
2524
+ principalProjectId: link.projectId,
2525
+ ...(flags.deploy !== undefined ? { only: flags.deploy } : {}),
2526
+ onProjectCreated: (slug, projectId) => {
2527
+ recordDeployProjectId(rootDir, slug, projectId);
2528
+ console.log(` ✎ anoté el id del proyecto de \`${slug}\` en \`${DEPLOYS_REGISTRY_FILE}\` (sin commitear: commitéalo)`);
2529
+ },
2530
+ });
2531
+ // The repo of THIS checkout — only when a targeted client declares a Vercel project (the check
2532
+ // that the project is connected here). Read from the `origin` remote, as `vault adopt` does; not
2533
+ // from the provision state, which a vault-only `provision.json` does not carry.
2534
+ const hasVercel = plan.deploys.some((deploy) => registry.deploys.find((d) => d.slug === deploy.slug)?.vercel !== undefined);
2535
+ let repo = null;
2536
+ if (hasVercel) {
2537
+ const candidate = await (deps.resolveOriginRepo ?? originRepoOf)(rootDir);
2538
+ repo = candidate && REPO_RE.test(candidate) ? candidate : null;
2539
+ }
2540
+ const tasks = await inspectDeployDestinations({
2541
+ plan,
2542
+ registry,
2543
+ client,
2544
+ rail,
2545
+ fetchImpl,
2546
+ repo,
2547
+ });
2548
+ printDeploysPlan(plan, tasks, flags.apply);
2549
+ const syncsPending = tasks.flatMap((task) => task.kind === 'vercel'
2550
+ ? task.pending.map((role) => `${task.deploy.slug}:${role}`)
2551
+ : task.pending
2552
+ ? [`${task.deploy.slug}:${task.label}`]
2553
+ : []);
2554
+ if (!flags.apply)
2555
+ return { plan, applied: null, syncsCreated: [], syncsPending };
2556
+ await confirmOverwrites(plan, flags, deps);
2557
+ const applied = await apply();
2558
+ for (const result of applied) {
2559
+ console.log(`\n✔ ${result.slug}: proyecto \`${result.project}\` ${result.created ? 'creado' : 'adoptado'}` +
2560
+ (result.written.length > 0
2561
+ ? ` · escritas: ${result.written.map((w) => `${w.environment}:/${w.key}`).join(', ')}`
2562
+ : ' · sin escrituras'));
2563
+ }
2564
+ const syncsCreated = [];
2565
+ for (const task of tasks) {
2566
+ const projectId = applied.find((r) => r.slug === task.deploy.slug)
2567
+ .projectId;
2568
+ if (task.kind === 'vercel') {
2569
+ if (task.missing.length === 0)
2570
+ continue;
2571
+ // Right before creating: the destination against the folder as it is NOW.
2572
+ const names = await listEnvVarNames(task.vercelProjectId, await rail.get('VERCEL_TOKEN'), task.teamId, { fetchImpl });
2573
+ for (const role of task.missing) {
2574
+ const spec = DEPLOY_VERCEL_SYNCS.find((s) => s.role === role);
2575
+ const foreign = foreignDestinationKeys(names
2576
+ .filter((v) => v.target.includes(spec.vercelTarget))
2577
+ .map((v) => v.key), await client.listSecretKeys(projectId, spec.source));
2578
+ if (foreign.length > 0) {
2579
+ throw new CLIError(`Me detengo ANTES de crear \`${spec.label}\` de \`${task.deploy.slug}\`: ${foreign.join(', ')} no están ` +
2580
+ `en ${formatLocation(spec.source)} y el primer sync las borraría. Los proyectos y valores de esta ` +
2581
+ 'corrida ya se escribieron; agrega esas claves en la bóveda y repite: adopta lo que ya existe.');
2582
+ }
2583
+ }
2584
+ const outcome = await ensureVaultSyncs({ client, projectId }, {
2585
+ roles: task.missing,
2586
+ guardForeignKeys: false,
2587
+ projectName: task.deploy.project,
2588
+ destinations: { vercelProjectId: task.vercelProjectId, teamId: task.teamId },
2589
+ }, { fetchImpl });
2590
+ for (const role of task.missing) {
2591
+ if (outcome.active[role])
2592
+ syncsCreated.push(`${task.deploy.slug}:${role}`);
2593
+ }
2594
+ continue;
2595
+ }
2596
+ if (task.exists || task.pending)
2597
+ continue;
2598
+ const source = { env: task.env, path: VAULT_ROOT_PATH };
2599
+ if (await findRailwaySync(client, projectId, task.destination, source, task.label))
2600
+ continue;
2601
+ const vars = await readRailwayVariables(await rail.get(RAILWAY_TOKEN_KEY), {
2602
+ projectId: task.destination.projectId,
2603
+ environmentId: task.destination.environmentId,
2604
+ serviceId: task.destination.serviceId,
2605
+ serviceName: task.destination.serviceName,
2606
+ }, fetchImpl);
2607
+ const foreign = foreignDestinationKeys([...vars.keys()].filter((key) => !isRailwayProvided(key)), await client.listSecretKeys(projectId, source));
2608
+ if (foreign.length > 0) {
2609
+ throw new CLIError(`Me detengo ANTES de crear el sync ${task.label} de \`${task.deploy.slug}\`: ${foreign.join(', ')} no ` +
2610
+ `están en ${formatLocation(source)} y el primer sync las borraría. Los proyectos y valores de esta ` +
2611
+ 'corrida ya se escribieron; agrega esas claves en la bóveda y repite.');
2612
+ }
2613
+ await createRailwaySyncFor(client, projectId, {
2614
+ name: railwaySyncName(task.deploy.project, task.env, task.destination.serviceName),
2615
+ source,
2616
+ destination: task.destination,
2617
+ connection: await requireRailwayConnection(client),
2618
+ label: task.label,
2619
+ });
2620
+ syncsCreated.push(`${task.deploy.slug}:${task.label}`);
2621
+ }
2622
+ if (syncsPending.length > 0) {
2623
+ console.log(`\n⏸ Syncs pendientes (faltan valores propios; no se crearon): ${syncsPending.join(', ')}. Carga esos ` +
2624
+ 'valores en el proyecto del despliegue y repite el --apply.');
2625
+ }
2626
+ return { plan, applied, syncsCreated, syncsPending };
2627
+ }