@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
@@ -46,6 +46,118 @@ import { TIMEKAST_DIR } from './constants.js';
46
46
  export const PROVISION_DIR = TIMEKAST_DIR;
47
47
  /** Filename of the provision state document inside {@link PROVISION_DIR}. */
48
48
  export const PROVISION_STATE_FILE = 'provision.json';
49
+ /** The canonical roles, in creation order. */
50
+ export const VAULT_SYNC_ROLES = [
51
+ 'vercel-production',
52
+ 'vercel-preview',
53
+ 'github-ci',
54
+ ];
55
+ /** The key of {@link NeonRevokePending} inside the `vault` block. */
56
+ export const NEON_REVOKE_PENDING_KEY = 'neonRevokePending';
57
+ /**
58
+ * Narrow a persisted {@link NeonRevokePending}, else `null`. A damaged block reads as "nothing
59
+ * pending": the replaced keys keep their name, so the next rotation lists them again anyway.
60
+ */
61
+ export function asNeonRevokePending(value) {
62
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
63
+ return null;
64
+ const obj = value;
65
+ if (typeof obj.orgId !== 'string' || obj.orgId.trim() === '')
66
+ return null;
67
+ const keyIds = Array.isArray(obj.keyIds)
68
+ ? obj.keyIds.filter((id) => typeof id === 'string' && id.trim() !== '')
69
+ : [];
70
+ if (keyIds.length === 0)
71
+ return null;
72
+ const marker = obj.syncJobsAtWrite;
73
+ if (marker === null || typeof marker !== 'object' || Array.isArray(marker))
74
+ return null;
75
+ const syncJobsAtWrite = {};
76
+ for (const [id, job] of Object.entries(marker)) {
77
+ if (typeof job === 'string')
78
+ syncJobsAtWrite[id] = job;
79
+ }
80
+ return { orgId: obj.orgId, keyIds, syncJobsAtWrite };
81
+ }
82
+ /** The environment slugs of ADR-005 (duplicated from `vault-api.ts` — see {@link VaultState}). */
83
+ export const VAULT_STATE_ENVS = Object.freeze({
84
+ main: 'main',
85
+ develop: 'develop',
86
+ local: 'local',
87
+ });
88
+ /**
89
+ * Build the `vault` block for a project id. Pure — no I/O, no external deps — so the local
90
+ * wrapper's tests (VAULT-003, root suite) can construct it exactly as provision writes it.
91
+ */
92
+ export function buildVaultState(projectId, opts = {}) {
93
+ return {
94
+ projectId,
95
+ envs: { ...VAULT_STATE_ENVS },
96
+ ...(opts.done === undefined ? {} : { done: opts.done }),
97
+ };
98
+ }
99
+ /**
100
+ * The `vault` block of `.timekast/provision.json` is PRESENT but malformed (`vault: null`, no
101
+ * `projectId`, missing `envs.*`…). Typed so every path that decides vault-vs-file stops on it: "I
102
+ * could not tell" must never read as "no vault" — that would write secrets to `.env.local` (or
103
+ * create a second vault project) in a repo whose secrets live in the vault. Mirrors the
104
+ * wrapper's `readVaultBlock` (`scripts/tools/with-vault.mjs`), which refuses the same shapes.
105
+ */
106
+ export class VaultLinkMalformedError extends CLIError {
107
+ constructor() {
108
+ super(`El bloque \`vault\` de \`${PROVISION_DIR}/${PROVISION_STATE_FILE}\` no tiene la forma esperada ` +
109
+ '(`projectId` + `envs.main`/`envs.develop`/`envs.local`). No decido a ciegas si este repo vive ' +
110
+ 'en la bóveda. Corrígelo a mano: si el repo está en la bóveda, escribe el bloque con esa forma ' +
111
+ '(`projectId` es el id de su proyecto en la bóveda, con el mismo slug; `envs` es ' +
112
+ '`{ "main": "main", "develop": "develop", "local": "local" }`); si no lo está, borra el bloque. ' +
113
+ '`--adopt` no lo arregla: conserva el bloque del state que reemplaza.');
114
+ this.name = 'VaultLinkMalformedError';
115
+ Object.setPrototypeOf(this, new.target.prototype);
116
+ }
117
+ }
118
+ /**
119
+ * The `vault` block of a parsed state object, fail-closed: `null` only when the key is ABSENT;
120
+ * a present block that does not narrow to {@link VaultState} throws.
121
+ *
122
+ * @throws {VaultLinkMalformedError} when `vault` is present but malformed (including `null`).
123
+ */
124
+ function vaultBlockOf(obj) {
125
+ if (!Object.prototype.hasOwnProperty.call(obj, 'vault') || obj.vault === undefined)
126
+ return null;
127
+ const vault = asVaultState(obj.vault);
128
+ if (vault === null)
129
+ throw new VaultLinkMalformedError();
130
+ return vault;
131
+ }
132
+ /** Narrow an unknown value to a well-formed {@link VaultState}, else `null`. */
133
+ export function asVaultState(value) {
134
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
135
+ return null;
136
+ const obj = value;
137
+ if (typeof obj.projectId !== 'string' || obj.projectId.trim() === '')
138
+ return null;
139
+ const envs = obj.envs;
140
+ if (!envs ||
141
+ typeof envs.main !== 'string' ||
142
+ typeof envs.develop !== 'string' ||
143
+ typeof envs.local !== 'string') {
144
+ return null;
145
+ }
146
+ return obj;
147
+ }
148
+ /**
149
+ * Key of `.timekast/provision.json` that records a repo which DECLINED the vault
150
+ * (`factory vault adopt --decline`, ADOPT-004). A sibling of the `vault` block — never inside it —
151
+ * so neither can be mistaken for the other. `vault adopt --apply` deletes it (adopting is the newer
152
+ * decision), and `provision --adopt --force` carries it like the `vault` block.
153
+ */
154
+ export const VAULT_DECLINED_KEY = 'vaultDeclined';
155
+ /** The valid {@link ProvisionTarget} values, for the `--target` validation and the reader. */
156
+ export const PROVISION_TARGETS = ['vercel', 'railway'];
157
+ /** Narrow an unknown value to a {@link ProvisionTarget}. */
158
+ export function isProvisionTarget(value) {
159
+ return typeof value === 'string' && PROVISION_TARGETS.includes(value);
160
+ }
49
161
  /** Absolute path to the provision state file under a repo root. */
