@timekast/factory 1.28.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/commands/backlog.js +52 -15
  2. package/dist/commands/doctor.js +210 -29
  3. package/dist/commands/env.js +154 -18
  4. package/dist/commands/invite-admin.js +62 -15
  5. package/dist/commands/provision.js +5815 -763
  6. package/dist/commands/status.js +12 -2
  7. package/dist/commands/ticket.js +4 -4
  8. package/dist/commands/update.js +97 -12
  9. package/dist/commands/vault.js +2627 -0
  10. package/dist/index.js +97 -33
  11. package/dist/lib/backlog-mapping.js +111 -18
  12. package/dist/lib/backlog-parse.js +1 -1
  13. package/dist/lib/claude-md.js +320 -0
  14. package/dist/lib/cloudflare-api.js +213 -37
  15. package/dist/lib/constants.js +70 -6
  16. package/dist/lib/env-file.js +45 -7
  17. package/dist/lib/env-wizard.js +66 -2
  18. package/dist/lib/initial-migration.js +3 -2
  19. package/dist/lib/neon-api.js +145 -10
  20. package/dist/lib/package-json.js +37 -16
  21. package/dist/lib/preflight.js +92 -38
  22. package/dist/lib/provision-preflight.js +158 -100
  23. package/dist/lib/provision-state.js +652 -20
  24. package/dist/lib/publish-engine.js +1 -1
  25. package/dist/lib/rail.js +400 -0
  26. package/dist/lib/railway-api.js +851 -0
  27. package/dist/lib/vault-adopt.js +1495 -0
  28. package/dist/lib/vault-api.js +1106 -0
  29. package/dist/lib/vault-config.js +21 -0
  30. package/dist/lib/vault-deploys.js +898 -0
  31. package/dist/lib/vercel-env.js +251 -31
  32. package/dist/lib/wrapper-wiring.js +160 -0
  33. package/dist/tui/env-wizard-flow.js +17 -3
  34. package/dist/tui/menu.js +4 -22
  35. package/dist/tui/provision-flow.js +68 -13
  36. package/package.json +1 -1
  37. package/dist/commands/secrets.js +0 -71
  38. package/dist/lib/env-merge.js +0 -238
