@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
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A repo with several deploys (MDEP-001) — the same code deployed once per client, each deploy with
|
|
3
|
+
* its OWN vault project seeded from a shared TEMPLATE project. Contract: `fx-secrets-vault §6`
|
|
4
|
+
* ("Un repo con varios despliegues"). Decision record: ADR-007.
|
|
5
|
+
*
|
|
6
|
+
* - The REGISTRY (`.timekast/deploys.json`, committed, no credentials) declares each deploy: its
|
|
7
|
+
* slug, name, domains and bucket per environment, the NAMES of the keys that are its own, and
|
|
8
|
+
* the ids of its destinations. Read as JSON and validated field by field: an unknown field or an
|
|
9
|
+
* unreadable file aborts naming the file — never read "de menos".
|
|
10
|
+
* - The PRINCIPAL project (the `vault` block of `.timekast/provision.json`) does not change: it
|
|
11
|
+
* keeps the local wrapper, `invite-admin`, `backlog` and CI. The template and the N clients are
|
|
12
|
+
* separate projects: `<repo>-defaults` (or the name the registry declares) and `<repo>-<slug>`.
|
|
13
|
+
* - PRECEDENCE, per key and environment: a key declared in `ownKeys` with a stored non-empty value
|
|
14
|
+
* in the client → kept; else what the registry derives (origin, rpID, bucket…); else the
|
|
15
|
+
* template. What is NOT declared own is re-seeded from the template on every run, so a rotation
|
|
16
|
+
* there reaches every client. `''` counts as absent.
|
|
17
|
+
* - ADOPTION BY ID: an existing client project (or the template) is used only when its id is
|
|
18
|
+
* annotated in the registry; a name alone could be another client's project. The apply writes
|
|
19
|
+
* back the id of each project it creates.
|
|
20
|
+
* - ALL-OR-NOTHING: every client is computed and checked BEFORE the first write (project creation
|
|
21
|
+
* included). A value of one client found in another — or in the principal — aborts with zero
|
|
22
|
+
* writes.
|
|
23
|
+
* - NO DELETIONS: the plan never announces "quita" and the apply only creates or changes; keys the
|
|
24
|
+
* client holds beyond the computed set are reported by NAME, informatively.
|
|
25
|
+
*
|
|
26
|
+
* Every read and write goes through the vault client (`vault-api.ts`), so its write guard applies.
|
|
27
|
+
* The credential is the PERSON's session only: {@link VaultClient.whoAmI} runs first.
|
|
28
|
+
*
|
|
29
|
+
* 🔴 NO-ECHO: plans, errors and every exported type carry key NAMES, environments and slugs —
|
|
30
|
+
* never a value. The values live only inside the closure {@link planDeploys} returns.
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { CLIError } from './cli-error.js';
|
|
35
|
+
import { RAIL_TIMEKAST } from './rail.js';
|
|
36
|
+
import { isRailKey } from './vault-adopt.js';
|
|
37
|
+
import { assertWriteAllowed, normalizeLocation, VAULT_CI_PATH, VAULT_ENV_DEVELOP, VAULT_ENV_MAIN, VAULT_ROOT_PATH, VaultApiError, } from './vault-api.js';
|
|
38
|
+
import { relatedOriginsFor } from './vercel-env.js';
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Constants
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/** Where the registry lives (repo-relative). Committed: ids and names, never a credential. */
|
|
43
|
+
export const DEPLOYS_REGISTRY_FILE = '.timekast/deploys.json';
|
|
44
|
+
/** A deploy slug: lowercase letters, digits and hyphens, starting with a letter or digit. */
|
|
45
|
+
export const DEPLOY_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
46
|
+
/** The deploy environments of a client project (its `local` is never seeded). */
|
|
47
|
+
export const DEPLOY_ENVS = [VAULT_ENV_MAIN, VAULT_ENV_DEVELOP];
|
|
48
|
+
/** The only environment the template project is read from. */
|
|
49
|
+
export const TEMPLATE_ENV = VAULT_ENV_MAIN;
|
|
50
|
+
/** Suffix of the template's default name: `<repo>-defaults`. */
|
|
51
|
+
export const TEMPLATE_DEFAULT_SUFFIX = 'defaults';
|
|
52
|
+
/**
|
|
53
|
+
* 🔴 Keys whose value belongs to ONE deploy: always compared across clients (no length or template
|
|
54
|
+
* exception) and forbidden in the template. Plus every `*_CLIENT_SECRET` and `AUTH_*_SECRET`
|
|
55
|
+
* ({@link isUniqueKey}).
|
|
56
|
+
*/
|
|
57
|
+
export const UNIQUE_KEYS = [
|
|
58
|
+
'DATABASE_URL',
|
|
59
|
+
'AUTH_SECRET',
|
|
60
|
+
'NEXTAUTH_SECRET',
|
|
61
|
+
'MFA_ENCRYPTION_KEY',
|
|
62
|
+
'NEON_API_KEY',
|
|
63
|
+
'NEON_PROJECT_ID',
|
|
64
|
+
'RESEND_API_KEY',
|
|
65
|
+
'CRON_SECRET',
|
|
66
|
+
'VAPID_PRIVATE_KEY',
|
|
67
|
+
'BACKLOG_API_KEY',
|
|
68
|
+
'BACKLOG_PROJECT_ID',
|
|
69
|
+
];
|
|
70
|
+
/** Values shorter than this are not compared across clients (`true`, `production`, a port). */
|
|
71
|
+
export const FOREIGN_VALUE_MIN_LENGTH = 16;
|
|
72
|
+
/** An environment suffix in a key name — never valid in the vault (`fx-secrets-vault §6`). */
|
|
73
|
+
export const ENV_SUFFIX_RE = /_(MAIN|DEVELOP)$/;
|
|
74
|
+
/** The keys the registry derives for every deploy (`deriveDeployValues`) — never a `bucketKey`. */
|
|
75
|
+
export const DERIVED_KEYS = [
|
|
76
|
+
'NEXT_PUBLIC_APP_URL',
|
|
77
|
+
'NEXT_PUBLIC_APP_ENV',
|
|
78
|
+
'WEBAUTHN_RP_ID',
|
|
79
|
+
'WEBAUTHN_RELATED_ORIGINS',
|
|
80
|
+
];
|
|
81
|
+
/** How the foreign-value check names the principal project when a value of it turns up. */
|
|
82
|
+
export const PRINCIPAL_OWNER = 'principal';
|
|
83
|
+
/** Whether `key` holds a value that belongs to one deploy only. */
|
|
84
|
+
export function isUniqueKey(key) {
|
|
85
|
+
return UNIQUE_KEYS.includes(key) || /_CLIENT_SECRET$/.test(key) || /^AUTH_.+_SECRET$/.test(key);
|
|
86
|
+
}
|
|
87
|
+
/** A bare host (`app.example.com`) — no scheme, no path, no port. */
|
|
88
|
+
const HOST_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
89
|
+
/** A key name as the vault and the deploys use it. */
|
|
90
|
+
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
91
|
+
/** A registry that cannot be read or does not have the shape — names the file, never guesses. */
|
|
92
|
+
export class DeploysRegistryError extends CLIError {
|
|
93
|
+
constructor(detail) {
|
|
94
|
+
super(`\`${DEPLOYS_REGISTRY_FILE}\` no se puede usar: ${detail}\n` +
|
|
95
|
+
'Ese archivo declara los despliegues del repo (fx-secrets-vault §6). No leo un registro "de menos": ' +
|
|
96
|
+
'corrígelo y vuelve a correr. No se escribió nada.');
|
|
97
|
+
this.name = 'DeploysRegistryError';
|
|
98
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function isObject(value) {
|
|
102
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
103
|
+
}
|
|
104
|
+
function strictObject(value, where, allowed) {
|
|
105
|
+
if (!isObject(value))
|
|
106
|
+
throw new DeploysRegistryError(`\`${where}\` debe ser un objeto.`);
|
|
107
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
108
|
+
if (unknown.length > 0) {
|
|
109
|
+
throw new DeploysRegistryError(`\`${where}\` tiene campos que el registro no admite: ${unknown.map((k) => `\`${k}\``).join(', ')}. ` +
|
|
110
|
+
`Admite: ${allowed.map((k) => `\`${k}\``).join(', ')}. Una credencial nunca va aquí.`);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function requiredString(object, key, where) {
|
|
115
|
+
const value = object[key];
|
|
116
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
117
|
+
throw new DeploysRegistryError(`\`${where}.${key}\` debe ser un texto no vacío.`);
|
|
118
|
+
}
|
|
119
|
+
return value.trim();
|
|
120
|
+
}
|
|
121
|
+
function optionalString(object, key, where) {
|
|
122
|
+
if (object[key] === undefined)
|
|
123
|
+
return undefined;
|
|
124
|
+
return requiredString(object, key, where);
|
|
125
|
+
}
|
|
126
|
+
function envMap(value, where, check) {
|
|
127
|
+
const object = strictObject(value, where, DEPLOY_ENVS);
|
|
128
|
+
const out = {};
|
|
129
|
+
for (const env of DEPLOY_ENVS) {
|
|
130
|
+
const entry = optionalString(object, env, where);
|
|
131
|
+
if (entry === undefined)
|
|
132
|
+
continue;
|
|
133
|
+
check(entry, `${where}.${env}`);
|
|
134
|
+
out[env] = entry;
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function assertHost(host, at) {
|
|
139
|
+
if (!HOST_RE.test(host)) {
|
|
140
|
+
throw new DeploysRegistryError(`\`${at}\` debe ser un host (\`app.ejemplo.com\`), sin https:// ni ruta.`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function parseDeploy(value, index) {
|
|
144
|
+
const where = `deploys[${index}]`;
|
|
145
|
+
const object = strictObject(value, where, [
|
|
146
|
+
'slug',
|
|
147
|
+
'name',
|
|
148
|
+
'domains',
|
|
149
|
+
'bucket',
|
|
150
|
+
'ownKeys',
|
|
151
|
+
'vercel',
|
|
152
|
+
'railway',
|
|
153
|
+
'neon',
|
|
154
|
+
'vault',
|
|
155
|
+
]);
|
|
156
|
+
const slug = requiredString(object, 'slug', where);
|
|
157
|
+
if (!DEPLOY_SLUG_RE.test(slug)) {
|
|
158
|
+
throw new DeploysRegistryError(`\`${where}.slug\` (\`${slug}\`) no es un slug válido: minúsculas, dígitos y guiones, empezando por letra o dígito.`);
|
|
159
|
+
}
|
|
160
|
+
const name = requiredString(object, 'name', where);
|
|
161
|
+
const domains = envMap(object.domains ?? {}, `${where}.domains`, assertHost);
|
|
162
|
+
const bucket = object.bucket === undefined ? undefined : envMap(object.bucket, `${where}.bucket`, () => { });
|
|
163
|
+
const ownRaw = object.ownKeys ?? [];
|
|
164
|
+
if (!Array.isArray(ownRaw) || ownRaw.some((k) => typeof k !== 'string' || !KEY_RE.test(k))) {
|
|
165
|
+
throw new DeploysRegistryError(`\`${where}.ownKeys\` debe ser una lista de NOMBRES de variable (nunca valores).`);
|
|
166
|
+
}
|
|
167
|
+
const entry = { slug, name, domains, ownKeys: [...new Set(ownRaw)] };
|
|
168
|
+
if (bucket)
|
|
169
|
+
entry.bucket = bucket;
|
|
170
|
+
if (object.vercel !== undefined) {
|
|
171
|
+
const vercel = strictObject(object.vercel, `${where}.vercel`, ['projectId', 'teamId']);
|
|
172
|
+
const teamId = optionalString(vercel, 'teamId', `${where}.vercel`);
|
|
173
|
+
entry.vercel = {
|
|
174
|
+
projectId: requiredString(vercel, 'projectId', `${where}.vercel`),
|
|
175
|
+
...(teamId ? { teamId } : {}),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (object.railway !== undefined) {
|
|
179
|
+
const railway = strictObject(object.railway, `${where}.railway`, ['projectId', 'services']);
|
|
180
|
+
if (!Array.isArray(railway.services) || railway.services.length === 0) {
|
|
181
|
+
throw new DeploysRegistryError(`\`${where}.railway.services\` debe ser una lista no vacía.`);
|
|
182
|
+
}
|
|
183
|
+
const services = railway.services.map((raw, i) => {
|
|
184
|
+
const at = `${where}.railway.services[${i}]`;
|
|
185
|
+
const service = strictObject(raw, at, ['serviceId', 'env', 'railwayEnv']);
|
|
186
|
+
const env = requiredString(service, 'env', at);
|
|
187
|
+
if (!DEPLOY_ENVS.includes(env)) {
|
|
188
|
+
throw new DeploysRegistryError(`\`${at}.env\` debe ser ${DEPLOY_ENVS.join(' o ')}.`);
|
|
189
|
+
}
|
|
190
|
+
const railwayEnv = optionalString(service, 'railwayEnv', at);
|
|
191
|
+
return {
|
|
192
|
+
serviceId: requiredString(service, 'serviceId', at),
|
|
193
|
+
env: env,
|
|
194
|
+
...(railwayEnv ? { railwayEnv } : {}),
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
entry.railway = {
|
|
198
|
+
projectId: requiredString(railway, 'projectId', `${where}.railway`),
|
|
199
|
+
services,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (object.neon !== undefined) {
|
|
203
|
+
const neon = strictObject(object.neon, `${where}.neon`, ['projectId']);
|
|
204
|
+
entry.neon = { projectId: requiredString(neon, 'projectId', `${where}.neon`) };
|
|
205
|
+
}
|
|
206
|
+
if (object.vault !== undefined) {
|
|
207
|
+
const vault = strictObject(object.vault, `${where}.vault`, ['projectId']);
|
|
208
|
+
entry.vault = { projectId: requiredString(vault, 'projectId', `${where}.vault`) };
|
|
209
|
+
}
|
|
210
|
+
return entry;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Validate a parsed registry. Strict: an unknown field anywhere aborts (a credential never slips
|
|
214
|
+
* in as an "extra"). Also refused: duplicate slugs, and two deploys writing to the same Vercel
|
|
215
|
+
* project or Railway service (two writers on one destination).
|
|
216
|
+
*/
|
|
217
|
+
export function parseDeploysRegistry(raw) {
|
|
218
|
+
const root = strictObject(raw, 'la raíz', ['$comment', 'template', 'bucketKey', 'deploys']);
|
|
219
|
+
const registry = { deploys: [] };
|
|
220
|
+
if (root.template !== undefined) {
|
|
221
|
+
const template = strictObject(root.template, 'template', ['project', 'projectId']);
|
|
222
|
+
const project = optionalString(template, 'project', 'template');
|
|
223
|
+
if (project !== undefined && !DEPLOY_SLUG_RE.test(project)) {
|
|
224
|
+
throw new DeploysRegistryError(`\`template.project\` (\`${project}\`) no es un slug válido.`);
|
|
225
|
+
}
|
|
226
|
+
const projectId = optionalString(template, 'projectId', 'template');
|
|
227
|
+
registry.template = { ...(project ? { project } : {}), ...(projectId ? { projectId } : {}) };
|
|
228
|
+
}
|
|
229
|
+
const bucketKey = optionalString(root, 'bucketKey', 'la raíz');
|
|
230
|
+
if (bucketKey !== undefined) {
|
|
231
|
+
if (!KEY_RE.test(bucketKey)) {
|
|
232
|
+
throw new DeploysRegistryError('`bucketKey` debe ser un nombre de variable.');
|
|
233
|
+
}
|
|
234
|
+
if (isUniqueKey(bucketKey) || DERIVED_KEYS.includes(bucketKey)) {
|
|
235
|
+
throw new DeploysRegistryError(`\`bucketKey\` (\`${bucketKey}\`) no puede ser una clave de valor único ni una que el registro ya ` +
|
|
236
|
+
`deriva (${DERIVED_KEYS.map((k) => `\`${k}\``).join(', ')}): el bucket la pisaría.`);
|
|
237
|
+
}
|
|
238
|
+
registry.bucketKey = bucketKey;
|
|
239
|
+
}
|
|
240
|
+
if (!Array.isArray(root.deploys) || root.deploys.length === 0) {
|
|
241
|
+
throw new DeploysRegistryError('`deploys` debe ser una lista con al menos un despliegue.');
|
|
242
|
+
}
|
|
243
|
+
registry.deploys = root.deploys.map((entry, index) => parseDeploy(entry, index));
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
const destinations = new Map();
|
|
246
|
+
for (const deploy of registry.deploys) {
|
|
247
|
+
if (seen.has(deploy.slug)) {
|
|
248
|
+
throw new DeploysRegistryError(`el slug \`${deploy.slug}\` aparece dos veces.`);
|
|
249
|
+
}
|
|
250
|
+
seen.add(deploy.slug);
|
|
251
|
+
if (deploy.bucket && Object.keys(deploy.bucket).length > 0 && !registry.bucketKey) {
|
|
252
|
+
throw new DeploysRegistryError(`\`${deploy.slug}\` declara un bucket, pero la raíz no declara \`bucketKey\` (la variable donde va).`);
|
|
253
|
+
}
|
|
254
|
+
const targets = [
|
|
255
|
+
...(deploy.vercel ? [`el proyecto de Vercel \`${deploy.vercel.projectId}\``] : []),
|
|
256
|
+
...(deploy.railway?.services ?? []).map((s) => `el servicio de Railway \`${s.serviceId}\` (${s.railwayEnv ?? s.env})`),
|
|
257
|
+
];
|
|
258
|
+
for (const target of targets) {
|
|
259
|
+
const owner = destinations.get(target);
|
|
260
|
+
if (owner && owner !== deploy.slug) {
|
|
261
|
+
throw new DeploysRegistryError(`\`${owner}\` y \`${deploy.slug}\` escriben en ${target}: dos escritores en un destino se pisan.`);
|
|
262
|
+
}
|
|
263
|
+
destinations.set(target, deploy.slug);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return registry;
|
|
267
|
+
}
|
|
268
|
+
/** Read and validate `.timekast/deploys.json`. Missing, unreadable or malformed → aborts naming it. */
|
|
269
|
+
export function readDeploysRegistry(rootDir) {
|
|
270
|
+
const file = path.join(rootDir, DEPLOYS_REGISTRY_FILE);
|
|
271
|
+
if (!existsSync(file)) {
|
|
272
|
+
throw new CLIError(`Este repo no tiene \`${DEPLOYS_REGISTRY_FILE}\`: es el registro de despliegues (slugs, dominios, ids; ` +
|
|
273
|
+
'sin credenciales) que este comando opera. Créalo y commitéalo (fx-secrets-vault §6). No se escribió nada.');
|
|
274
|
+
}
|
|
275
|
+
let parsed;
|
|
276
|
+
try {
|
|
277
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
throw new DeploysRegistryError('no es JSON válido o no se pudo leer.');
|
|
281
|
+
}
|
|
282
|
+
return parseDeploysRegistry(parsed);
|
|
283
|
+
}
|
|
284
|
+
/** The deploy of `slug`; an unknown one names the valid slugs. */
|
|
285
|
+
export function resolveDeploy(registry, slug) {
|
|
286
|
+
if (!DEPLOY_SLUG_RE.test(slug)) {
|
|
287
|
+
throw new CLIError(`\`${slug}\` no es un slug de despliegue válido (minúsculas, dígitos y guiones). ` +
|
|
288
|
+
`Los del registro: ${registry.deploys.map((d) => `\`${d.slug}\``).join(', ')}.`);
|
|
289
|
+
}
|
|
290
|
+
const deploy = registry.deploys.find((d) => d.slug === slug);
|
|
291
|
+
if (!deploy) {
|
|
292
|
+
throw new CLIError(`\`${slug}\` no está en \`${DEPLOYS_REGISTRY_FILE}\`. Los despliegues válidos: ` +
|
|
293
|
+
`${registry.deploys.map((d) => `\`${d.slug}\``).join(', ')}.`);
|
|
294
|
+
}
|
|
295
|
+
return deploy;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Name every project and refuse a reserved one: a client project can never be `rail-timekast`, the
|
|
299
|
+
* template or the principal — neither by its deploy slug nor by its full name.
|
|
300
|
+
*/
|
|
301
|
+
export function deployProjectNames(registry, principal) {
|
|
302
|
+
const template = registry.template?.project ?? `${principal}-${TEMPLATE_DEFAULT_SUFFIX}`;
|
|
303
|
+
const reserved = new Set([RAIL_TIMEKAST.name, template, principal]);
|
|
304
|
+
if (template === principal || template === RAIL_TIMEKAST.name) {
|
|
305
|
+
throw new CLIError(`La plantilla no puede llamarse \`${template}\`: es el proyecto principal o el rail. No se escribió nada.`);
|
|
306
|
+
}
|
|
307
|
+
if (!template.startsWith(`${principal}-`)) {
|
|
308
|
+
throw new CLIError(`La plantilla \`${template}\` no empieza por \`${principal}-\` (el nombre del proyecto principal): ` +
|
|
309
|
+
'la plantilla de un repo se llama `<repo>-…`, así no se confunde con la de otro. Cambia ' +
|
|
310
|
+
`\`template.project\` en \`${DEPLOYS_REGISTRY_FILE}\`. No se escribió nada.`);
|
|
311
|
+
}
|
|
312
|
+
const clients = new Map();
|
|
313
|
+
for (const deploy of registry.deploys) {
|
|
314
|
+
const project = `${principal}-${deploy.slug}`;
|
|
315
|
+
if (reserved.has(deploy.slug) || reserved.has(project)) {
|
|
316
|
+
throw new CLIError(`El despliegue \`${deploy.slug}\` da el proyecto \`${project}\`, un nombre reservado ` +
|
|
317
|
+
`(\`${RAIL_TIMEKAST.name}\`, la plantilla \`${template}\` o el principal \`${principal}\`). ` +
|
|
318
|
+
`Cambia su slug en \`${DEPLOYS_REGISTRY_FILE}\`. No se escribió nada.`);
|
|
319
|
+
}
|
|
320
|
+
clients.set(deploy.slug, project);
|
|
321
|
+
}
|
|
322
|
+
return { principal, template, clients };
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* What the registry derives, per environment — the kit's own rules (`buildEnvPlan`): the origin of
|
|
326
|
+
* each environment, ONE rpID (the production host) with the develop origin as related origin, the
|
|
327
|
+
* deployment identity, and the bucket into `bucketKey`.
|
|
328
|
+
*/
|
|
329
|
+
export function deriveDeployValues(deploy, bucketKey) {
|
|
330
|
+
const out = { main: {}, develop: {} };
|
|
331
|
+
out.main.NEXT_PUBLIC_APP_ENV = 'production';
|
|
332
|
+
out.develop.NEXT_PUBLIC_APP_ENV = 'preview';
|
|
333
|
+
const prod = deploy.domains.main;
|
|
334
|
+
const dev = deploy.domains.develop;
|
|
335
|
+
for (const env of DEPLOY_ENVS) {
|
|
336
|
+
const host = deploy.domains[env];
|
|
337
|
+
if (host)
|
|
338
|
+
out[env].NEXT_PUBLIC_APP_URL = `https://${host}`;
|
|
339
|
+
if (prod)
|
|
340
|
+
out[env].WEBAUTHN_RP_ID = prod;
|
|
341
|
+
if (dev)
|
|
342
|
+
out[env].WEBAUTHN_RELATED_ORIGINS = relatedOriginsFor([dev]);
|
|
343
|
+
const bucket = deploy.bucket?.[env];
|
|
344
|
+
if (bucket && bucketKey)
|
|
345
|
+
out[env][bucketKey] = bucket;
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The set of each environment by precedence: own stored non-empty value (only for a key declared
|
|
351
|
+
* in `ownKeys`) → derived from the registry → template. A key with none of the three is absent.
|
|
352
|
+
* In `develop`, a declared key with no value there, nothing derived, and an own value in `main`
|
|
353
|
+
* takes `main`'s (the apply writes it as a reference): an own value is never replaced by the
|
|
354
|
+
* template's in the other environment.
|
|
355
|
+
*/
|
|
356
|
+
export function buildEnvironmentSets(input) {
|
|
357
|
+
const own = new Set(input.ownKeys);
|
|
358
|
+
const out = {};
|
|
359
|
+
for (const env of DEPLOY_ENVS) {
|
|
360
|
+
const derived = input.derived[env];
|
|
361
|
+
const keys = new Set([...input.template.keys(), ...Object.keys(derived), ...own]);
|
|
362
|
+
const values = new Map();
|
|
363
|
+
const sources = new Map();
|
|
364
|
+
for (const key of keys) {
|
|
365
|
+
const stored = input.stored[env].get(key);
|
|
366
|
+
if (own.has(key) && stored !== undefined && stored !== '') {
|
|
367
|
+
values.set(key, stored);
|
|
368
|
+
sources.set(key, 'propio');
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const fromRegistry = derived[key];
|
|
372
|
+
if (fromRegistry !== undefined) {
|
|
373
|
+
values.set(key, fromRegistry);
|
|
374
|
+
sources.set(key, 'registro');
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const storedMain = input.stored.main.get(key);
|
|
378
|
+
if (env === VAULT_ENV_DEVELOP &&
|
|
379
|
+
own.has(key) &&
|
|
380
|
+
storedMain !== undefined &&
|
|
381
|
+
storedMain !== '') {
|
|
382
|
+
values.set(key, storedMain);
|
|
383
|
+
sources.set(key, 'propio de main');
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const fromTemplate = input.template.get(key);
|
|
387
|
+
if (fromTemplate !== undefined && fromTemplate !== '') {
|
|
388
|
+
values.set(key, fromTemplate);
|
|
389
|
+
sources.set(key, 'plantilla');
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
out[env] = { values, sources };
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* The diff of one environment: what the apply adds or changes. Compared on EXPANDED values, so a
|
|
398
|
+
* key whose value did not change is never rewritten. Own values are never written (they are what
|
|
399
|
+
* the vault already has). Keys the client holds beyond the computed set are not here: see
|
|
400
|
+
* {@link extraKeys}.
|
|
401
|
+
*/
|
|
402
|
+
export function diffEnv(environment, before, after, mainAfter) {
|
|
403
|
+
const changes = [];
|
|
404
|
+
for (const [key, value] of after.values) {
|
|
405
|
+
if (after.sources.get(key) === 'propio')
|
|
406
|
+
continue;
|
|
407
|
+
const had = before.get(key);
|
|
408
|
+
if (had === value)
|
|
409
|
+
continue;
|
|
410
|
+
const reference = environment === VAULT_ENV_DEVELOP &&
|
|
411
|
+
mainAfter !== undefined &&
|
|
412
|
+
mainAfter.values.get(key) === value;
|
|
413
|
+
changes.push({
|
|
414
|
+
environment,
|
|
415
|
+
key,
|
|
416
|
+
action: had === undefined ? 'añade' : 'cambia',
|
|
417
|
+
as: reference ? 'referencia' : 'valor',
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return changes.sort((a, b) => a.key.localeCompare(b.key));
|
|
421
|
+
}
|
|
422
|
+
/** Keys the client holds that the computed set does not — informative, never deleted. */
|
|
423
|
+
export function extraKeys(before, after) {
|
|
424
|
+
return [...before.keys()].filter((key) => !after.values.has(key)).sort();
|
|
425
|
+
}
|
|
426
|
+
/** The identifiers of a deploy's registry entry (domains, buckets, ids), lowercased. */
|
|
427
|
+
export function registryIdentifiers(deploy) {
|
|
428
|
+
const ids = [
|
|
429
|
+
...Object.values(deploy.domains),
|
|
430
|
+
...Object.values(deploy.bucket ?? {}),
|
|
431
|
+
deploy.vercel?.projectId,
|
|
432
|
+
deploy.railway?.projectId,
|
|
433
|
+
...(deploy.railway?.services ?? []).map((s) => s.serviceId),
|
|
434
|
+
deploy.neon?.projectId,
|
|
435
|
+
];
|
|
436
|
+
return [...new Set(ids.filter((id) => Boolean(id)).map((id) => id.toLowerCase()))];
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The tokens of a value an identifier is compared with: the whole value and its pieces split on
|
|
440
|
+
* anything that is not part of a host or an id (`https://a.com/x` → `a.com`, `x`, …).
|
|
441
|
+
*/
|
|
442
|
+
function tokensOf(value) {
|
|
443
|
+
const lower = value.toLowerCase();
|
|
444
|
+
return new Set([lower, ...lower.split(/[^a-z0-9._-]+/).filter(Boolean)]);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Whether a registry identifier appears in `tokens`, on LABEL boundaries (`.`), never as a free
|
|
448
|
+
* substring: the whole token, or a run of its labels — `app.cliente-b.com` inside
|
|
449
|
+
* `cdn.app.cliente-b.com` (a subdomain), `cliente-b-prod` as the first label of
|
|
450
|
+
* `cliente-b-prod.<account>.r2…` (a bucket host). `cliente-b` does NOT match `cliente-bb.com`.
|
|
451
|
+
*/
|
|
452
|
+
export function identifierInTokens(identifier, tokens) {
|
|
453
|
+
const id = identifier.toLowerCase();
|
|
454
|
+
for (const token of tokens) {
|
|
455
|
+
if (token === id)
|
|
456
|
+
return true;
|
|
457
|
+
if (`.${token}.`.includes(`.${id}.`))
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Values of `deploy` that belong to another deploy. A value is compared with EVERY value (any key,
|
|
464
|
+
* any environment) of each other deploy — computed after precedence AND stored — and with the
|
|
465
|
+
* registry identifiers of the others (domains, buckets, ids). Exceptions for a non-unique key: a
|
|
466
|
+
* value identical to the template's in that key, and values shorter than
|
|
467
|
+
* {@link FOREIGN_VALUE_MIN_LENGTH}. A unique key ({@link isUniqueKey}) is always compared.
|
|
468
|
+
*/
|
|
469
|
+
export function findForeignValues(input) {
|
|
470
|
+
const found = new Map();
|
|
471
|
+
const add = (hit) => {
|
|
472
|
+
found.set(`${hit.environment}|${hit.key}|${hit.owner}`, hit);
|
|
473
|
+
};
|
|
474
|
+
const othersValues = input.others.map((other) => {
|
|
475
|
+
const all = new Set();
|
|
476
|
+
for (const map of other.values)
|
|
477
|
+
for (const value of map.values())
|
|
478
|
+
if (value !== '')
|
|
479
|
+
all.add(value);
|
|
480
|
+
return { slug: other.slug, all, identifiers: other.identifiers };
|
|
481
|
+
});
|
|
482
|
+
for (const env of DEPLOY_ENVS) {
|
|
483
|
+
for (const [key, value] of input.sets[env]) {
|
|
484
|
+
if (value === '')
|
|
485
|
+
continue;
|
|
486
|
+
const unique = isUniqueKey(key);
|
|
487
|
+
const exempt = !unique && (value.length < FOREIGN_VALUE_MIN_LENGTH || input.template.get(key) === value);
|
|
488
|
+
const tokens = tokensOf(value);
|
|
489
|
+
for (const other of othersValues) {
|
|
490
|
+
if (!exempt && other.all.has(value)) {
|
|
491
|
+
add({ deploy: input.deploy, key, environment: env, owner: other.slug });
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (other.identifiers.some((id) => identifierInTokens(id, tokens))) {
|
|
495
|
+
add({ deploy: input.deploy, key, environment: env, owner: other.slug });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return [...found.values()].sort((a, b) => a.environment.localeCompare(b.environment) || a.key.localeCompare(b.key));
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* The template's keys that would contaminate every client: a unique key, a rail token, an
|
|
504
|
+
* environment suffix, or a value identical to a client's OWN stored value (non-short).
|
|
505
|
+
*/
|
|
506
|
+
export function findTemplateProblems(template, ownValues) {
|
|
507
|
+
// `ownValues` must come WITHOUT the values identical to the template's in the same key: those
|
|
508
|
+
// are the template's own copy, seeded by an earlier run (see `planDeploys`).
|
|
509
|
+
const problems = [];
|
|
510
|
+
for (const [key, value] of template) {
|
|
511
|
+
if (isUniqueKey(key))
|
|
512
|
+
problems.push({ key, reason: 'única' });
|
|
513
|
+
else if (isRailKey(key))
|
|
514
|
+
problems.push({ key, reason: 'rail' });
|
|
515
|
+
else if (ENV_SUFFIX_RE.test(key))
|
|
516
|
+
problems.push({ key, reason: 'sufijo' });
|
|
517
|
+
else if (value.length >= FOREIGN_VALUE_MIN_LENGTH) {
|
|
518
|
+
const owner = ownValues.find((own) => own.values.has(value));
|
|
519
|
+
if (owner)
|
|
520
|
+
problems.push({ key, reason: 'propia', owner: owner.slug });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return problems.sort((a, b) => a.key.localeCompare(b.key));
|
|
524
|
+
}
|
|
525
|
+
const TEMPLATE_REASON = {
|
|
526
|
+
única: 'es una clave de valor único por despliegue',
|
|
527
|
+
rail: 'es un token del rail (fx-secrets-vault §2)',
|
|
528
|
+
sufijo: 'lleva sufijo de entorno (fx-secrets-vault §6: nunca `_MAIN`/`_DEVELOP`)',
|
|
529
|
+
propia: 'repite el valor propio de un despliegue',
|
|
530
|
+
};
|
|
531
|
+
/** A value of one client found in another: abort before the first write, naming it. */
|
|
532
|
+
export class ForeignValueError extends CLIError {
|
|
533
|
+
hits;
|
|
534
|
+
constructor(hits) {
|
|
535
|
+
super('No escribo nada: un valor de un despliegue aparece en otro (o en el proyecto principal).\n' +
|
|
536
|
+
hits
|
|
537
|
+
.map((hit) => ` · \`${hit.key}\` en ${hit.environment} de \`${hit.deploy}\` es de \`${hit.owner}\``)
|
|
538
|
+
.join('\n') +
|
|
539
|
+
'\nCorrige el valor en la bóveda (o en el registro) del despliegue que lo tiene mal y repite. ' +
|
|
540
|
+
'No se creó ningún proyecto ni se escribió ningún valor.');
|
|
541
|
+
this.name = 'ForeignValueError';
|
|
542
|
+
this.hits = hits;
|
|
543
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const MAIN_ROOT = { env: VAULT_ENV_MAIN, path: VAULT_ROOT_PATH };
|
|
547
|
+
const DEVELOP_ROOT = { env: VAULT_ENV_DEVELOP, path: VAULT_ROOT_PATH };
|
|
548
|
+
const ROOT_OF = { main: MAIN_ROOT, develop: DEVELOP_ROOT };
|
|
549
|
+
function emptyEnvValues() {
|
|
550
|
+
return { main: new Map(), develop: new Map() };
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* S1 — an existing project is adopted only by its ANNOTATED id. A name alone proves nothing: a
|
|
554
|
+
* project with that slug may belong to someone else, and adopting it would write this client's
|
|
555
|
+
* values into it. Names the project and the registry field, never an id to copy blindly.
|
|
556
|
+
*/
|
|
557
|
+
function assertAnnotated(input) {
|
|
558
|
+
const { found, annotated, project, field, owner } = input;
|
|
559
|
+
if (found && annotated === undefined) {
|
|
560
|
+
throw new CLIError(`La bóveda ya tiene un proyecto \`${project}\` (${owner}), pero \`${DEPLOYS_REGISTRY_FILE}\` no anota su ` +
|
|
561
|
+
`id en \`${field}\`. No adopto un proyecto sólo por su nombre: podría ser de otro cliente.\n` +
|
|
562
|
+
` · Verifica en el dashboard de la bóveda que \`${project}\` es de ${owner} (sus miembros, sus ` +
|
|
563
|
+
`secretos y sus syncs), copia su id a \`${field}\` y repite.\n` +
|
|
564
|
+
' · Si no es suyo, no lo toques: cambia el slug en el registro.\n' +
|
|
565
|
+
'No se escribió nada.');
|
|
566
|
+
}
|
|
567
|
+
if (found && annotated !== undefined && annotated !== found.id) {
|
|
568
|
+
throw new CLIError(`El proyecto \`${project}\` (${owner}) no es el que anota \`${field}\` en \`${DEPLOYS_REGISTRY_FILE}\`: ` +
|
|
569
|
+
'los ids no coinciden. O el registro está mal, o el proyecto con ese nombre es otro. Verifica en el ' +
|
|
570
|
+
'dashboard de la bóveda cuál es el de este cliente y corrige el registro. No se escribió nada.');
|
|
571
|
+
}
|
|
572
|
+
if (!found && annotated !== undefined) {
|
|
573
|
+
throw new CLIError(`\`${field}\` anota un id para \`${project}\` (${owner}), pero tu sesión no ve ese proyecto: o ya no ` +
|
|
574
|
+
'existe, o no tienes acceso a él.\n' +
|
|
575
|
+
' · Si no tienes acceso: pídelo a un admin de la bóveda (el acceso es por proyecto).\n' +
|
|
576
|
+
` · Si lo borraron: quita \`${field}\` del registro y repite (el \`--apply\` lo crea y anota el nuevo).\n` +
|
|
577
|
+
'No se escribió nada.');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* S3 — every value the PRINCIPAL project holds, in each environment and in its `/ci` folders:
|
|
582
|
+
* a client value found there came from the principal and is foreign too.
|
|
583
|
+
*/
|
|
584
|
+
async function readPrincipalValues(client, principal) {
|
|
585
|
+
const out = [];
|
|
586
|
+
for (const env of principal.environments.map((e) => e.slug)) {
|
|
587
|
+
out.push(await client.readSecrets(principal.id, { env, path: VAULT_ROOT_PATH }, { expandReferences: true }));
|
|
588
|
+
if (env === VAULT_ENV_MAIN || env === VAULT_ENV_DEVELOP) {
|
|
589
|
+
out.push(await client.readSecrets(principal.id, { env, path: VAULT_CI_PATH }, { expandReferences: true }));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Write a created project's id back into `.timekast/deploys.json` (`deploys[i].vault.projectId`),
|
|
596
|
+
* preserving the rest of the file: JSON with two spaces and a final newline. The file stays
|
|
597
|
+
* uncommitted — whoever runs commits it.
|
|
598
|
+
*/
|
|
599
|
+
export function recordDeployProjectId(rootDir, slug, projectId) {
|
|
600
|
+
const file = path.join(rootDir, DEPLOYS_REGISTRY_FILE);
|
|
601
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
602
|
+
const entry = Array.isArray(raw.deploys)
|
|
603
|
+
? raw.deploys.find((d) => isObject(d) && d.slug === slug)
|
|
604
|
+
: undefined;
|
|
605
|
+
if (!isObject(entry)) {
|
|
606
|
+
throw new DeploysRegistryError(`no encontré el despliegue \`${slug}\` para anotar su proyecto.`);
|
|
607
|
+
}
|
|
608
|
+
entry.vault = { projectId };
|
|
609
|
+
writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Read everything, compute every client, check them all — and only then hand back an `apply`.
|
|
613
|
+
* Reads only: no project is created and no value written here, so a stop of the plan (with or
|
|
614
|
+
* without `--apply`) leaves the vault exactly as it was.
|
|
615
|
+
*
|
|
616
|
+
* The `apply` writes in a fixed order — new projects (each id handed to `onProjectCreated` right
|
|
617
|
+
* away), then values. A failure INSIDE the apply leaves what was already written; running it again
|
|
618
|
+
* adopts it (the ids are annotated) and completes the rest.
|
|
619
|
+
*
|
|
620
|
+
* @param only - the deploy of `--deploy <slug>`; the others are still read (their values and
|
|
621
|
+
* identifiers are what the foreign-value check compares against).
|
|
622
|
+
*/
|
|
623
|
+
export async function planDeploys(input) {
|
|
624
|
+
const { client, registry } = input;
|
|
625
|
+
// 0. A person behind the session — before anything else is read.
|
|
626
|
+
const person = await client.whoAmI();
|
|
627
|
+
const targetedSlug = input.only === undefined ? null : resolveDeploy(registry, input.only).slug;
|
|
628
|
+
const principal = await client.getProject(input.principalProjectId);
|
|
629
|
+
const names = deployProjectNames(registry, principal.slug);
|
|
630
|
+
// 1. The template: must exist, with its id annotated; read with references expanded (the vault
|
|
631
|
+
// cannot reference across projects, so each client gets a copy by value).
|
|
632
|
+
const templateProject = await client.findProjectBySlug(names.template);
|
|
633
|
+
if (!templateProject && registry.template?.projectId === undefined) {
|
|
634
|
+
throw new CLIError(`La bóveda no tiene la plantilla \`${names.template}\` (o tu sesión no tiene acceso a ella). Es el ` +
|
|
635
|
+
'proyecto con los valores comunes a todos los despliegues: créalo (sólo con el entorno `main`) y anota ' +
|
|
636
|
+
'su id en `template.projectId`, o pide acceso a un admin de la bóveda. No se escribió nada.');
|
|
637
|
+
}
|
|
638
|
+
assertAnnotated({
|
|
639
|
+
found: templateProject,
|
|
640
|
+
annotated: registry.template?.projectId,
|
|
641
|
+
project: names.template,
|
|
642
|
+
field: 'template.projectId',
|
|
643
|
+
owner: 'la plantilla',
|
|
644
|
+
});
|
|
645
|
+
const templateId = templateProject.id;
|
|
646
|
+
const template = await client.readSecrets(templateId, { env: TEMPLATE_ENV, path: VAULT_ROOT_PATH }, { expandReferences: true });
|
|
647
|
+
// 2. Every client: found (and annotated) → read both deploy roots; missing → created only if
|
|
648
|
+
// targeted and not annotated.
|
|
649
|
+
const states = [];
|
|
650
|
+
for (const [index, deploy] of registry.deploys.entries()) {
|
|
651
|
+
const project = names.clients.get(deploy.slug);
|
|
652
|
+
const targeted = targetedSlug === null || targetedSlug === deploy.slug;
|
|
653
|
+
const found = await client.findProjectBySlug(project);
|
|
654
|
+
assertAnnotated({
|
|
655
|
+
found,
|
|
656
|
+
annotated: deploy.vault?.projectId,
|
|
657
|
+
project,
|
|
658
|
+
field: `deploys[${index}].vault.projectId`,
|
|
659
|
+
owner: `el despliegue \`${deploy.slug}\``,
|
|
660
|
+
});
|
|
661
|
+
if (!found) {
|
|
662
|
+
if (!targeted) {
|
|
663
|
+
throw new CLIError(`No puedo leer el proyecto \`${project}\` del despliegue \`${deploy.slug}\`: no existe todavía, o tu ` +
|
|
664
|
+
'sesión no tiene acceso a él. Sin leerlo no puedo comprobar que otro despliegue no copie sus valores.\n' +
|
|
665
|
+
` · Si no existe: corre sin \`--deploy\` (o con \`--deploy ${deploy.slug}\`) para crearlo.\n` +
|
|
666
|
+
' · Si existe: pide acceso a ese proyecto a un admin de la bóveda (el acceso es por proyecto).\n' +
|
|
667
|
+
'No se escribió nada.');
|
|
668
|
+
}
|
|
669
|
+
states.push({ deploy, index, project, projectId: null, stored: emptyEnvValues(), targeted });
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const stored = emptyEnvValues();
|
|
673
|
+
for (const env of DEPLOY_ENVS) {
|
|
674
|
+
stored[env] = await client.readSecrets(found.id, ROOT_OF[env], { expandReferences: true });
|
|
675
|
+
}
|
|
676
|
+
states.push({ deploy, index, project, projectId: found.id, stored, targeted });
|
|
677
|
+
}
|
|
678
|
+
// 3. The template must not contaminate anyone.
|
|
679
|
+
const ownValues = states.map((state) => {
|
|
680
|
+
const values = new Set();
|
|
681
|
+
for (const env of DEPLOY_ENVS) {
|
|
682
|
+
for (const key of state.deploy.ownKeys) {
|
|
683
|
+
const value = state.stored[env].get(key);
|
|
684
|
+
// Identical to the template's value in the same key = a copy an earlier run seeded.
|
|
685
|
+
if (value && value !== template.get(key))
|
|
686
|
+
values.add(value);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return { slug: state.deploy.slug, values };
|
|
690
|
+
});
|
|
691
|
+
const problems = findTemplateProblems(template, ownValues);
|
|
692
|
+
if (problems.length > 0) {
|
|
693
|
+
throw new CLIError(`La plantilla \`${names.template}\` tiene claves que no siembro a ningún despliegue:\n` +
|
|
694
|
+
problems
|
|
695
|
+
.map((p) => ` · \`${p.key}\`: ${TEMPLATE_REASON[p.reason]}${p.owner ? ` (\`${p.owner}\`)` : ''}`)
|
|
696
|
+
.join('\n') +
|
|
697
|
+
'\nQuítalas (o renómbralas) en la plantilla y repite. No se escribió nada.');
|
|
698
|
+
}
|
|
699
|
+
// 4. Compute every client (the non-targeted ones too: they are what the others compare with).
|
|
700
|
+
for (const state of states) {
|
|
701
|
+
state.computed = buildEnvironmentSets({
|
|
702
|
+
template,
|
|
703
|
+
stored: state.stored,
|
|
704
|
+
derived: deriveDeployValues(state.deploy, registry.bucketKey),
|
|
705
|
+
ownKeys: state.deploy.ownKeys,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
// 5. All-or-nothing: every targeted client against every other AND the principal, before the
|
|
709
|
+
// first write. A targeted client is compared by what it WILL hold (stored, overridden by the
|
|
710
|
+
// computed set); another targeted one likewise — a stale template copy the apply replaces is
|
|
711
|
+
// not its value. A client this run does not write is compared by what it holds AND what it
|
|
712
|
+
// would compute. The principal, by everything it holds (every environment, `/ci` included).
|
|
713
|
+
const principalValues = await readPrincipalValues(client, principal);
|
|
714
|
+
const resultingOf = (state) => {
|
|
715
|
+
const computed = state.computed;
|
|
716
|
+
const resulting = emptyEnvValues();
|
|
717
|
+
for (const env of DEPLOY_ENVS) {
|
|
718
|
+
resulting[env] = new Map([...state.stored[env], ...computed[env].values]);
|
|
719
|
+
}
|
|
720
|
+
return resulting;
|
|
721
|
+
};
|
|
722
|
+
const hits = [];
|
|
723
|
+
for (const state of states.filter((s) => s.targeted)) {
|
|
724
|
+
const resulting = resultingOf(state);
|
|
725
|
+
hits.push(...findForeignValues({
|
|
726
|
+
deploy: state.deploy.slug,
|
|
727
|
+
sets: resulting,
|
|
728
|
+
template,
|
|
729
|
+
others: [
|
|
730
|
+
...states
|
|
731
|
+
.filter((other) => other !== state)
|
|
732
|
+
.map((other) => ({
|
|
733
|
+
slug: other.deploy.slug,
|
|
734
|
+
values: other.targeted
|
|
735
|
+
? DEPLOY_ENVS.map((env) => resultingOf(other)[env])
|
|
736
|
+
: DEPLOY_ENVS.flatMap((env) => [
|
|
737
|
+
other.stored[env],
|
|
738
|
+
other.computed[env].values,
|
|
739
|
+
]),
|
|
740
|
+
identifiers: registryIdentifiers(other.deploy),
|
|
741
|
+
})),
|
|
742
|
+
{ slug: PRINCIPAL_OWNER, values: principalValues, identifiers: [] },
|
|
743
|
+
],
|
|
744
|
+
}));
|
|
745
|
+
}
|
|
746
|
+
if (hits.length > 0)
|
|
747
|
+
throw new ForeignValueError(hits);
|
|
748
|
+
// 6. The plan + the write guard judged for every planned write, still before any call.
|
|
749
|
+
const plans = [];
|
|
750
|
+
const writes = new Map();
|
|
751
|
+
for (const state of states.filter((s) => s.targeted)) {
|
|
752
|
+
const computed = state.computed;
|
|
753
|
+
const changes = [
|
|
754
|
+
...diffEnv(VAULT_ENV_MAIN, state.stored.main, computed.main),
|
|
755
|
+
...diffEnv(VAULT_ENV_DEVELOP, state.stored.develop, computed.develop, computed.main),
|
|
756
|
+
];
|
|
757
|
+
for (const change of changes) {
|
|
758
|
+
const location = normalizeLocation(ROOT_OF[change.environment]);
|
|
759
|
+
const value = change.as === 'referencia'
|
|
760
|
+
? `\${${VAULT_ENV_MAIN}.${change.key}}`
|
|
761
|
+
: computed[change.environment].values.get(change.key);
|
|
762
|
+
assertWriteAllowed(change.key, value, location);
|
|
763
|
+
}
|
|
764
|
+
const ownMissing = {};
|
|
765
|
+
const extra = {};
|
|
766
|
+
const folderKeys = {};
|
|
767
|
+
for (const env of DEPLOY_ENVS) {
|
|
768
|
+
extra[env] = extraKeys(state.stored[env], computed[env]);
|
|
769
|
+
ownMissing[env] = state.deploy.ownKeys.filter((key) => !computed[env].values.has(key)).sort();
|
|
770
|
+
folderKeys[env] = [
|
|
771
|
+
...new Set([...state.stored[env].keys(), ...computed[env].values.keys()]),
|
|
772
|
+
].sort();
|
|
773
|
+
}
|
|
774
|
+
plans.push({
|
|
775
|
+
slug: state.deploy.slug,
|
|
776
|
+
name: state.deploy.name,
|
|
777
|
+
project: state.project,
|
|
778
|
+
projectId: state.projectId,
|
|
779
|
+
create: state.projectId === null,
|
|
780
|
+
changes,
|
|
781
|
+
extra,
|
|
782
|
+
ownMissing,
|
|
783
|
+
folderKeys,
|
|
784
|
+
});
|
|
785
|
+
writes.set(state.deploy.slug, { changes, computed });
|
|
786
|
+
}
|
|
787
|
+
const plan = {
|
|
788
|
+
person: person.identity,
|
|
789
|
+
principal: { slug: principal.slug, id: principal.id },
|
|
790
|
+
template: { slug: names.template, id: templateId },
|
|
791
|
+
deploys: plans,
|
|
792
|
+
projects: [
|
|
793
|
+
{ role: PRINCIPAL_OWNER, slug: principal.slug, id: principal.id },
|
|
794
|
+
{ role: 'plantilla', slug: names.template, id: templateId },
|
|
795
|
+
...states
|
|
796
|
+
.filter((state) => state.projectId !== null)
|
|
797
|
+
.map((state) => ({
|
|
798
|
+
role: state.deploy.slug,
|
|
799
|
+
slug: state.project,
|
|
800
|
+
id: state.projectId,
|
|
801
|
+
})),
|
|
802
|
+
],
|
|
803
|
+
};
|
|
804
|
+
const apply = async () => {
|
|
805
|
+
const results = [];
|
|
806
|
+
// New projects first: if one cannot be created, only the projects created before it exist
|
|
807
|
+
// (their ids already annotated) and no value has been written.
|
|
808
|
+
const ordered = [...plans].sort((a, b) => Number(b.create) - Number(a.create));
|
|
809
|
+
const ids = new Map();
|
|
810
|
+
const createdSoFar = [];
|
|
811
|
+
for (const deploy of ordered) {
|
|
812
|
+
let layout;
|
|
813
|
+
try {
|
|
814
|
+
// `mustCreate`: a project that appears between the plan and this call is never adopted —
|
|
815
|
+
// the client stops on finding it, before any write on it (no layout, no members).
|
|
816
|
+
layout = await client.ensureProjectLayout({
|
|
817
|
+
name: deploy.project,
|
|
818
|
+
slug: deploy.project,
|
|
819
|
+
...(deploy.create ? { mustCreate: true } : {}),
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
if (error instanceof VaultApiError && error.kind === 'project-exists') {
|
|
824
|
+
throw new CLIError(`El proyecto \`${deploy.project}\` apareció en la bóveda entre el plan y la escritura. No lo adopto ` +
|
|
825
|
+
`sin su id anotado (no escribí nada en él): verifica que es de \`${deploy.slug}\`, anótalo y repite.` +
|
|
826
|
+
writtenBefore(createdSoFar));
|
|
827
|
+
}
|
|
828
|
+
if (deploy.create && error instanceof VaultApiError) {
|
|
829
|
+
throw new CLIError(`No pude crear \`${deploy.project}\` (el plan lo marcaba "se crea" porque tu sesión no lo ve). Dos ` +
|
|
830
|
+
'causas posibles:\n' +
|
|
831
|
+
' · Existe con ese slug y tu sesión no tiene acceso: pide acceso a un admin de la bóveda, ' +
|
|
832
|
+
`verifica que es de \`${deploy.slug}\` y anota su id en el registro.\n` +
|
|
833
|
+
` · No existe y la bóveda no dejó crearlo: ${error.message}` +
|
|
834
|
+
writtenBefore(createdSoFar));
|
|
835
|
+
}
|
|
836
|
+
throw error;
|
|
837
|
+
}
|
|
838
|
+
if (deploy.create !== layout.projectCreated ||
|
|
839
|
+
(!deploy.create && layout.project.id !== deploy.projectId)) {
|
|
840
|
+
throw new CLIError(`El proyecto \`${deploy.project}\` no es el que el plan leyó. Repite el comando para planear de nuevo.` +
|
|
841
|
+
writtenBefore(createdSoFar));
|
|
842
|
+
}
|
|
843
|
+
if (layout.projectCreated) {
|
|
844
|
+
const before = [...createdSoFar];
|
|
845
|
+
createdSoFar.push(deploy.project);
|
|
846
|
+
try {
|
|
847
|
+
input.onProjectCreated?.(deploy.slug, layout.project.id);
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
// The project exists and its id is nowhere else: hand it over to be annotated by hand
|
|
851
|
+
// (an id, never a secret). Without it the next `--apply` refuses to adopt the project.
|
|
852
|
+
const cause = error instanceof Error ? error.message : 'error desconocido';
|
|
853
|
+
throw new CLIError(`Creé el proyecto \`${deploy.project}\` del despliegue \`${deploy.slug}\` (id \`${layout.project.id}\`), ` +
|
|
854
|
+
`pero no pude anotar su id en \`${DEPLOYS_REGISTRY_FILE}\`: ${cause}\n` +
|
|
855
|
+
` · Anótalo a mano en ese archivo, en el despliegue \`${deploy.slug}\`: ` +
|
|
856
|
+
`"vault": { "projectId": "${layout.project.id}" } (es un id, no un secreto), y repite el \`--apply\`.\n` +
|
|
857
|
+
' · Sin ese id anotado, la corrida siguiente no adopta el proyecto (podría ser de otro cliente).' +
|
|
858
|
+
(before.length === 0 ? '\nNingún valor se escribió todavía.' : writtenBefore(before)));
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
ids.set(deploy.slug, { id: layout.project.id, created: layout.projectCreated });
|
|
862
|
+
}
|
|
863
|
+
for (const deploy of plans) {
|
|
864
|
+
const { id, created } = ids.get(deploy.slug);
|
|
865
|
+
const { changes, computed } = writes.get(deploy.slug);
|
|
866
|
+
const written = [];
|
|
867
|
+
for (const env of DEPLOY_ENVS) {
|
|
868
|
+
const values = {};
|
|
869
|
+
for (const change of changes) {
|
|
870
|
+
if (change.environment !== env || change.as !== 'valor')
|
|
871
|
+
continue;
|
|
872
|
+
values[change.key] = computed[env].values.get(change.key);
|
|
873
|
+
}
|
|
874
|
+
if (Object.keys(values).length > 0) {
|
|
875
|
+
await client.writeSecrets(id, ROOT_OF[env], values, 'upsert');
|
|
876
|
+
for (const key of Object.keys(values))
|
|
877
|
+
written.push({ environment: env, key });
|
|
878
|
+
}
|
|
879
|
+
for (const change of changes) {
|
|
880
|
+
if (change.environment !== env || change.as !== 'referencia')
|
|
881
|
+
continue;
|
|
882
|
+
await client.writeReference(id, ROOT_OF[env], change.key, { ...MAIN_ROOT, key: change.key }, 'upsert');
|
|
883
|
+
written.push({ environment: env, key: change.key });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
results.push({ slug: deploy.slug, project: deploy.project, projectId: id, created, written });
|
|
887
|
+
}
|
|
888
|
+
return results;
|
|
889
|
+
};
|
|
890
|
+
return { plan, apply };
|
|
891
|
+
}
|
|
892
|
+
/** What a stop inside the apply leaves behind — the projects it already created, by name. */
|
|
893
|
+
function writtenBefore(created) {
|
|
894
|
+
return created.length === 0
|
|
895
|
+
? '\nNo se escribió nada.'
|
|
896
|
+
: `\nAntes de esta falla se crearon ${created.map((p) => `\`${p}\``).join(', ')} (sus ids ya están ` +
|
|
897
|
+
`anotados en \`${DEPLOYS_REGISTRY_FILE}\`); ningún valor se escribió todavía. Al repetir, se adoptan.`;
|
|
898
|
+
}
|