50
162
  export function provisionStatePath(rootDir) {
51
163
  return path.join(rootDir, PROVISION_DIR, PROVISION_STATE_FILE);
@@ -80,10 +192,12 @@ function asProvisionState(parsed) {
80
192
  *
81
193
  * - file absent → `null` (nothing to resume — a fresh run)
82
194
  * - file present + valid → the typed {@link ProvisionState}
83
- * - file present + broken → `CLIError` pointing at `--force` to start over
195
+ * - file unreadable / not JSON / not an object → `CLIError` with {@link UNREADABLE_STATE_REMEDY} (git holds the
196
+ * committed copy; `--force` refuses that file — see `runProvision`)
197
+ * - JSON of the wrong shape → `CLIError` pointing at `--force` to start over: the file parses,
198
+ * so `--force` still carries its `vault` and `resend` blocks into the fresh run
84
199
  *
85
- * "Broken" covers an unreadable file (perms/race), non-JSON content, and
86
- * JSON-of-the-wrong-shape. Never throws a raw `JSON.parse` error.
200
+ * Never throws a raw `JSON.parse` error.
87
201
  *
88
202
  * @throws {CLIError} when the file exists but cannot be parsed into a valid state.
89
203
  */
@@ -96,22 +210,25 @@ export function readProvisionState(rootDir) {
96
210
  text = readFileSync(file, 'utf8');
97
211
  }
98
212
  catch {
99
- throw new CLIError(`No se pudo leer el estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`). ` +
100
- 'Si está dañado, vuelve a correr con `--force` para descartarlo y empezar de cero.');
213
+ throw new CLIError(`No se pudo leer el estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`): ` +
214
+ UNREADABLE_STATE_REMEDY);
101
215
  }
102
216
  let parsed;
103
217
  try {
104
218
  parsed = JSON.parse(text);
105
219
  }
106
220
  catch {
107
- throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) está corrupto (JSON inválido). ` +
108
- 'Vuelve a correr con `--force` para descartarlo y empezar de cero.');
221
+ throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) está corrupto (JSON inválido): ` +
222
+ UNREADABLE_STATE_REMEDY);
109
223
  }
110
224
  const state = asProvisionState(parsed);