@@ -0,0 +1,1106 @@
1
+ /**
2
+ * The vault API client (VAULT-001) — the ONLY place where the CLI talks to the REST API of the
3
+ * org secrets vault (self-hosted Infisical) about a PROJECT's secrets: create/adopt the project of
4
+ * a repo, its environments, folders and members, read and write secrets per environment + folder,
5
+ * create imports between folders and write keys that are references to another key.
6
+ * Contract: `fx-secrets-vault §3/§6/§7`. Layout it writes into: ADR-005.
7
+ *
8
+ * CREDENTIAL: the PERSON's session — the token of `infisical user get token --domain=… --plain`,
9
+ * obtained through the rail's runner seam (`rail.ts`) and kept only in this client's memory.
10
+ * There is no machine-identity path. The domain comes from the embedded copy of
11
+ * `.claude/policy/vault.json` (`VAULT_CONFIG`) — NEVER from the repo's file at runtime, which a
12
+ * pull request could point at another host that would then receive the person's session.
13
+ *
14
+ * IDEMPOTENT: every create ADOPTS what already exists (project by slug, environment, folder,
15
+ * membership, import), so a second full run creates nothing and leaves the same state.
16
+ *
17
+ * SECRET SYNCS (SYNC-001, ADR-005 §8): the client also resolves the organization's app connections
18
+ * (by TYPE — `vercel`, `github` — never by a fixed name) and lists / creates / deletes the syncs of
19
+ * a project. A create goes through {@link assertSyncAllowed} before any call: the only pairs are
20
+ * `main:/` → Vercel production, `develop:/` → Vercel preview and `develop:/ci` → GitHub, always with
21
+ * deletion in the destination ON and first sync "overwrite the destination". Which sync already
22
+ * exists, and whether it may be adopted, is `provision`'s decision (it knows the destinations).
23
+ * No sync the client creates has deletion in the destination OFF.
24
+ *
25
+ * FAILURES ARE TYPED (`CLIError` subclasses, never a raw 401/403):
26
+ * - no session / expired session → `RailNoSessionError` (names `infisical login --domain=…`)
27
+ * - `infisical` not installed → `RailNotInstalledError`
28
+ * - live session, no access → `VaultNoAccessError` (names the project)
29
+ * - slug held by a project the session cannot see → `VaultSlugTakenError` (names the slug; never
30
+ * invents an alternate one)
31
+ * - a write the guard refuses → `VaultGuardError` (names the key and the destination)
32
+ *
33
+ * 🔴 WRITE GUARD — the real protection against a local-only key (`LOCAL_ONLY_KEYS` of
34
+ * `env-file.ts`) reaching a folder that syncs to a deploy. It runs BEFORE any network call
35
+ * (session included) and compares NORMALIZED environment + path, and the client then writes to
36
+ * that same normalized location, so what the guard judged is exactly where the value lands.
37
+ * It covers the three ways the client puts a key in a folder: a plain write, an import, and a
38
+ * reference. Residue (declared in ADR-005 and the skill): the dashboard and `infisical secrets
39
+ * set` do not go through it.
40
+ *
41
+ * 🔴 NO-ECHO — errors, logs and exceptions carry key NAMES, never values nor the session token.
42
+ * No response body is ever quoted (an API error may echo the request body, i.e. the value), no
43
+ * error carries a `cause`, and the transport's own failures are rebuilt without their message.
44
+ * This module never writes to stdout/stderr.
45
+ *
46
+ * 🔴 FAIL-CLOSED UNDER TESTS — the CLI's Vitest setup (`cli/tests/setup/isolate-home-path.ts`)
47
+ * installs a marker on `globalThis` (`VAULT_TEST_ISOLATION_KEY`). While it is present, a client
48
+ * without an injected transport AND session source THROWS on its first network call, before
49
+ * touching the network or the `infisical` binary. Same pattern as the rail (ADR-006): deliberately
50
+ * NOT `process.env.VITEST`, which a CLI spawned as a subprocess by a test would inherit and turn
51
+ * a real run into a refusal — `globalThis` lives only in the Vitest process. The same setup also
52
+ * points `PATH`/`HOME` at a temp dir, so the real binary and the person's `~/.infisical` config
53
+ * are out of reach.
54
+ */
55
+ import { CLIError } from './cli-error.js';
56
+ import { LOCAL_ONLY_KEYS } from './env-file.js';
57
+ import { DEFAULT_RUNNER_OVERRIDE_KEY, RailNoSessionError, RailNotInstalledError, createExecaInfisicalRunner, loginCommand, } from './rail.js';
58
+ import { VAULT_CONFIG } from './vault-config.js';
59
+ // ---------------------------------------------------------------------------
60
+ // Layout constants (ADR-005)
61
+ // ---------------------------------------------------------------------------
62
+ /** Environment of production (syncs to Vercel production). */
63
+ export const VAULT_ENV_MAIN = 'main';
64
+ /** Environment of preview (syncs to Vercel preview). */
65
+ export const VAULT_ENV_DEVELOP = 'develop';
66
+ /** The developer machine's environment — never synced. */
67
+ export const VAULT_ENV_LOCAL = 'local';
68
+ /** The folder of each deploy environment that is NOT the runtime (`develop:/ci` → GitHub). */
69
+ export const VAULT_CI_PATH = '/ci';
70
+ /** The root folder of an environment. */
71
+ export const VAULT_ROOT_PATH = '/';
72
+ /** The three environments of a repo's vault project, in creation order. */
73
+ export const VAULT_LAYOUT_ENVIRONMENTS = [
74
+ { name: VAULT_ENV_MAIN, slug: VAULT_ENV_MAIN },
75
+ { name: VAULT_ENV_DEVELOP, slug: VAULT_ENV_DEVELOP },
76
+ { name: VAULT_ENV_LOCAL, slug: VAULT_ENV_LOCAL },
77
+ ];
78
+ /**
79
+ * Environments Infisical creates by default with a new project. The layout does not use them, so
80
+ * a project CREATED in this run drops them; an ADOPTED project is never pruned (someone may use
81
+ * them).
82
+ */
83
+ export const VAULT_DEFAULT_ENV_SLUGS = ['dev', 'staging', 'prod'];
84
+ /** Role given to the organization admins added to a project (Decision 22). */
85
+ const MEMBER_ROLE_SLUG = 'admin';
86
+ /** Org role that marks an organization admin. */
87
+ const ORG_ADMIN_ROLE = 'admin';
88
+ /** Time budget (ms) per API call and for obtaining the session. */
89
+ export const VAULT_API_TIMEOUT_MS = 30_000;
90
+ /**
91
+ * Key on `globalThis` where the CLI's Vitest setup installs its isolation marker. Test-only seam:
92
+ * production code never sets it, and a subprocess does not inherit it. The string is duplicated in
93
+ * `cli/tests/setup/isolate-home-path.ts` (which must not import `src/`); `vault-api.test.ts` pins
94
+ * the link.
95
+ */
96
+ export const VAULT_TEST_ISOLATION_KEY = Symbol.for('@timekast/factory:vault.test-isolation');
97
+ /** The single import the guard allows: `develop:/ci` → `local:/`. */
98
+ const ALLOWED_IMPORT = {
99
+ from: { env: VAULT_ENV_DEVELOP, path: VAULT_CI_PATH },
100
+ to: { env: VAULT_ENV_LOCAL, path: VAULT_ROOT_PATH },
101
+ };
102
+ /** Base of the client's own failures. A `CLIError`: the top-level handler prints only the message. */
103
+ export class VaultApiError extends CLIError {
104
+ kind;
105
+ constructor(kind, message) {
106
+ super(message);
107
+ this.name = 'VaultApiError';
108
+ this.kind = kind;
109
+ Object.setPrototypeOf(this, new.target.prototype);
110
+ }
111
+ }
112
+ /** Live session without access to the project (access is per project). */
113
+ export class VaultNoAccessError extends VaultApiError {
114
+ project;
115
+ constructor(project) {
116
+ super('no-access', `Tu sesión de la bóveda está activa, pero no tienes acceso al proyecto \`${project}\` ` +
117
+ '— o ese proyecto no existe.\n' +
118
+ 'El acceso es por proyecto (estar en la organización no lo da): pide acceso a un admin de la bóveda.');
119
+ this.name = 'VaultNoAccessError';
120
+ this.project = project;
121
+ Object.setPrototypeOf(this, new.target.prototype);
122
+ }
123
+ }
124
+ /** The slug belongs to a project the session cannot see. Never answered with an alternate slug. */
125
+ export class VaultSlugTakenError extends VaultApiError {
126
+ slug;
127
+ constructor(slug) {
128
+ super('slug-taken', `Ya existe en la bóveda un proyecto con el slug \`${slug}\` y tu sesión no tiene acceso a él.\n` +
129
+ 'No creo otro con un nombre distinto: pide a un admin de la bóveda que te dé acceso a ese proyecto ' +
130
+ 'y vuelve a correr.');
131
+ this.name = 'VaultSlugTakenError';
132
+ this.slug = slug;
133
+ Object.setPrototypeOf(this, new.target.prototype);
134
+ }
135
+ }
136
+ /** The write guard refused a key/import/reference. Names key + destination, never the value. */
137
+ export class VaultGuardError extends VaultApiError {
138
+ key;
139
+ destination;
140
+ constructor(key, destination, detail) {
141
+ super('guard', `No escribo \`${key}\` en \`${destination}\`: ${detail}\n` +
142
+ 'Las claves sólo-locales (key de Neon, credenciales del backlog central) nunca van a una carpeta ' +
143
+ 'que se sincroniza a un despliegue (ver fx-secrets-vault §7).');
144
+ this.name = 'VaultGuardError';
145
+ this.key = key;
146
+ this.destination = destination;
147
+ Object.setPrototypeOf(this, new.target.prototype);
148
+ }
149
+ }
150
+ /** Under the CLI's Vitest setup without an injected transport/session: refuses before touching anything. */
151
+ export class VaultSeamMissingError extends VaultApiError {
152
+ constructor(seam) {
153
+ super('seam-missing', `vault-api: falta el seam \`${seam}\` bajo Vitest. Un test NUNCA habla con la bóveda real: ` +
154
+ 'pasa `transport` y `session` a createVaultClient (tests/unit/vault-api.test.ts tiene el patrón).');
155
+ this.name = 'VaultSeamMissingError';
156
+ Object.setPrototypeOf(this, new.target.prototype);
157
+ }
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Location normalization + the write guard (pure, no network)
161
+ // ---------------------------------------------------------------------------
162
+ /**
163
+ * Canonical form of a location: environment trimmed + lowercased; path with every empty/`.`
164
+ * segment dropped, `\` treated as `/`, segments lowercased, one leading `/`. `..` is refused.
165
+ * The client both GUARDS and WRITES with this form, so a spelling variant (`/CI`, `ci`, `//`,
166
+ * `MAIN`) cannot make the guard judge one place while the value lands in another.
167
+ */
168
+ export function normalizeLocation(location) {
169
+ const env = location.env.trim().toLowerCase();
170
+ if (env === '') {
171
+ throw new VaultApiError('invalid-location', 'Destino inválido en la bóveda: falta el entorno.');
172
+ }
173
+ const segments = location.path
174
+ .split(/[\\/]/)
175
+ .map((segment) => segment.trim())
176
+ .filter((segment) => segment !== '' && segment !== '.');
177
+ if (segments.includes('..')) {
178
+ throw new VaultApiError('invalid-location', `Destino inválido en la bóveda: la ruta de \`${env}\` no puede subir de carpeta (\`..\`).`);
179
+ }
180
+ return { env, path: `/${segments.map((segment) => segment.toLowerCase()).join('/')}` };
181
+ }
182
+ /** `env:/path` — how a destination is named in messages. */
183
+ export function formatLocation(location) {
184
+ return `${location.env}:${location.path}`;
185
+ }
186
+ function isLocalEnv(location) {
187
+ return location.env === VAULT_ENV_LOCAL;
188
+ }
189
+ function isCiFolderOf(location, env) {
190
+ return location.env === env && location.path === VAULT_CI_PATH;
191
+ }
192
+ /**
193
+ * Where each local-only key MAY be written (ADR-005) — directly OR as a reference: a reference to a
194
+ * local-only key may only be written where that key could be written itself, because a sync
195
+ * resolves the reference and ships the value. (`develop:/ci` IS synced — to GitHub Secrets — so a
196
+ * "synced or not" boolean cannot tell that the Neon key may go there and the backlog key may not.)
197
+ * A key of `LOCAL_ONLY_KEYS` without an entry here is refused everywhere (fail-closed).
198
+ * - Neon key + project id: `develop:/ci` (CI's E2E branches) and `local`.
199
+ * - Backlog-central credentials: `local` only.
200
+ * - `DATABASE_URL_MAIN`: nowhere — a repo in the vault reads `main`'s `DATABASE_URL` instead.
201
+ */
202
+ export const LOCAL_ONLY_ALLOWED_DESTINATIONS = {
203
+ NEON_API_KEY: (location) => isLocalEnv(location) || isCiFolderOf(location, VAULT_ENV_DEVELOP),
204
+ NEON_PROJECT_ID: (location) => isLocalEnv(location) || isCiFolderOf(location, VAULT_ENV_DEVELOP),
205
+ BACKLOG_API_KEY: isLocalEnv,
206
+ BACKLOG_PROJECT_ID: isLocalEnv,
207
+ DATABASE_URL_MAIN: () => false,
208
+ };
209
+ /** The local-only key a (possibly misspelled) name stands for, or `null`. */
210
+ function localOnlyKeyOf(key) {
211
+ const canonical = key.trim().toUpperCase();
212
+ return LOCAL_ONLY_KEYS.has(canonical) ? canonical : null;
213
+ }
214
+ /** Key names referenced by `${…}` expressions in a value (the last dotted segment of each). */
215
+ export function referencedKeys(value) {
216
+ const keys = [];
217
+ for (const match of value.matchAll(/\$\{([^}]*)\}/g)) {
218
+ const segments = (match[1] ?? '').split('.');
219
+ const last = segments[segments.length - 1]?.trim();
220
+ if (last)
221
+ keys.push(last);
222
+ }
223
+ return keys;
224
+ }
225
+ /** Whether the local-only key `canonical` may be written at `location` (fail-closed). */
226
+ function localOnlyAllowedAt(canonical, location) {
227
+ const allowed = LOCAL_ONLY_ALLOWED_DESTINATIONS[canonical];
228
+ return Boolean(allowed && allowed(location));
229
+ }
230
+ /**
231
+ * Throws `VaultGuardError` when writing `key` (with `value`) at `location` would put a local-only
232
+ * key — directly or through a reference — where {@link LOCAL_ONLY_ALLOWED_DESTINATIONS} does not
233
+ * admit it. The DESTINATION is judged per key, never with a "synced or not" boolean: a reference to
234
+ * `BACKLOG_API_KEY` in `develop:/ci` is refused (that folder syncs to GitHub), one to `NEON_API_KEY`
235
+ * there is accepted (the key itself may live there). `location` must be normalized. Never looks at
236
+ * the value beyond its `${…}` references, and never quotes it.
237
+ */
238
+ export function assertWriteAllowed(key, value, location) {
239
+ const destination = formatLocation(location);
240
+ const localOnly = localOnlyKeyOf(key);
241
+ if (localOnly !== null && !localOnlyAllowedAt(localOnly, location)) {
242
+ throw new VaultGuardError(key, destination, 'es una clave sólo-local y ese destino no la admite.');
243
+ }
244
+ for (const referenced of referencedKeys(value)) {
245
+ const referencedLocalOnly = localOnlyKeyOf(referenced);
246
+ if (referencedLocalOnly !== null && !localOnlyAllowedAt(referencedLocalOnly, location)) {
247
+ throw new VaultGuardError(key, destination, `es una referencia a \`${referenced}\`, clave sólo-local que ese destino no admite.`);
248
+ }
249
+ }
250
+ }
251
+ /** Throws `VaultGuardError` for any import other than `develop:/ci` → `local:/` (allowlist). */
252
+ export function assertImportAllowed(from, to) {
253
+ const allowed = from.env === ALLOWED_IMPORT.from.env &&
254
+ from.path === ALLOWED_IMPORT.from.path &&
255
+ to.env === ALLOWED_IMPORT.to.env &&
256
+ to.path === ALLOWED_IMPORT.to.path;
257
+ if (!allowed) {
258
+ throw new VaultGuardError(`import de ${formatLocation(from)}`, formatLocation(to), `el único import permitido es ${formatLocation(ALLOWED_IMPORT.from)} → ${formatLocation(ALLOWED_IMPORT.to)}.`);
259
+ }
260
+ }
261
+ /**
262
+ * `syncOptions.initialSyncBehavior` of every repo sync (field and value from the instance's
263
+ * OpenAPI of `POST /api/v1/secret-syncs/{vercel|github}`): the destination was checked EMPTY
264
+ * before the sync is created, so the vault overwrites it. A destination with foreign keys never
265
+ * gets here — it stops and points at the adoption (`factory vault adopt`).
266
+ */
267
+ export const VAULT_SYNC_INITIAL_BEHAVIOR = 'overwrite-destination';
268
+ /**
269
+ * `syncOptions.disableSecretDeletion` of every sync the client creates: `false` — the sync DELETES
270
+ * in the destination what is deleted in the vault (ADR-005 §4: the vault is the only writer of its
271
+ * destination).
272
+ */
273
+ export const VAULT_SYNC_DISABLE_DELETION = false;
274
+ /**
275
+ * The ONLY sources a Railway sync may have: the root of a deploy environment — `main:/` or
276
+ * `develop:/` ("cada entorno a su igual en Railway", `fx-secrets-vault §7`). Never `local`, never a
277
+ * `/ci` folder (the project's Neon key lives in `develop:/ci`, and a deploy must not receive it).
278
+ */
279
+ export const VAULT_RAILWAY_SYNC_SOURCES = [
280
+ { env: VAULT_ENV_MAIN, path: VAULT_ROOT_PATH },
281
+ { env: VAULT_ENV_DEVELOP, path: VAULT_ROOT_PATH },
282
+ ];
283
+ /** Throws `VaultGuardError` unless `source` (normalized) is one of {@link VAULT_RAILWAY_SYNC_SOURCES}. */
284
+ export function assertRailwaySyncAllowed(source) {
285
+ const allowed = VAULT_RAILWAY_SYNC_SOURCES.some((pair) => pair.env === source.env && pair.path === source.path);
286
+ if (!allowed) {
287
+ throw new VaultGuardError(`sync de ${formatLocation(source)}`, 'Railway', 'un sync a Railway sólo sale de main:/ o de develop:/.');
288
+ }
289
+ }
290
+ /**
291
+ * The ONLY source → destination pairs a repo sync may have (ADR-005 §1): `main:/` → Vercel
292
+ * production, `develop:/` → Vercel preview, `develop:/ci` → GitHub. Everything else — a sync from
293
+ * `local`, from `/ci` to Vercel, from `/` to GitHub, from `main:/ci` — is refused before any call.
294
+ */
295
+ export const VAULT_SYNC_ALLOWED = [
296
+ {
297
+ destination: 'vercel',
298
+ source: { env: VAULT_ENV_MAIN, path: VAULT_ROOT_PATH },
299
+ vercelTarget: 'production',
300
+ },
301
+ {
302
+ destination: 'vercel',
303
+ source: { env: VAULT_ENV_DEVELOP, path: VAULT_ROOT_PATH },
304
+ vercelTarget: 'preview',
305
+ },
306
+ { destination: 'github', source: { env: VAULT_ENV_DEVELOP, path: VAULT_CI_PATH } },
307
+ ];
308
+ /**
309
+ * Throws `VaultGuardError` when `spec` pairs a source and a destination that ADR-005 does not
310
+ * allow (see {@link VAULT_SYNC_ALLOWED}). `source` must be normalized. The local-only keys of
311
+ * `develop:/ci` (the Neon key) reach GitHub by design; what this guard stops is that folder — or
312
+ * `local` — ever reaching Vercel.
313
+ */
314
+ export function assertSyncAllowed(spec, source) {
315
+ const target = spec.destination === 'vercel' ? spec.vercel.target : undefined;
316
+ const allowed = VAULT_SYNC_ALLOWED.some((pair) => pair.destination === spec.destination &&
317
+ pair.source.env === source.env &&
318
+ pair.source.path === source.path &&
319
+ pair.vercelTarget === target);
320
+ if (!allowed) {
321
+ const destination = spec.destination === 'vercel' ? `Vercel ${target ?? ''}`.trim() : 'GitHub';
322
+ throw new VaultGuardError(`sync de ${formatLocation(source)}`, destination, 'los únicos syncs permitidos son main:/ → Vercel production, develop:/ → Vercel preview y ' +
323
+ 'develop:/ci → GitHub.');
324
+ }
325
+ }
326
+ /**
327
+ * The `${…}` expression that references `target` from `from` (both normalized): `${KEY}` in the
328
+ * same folder, `${env.KEY}` at another root, `${env.folder.KEY}` inside a folder.
329
+ */
330
+ export function referenceExpression(from, target) {
331
+ if (from.env === target.env && from.path === target.path)
332
+ return `\${${target.key}}`;
333
+ const folders = target.path === VAULT_ROOT_PATH ? [] : target.path.slice(1).split('/');
334
+ return `\${${[target.env, ...folders, target.key].join('.')}}`;
335
+ }
336
+ // ---------------------------------------------------------------------------
337
+ // Real seams
338
+ // ---------------------------------------------------------------------------
339
+ /** The real transport: `fetch` with a timeout. Its failures are rebuilt without their message. */
340
+ export function createFetchVaultTransport(timeoutMs = VAULT_API_TIMEOUT_MS) {
341
+ return async ({ method, url, token, body }) => {
342
+ const host = new URL(url).origin;
343
+ const headers = {
344
+ Authorization: `Bearer ${token}`,
345
+ Accept: 'application/json',
346
+ };
347
+ if (body !== undefined)
348
+ headers['Content-Type'] = 'application/json';
349
+ let response;
350
+ try {
351
+ response = await fetch(url, {
352
+ method,
353
+ headers,
354
+ body: body === undefined ? undefined : JSON.stringify(body),
355
+ signal: AbortSignal.timeout(timeoutMs),
356
+ });
357
+ }
358
+ catch (error) {
359
+ const name = error?.name;
360
+ if (name === 'TimeoutError' || name === 'AbortError') {
361
+ throw new VaultApiError('timeout', `La bóveda (${host}) no respondió en ${Math.round(timeoutMs / 1000)} s. Revisa tu conexión.`);
362
+ }
363
+ throw new VaultApiError('unrecognized', `No pude conectar con la bóveda (${host}). Revisa tu conexión.`);
364
+ }
365
+ let text = '';
366
+ try {
367
+ text = await response.text();
368
+ }
369
+ catch {
370
+ text = '';
371
+ }
372
+ let parsed = null;
373
+ if (text !== '') {
374
+ try {
375
+ parsed = JSON.parse(text);
376
+ }
377
+ catch {
378
+ parsed = null;
379
+ }
380
+ }
381
+ return { status: response.status, body: parsed };
382
+ };
383
+ }
384
+ /** The rail's default runner (the Vitest refusing one when installed), else the real execa one. */
385
+ function resolveDefaultRunner() {
386
+ const override = globalThis[DEFAULT_RUNNER_OVERRIDE_KEY];
387
+ return typeof override === 'function'
388
+ ? override
389
+ : createExecaInfisicalRunner();
390
+ }
391
+ /**
392
+ * The person's session token (`infisical user get token --plain`). Decided by the EXIT CODE only
393
+ * (`fx-secrets-vault §3`): without a session infisical prints its login notice on stdout and exits
394
+ * non-zero, so reading the output would call a dead session alive.
395
+ */
396
+ async function obtainSessionToken(runner, config, timeoutMs) {
397
+ let result;
398
+ try {
399
+ result = await runner(['user', 'get', 'token', `--domain=${config.domain}`, '--plain'], {
400
+ timeoutMs,
401
+ domain: config.domain,
402
+ });
403
+ }
404
+ catch {
405
+ throw new VaultApiError('unrecognized', `No pude obtener tu sesión de la bóveda. Revisa \`${loginCommand(config)}\`.`);
406
+ }
407
+ if (result.notFound)
408
+ throw new RailNotInstalledError(config);
409
+ if (result.timedOut) {
410
+ throw new VaultApiError('timeout', `\`infisical\` no respondió en ${Math.round(timeoutMs / 1000)} s al pedir tu sesión. ` +
411
+ `Si caducó, entra de nuevo con \`${loginCommand(config)}\`.`);
412
+ }
413
+ if (result.exitCode !== 0)
414
+ throw new RailNoSessionError(config);
415
+ const token = result.stdout.trim();
416
+ if (token === '' || /\s/.test(token)) {
417
+ throw new VaultApiError('unrecognized', 'La bóveda devolvió una sesión que no reconozco. Actualiza `infisical` (`brew upgrade infisical`) ' +
418
+ `y entra con \`${loginCommand(config)}\`.`);
419
+ }
420
+ return token;
421
+ }
422
+ function asObject(value) {
423
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
424
+ ? value
425
+ : null;
426
+ }
427
+ function asArray(value) {
428
+ return Array.isArray(value) ? value : [];
429
+ }
430
+ function asString(value) {
431
+ return typeof value === 'string' ? value : null;
432
+ }
433
+ /** Build a client. Its session token is cached in memory and dies with the client. */
434
+ export function createVaultClient(deps = {}) {
435
+ const config = deps.config ?? VAULT_CONFIG;
436
+ const timeoutMs = deps.timeoutMs ?? VAULT_API_TIMEOUT_MS;
437
+ const projectLabels = new Map();
438
+ let sessionToken;
439
+ function labelOf(projectId) {
440
+ return projectLabels.get(projectId) ?? projectId;
441
+ }
442
+ function assertSeams() {
443
+ if (globalThis[VAULT_TEST_ISOLATION_KEY] === undefined)
444
+ return;
445
+ if (!deps.transport)
446
+ throw new VaultSeamMissingError('transport');
447
+ if (!deps.session && !deps.runner)
448
+ throw new VaultSeamMissingError('session');
449
+ }
450
+ function token() {
451
+ if (!sessionToken) {
452
+ const source = deps.session ??
453
+ (() => obtainSessionToken(deps.runner ?? resolveDefaultRunner(), config, timeoutMs));
454
+ sessionToken = source().catch((error) => {
455
+ sessionToken = undefined;
456
+ throw error instanceof CLIError
457
+ ? error
458
+ : new VaultApiError('unrecognized', 'No pude obtener tu sesión de la bóveda.');
459
+ });
460
+ }
461
+ return sessionToken;
462
+ }
463
+ /**
464
+ * One API call. 401 → no session; 403 → no access. Other statuses go back to the caller, which
465
+ * decides (404 on a delete = already gone). The response body is NEVER quoted in an error.
466
+ */
467
+ async function call(operation, method, pathname, options = {}) {
468
+ assertSeams();
469
+ const transport = deps.transport ?? createFetchVaultTransport(timeoutMs);
470
+ const bearer = await token();
471
+ const url = new URL(pathname, config.domain);
472
+ for (const [name, value] of Object.entries(options.query ?? {}))
473
+ url.searchParams.set(name, value);
474
+ let response;
475
+ try {
476
+ response = await transport({
477
+ method,
478
+ url: url.toString(),
479
+ token: bearer,
480
+ ...(options.body === undefined ? {} : { body: options.body }),
481
+ });
482
+ }
483
+ catch (error) {
484
+ if (error instanceof VaultApiError)
485
+ throw error;
486
+ throw new VaultApiError('unrecognized', `La llamada a la bóveda falló al ${operation}.`);
487
+ }
488
+ if (response.status === 401)
489
+ throw new RailNoSessionError(config);
490
+ if (response.status === 403) {
491
+ throw new VaultNoAccessError(options.projectId ? labelOf(options.projectId) : operation);
492
+ }
493
+ return response;
494
+ }
495
+ function unexpected(operation, status) {
496
+ return new VaultApiError('unrecognized', `La bóveda respondió ${status} al ${operation}. No lo reconozco; si persiste, repórtalo al equipo del Factory.`);
497
+ }
498
+ function ok(response, operation) {
499
+ if (response.status < 200 || response.status >= 300)
500
+ throw unexpected(operation, response.status);
501
+ return response.body;
502
+ }
503
+ function parseProject(raw, operation) {
504
+ const object = asObject(raw);
505
+ const id = asString(object?.id) ?? asString(object?._id);
506
+ const slug = asString(object?.slug);
507
+ if (!object || !id || !slug) {
508
+ throw new VaultApiError('unrecognized', `Respuesta inesperada de la bóveda al ${operation}.`);
509
+ }
510
+ const environments = asArray(object.environments).flatMap((entry) => {
511
+ const env = asObject(entry);
512
+ const envSlug = asString(env?.slug);
513
+ if (!env || !envSlug)
514
+ return [];
515
+ return [{ id: asString(env.id) ?? '', name: asString(env.name) ?? envSlug, slug: envSlug }];
516
+ });
517
+ const project = {
518
+ id,
519
+ name: asString(object.name) ?? slug,
520
+ slug,
521
+ orgId: asString(object.orgId) ?? '',
522
+ environments,
523
+ hasDeleteProtection: typeof object.hasDeleteProtection === 'boolean' ? object.hasDeleteProtection : null,
524
+ };
525
+ projectLabels.set(id, slug);
526
+ return project;
527
+ }
528
+ // -- identity (MDEP-001) --------------------------------------------------
529
+ async function whoAmI() {
530
+ const operation = 'leer tu usuario de la bóveda';
531
+ const noPerson = new VaultApiError('no-person', 'El token de la bóveda no corresponde a una persona: este comando sólo corre con tu sesión. ' +
532
+ `Entra con \`${loginCommand(config)}\` y vuelve a correr. No se escribió nada.`);
533
+ let response;
534
+ try {
535
+ response = await call(operation, 'GET', '/api/v1/user');
536
+ }
537
+ catch (error) {
538
+ // 403 — the instance knows the token but not as a user.
539
+ if (error instanceof VaultNoAccessError)
540
+ throw noPerson;
541
+ throw error;
542
+ }
543
+ if (response.status < 200 || response.status >= 300)
544
+ throw noPerson;
545
+ const identity = identityOf(asObject(response.body)?.user);
546
+ if (!identity)
547
+ throw noPerson;
548
+ return { identity };
549
+ }
550
+ // -- projects ------------------------------------------------------------
551
+ async function findProjectBySlug(slug) {
552
+ const operation = 'listar tus proyectos';
553
+ const body = asObject(ok(await call(operation, 'GET', '/api/v1/workspace'), operation));
554
+ for (const entry of asArray(body?.workspaces)) {
555
+ if (asString(asObject(entry)?.slug) === slug)
556
+ return parseProject(entry, operation);
557
+ }
558
+ return null;
559
+ }
560
+ async function getProject(projectId) {
561
+ const operation = `leer el proyecto \`${labelOf(projectId)}\``;
562
+ const response = await call(operation, 'GET', `/api/v1/workspace/${encodeURIComponent(projectId)}`, {
563
+ projectId,
564
+ });
565
+ if (response.status === 404)
566
+ throw new VaultNoAccessError(labelOf(projectId));
567
+ return parseProject(asObject(ok(response, operation))?.workspace, operation);
568
+ }
569
+ async function ensureProject(spec) {
570
+ const existing = await findProjectBySlug(spec.slug);
571
+ if (existing && spec.mustCreate) {
572
+ // The caller planned a creation: a project that appeared meanwhile is someone's — never adopted.
573
+ throw new VaultApiError('project-exists', `El proyecto \`${spec.slug}\` ya existe en la bóveda y esta corrida iba a crearlo: no lo adopto ` +
574
+ '(no escribí nada en él).');
575
+ }
576
+ if (existing)
577
+ return { project: existing, created: false };
578
+ const operation = `crear el proyecto \`${spec.slug}\``;
579
+ // Born protected, in the SAME request (ADOPT-006): a PATCH afterwards could fail and leave a
580
+ // brand-new project deletable.
581
+ const response = await call(operation, 'POST', '/api/v2/workspace', {
582
+ body: { projectName: spec.name, slug: spec.slug, hasDeleteProtection: true },
583
+ });
584
+ if (response.status === 409)
585
+ throw new VaultSlugTakenError(spec.slug);
586
+ if (response.status === 400 || response.status === 422) {
587
+ // Only a duplicate-slug answer becomes slug-taken; the message is tested, never quoted.
588
+ const message = asString(asObject(response.body)?.message) ?? '';
589
+ if (/slug|already exists|duplicate|conflict/i.test(message))
590
+ throw new VaultSlugTakenError(spec.slug);
591
+ throw unexpected(operation, response.status);
592
+ }
593
+ const project = parseProject(asObject(ok(response, operation))?.project, operation);
594
+ return { project, created: true };
595
+ }
596
+ /** The `PATCH /api/v1/workspace/{id}` of the protection. A failure names the project + cause. */
597
+ async function patchDeleteProtection(projectId, operation, body) {
598
+ const label = labelOf(projectId);
599
+ let response;
600
+ try {
601
+ response = await call(operation, 'PATCH', `/api/v1/workspace/${encodeURIComponent(projectId)}`, {
602
+ body,
603
+ projectId,
604
+ });
605
+ }
606
+ catch (error) {
607
+ // A dead session and a missing test seam keep their own typed error.
608
+ if (error instanceof RailNoSessionError || error instanceof VaultSeamMissingError)
609
+ throw error;
610
+ const cause = error instanceof Error ? error.message : 'error desconocido';
611
+ throw new VaultApiError('delete-protection', `No pude ${operation} (proyecto \`${label}\`): ${cause}`);
612
+ }
613
+ if (response.status === 404) {
614
+ throw new VaultApiError('delete-protection', `No pude ${operation} (proyecto \`${label}\`): la bóveda ya no lo encuentra (404).`);
615
+ }
616
+ if (response.status < 200 || response.status >= 300) {
617
+ throw new VaultApiError('delete-protection', `No pude ${operation} (proyecto \`${label}\`): la bóveda respondió ${response.status}.`);
618
+ }
619
+ }
620
+ async function ensureDeleteProtection(projectId) {
621
+ // Read BY ID: the listing never carries the field. Only a `true` read is "already on"; an
622
+ // unknown answer is turned on like an OFF one (the PATCH is idempotent on the instance).
623
+ const current = await getProject(projectId);
624
+ if (current.hasDeleteProtection === true) {
625
+ return { deleteProtection: 'already-on', slug: current.slug };
626
+ }
627
+ await patchDeleteProtection(projectId, 'encender la protección contra borrado', {
628
+ hasDeleteProtection: true,
629
+ });
630
+ return { deleteProtection: 'enabled-now', slug: current.slug };
631
+ }
632
+ async function disableDeleteProtection(projectId) {
633
+ await patchDeleteProtection(projectId, 'apagar la protección contra borrado', {
634
+ hasDeleteProtection: false,
635
+ });
636
+ }
637
+ async function deleteProject(projectId) {
638
+ const operation = `borrar el proyecto \`${labelOf(projectId)}\``;
639
+ const response = await call(operation, 'DELETE', `/api/v1/workspace/${encodeURIComponent(projectId)}`, {
640
+ projectId,
641
+ });
642
+ if (response.status === 404)
643
+ return { deleted: false };
644
+ ok(response, operation);
645
+ return { deleted: true };
646
+ }
647
+ // -- environments --------------------------------------------------------
648
+ async function ensureEnvironments(projectId, environments) {
649
+ const project = await getProject(projectId);
650
+ const existing = new Set(project.environments.map((env) => env.slug));
651
+ const created = [];
652
+ const unchanged = [];
653
+ for (const env of environments) {
654
+ const slug = env.slug.trim().toLowerCase();
655
+ if (existing.has(slug)) {
656
+ unchanged.push(slug);
657
+ continue;
658
+ }
659
+ const operation = `crear el entorno \`${slug}\` en \`${labelOf(projectId)}\``;
660
+ ok(await call(operation, 'POST', `/api/v1/workspace/${encodeURIComponent(projectId)}/environments`, {
661
+ body: { name: env.name, slug },
662
+ projectId,
663
+ }), operation);
664
+ existing.add(slug);
665
+ created.push(slug);
666
+ }
667
+ return { created, unchanged };
668
+ }
669
+ async function deleteEnvironment(projectId, slug) {
670
+ const project = await getProject(projectId);
671
+ const target = project.environments.find((env) => env.slug === slug.trim().toLowerCase());
672
+ if (!target)
673
+ return { deleted: false };
674
+ const operation = `borrar el entorno \`${target.slug}\` de \`${labelOf(projectId)}\``;
675
+ const response = await call(operation, 'DELETE', `/api/v1/workspace/${encodeURIComponent(projectId)}/environments/${encodeURIComponent(target.id)}`, { projectId });
676
+ if (response.status === 404)
677
+ return { deleted: false };
678
+ ok(response, operation);
679
+ return { deleted: true };
680
+ }
681
+ // -- folders -------------------------------------------------------------
682
+ function splitFolder(location) {
683
+ const segments = location.path.slice(1).split('/');
684
+ const name = segments.pop() ?? '';
685
+ if (name === '') {
686
+ throw new VaultApiError('invalid-location', 'La raíz de un entorno no es una carpeta que se cree o borre.');
687
+ }
688
+ return { parent: `/${segments.join('/')}`, name };
689
+ }
690
+ async function listFolders(projectId, env, parent) {
691
+ const operation = `listar las carpetas de \`${env}:${parent}\``;
692
+ const body = asObject(ok(await call(operation, 'GET', '/api/v1/folders', {
693
+ query: { workspaceId: projectId, environment: env, path: parent },
694
+ projectId,
695
+ }), operation));
696
+ return asArray(body?.folders).flatMap((entry) => {
697
+ const folder = asObject(entry);
698
+ const name = asString(folder?.name);
699
+ return name ? [{ id: asString(folder?.id) ?? name, name }] : [];
700
+ });
701
+ }
702
+ async function ensureFolder(projectId, rawLocation) {
703
+ const location = normalizeLocation(rawLocation);
704
+ const { parent, name } = splitFolder(location);
705
+ const folders = await listFolders(projectId, location.env, parent);
706
+ if (folders.some((folder) => folder.name.toLowerCase() === name))
707
+ return { created: false };
708
+ const operation = `crear la carpeta \`${formatLocation(location)}\``;
709
+ ok(await call(operation, 'POST', '/api/v1/folders', {
710
+ body: { workspaceId: projectId, environment: location.env, name, path: parent },
711
+ projectId,
712
+ }), operation);
713
+ return { created: true };
714
+ }
715
+ async function deleteFolder(projectId, rawLocation) {
716
+ const location = normalizeLocation(rawLocation);
717
+ const { parent, name } = splitFolder(location);
718
+ const folder = (await listFolders(projectId, location.env, parent)).find((entry) => entry.name.toLowerCase() === name);
719
+ if (!folder)
720
+ return { deleted: false };
721
+ const operation = `borrar la carpeta \`${formatLocation(location)}\``;
722
+ const response = await call(operation, 'DELETE', `/api/v1/folders/${encodeURIComponent(folder.id)}`, {
723
+ body: { workspaceId: projectId, environment: location.env, path: parent },
724
+ projectId,
725
+ });
726
+ if (response.status === 404)
727
+ return { deleted: false };
728
+ ok(response, operation);
729
+ return { deleted: true };
730
+ }
731
+ // -- members (Decision 22) -----------------------------------------------
732
+ function identityOf(raw) {
733
+ const user = asObject(raw);
734
+ const identity = asString(user?.username) ?? asString(user?.email);
735
+ return identity ? identity.toLowerCase() : null;
736
+ }
737
+ async function ensureMembers(projectId) {
738
+ const project = await getProject(projectId);
739
+ const label = labelOf(projectId);
740
+ const selfOperation = 'leer tu usuario de la bóveda';
741
+ const self = identityOf(asObject(ok(await call(selfOperation, 'GET', '/api/v1/user'), selfOperation))?.user);
742
+ const adminsOperation = 'consultar los admins de la organización';
743
+ const orgBody = asObject(ok(await call(adminsOperation, 'GET', `/api/v2/organizations/${encodeURIComponent(project.orgId)}/memberships`), adminsOperation));
744
+ const admins = asArray(orgBody?.users).flatMap((entry) => {
745
+ const membership = asObject(entry);
746
+ if (asString(membership?.role) !== ORG_ADMIN_ROLE)
747
+ return [];
748
+ const status = asString(membership?.status);
749
+ if (status !== null && status !== 'accepted')
750
+ return [];
751
+ const identity = identityOf(membership?.user);
752
+ return identity ? [identity] : [];
753
+ });
754
+ const membersOperation = `leer los miembros de \`${label}\``;
755
+ const membersBody = asObject(ok(await call(membersOperation, 'GET', `/api/v1/workspace/${encodeURIComponent(projectId)}/memberships`, { projectId }), membersOperation));
756
+ const current = new Set(asArray(membersBody?.memberships).flatMap((entry) => {
757
+ const identity = identityOf(asObject(entry)?.user);
758
+ return identity ? [identity] : [];
759
+ }));
760
+ const desired = [...new Set([...(self ? [self] : []), ...admins])];
761
+ const missing = desired.filter((identity) => !current.has(identity));
762
+ const unchanged = desired.filter((identity) => current.has(identity));
763
+ if (missing.length > 0) {
764
+ const addOperation = `agregar miembros a \`${label}\``;
765
+ ok(await call(addOperation, 'POST', `/api/v2/workspace/${encodeURIComponent(projectId)}/memberships`, {
766
+ body: { usernames: missing, roleSlugs: [MEMBER_ROLE_SLUG] },
767
+ projectId,
768
+ }), addOperation);
769
+ }
770
+ return { added: missing, unchanged };
771
+ }
772
+ // -- secrets -------------------------------------------------------------
773
+ function secretEntries(raw) {
774
+ return asArray(raw).flatMap((entry) => {
775
+ const secret = asObject(entry);
776
+ const key = asString(secret?.secretKey);
777
+ const value = asString(secret?.secretValue);
778
+ return key !== null && value !== null ? [[key, value]] : [];
779
+ });
780
+ }
781
+ async function readSecrets(projectId, rawLocation, options = {}) {
782
+ const location = normalizeLocation(rawLocation);
783
+ const operation = `leer los secretos de \`${formatLocation(location)}\``;
784
+ const body = asObject(ok(await call(operation, 'GET', '/api/v3/secrets/raw', {
785
+ query: {
786
+ workspaceId: projectId,
787
+ environment: location.env,
788
+ secretPath: location.path,
789
+ expandSecretReferences: String(options.expandReferences === true),
790
+ include_imports: String(options.includeImports === true),
791
+ },
792
+ projectId,
793
+ }), operation));
794
+ // Own values first: they win over an imported key of the same name (ADR-005, check (c)).
795
+ const secrets = new Map(secretEntries(body?.secrets));
796
+ if (options.includeImports === true) {
797
+ for (const imported of asArray(body?.imports)) {
798
+ for (const [key, value] of secretEntries(asObject(imported)?.secrets)) {
799
+ if (!secrets.has(key))
800
+ secrets.set(key, value);
801
+ }
802
+ }
803
+ }
804
+ return secrets;
805
+ }
806
+ async function writeNormalized(projectId, location, entries, mode) {
807
+ const existing = await readSecrets(projectId, location);
808
+ const result = { created: [], updated: [], unchanged: [] };
809
+ for (const [key, value] of entries) {
810
+ const current = existing.get(key);
811
+ if (current !== undefined && (mode === 'create-if-missing' || current === value)) {
812
+ result.unchanged.push(key);
813
+ continue;
814
+ }
815
+ const method = current === undefined ? 'POST' : 'PATCH';
816
+ const operation = `escribir \`${key}\` en \`${formatLocation(location)}\``;
817
+ ok(await call(operation, method, `/api/v3/secrets/raw/${encodeURIComponent(key)}`, {
818
+ body: {
819
+ workspaceId: projectId,
820
+ environment: location.env,
821
+ secretPath: location.path,
822
+ secretValue: value,
823
+ type: 'shared',
824
+ },
825
+ projectId,
826
+ }), operation);
827
+ (current === undefined ? result.created : result.updated).push(key);
828
+ }
829
+ return result;
830
+ }
831
+ async function writeSecrets(projectId, rawLocation, entries, mode) {
832
+ const location = normalizeLocation(rawLocation);
833
+ const list = Object.entries(entries).map(([key, value]) => [key.trim(), value]);
834
+ // The guard judges EVERY key before the first network call: a refused batch writes nothing.
835
+ for (const [key, value] of list)
836
+ assertWriteAllowed(key, value, location);
837
+ return writeNormalized(projectId, location, list, mode);
838
+ }
839
+ async function writeReference(projectId, rawLocation, key, rawTarget, mode) {
840
+ const location = normalizeLocation(rawLocation);
841
+ const targetLocation = normalizeLocation(rawTarget);
842
+ const target = { ...targetLocation, key: rawTarget.key.trim() };
843
+ const expression = referenceExpression(location, target);
844
+ const name = key.trim();
845
+ assertWriteAllowed(name, expression, location);
846
+ // A reference to a key that does not exist would sync a literal or an empty value.
847
+ const targetSecrets = await readSecrets(projectId, targetLocation);
848
+ if (!targetSecrets.has(target.key)) {
849
+ throw new VaultApiError('reference-target-missing', `No escribo \`${name}\` en \`${formatLocation(location)}\` como referencia: la clave \`${target.key}\` ` +
850
+ `no existe en \`${formatLocation(targetLocation)}\`. Escríbela primero.`);
851
+ }
852
+ return writeNormalized(projectId, location, [[name, expression]], mode);
853
+ }
854
+ // -- imports -------------------------------------------------------------
855
+ async function ensureImport(projectId, rawFrom, rawTo) {
856
+ const from = normalizeLocation(rawFrom);
857
+ const to = normalizeLocation(rawTo);
858
+ assertImportAllowed(from, to);
859
+ const listOperation = `leer los imports de \`${formatLocation(to)}\``;
860
+ const body = asObject(ok(await call(listOperation, 'GET', '/api/v1/secret-imports', {
861
+ query: { workspaceId: projectId, environment: to.env, path: to.path },
862
+ projectId,
863
+ }), listOperation));
864
+ const exists = asArray(body?.secretImports).some((entry) => {
865
+ const imported = asObject(entry);
866
+ const env = asString(asObject(imported?.importEnv)?.slug);
867
+ const path = asString(imported?.importPath);
868
+ return (env !== null &&
869
+ path !== null &&
870
+ normalizeLocation({ env, path }).path === from.path &&
871
+ env === from.env);
872
+ });
873
+ if (exists)
874
+ return { created: false };
875
+ const operation = `importar \`${formatLocation(from)}\` en \`${formatLocation(to)}\``;
876
+ ok(await call(operation, 'POST', '/api/v1/secret-imports', {
877
+ body: {
878
+ workspaceId: projectId,
879
+ environment: to.env,
880
+ path: to.path,
881
+ import: { environment: from.env, path: from.path },
882
+ },
883
+ projectId,
884
+ }), operation);
885
+ return { created: true };
886
+ }
887
+ // -- the full layout -----------------------------------------------------
888
+ async function ensureProjectLayout(spec) {
889
+ const { project, created: projectCreated } = await ensureProject(spec);
890
+ // A new project was born protected in its POST; a pre-existing one is read by id and turned on
891
+ // only when it is not (zero writes when it already is).
892
+ const deleteProtection = projectCreated
893
+ ? 'enabled-now'
894
+ : (await ensureDeleteProtection(project.id)).deleteProtection;
895
+ const pruned = [];
896
+ if (projectCreated) {
897
+ const keep = new Set(VAULT_LAYOUT_ENVIRONMENTS.map((env) => env.slug));
898
+ for (const env of project.environments) {
899
+ if (VAULT_DEFAULT_ENV_SLUGS.includes(env.slug) && !keep.has(env.slug)) {
900
+ if ((await deleteEnvironment(project.id, env.slug)).deleted)
901
+ pruned.push(env.slug);
902
+ }
903
+ }
904
+ }
905
+ const environments = await ensureEnvironments(project.id, VAULT_LAYOUT_ENVIRONMENTS);
906
+ const folders = { created: [], unchanged: [] };
907
+ for (const env of [VAULT_ENV_MAIN, VAULT_ENV_DEVELOP]) {
908
+ const location = { env, path: VAULT_CI_PATH };
909
+ const { created } = await ensureFolder(project.id, location);
910
+ (created ? folders.created : folders.unchanged).push(formatLocation(location));
911
+ }
912
+ let members;
913
+ try {
914
+ members = await ensureMembers(project.id);
915
+ }
916
+ catch (error) {
917
+ if (error instanceof RailNoSessionError)
918
+ throw error;
919
+ throw new VaultApiError('members-incomplete', `El proyecto \`${project.slug}\` existe, pero no pude completar sus miembros (quien lo crea + los admins ` +
920
+ 'de la organización): puede haber quedado sólo contigo, e invisible para los demás. ' +
921
+ 'Vuelve a correr el alta: adopta lo que ya existe y agrega a los que falten.');
922
+ }
923
+ const { created: importCreated } = await ensureImport(project.id, ALLOWED_IMPORT.from, ALLOWED_IMPORT.to);
924
+ return {
925
+ project: await getProject(project.id),
926
+ projectCreated,
927
+ environments: { ...environments, pruned },
928
+ folders,
929
+ members,
930
+ importCreated,
931
+ deleteProtection,
932
+ };
933
+ }
934
+ // -- secret syncs (SYNC-001) ----------------------------------------------
935
+ async function listSecretKeys(projectId, location) {
936
+ return [...(await readSecrets(projectId, location)).keys()];
937
+ }
938
+ /** `vercel` / `github` only — the destinations a repo syncs to — so nothing else reaches a URL. */
939
+ function assertDestination(destination) {
940
+ if (destination !== 'vercel' && destination !== 'github') {
941
+ throw new VaultApiError('unrecognized', `Destino de sync desconocido: \`${destination}\` (sólo vercel y github).`);
942
+ }
943
+ }
944
+ async function findAppConnection(app) {
945
+ // A closed list, so nothing else reaches the URL.
946
+ if (app !== 'railway')
947
+ assertDestination(app);
948
+ const operation = `consultar la conexión de la organización con ${app}`;
949
+ const body = asObject(ok(await call(operation, 'GET', `/api/v1/app-connections/${app}`), operation));
950
+ const connections = asArray(body?.appConnections).flatMap((entry) => {
951
+ const connection = asObject(entry);
952
+ const id = asString(connection?.id);
953
+ const connectionApp = asString(connection?.app);
954
+ if (!connection || !id || connectionApp !== app)
955
+ return [];
956
+ // A connection scoped to ONE vault project (a client with infrastructure of its own)
957
+ // is not the organization's: the instance lets only that project use it. It never
958
+ // counts toward — or breaks — the choice of the org-wide connection.
959
+ if (asString(connection.projectId))
960
+ return [];
961
+ return [
962
+ {
963
+ id,
964
+ name: asString(connection.name) ?? id,
965
+ app: connectionApp,
966
+ method: asString(connection.method),
967
+ },
968
+ ];
969
+ });
970
+ if (connections.length > 1) {
971
+ throw new VaultApiError('app-connection-ambiguous', `La organización tiene ${connections.length} conexiones con ${app} ` +
972
+ `(${connections.map((c) => `\`${c.name}\``).join(', ')}) y no elijo una a ciegas. ` +
973
+ 'Pide a un admin de la bóveda que deje una sola de ese tipo en la organización y vuelve a correr.');
974
+ }
975
+ return connections[0] ?? null;
976
+ }
977
+ function parseSync(raw, operation) {
978
+ const sync = asObject(raw);
979
+ const id = asString(sync?.id);
980
+ if (!sync || !id) {
981
+ throw new VaultApiError('unrecognized', `Respuesta inesperada de la bóveda al ${operation}.`);
982
+ }
983
+ const envSlug = asString(asObject(sync.environment)?.slug);
984
+ const folderPath = asString(asObject(sync.folder)?.path);
985
+ const options = asObject(sync.syncOptions);
986
+ return {
987
+ id,
988
+ name: asString(sync.name) ?? id,
989
+ destination: asString(sync.destination) ?? '',
990
+ connectionId: asString(sync.connectionId) ?? asString(asObject(sync.connection)?.id),
991
+ source: envSlug !== null && folderPath !== null
992
+ ? normalizeLocation({ env: envSlug, path: folderPath })
993
+ : null,
994
+ initialSyncBehavior: asString(options?.initialSyncBehavior),
995
+ disableSecretDeletion: options?.disableSecretDeletion === true,
996
+ isAutoSyncEnabled: typeof sync.isAutoSyncEnabled === 'boolean' ? sync.isAutoSyncEnabled : null,
997
+ keySchema: asString(options?.keySchema),
998
+ destinationConfig: asObject(sync.destinationConfig) ?? {},
999
+ syncStatus: asString(sync.syncStatus),
1000
+ lastSyncJobId: asString(sync.lastSyncJobId),
1001
+ };
1002
+ }
1003
+ async function listSecretSyncs(projectId) {
1004
+ const operation = `listar los syncs de \`${labelOf(projectId)}\``;
1005
+ const body = asObject(ok(await call(operation, 'GET', '/api/v1/secret-syncs', {
1006
+ query: { projectId },
1007
+ projectId,
1008
+ }), operation));
1009
+ return asArray(body?.secretSyncs).map((entry) => parseSync(entry, operation));
1010
+ }
1011
+ async function createSecretSync(projectId, spec) {
1012
+ const source = normalizeLocation(spec.source);
1013
+ // The guard runs BEFORE any network call: a refused pair never reaches the instance.
1014
+ assertSyncAllowed(spec, source);
1015
+ return postSecretSync(projectId, spec, source);
1016
+ }
1017
+ async function postSecretSync(projectId, spec, source) {
1018
+ const destinationConfig = spec.destination === 'vercel'
1019
+ ? {
1020
+ scope: 'project',
1021
+ app: spec.vercel.projectId,
1022
+ appName: spec.vercel.projectName,
1023
+ env: spec.vercel.target,
1024
+ teamId: spec.vercel.teamId,
1025
+ }
1026
+ : { scope: 'repository', owner: spec.github.owner, repo: spec.github.repo };
1027
+ return postSyncRequest(projectId, spec.destination, spec.name, spec.connectionId, source, destinationConfig);
1028
+ }
1029
+ async function createRailwaySync(projectId, spec) {
1030
+ const source = normalizeLocation(spec.source);
1031
+ // The guard runs BEFORE any network call: a refused source never reaches the instance.
1032
+ assertRailwaySyncAllowed(source);
1033
+ const r = spec.railway;
1034
+ return postSyncRequest(projectId, 'railway', spec.name, spec.connectionId, source, {
1035
+ projectId: r.projectId,
1036
+ projectName: r.projectName,
1037
+ environmentId: r.environmentId,
1038
+ environmentName: r.environmentName,
1039
+ serviceId: r.serviceId,
1040
+ serviceName: r.serviceName,
1041
+ });
1042
+ }
1043
+ async function postSyncRequest(projectId, destination, name, connectionId, source, destinationConfig) {
1044
+ const operation = `crear el sync ${formatLocation(source)} → ${destination}`;
1045
+ const response = await call(operation, 'POST', `/api/v1/secret-syncs/${destination}`, {
1046
+ body: {
1047
+ name,
1048
+ projectId,
1049
+ connectionId,
1050
+ environment: source.env,
1051
+ secretPath: source.path,
1052
+ isAutoSyncEnabled: true,
1053
+ syncOptions: {
1054
+ initialSyncBehavior: VAULT_SYNC_INITIAL_BEHAVIOR,
1055
+ // Fixed for every sync the client creates: none is born with deletion OFF (ADR-005 §4).
1056
+ disableSecretDeletion: VAULT_SYNC_DISABLE_DELETION,
1057
+ },
1058
+ destinationConfig,
1059
+ },
1060
+ projectId,
1061
+ });
1062
+ return parseSync(asObject(ok(response, operation))?.secretSync, operation);
1063
+ }
1064
+ async function deleteSecretSync(sync) {
1065
+ // RWAY-006: a Railway sync is deleted by the id `provision` recorded (`--destroy`).
1066
+ if (sync.destination !== 'railway')
1067
+ assertDestination(sync.destination);
1068
+ const operation = `borrar el sync \`${sync.id}\``;
1069
+ const response = await call(operation, 'DELETE', `/api/v1/secret-syncs/${sync.destination}/${encodeURIComponent(sync.id)}`);
1070
+ if (response.status === 404)
1071
+ return { deleted: false };
1072
+ ok(response, operation);
1073
+ return { deleted: true };
1074
+ }
1075
+ async function triggerSecretSync(sync) {
1076
+ assertDestination(sync.destination);
1077
+ const operation = `pedir que corra el sync \`${sync.id}\``;
1078
+ ok(await call(operation, 'POST', `/api/v1/secret-syncs/${sync.destination}/${encodeURIComponent(sync.id)}/sync-secrets`), operation);
1079
+ }
1080
+ return {
1081
+ whoAmI,
1082
+ findProjectBySlug,
1083
+ getProject,
1084
+ ensureProject,
1085
+ ensureDeleteProtection,
1086
+ disableDeleteProtection,
1087
+ deleteProject,
1088
+ ensureEnvironments,
1089
+ deleteEnvironment,
1090
+ ensureFolder,
1091
+ deleteFolder,
1092
+ ensureMembers,
1093
+ readSecrets,
1094
+ writeSecrets,
1095
+ writeReference,
1096
+ ensureImport,
1097
+ ensureProjectLayout,
1098
+ listSecretKeys,
1099
+ findAppConnection,
1100
+ listSecretSyncs,
1101
+ createSecretSync,
1102
+ createRailwaySync,
1103
+ deleteSecretSync,
1104
+ triggerSecretSync,
1105
+ };
1106
+ }