@timekast/factory 1.28.1 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/backlog.js +52 -15
- package/dist/commands/doctor.js +210 -29
- package/dist/commands/env.js +154 -18
- package/dist/commands/invite-admin.js +62 -15
- package/dist/commands/provision.js +5815 -763
- package/dist/commands/status.js +12 -2
- package/dist/commands/ticket.js +4 -4
- package/dist/commands/update.js +97 -12
- package/dist/commands/vault.js +2627 -0
- package/dist/index.js +97 -33
- package/dist/lib/backlog-mapping.js +111 -18
- package/dist/lib/backlog-parse.js +1 -1
- package/dist/lib/claude-md.js +320 -0
- package/dist/lib/cloudflare-api.js +213 -37
- package/dist/lib/constants.js +70 -6
- package/dist/lib/env-file.js +45 -7
- package/dist/lib/env-wizard.js +66 -2
- package/dist/lib/initial-migration.js +3 -2
- package/dist/lib/neon-api.js +145 -10
- package/dist/lib/package-json.js +37 -16
- package/dist/lib/preflight.js +92 -38
- package/dist/lib/provision-preflight.js +158 -100
- package/dist/lib/provision-state.js +652 -20
- package/dist/lib/publish-engine.js +1 -1
- package/dist/lib/rail.js +400 -0
- package/dist/lib/railway-api.js +851 -0
- package/dist/lib/vault-adopt.js +1495 -0
- package/dist/lib/vault-api.js +1106 -0
- package/dist/lib/vault-config.js +21 -0
- package/dist/lib/vault-deploys.js +898 -0
- package/dist/lib/vercel-env.js +251 -31
- package/dist/lib/wrapper-wiring.js +160 -0
- package/dist/tui/env-wizard-flow.js +17 -3
- package/dist/tui/menu.js +4 -22
- package/dist/tui/provision-flow.js +68 -13
- package/package.json +1 -1
- package/dist/commands/secrets.js +0 -71
- package/dist/lib/env-merge.js +0 -238
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}\`)
|
|
385
|
-
|
|
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
|
-
//
|
|
401
|
-
//
|
|
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
|
-
'
|
|
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
|
+
}
|