111
225
  if (!state) {
112
226
  throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) tiene una estructura inesperada. ` +
113
227
  'Vuelve a correr con `--force` para descartarlo y empezar de cero.');
114
228
  }
229
+ // Fail-closed on a present-but-malformed `vault` block: `state.vault` decides vault-vs-file
230
+ // for every later step (`vaultCtxFor`), and a truthy garbage block must not reach it either.
231
+ vaultBlockOf(state);
115
232
  return state;
116
233
  }
117
234
  /**
@@ -127,11 +244,15 @@ export function readProvisionState(rootDir) {
127
244
  * created resource's id is durable before the next (riskier) step runs.
128
245
  */
129
246
  export function writeProvisionState(rootDir, state) {
247
+ writeStateDocument(rootDir, state);
248
+ }
249
+ /** The atomic temp-file + rename swap every writer of the state file goes through. */
250
+ function writeStateDocument(rootDir, document) {
130
251
  const dir = path.join(rootDir, PROVISION_DIR);
131
252
  mkdirSync(dir, { recursive: true });
132
253
  const target = provisionStatePath(rootDir);
133
254
  const tmp = path.join(dir, `.${PROVISION_STATE_FILE}.tmp`);
134
- const body = `${JSON.stringify(state, null, 2)}\n`;
255
+ const body = `${JSON.stringify(document, null, 2)}\n`;
135
256
  try {
136
257
  writeFileSync(tmp, body, 'utf8');
137
258
  renameSync(tmp, target);
@@ -143,6 +264,91 @@ export function writeProvisionState(rootDir, state) {
143
264
  `${error?.message ?? 'error desconocido'}.`);
144
265
  }
145
266
  }
267
+ /**
268
+ * The raw JSON object of the state file, whatever its shape; `null` when the file is absent.
269
+ *
270
+ * @throws {CLIError} when it exists but cannot be read, is not JSON or is not an object.
271
+ */
272
+ function readStateObject(rootDir) {
273
+ const file = provisionStatePath(rootDir);
274
+ if (!existsSync(file))
275
+ return null;
276
+ let parsed;
277
+ try {
278
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
279
+ }
280
+ catch {
281
+ throw new CLIError(`No pude leer \`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`: ${UNREADABLE_STATE_REMEDY}`);
282
+ }
283
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
284
+ throw new CLIError(`\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\` no es un objeto JSON: ${UNREADABLE_STATE_REMEDY}`);
285
+ }
286
+ return parsed;
287
+ }
288
+ /**
289
+ * RWAY-003 — the SAVED destination (`target`) of `.timekast/provision.json`, in either shape of
290
+ * the file. `null` without a file or without the field. This is the ONLY thing of the file that
291
+ * decides a destination: no other block or file is evidence of one.
292
+ *
293
+ * 🔴 FAIL-CLOSED on a `target` that is present but is not `vercel`/`railway`: read as "no
294
+ * target", the next run would discover again and could save a different one over it.
295
+ *
296
+ * @throws {CLIError} when the file is unreadable, or `target` has an unknown value.
297
+ */
298
+ export function readProvisionTarget(rootDir) {
299
+ const obj = readStateObject(rootDir);
300
+ if (!obj || !Object.prototype.hasOwnProperty.call(obj, 'target'))
301
+ return null;
302
+ if (isProvisionTarget(obj.target))
303
+ return obj.target;
304
+ throw new CLIError(`\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\` tiene un \`target\` que no reconozco ` +
305
+ `(\`${String(obj.target)}\`; válidos: ${PROVISION_TARGETS.join(', ')}). No lo sustituyo por mi ` +
306
+ `cuenta: ${UNREADABLE_STATE_REMEDY}`);
307
+ }
308
+ /**
309
+ * RWAY-003 — the repo's GitHub IDENTITY as the state records it (`github.repo` of the step shape,
310
+ * `githubRepo` of the adopted one); `null` when neither is there. Identity, never destination:
311
+ * it says WHICH repo to ask the platforms about, not where it deploys.
312
+ */
313
+ export function readStateRepoIdentity(rootDir) {
314
+ const obj = readStateObject(rootDir);
315
+ if (!obj)
316
+ return null;
317
+ const github = obj.github;
318
+ const candidates = [github?.repo, obj.githubRepo];
319
+ for (const value of candidates) {
320
+ if (typeof value === 'string' && value.trim() !== '' && value !== ADOPT_UNKNOWN)
321
+ return value;
322
+ }
323
+ return null;
324
+ }
325
+ /**
326
+ * RWAY-003 — save `target` in `.timekast/provision.json`, touching NOTHING else of the file (in
327
+ * either shape). Without a file: only when `minimal` is given, it creates the minimal valid state
328
+ * with the target and no provider ids (a repo older than the CLI). Same atomic swap as every
329
+ * other writer.
330
+ *
331
+ * @returns `true` when it wrote, `false` when there was no file and no `minimal`.
332
+ * @throws {CLIError} when the file is unreadable (fail-closed: never rewritten blind).
333
+ */
334
+ export function writeProvisionTarget(rootDir, target, minimal) {
335
+ const obj = readStateObject(rootDir);
336
+ if (obj) {
337
+ writeStateDocument(rootDir, { ...obj, target });
338
+ return true;
339
+ }
340
+ if (!minimal)
341
+ return false;
342
+ const state = {
343
+ projectName: minimal.projectName,
344
+ lifecycle: minimal.lifecycle,
345
+ scope: minimal.scope,
346
+ target,
347
+ createdAt: (minimal.now ?? (() => new Date()))().toISOString(),
348
+ };
349
+ writeStateDocument(rootDir, state);
350
+ return true;
351
+ }
146
352
  /**
147
353
  * Discard the provision state (the `--force` path). Best-effort: a missing file
148
354
  * is a no-op, so `--force` is safe to pass on a fresh run.
@@ -192,8 +398,16 @@ export function adoptState(rootDir, partial, options = {}) {
192
398
  throw new CLIError(`Ya existe un estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`). ` +
193
399
  'Usa `--force` para sobrescribirlo con el resultado del adopt, o bórralo si reutilizas este directorio.');
194
400
  }
401
+ // Last-line guard (`runAdopt` checks first): an unreadable file carries nothing, so the
402
+ // rewrite would drop its `vault` block — never, not even with `force`.
403
+ if (isProvisionStateUnreadable(rootDir)) {
404
+ throw new CLIError(`No puedo leer \`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`: no lo reconstruyo, ni con \`--force\`. ` +
405
+ `Hazlo así: ${UNREADABLE_STATE_REMEDY}`);
406
+ }
195
407
  const slug = partial.slug ?? ADOPT_UNKNOWN;
196
408
  const now = options.now ?? (() => new Date());
409
+ // VAULT-002: carry the vault link (+ the Resend key id) of the state being overwritten.
410
+ const carried = readCarriedVaultFields(rootDir);
197
411
  const state = {
198
412
  lifecycle: 'live',
199
413
  slug,
@@ -204,6 +418,7 @@ export function adoptState(rootDir, partial, options = {}) {
204
418
  githubRepo: partial.githubRepo ?? ADOPT_UNKNOWN,
205
419
  domains: partial.domains ?? { dev: ADOPT_UNKNOWN, prod: ADOPT_UNKNOWN },
206
420
  adoptedAt: now().toISOString(),
421
+ ...carried,
207
422
  };
208
423
  const dir = path.join(rootDir, PROVISION_DIR);
209
424
  mkdirSync(dir, { recursive: true });
@@ -221,6 +436,123 @@ export function adoptState(rootDir, partial, options = {}) {
221
436
  }
222
437
  return state;
223
438
  }
439
+ /**
440
+ * The `vault` + `resend` blocks of the state file on disk, whatever its shape — so a rewrite of
441
+ * the file in another shape (`--adopt --force`) keeps them. An unreadable file would carry
442
+ * nothing, which is why `adoptState` refuses it before calling this.
443
+ */
444
+ function readCarriedVaultFields(rootDir) {
445
+ const file = provisionStatePath(rootDir);
446
+ if (!existsSync(file))
447
+ return {};
448
+ let parsed;
449
+ try {
450
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
451
+ }
452
+ catch {
453
+ return {};
454
+ }
455
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
456
+ return {};
457
+ // 🔴 A present-but-malformed `vault` block stops the rewrite: dropping it would turn a vault
458
+ // repo into a "no vault" one on disk (VaultLinkMalformedError).
459
+ const vault = vaultBlockOf(parsed);
460
+ const resend = parsed.resend;
461
+ const backlog = carriedBacklog(parsed.backlog);
462
+ const declined = parsed[VAULT_DECLINED_KEY];
463
+ return {
464
+ ...(vault ? { vault } : {}),
465
+ ...(resend && typeof resend.apiKeyId === 'string' ? { resend } : {}),
466
+ ...(backlog ? { backlog } : {}),
467
+ ...(isVaultDeclinedMark(declined) ? { vaultDeclined: declined } : {}),
468
+ ...(isProvisionTarget(parsed.target) ? { target: parsed.target } : {}),
469
+ ...(isPlainObject(parsed.railway) ? { railway: parsed.railway } : {}),
470
+ };
471
+ }
472
+ /** A non-null, non-array object. */
473
+ function isPlainObject(value) {
474
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
475
+ }
476
+ /**
477
+ * RWAY-006 — the `railway` block of the state file, in either shape; `null` without a file or a
478
+ * block. Ids only. The drift (`doctor`/`status`) reads ONLY these ids (never a search by repo).
479
+ *
480
+ * @throws {CLIError} when the file is unreadable (the same remedy as every other reader).
481
+ */
482
+ export function readRailwayBlock(rootDir) {
483
+ const obj = readStateObject(rootDir);
484
+ if (!obj || !isPlainObject(obj.railway))
485
+ return null;
486
+ return obj.railway;
487
+ }
488
+ /**
489
+ * RWAY-006 — the Railway `--adopt` over an EXISTING state file: write `target: 'railway'` and the
490
+ * `railway` block, touching NOTHING else of the file (either shape). The block of the destination
491
+ * being left (`vercel`, `vercelProjectId`, …) stays exactly as it was (Decision 17). Same atomic
492
+ * swap as every other writer.
493
+ *
494
+ * @throws {CLIError} when there is no file, or it is unreadable (never rewritten blind).
495
+ */
496
+ export function writeRailwayAdoption(rootDir, railway) {
497
+ const obj = readStateObject(rootDir);
498
+ if (!obj) {
499
+ throw new CLIError(`No hay \`${PROVISION_DIR}/${PROVISION_STATE_FILE}\` sobre el cual registrar el bloque \`railway\`.`);
500
+ }
501
+ writeStateDocument(rootDir, { ...obj, target: 'railway', railway });
502
+ }
503
+ /** A persisted `backlog` block narrowed to {@link BacklogState}, else `null` (nothing carried). */
504
+ function carriedBacklog(value) {
505
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
506
+ return null;
507
+ const block = value;
508
+ if (typeof block.projectId !== 'string' || block.projectId.trim() === '')
509
+ return null;
510
+ return {
511
+ projectId: block.projectId,
512
+ ...(block.keyMinted === true ? { keyMinted: true } : {}),
513
+ };
514
+ }
515
+ /**
516
+ * The `resend` block of the state file on disk (either shape), read BEFORE a `--force` discard so
517
+ * the new run inherits it: without it the id of the project's live Resend key would be lost with
518
+ * the file, and the key would stay alive in Resend forever. The new run then queues that key in
519
+ * `pendingRevokeIds` when it mints a replacement, and revokes it once the new one is published.
520
+ * Best-effort: an unreadable file or a malformed block carries nothing.
521
+ */
522
+ export function readCarriedResend(rootDir) {
523
+ const file = provisionStatePath(rootDir);
524
+ if (!existsSync(file))
525
+ return null;
526
+ let parsed;
527
+ try {
528
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
529
+ }
530
+ catch {
531
+ return null;
532
+ }
533
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
534
+ return null;
535
+ const resend = parsed.resend;
536
+ if (!resend || typeof resend !== 'object')
537
+ return null;
538
+ if (typeof resend.apiKeyId !== 'string' || resend.apiKeyId.trim() === '')
539
+ return null;
540
+ if (typeof resend.domain !== 'string')
541
+ return null;
542
+ const pending = Array.isArray(resend.pendingRevokeIds)
543
+ ? resend.pendingRevokeIds.filter((id) => typeof id === 'string' && id !== '')
544
+ : [];
545
+ return {
546
+ apiKeyId: resend.apiKeyId,
547
+ domain: resend.domain,
548
+ ...(pending.length > 0 ? { pendingRevokeIds: pending } : {}),
549
+ };
550
+ }
551
+ /**
552
+ * RWAY-003 — the services that DEPLOY: a run that executes one of them needs a destination
553
+ * (`--target`, the saved one, or the discovered one). `vault`, `neon` and `backlog` do not.
554
+ */
555
+ export const DEPLOY_SERVICES = ['github', 'vercel', 'railway', 'dns'];
224
556
  /**
225
557
  * True when `service`'s create step is already recorded in `state` — the check
226
558
  * `--resume` uses to skip a completed step. A `null` state (no prior run) means
@@ -230,6 +562,10 @@ export function isServiceComplete(state, service) {
230
562
  if (!state)
231
563
  return false;
232
564
  switch (service) {
565
+ case 'vault':
566
+ // Complete only when the whole alta finished — NOT on the mere presence of the block
567
+ // (the project id is recorded as soon as the project exists, before members/import).
568
+ return state.vault?.done === true;
233
569
  case 'neon':
234
570
  // Neon is complete only when the FINAL step landed (key minted + stored as
235
571
  // the GitHub Secret). developBranchId alone is NOT done — the steps after it
@@ -239,25 +575,97 @@ export function isServiceComplete(state, service) {
239
575
  case 'vercel':
240
576
  // Complete only when the env sync landed — NOT on the mere presence of
241
577
  // state.vercel (the github step writes `projectId` before the vercel step
242
- // runs, so that alone would wrongly skip the env push).
243
- return state.vercel?.envSynced === true;
578
+ // runs, so that alone would wrongly skip the env push). With a vault (SYNC-001) the
579
+ // values landing in the vault is not enough: the sync must have delivered them and the
580
+ // first deployment been triggered (`firstDeployTriggered`), or a timed-out wait would be
581
+ // skipped by `--resume` and the project would never deploy.
582
+ return (state.vercel?.envSynced === true &&
583
+ (state.vault === undefined || state.vercel.firstDeployTriggered === true));
244
584
  case 'github':
245
585
  // Complete only when the whole step finished — NOT on the mere presence of
246
586
  // state.github (its `repo` is recorded at the START of the step, so a step
247
587
  // that failed mid-way would be wrongly skipped on resume).
248
588
  return state.github?.done === true;
249
589
  case 'dns':
250
- return state.dns !== undefined;
590
+ // Complete only when the Resend key reached the deploys too — NOT on the mere presence
591
+ // of state.dns (a run that attached the domains but could not publish the key records
592
+ // `resendKeyPending`, and `--resume` must retry that part instead of skipping it).
593
+ return state.dns !== undefined && state.dns.resendKeyPending !== true;
251
594
  case 'backlog':
252
595
  // Complete only when the per-project key was minted AND written — NOT on the
253
596
  // mere presence of state.backlog (its `projectId` is recorded before the mint,
254
597
  // so a run that created the project but failed to mint would be wrongly skipped
255
598
  // on resume, and the show-once key would never reach `.env.local`).
256
599
  return state.backlog?.keyMinted === true;
600
+ case 'railway':
601
+ // Complete once the whole step finished AND no environment's healthcheck is still pending:
602
+ // a `--resume` over a `pending` one re-enters the step, which then only repeats the probe.
603
+ return (state.railway?.done === true &&
604
+ !Object.values(state.railway.healthcheck ?? {}).includes('pending'));
257
605
  default:
258
606
  return false;
259
607
  }
260
608
  }
609
+ const RAILWAY_ROLES = ['main', 'develop'];
610
+ /** The Railway ids of a persisted `railway` block (either shape), or `null` without one. */
611
+ function railwayIdsOf(block) {
612
+ if (block === null || typeof block !== 'object' || Array.isArray(block))
613
+ return null;
614
+ const rw = block;
615
+ const byRole = (value) => {
616
+ const out = {};
617
+ if (value === null || typeof value !== 'object')
618
+ return out;
619
+ for (const role of RAILWAY_ROLES) {
620
+ const id = realId(value[role]);
621
+ if (id)
622
+ out[role] = id;
623
+ }
624
+ return out;
625
+ };
626
+ const customDomains = [];
627
+ const push = (id, label) => {
628
+ if (id && !customDomains.some((d) => d.id === id))
629
+ customDomains.push({ id, label });
630
+ };
631
+ const kit = byRole(rw.customDomains);
632
+ for (const role of RAILWAY_ROLES)
633
+ push(kit[role] ?? null, `dominio del kit en \`${role}\``);
634
+ push(realId(rw.clientDomainId), 'dominio del cliente en `main`');
635
+ for (const adopted of Array.isArray(rw.adoptedDomains) ? rw.adoptedDomains : []) {
636
+ const host = realDomain(adopted?.domain);
637
+ push(realId(adopted?.id), `\`${host ?? '?'}\` en \`${adopted?.role ?? '?'}\``);
638
+ }
639
+ const pending = Array.isArray(rw.syncs?.pending)
640
+ ? rw.syncs.pending.filter((role) => RAILWAY_ROLES.includes(role))
641
+ : [];
642
+ return {
643
+ projectId: realId(rw.projectId),
644
+ serviceId: realId(rw.serviceId),
645
+ environments: byRole(rw.environments),
646
+ customDomains,
647
+ syncIds: byRole(rw.syncs?.ids),
648
+ syncsPending: pending,
649
+ creatingProjectName: typeof rw.creating?.projectName === 'string' && rw.creating.projectName.trim() !== ''
650
+ ? rw.creating.projectName.trim()
651
+ : null,
652
+ };
653
+ }
654
+ /** The Cloudflare record refs of a persisted `dns` block, well-formed ones only. */
655
+ function cloudflareRecordsOf(dns) {
656
+ if (dns === null || typeof dns !== 'object')
657
+ return [];
658
+ const list = dns.cloudflareRecords;
659
+ if (!Array.isArray(list))
660
+ return [];
661
+ return list.flatMap((r) => {
662
+ const zoneId = realId(r?.zoneId);
663
+ const id = realId(r?.id);
664
+ if (!zoneId || !id || (r.type !== 'CNAME' && r.type !== 'TXT'))
665
+ return [];
666
+ return [{ zoneId, id, type: r.type, name: typeof r.name === 'string' ? r.name : '' }];
667
+ });
668
+ }
261
669
  /** Coerce a persisted field to a real id, mapping the `unknown` sentinel + empties to `null`. */
262
670
  function realId(value) {
263
671
  if (typeof value !== 'string')
@@ -369,20 +777,20 @@ export function readDestroyTarget(rootDir) {
369
777
  text = readFileSync(file, 'utf8');
370
778
  }
371
779
  catch {
372
- throw new CLIError(`No se pudo leer el estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`). ` +
373
- 'Si está dañado, bórralo o reconstrúyelo con `--adopt`.');
780
+ throw new CLIError(`No se pudo leer el estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`): ` +
781
+ UNREADABLE_STATE_REMEDY);
374
782
  }
375
783
  let parsed;
376
784
  try {
377
785
  parsed = JSON.parse(text);
378
786
  }
379
787
  catch {
380
- throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) está corrupto (JSON inválido). ` +
381
- 'Bórralo o reconstrúyelo con `--adopt`.');
788
+ throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) está corrupto (JSON inválido): ` +
789
+ UNREADABLE_STATE_REMEDY);
382
790
  }
383
791
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
384
- throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) tiene una estructura inesperada. ` +
385
- 'Bórralo o reconstrúyelo con `--adopt`.');
792
+ throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) no es un objeto JSON: ` +
793
+ UNREADABLE_STATE_REMEDY);
386
794
  }
387
795
  const obj = parsed;
388
796
  if (isAdoptedShape(obj)) {
@@ -397,17 +805,28 @@ export function readDestroyTarget(rootDir) {
397
805
  neonBranchDevId: realId(adopted.neonBranchDevId),
398
806
  vercelProjectId: realId(adopted.vercelProjectId),
399
807
  githubRepo: realRepo(adopted.githubRepo),
400
- // The adopt backfill (PROV-007) does not reconstruct backlog central — nothing
401
- // to tear down from an adopted stack; a normal provision state carries it.
808
+ // An adopted shape may CARRY a `backlog` block (`adoptState` keeps it for the preflight),
809
+ // but `--destroy` does not tear it down there — same policy as its `vault` block below.
402
810
  backlogProjectId: null,
403
811
  domains,
812
+ // The adopted shape can CARRY a `vault` block (see `adoptState`), but `--destroy` does not
813
+ // act on it there: an adopted stack is a live repo born before the CLI, and its vault is
814
+ // left to the adoption's owners (VAULT-004 §4).
815
+ vaultProjectId: null,
816
+ // The `resend` block, unlike `vault`/`backlog`, IS acted on in the adopted shape: the key is
817
+ // the project's own (send-only, limited to its domain), so revoking it touches nothing else,
818
+ // and dropping its id with the state would leave a live key nobody can name.
819
+ ...resendIdsOf(adopted.resend),
820
+ target: isProvisionTarget(obj.target) ? obj.target : null,
821
+ railway: railwayIdsOf(obj.railway),
822
+ cloudflareRecords: cloudflareRecordsOf(obj.dns),
404
823
  };
405
824
  }
406
825
  // Step-by-step ProvisionState shape.
407
826
  const step = obj;
408
827
  if (typeof step.projectName !== 'string' || step.projectName === '') {
409
828
  throw new CLIError(`El estado de provision (\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) no tiene un \`projectName\`/\`slug\` válido. ` +
410
- 'Bórralo o reconstrúyelo con `--adopt`.');
829
+ 'Reconstrúyelo con `factory provision --adopt --force` (conserva su bloque `vault`); no lo borres: perderías el vínculo con la bóveda.');
411
830
  }
412
831
  const lifecycle = step.lifecycle === 'ephemeral' ? 'ephemeral' : 'live';
413
832
  // The `dns` block now records the interim DEV domain (`{slug}-dev.<org zone>`)
@@ -437,5 +856,218 @@ export function readDestroyTarget(rootDir) {
437
856
  githubRepo: realRepo(step.github?.repo),
438
857
  backlogProjectId: realId(step.backlog?.projectId),
439
858
  domains,
859
+ vaultProjectId: realId(asVaultState(step.vault)?.projectId),
860
+ ...resendIdsOf(step.resend),
861
+ target: isProvisionTarget(obj.target) ? obj.target : null,
862
+ railway: railwayIdsOf(obj.railway),
863
+ cloudflareRecords: cloudflareRecordsOf(step.dns),
864
+ };
865
+ }
866
+ /**
867
+ * The Resend key ids `--destroy` revokes, read from a persisted `resend` block of EITHER shape:
868
+ * the current key + the previous ones still pending. A missing or malformed block yields none.
869
+ */
870
+ function resendIdsOf(block) {
871
+ if (block === null || typeof block !== 'object' || Array.isArray(block)) {
872
+ return { resendApiKeyId: null, resendPendingRevokeIds: [] };
873
+ }
874
+ const resend = block;
875
+ return {
876
+ resendApiKeyId: realId(resend.apiKeyId),
877
+ resendPendingRevokeIds: (Array.isArray(resend.pendingRevokeIds) ? resend.pendingRevokeIds : [])
878
+ .map(realId)
879
+ .filter((id) => id !== null),
880
+ };
881
+ }
882
+ // ─────────────────────────────────────────────────────────────────────────────
883
+ // VAULT-002 — does this repo live in the vault?
884
+ // ─────────────────────────────────────────────────────────────────────────────
885
+ /**
886
+ * The remedy for a `.timekast/provision.json` that exists but cannot be read or parsed — the ONE
887
+ * wording every reader uses. The file is COMMITTED, so git holds the last good copy. NOT
888
+ * `--adopt`: over a file it cannot read, the adopt carries nothing, and the rebuilt state loses
889
+ * the `vault` block — the repo would then be treated as one without a vault (`runAdopt` refuses
890
+ * that case for the same reason).
891
+ */
892
+ export const UNREADABLE_STATE_REMEDY = `restáuralo desde git (\`git checkout -- ${PROVISION_DIR}/${PROVISION_STATE_FILE}\`) o corrígelo a mano. ` +
893
+ 'No lo reconstruyas con `factory provision --adopt`: sin poder leerlo, el adopt pierde el bloque `vault`.';
894
+ /**
895
+ * True when `.timekast/provision.json` EXISTS but cannot be read, is not JSON, or is JSON that is
896
+ * not an object (`null`, an array, a scalar). `--adopt
897
+ * --force` refuses that case: it cannot carry the `vault` block of a file it cannot parse, so the
898
+ * rewrite would silently turn a repo that lives in the vault into one that does not.
899
+ */
900
+ export function isProvisionStateUnreadable(rootDir) {
901
+ const file = provisionStatePath(rootDir);
902
+ if (!existsSync(file))
903
+ return false;
904
+ try {
905
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
906
+ // A state is always a JSON object: `null`, an array or a scalar is as unusable as garbage,
907
+ // and reading it as "no state" would let `--force`/`--no-vault` drop the vault link.
908
+ return parsed === null || typeof parsed !== 'object' || Array.isArray(parsed);
909
+ }
910
+ catch {
911
+ return true;
912
+ }
913
+ }
914
+ /**
915
+ * Read ONLY the `vault` block of `.timekast/provision.json`, whatever the shape of the rest of
916
+ * the file (step-by-step or adopted). Returns `null` when there is no state file or it carries NO
917
+ * `vault` key.
918
+ *
919
+ * 🔴 FAIL-CLOSED on a file that exists but cannot be read or parsed, AND on a `vault` block that
920
+ * is present but malformed (`vault: null` included): this answer decides whether a writer may
921
+ * touch `.env.local`, and "I could not tell" must never read as "no vault" — that would write
922
+ * secrets to a file Next.js loads in a repo whose secrets live in the vault.
923
+ *
924
+ * @throws {CLIError} when the state file exists but is unreadable or not JSON.
925
+ * @throws {VaultLinkMalformedError} when the `vault` block is present but malformed.
926
+ */
927
+ export function readVaultLink(rootDir) {
928
+ const file = provisionStatePath(rootDir);
929
+ if (!existsSync(file))
930
+ return null;
931
+ let parsed;
932
+ try {
933
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
934
+ }
935
+ catch {
936
+ throw new CLIError(`No pude leer \`${PROVISION_DIR}/${PROVISION_STATE_FILE}\` para saber si este repo vive en la bóveda. ` +
937
+ `No escribo secretos a ciegas: ${UNREADABLE_STATE_REMEDY}`);
938
+ }
939
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
940
+ // Not an object → not a state: fail closed like an unparseable file (never "no vault").
941
+ throw new CLIError(`\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\` no es un objeto JSON. ` +
942
+ `No escribo secretos a ciegas: ${UNREADABLE_STATE_REMEDY}`);
943
+ }
944
+ return vaultBlockOf(parsed);
945
+ }
946
+ /**
947
+ * The Neon revocations pending in the `vault` block ({@link NeonRevokePending}), or `null` when
948
+ * there are none — or no block. Same fail-closed reading as {@link readVaultLink}.
949
+ */
950
+ export function readNeonRevokePending(rootDir) {
951
+ const link = readVaultLink(rootDir);
952
+ return link ? asNeonRevokePending(link.neonRevokePending) : null;
953
+ }
954
+ /**
955
+ * ADOPT-008 — the NARROW writer of {@link NeonRevokePending}: reads `.timekast/provision.json`,
956
+ * patches ONLY `vault.neonRevokePending` (sets it, or deletes it with `null`) and writes the file
957
+ * back with every other key — of the block and of the file, in either shape — untouched.
958
+ *
959
+ * 🔴 Never `state.vault = buildVaultState(...)` and never a full `writeProvisionState`: an adopted
960
+ * file holds `vercelProjectId`, `githubRepo` and the domains, and a whole-state write in another
961
+ * shape destroys them (incident 2026-09-12). That is why it writes even when a `--remint` over the
962
+ * adopted shape persists nothing else. When `state` is given, its in-memory `vault` block gets the
963
+ * same field, so a later `writeProvisionState` of the same run keeps it.
964
+ *
965
+ * @throws {CLIError} when there is no state file or no `vault` block (nowhere to record it), or
966
+ * the file is unreadable. {@link VaultLinkMalformedError} on a malformed block.
967
+ */
968
+ export function writeNeonRevokePending(rootDir, pending, state) {
969
+ const file = provisionStatePath(rootDir);
970
+ if (!existsSync(file)) {
971
+ throw new CLIError(`No hay \`${PROVISION_DIR}/${PROVISION_STATE_FILE}\` donde registrar las keys de Neon por revocar.`);
972
+ }
973
+ let raw;
974
+ try {
975
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
976
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
977
+ throw new Error();
978
+ raw = parsed;
979
+ }
980
+ catch {
981
+ throw new CLIError(`No pude leer \`${PROVISION_DIR}/${PROVISION_STATE_FILE}\` para registrar las keys de Neon por revocar: ` +
982
+ UNREADABLE_STATE_REMEDY);
983
+ }
984
+ const block = vaultBlockOf(raw);
985
+ if (!block) {
986
+ throw new CLIError(`\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\` no tiene bloque \`vault\`: no hay dónde registrar las keys de Neon por revocar.`);
987
+ }
988
+ const target = raw.vault;
989
+ if (pending)
990
+ target[NEON_REVOKE_PENDING_KEY] = pending;
991
+ else
992
+ delete target[NEON_REVOKE_PENDING_KEY];
993
+ const dir = path.join(rootDir, PROVISION_DIR);
994
+ const tmp = path.join(dir, `.${PROVISION_STATE_FILE}.tmp`);
995
+ try {
996
+ writeFileSync(tmp, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
997
+ renameSync(tmp, file);
998
+ }
999
+ catch (error) {
1000
+ rmSync(tmp, { force: true });
1001
+ throw new CLIError(`No se pudo escribir \`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`: ` +
1002
+ `${error?.message ?? 'error desconocido'}.`);
1003
+ }
1004
+ if (state?.vault) {
1005
+ if (pending)
1006
+ state.vault.neonRevokePending = pending;
1007
+ else
1008
+ delete state.vault.neonRevokePending;
1009
+ }
1010
+ }
1011
+ /**
1012
+ * The union of two pending records: every key id of both (the previous ones first, no duplicates),
1013
+ * with the org and the delivery marker of `next` — the current run's. A pending id is never lost by
1014
+ * a later writer: a key that stays alive with nobody to revoke it is the damage this prevents.
1015
+ */
1016
+ export function mergeNeonRevokePending(previous, next) {
1017
+ const prev = asNeonRevokePending(previous);
1018
+ return {
1019
+ orgId: next.orgId,
1020
+ keyIds: [...new Set([...(prev?.keyIds ?? []), ...next.keyIds])],
1021
+ syncJobsAtWrite: next.syncJobsAtWrite,
440
1022
  };
441
1023
  }
1024
+ /**
1025
+ * ADD a run's pending revocations to what the file (and the run's in-memory state) already holds —
1026
+ * {@link mergeNeonRevokePending} through the narrow {@link writeNeonRevokePending}. Every writer of
1027
+ * NEW pending ids goes through here; only a settle, which shrinks the list, writes it directly.
1028
+ */
1029
+ export function addNeonRevokePending(rootDir, pending, state) {
1030
+ let merged = mergeNeonRevokePending(readNeonRevokePending(rootDir), pending);
1031
+ if (state?.vault?.neonRevokePending) {
1032
+ merged = mergeNeonRevokePending(state.vault.neonRevokePending, merged);
1033
+ }
1034
+ writeNeonRevokePending(rootDir, merged, state);
1035
+ return merged;
1036
+ }
1037
+ /** True when the repo is linked to a vault project (see {@link readVaultLink}). */
1038
+ export function hasVaultLink(rootDir) {
1039
+ return readVaultLink(rootDir) !== null;
1040
+ }
1041
+ /** Narrow a persisted {@link VAULT_DECLINED_KEY} value to a {@link VaultDeclinedMark}. */
1042
+ function isVaultDeclinedMark(value) {
1043
+ return (value !== null &&
1044
+ typeof value === 'object' &&
1045
+ !Array.isArray(value) &&
1046
+ typeof value.at === 'string');
1047
+ }
1048
+ /**
1049
+ * Whether `.timekast/provision.json` records that the repo DECLINED the vault (ADOPT-004), in
1050
+ * either shape of the file. `false` without a file or without the key. A key that is present
1051
+ * counts as declined whatever its value: the mark only ever makes `doctor` stop suggesting
1052
+ * `vault adopt`, so reading a damaged one as "declined" costs a suggestion, never a secret.
1053
+ *
1054
+ * @throws {CLIError} when the file exists but is unreadable, not JSON or not an object — the same
1055
+ * fail-closed answer {@link readVaultLink} gives ("I could not tell" is never "not declined").
1056
+ */
1057
+ export function readVaultDeclined(rootDir) {
1058
+ const file = provisionStatePath(rootDir);
1059
+ if (!existsSync(file))
1060
+ return false;
1061
+ let parsed;
1062
+ try {
1063
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
1064
+ }
1065
+ catch {
1066
+ throw new CLIError(`No pude leer \`${PROVISION_DIR}/${PROVISION_STATE_FILE}\` para saber si este repo declinó la bóveda: ` +
1067
+ UNREADABLE_STATE_REMEDY);
1068
+ }
1069
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1070
+ throw new CLIError(`\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\` no es un objeto JSON: ${UNREADABLE_STATE_REMEDY}`);
1071
+ }
1072
+ return Object.prototype.hasOwnProperty.call(parsed, VAULT_DECLINED_KEY);
1073
+ }