@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,1495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADOPT-001 — the pieces of `factory vault adopt` that decide WHAT happens: the drift census of
|
|
3
|
+
* the files the wrapper runs, the inventory by name and non-reversible hash, the state of each
|
|
4
|
+
* key, the layout the apply writes (ADR-005), the confirmations it needs, the repo ↔ vault link
|
|
5
|
+
* it records and the retirement of `.env.local`. The command (`commands/vault.ts`) orchestrates
|
|
6
|
+
* them against the real services. Contract: `fx-secrets-vault §8`.
|
|
7
|
+
*
|
|
8
|
+
* 🔴 NO-ECHO. Values live in {@link InventoryValues} and in {@link PlannedWrite} — in memory, for
|
|
9
|
+
* the time of one run. Nothing that goes into the plan (`buildPlanEntries`) carries a value: only key
|
|
10
|
+
* NAMES, locations, and a display hash that cannot be reversed by dictionary (see
|
|
11
|
+
* {@link createDisplayHasher}). Errors name keys, never values.
|
|
12
|
+
*
|
|
13
|
+
* FIRST SYNC ON A LIVE DEPLOY (the user's answer, 2026-09-24): "importar primero; hacia GitHub,
|
|
14
|
+
* listar los nombres y detenerse antes de crear el sync si falta alguno en develop:/ci". So the
|
|
15
|
+
* layout below imports into the vault everything a deploy holds (a sync created afterwards finds
|
|
16
|
+
* the vault as a superset of its destination and deletes nothing), and GitHub — whose secrets are
|
|
17
|
+
* write-only — is compared by NAMES against `develop:/ci`.
|
|
18
|
+
*/
|
|
19
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { CLIError } from './cli-error.js';
|
|
23
|
+
import { isFactoryCheckout } from './initial-migration.js';
|
|
24
|
+
import { VAULT_WRAPPED_SCRIPTS, WITH_VAULT_SCRIPT_FILE } from './constants.js';
|
|
25
|
+
import { LOCAL_ONLY_KEYS } from './env-file.js';
|
|
26
|
+
import { normalizeThenHash } from './lockfile.js';
|
|
27
|
+
import { getNeonProjectOrg, listNeonProjects } from './neon-api.js';
|
|
28
|
+
import { detectCoreProfile, VAULT_POLICY_PATH } from './provision-preflight.js';
|
|
29
|
+
import { adoptState, buildVaultState, hasProvisionState, isProvisionStateUnreadable, PROVISION_DIR, PROVISION_STATE_FILE, provisionStatePath, readDestroyTarget, readProvisionTarget, readRailwayBlock, readVaultDeclined, readVaultLink, UNREADABLE_STATE_REMEDY, VAULT_DECLINED_KEY, } from './provision-state.js';
|
|
30
|
+
import { createRailResolver, RailError } from './rail.js';
|
|
31
|
+
import { connectedProjects, createRailwayClient, railwayFetchTransport, RailwaySeamMissingError, } from './railway-api.js';
|
|
32
|
+
import { createVaultClient, formatLocation, VAULT_CI_PATH, VAULT_ENV_DEVELOP, VAULT_ENV_LOCAL, VAULT_ENV_MAIN, VAULT_ROOT_PATH, VAULT_TEST_ISOLATION_KEY, VaultSeamMissingError, } from './vault-api.js';
|
|
33
|
+
import { listEnvVarsWithValues, locateVercelProject } from './vercel-env.js';
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Constants
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/** The developer machine's env file — the source of `local`, never of `main`/`develop`. */
|
|
38
|
+
export const ENV_LOCAL_FILE = '.env.local';
|
|
39
|
+
/** What `.env.local` is renamed to at the end of a successful `--apply` (`fx-secrets-vault §8`). */
|
|
40
|
+
export const ENV_LOCAL_BACKUP_FILE = '.env.local.pre-vault.bak';
|
|
41
|
+
/**
|
|
42
|
+
* Key of `.timekast/provision.json` that records a repo which DECLINED the vault. Lives in
|
|
43
|
+
* `provision-state.ts` with its reader (ADOPT-004); re-exported here, where ADOPT-001 put it.
|
|
44
|
+
*/
|
|
45
|
+
export { VAULT_DECLINED_KEY } from './provision-state.js';
|
|
46
|
+
/**
|
|
47
|
+
* 🔴 THE list of the keys that are never a project's (`fx-secrets-vault §5`): the rail's tokens and
|
|
48
|
+
* coordinates. Found in `.env.local` OR in a deploy they are NOT imported — the vault never holds a
|
|
49
|
+
* rail token (`fx-secrets-vault §2`) — whatever their value: an old or rotated copy is still not
|
|
50
|
+
* the project's. The sync created afterwards removes them from a deploy. Confirmed by whoever runs
|
|
51
|
+
* the adoption. `NEON_API_KEY` and `RESEND_API_KEY` are NOT here: each can be the project's own
|
|
52
|
+
* (the Neon key is imported only when proven project-scoped, the Resend team key is replaced).
|
|
53
|
+
* `SHORTIO_API_KEY` in Factory mode goes to `develop:/ci` with the RAIL's value (never a file's).
|
|
54
|
+
*/
|
|
55
|
+
export const RETIRED_RAIL_KEYS = [
|
|
56
|
+
'VERCEL_TOKEN',
|
|
57
|
+
'VERCEL_TEAM_ID',
|
|
58
|
+
'CLOUDFLARE_API_TOKEN',
|
|
59
|
+
'CLOUDFLARE_R2_TOKEN',
|
|
60
|
+
'RAILWAY_TOKEN',
|
|
61
|
+
'SHORTIO_API_KEY',
|
|
62
|
+
'GAMMA_API_KEY',
|
|
63
|
+
'BACKLOG_ADMIN_TOKEN',
|
|
64
|
+
'PROPOSAL_ASSETS_R2_TOKEN',
|
|
65
|
+
'NEON_ORG',
|
|
66
|
+
];
|
|
67
|
+
/** Rail families by prefix (`CLOUDFLARE_*`, `GAMMA_*`) — same list, for the names §5 groups. */
|
|
68
|
+
const RAIL_KEY_PREFIXES = ['CLOUDFLARE_', 'GAMMA_'];
|
|
69
|
+
/** Keys the kit retired and nothing reads anymore: never imported, named "retirada". */
|
|
70
|
+
export const RETIRED_UNREAD_KEYS = ['NEON_ORGANIZATION_ID'];
|
|
71
|
+
/** Whether `key` is a rail key ({@link RETIRED_RAIL_KEYS}, or one of its prefixed families). */
|
|
72
|
+
export function isRailKey(key) {
|
|
73
|
+
return RETIRED_RAIL_KEYS.includes(key) || RAIL_KEY_PREFIXES.some((p) => key.startsWith(p));
|
|
74
|
+
}
|
|
75
|
+
/** The URL shortener key — a rail token, except in Factory mode (its CI needs a copy). */
|
|
76
|
+
export const SHORTIO_KEY = 'SHORTIO_API_KEY';
|
|
77
|
+
/** The GitHub repo of the kit's origin — the second signal of Factory mode. */
|
|
78
|
+
export const FACTORY_REPO = 'TimeKast/TimeKast-Factory';
|
|
79
|
+
/** The Factory's own slug (`project-config.md §1`) — its vault project is named like this. */
|
|
80
|
+
export const FACTORY_SLUG = 'timekast-factory';
|
|
81
|
+
/**
|
|
82
|
+
* Whether the RESOLVED identity is the Factory's: slug `timekast-factory` AND repo
|
|
83
|
+
* `TimeKast/TimeKast-Factory`. Factory mode (two signals) is necessary but not enough to copy the
|
|
84
|
+
* rail's `SHORTIO_API_KEY`: a clone or a fork whose slug or repo resolves to anything else must not
|
|
85
|
+
* receive a rail token (`fx-secrets-vault §2`).
|
|
86
|
+
*/
|
|
87
|
+
export function isFactoryIdentity(slug, repo) {
|
|
88
|
+
return (slug.toLowerCase() === FACTORY_SLUG &&
|
|
89
|
+
repo !== null &&
|
|
90
|
+
repo.toLowerCase() === FACTORY_REPO.toLowerCase());
|
|
91
|
+
}
|
|
92
|
+
/** The Resend key: when a source holds the rail's TEAM key, the project's own replaces it. */
|
|
93
|
+
export const RESEND_KEY = 'RESEND_API_KEY';
|
|
94
|
+
/** The local-only key of `.env.local` that becomes `main:/ci`'s `DATABASE_URL` (Decision 3). */
|
|
95
|
+
export const DATABASE_URL_MAIN_KEY = 'DATABASE_URL_MAIN';
|
|
96
|
+
const DATABASE_URL_KEY = 'DATABASE_URL';
|
|
97
|
+
/** The keys CI reads from `develop:/ci` that come from `.env.local` (they are local-only). */
|
|
98
|
+
const CI_KEYS_FROM_LOCAL = ['NEON_API_KEY', 'NEON_PROJECT_ID'];
|
|
99
|
+
/** The project's Neon key — imported only when proven project-scoped. */
|
|
100
|
+
export const NEON_API_KEY = 'NEON_API_KEY';
|
|
101
|
+
/** Length (hex chars) of the display hash. */
|
|
102
|
+
export const DISPLAY_HASH_LENGTH = 12;
|
|
103
|
+
const MAIN_ROOT = { env: VAULT_ENV_MAIN, path: VAULT_ROOT_PATH };
|
|
104
|
+
const MAIN_CI = { env: VAULT_ENV_MAIN, path: VAULT_CI_PATH };
|
|
105
|
+
const DEVELOP_ROOT = { env: VAULT_ENV_DEVELOP, path: VAULT_ROOT_PATH };
|
|
106
|
+
const DEVELOP_CI = { env: VAULT_ENV_DEVELOP, path: VAULT_CI_PATH };
|
|
107
|
+
const LOCAL_ROOT = { env: VAULT_ENV_LOCAL, path: VAULT_ROOT_PATH };
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// The display hash
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
/**
|
|
112
|
+
* A hasher for the plan's display: HMAC-SHA256 keyed with 32 random bytes drawn ONCE per run,
|
|
113
|
+
* truncated to {@link DISPLAY_HASH_LENGTH} hex chars.
|
|
114
|
+
*
|
|
115
|
+
* Why not a plain SHA-256: a value of low entropy (`true`, a 4-digit PIN) would be recovered by
|
|
116
|
+
* hashing a small dictionary. With a key that never leaves the process's memory — not printed,
|
|
117
|
+
* not written, not exported — the same dictionary produces nothing comparable. Per run: two
|
|
118
|
+
* hashes are comparable WITHIN one plan (that is the point: equal/different at a glance) and never
|
|
119
|
+
* across runs, so a pasted plan does not become a lookup table over time. Truncated: the display
|
|
120
|
+
* only has to tell values apart within one plan; the state (`igual`/`distinta`) is decided on the
|
|
121
|
+
* values themselves, never on this string.
|
|
122
|
+
*/
|
|
123
|
+
export function createDisplayHasher() {
|
|
124
|
+
const key = randomBytes(32);
|
|
125
|
+
return (value) => createHmac('sha256', key).update(value, 'utf8').digest('hex').slice(0, DISPLAY_HASH_LENGTH);
|
|
126
|
+
}
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Precondition 2 — the drift census of what the wrapper runs
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
/** A repo path inside a script value (`scripts/tools/db-query.ts`, `src/lib/db/seed.ts`…). */
|
|
131
|
+
const SCRIPT_PATH_RE = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\.(?:m?[jt]s|c[jt]s)$/;
|
|
132
|
+
/**
|
|
133
|
+
* The files the wrapped scripts invoke — read off the SAME registry the wiring uses
|
|
134
|
+
* (`VAULT_WRAPPED_SCRIPTS`) — plus the wrapper itself and `vault.json`, which it reads. Only the
|
|
135
|
+
* ones the lockfile tracks can drift (`src/` never is), so the census filters by it.
|
|
136
|
+
*/
|
|
137
|
+
export function wrapperRunFiles() {
|
|
138
|
+
const files = new Set([WITH_VAULT_SCRIPT_FILE, VAULT_POLICY_PATH]);
|
|
139
|
+
for (const spec of Object.values(VAULT_WRAPPED_SCRIPTS)) {
|
|
140
|
+
for (const value of [spec.cmd, ...spec.unwrapped]) {
|
|
141
|
+
for (const token of value.split(/\s+/)) {
|
|
142
|
+
if (SCRIPT_PATH_RE.test(token))
|
|
143
|
+
files.add(token);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return [...files].sort();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The census of `factory update --verify`, restricted to {@link wrapperRunFiles}: every one the
|
|
151
|
+
* lockfile tracks that is missing on disk or differs from the registered hash (same normalized
|
|
152
|
+
* hash `update` uses). Empty = no fork in what the wrapper wraps.
|
|
153
|
+
*/
|
|
154
|
+
export function wrapperDrift(rootDir, lock) {
|
|
155
|
+
const wanted = new Set(wrapperRunFiles());
|
|
156
|
+
const drifted = [];
|
|
157
|
+
for (const entry of lock.files) {
|
|
158
|
+
if (!wanted.has(entry.path))
|
|
159
|
+
continue;
|
|
160
|
+
const abs = path.join(rootDir, entry.path);
|
|
161
|
+
if (!existsSync(abs)) {
|
|
162
|
+
drifted.push(entry.path);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (normalizeThenHash(readFileSync(abs, 'utf8')) !== entry.hash)
|
|
166
|
+
drifted.push(entry.path);
|
|
167
|
+
}
|
|
168
|
+
return drifted.sort();
|
|
169
|
+
}
|
|
170
|
+
/** Placeholder of a display source whose value cannot be read. */
|
|
171
|
+
export const UNREADABLE_MARK = 'ilegible';
|
|
172
|
+
/** Classify one key (see {@link InventoryState}). Decided on the VALUES, never on a hash. */
|
|
173
|
+
export function classifyKey(name, values) {
|
|
174
|
+
const deploy = [];
|
|
175
|
+
if (values.production.has(name))
|
|
176
|
+
deploy.push(values.production.get(name) ?? null);
|
|
177
|
+
if (values.preview.has(name))
|
|
178
|
+
deploy.push(values.preview.get(name) ?? null);
|
|
179
|
+
for (const source of values.railway.values()) {
|
|
180
|
+
if (source.has(name))
|
|
181
|
+
deploy.push(source.get(name));
|
|
182
|
+
}
|
|
183
|
+
if (deploy.some((value) => value === null))
|
|
184
|
+
return 'ilegible';
|
|
185
|
+
const local = values.local.get(name);
|
|
186
|
+
if (local === undefined)
|
|
187
|
+
return 'sólo en el deploy';
|
|
188
|
+
if (deploy.length === 0)
|
|
189
|
+
return 'sólo local';
|
|
190
|
+
return deploy.every((value) => value === local) ? 'igual' : 'distinta';
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The writes of the apply, IN ORDER (a reference is always written after its target):
|
|
194
|
+
*
|
|
195
|
+
* · `main:/` ← Vercel production (never `.env.local`).
|
|
196
|
+
* · `main:/ci` ← `DATABASE_URL_MAIN` of `.env.local`, renamed `DATABASE_URL` (no suffixes).
|
|
197
|
+
* · `develop:/` ← Vercel preview; a value equal to production's is a REFERENCE to `main:/`
|
|
198
|
+
* (one copy of each shared value, `fx-secrets-vault §7`).
|
|
199
|
+
* · `develop:/ci` ← the project's Neon key + id from `.env.local`; `DATABASE_URL` as a reference
|
|
200
|
+
* to `develop:/` (what provision writes).
|
|
201
|
+
* · `local` ← `.env.local`: a value equal to `main:/`'s is a reference there, one equal to
|
|
202
|
+
* `develop:/`'s own value a reference there, the rest its own. The Neon key and
|
|
203
|
+
* id arrive through the import of `develop:/ci`, so they are not repeated.
|
|
204
|
+
*
|
|
205
|
+
* Railway is not part of this layout: what only Railway holds is planned by
|
|
206
|
+
* {@link planRailwayImport} (ADOPT-002), one deploy root at a time, after these writes.
|
|
207
|
+
*/
|
|
208
|
+
export function planAdoptionLayout(values, options) {
|
|
209
|
+
const writes = [];
|
|
210
|
+
// A deploy never feeds a local-only key into `main:/`/`develop:/` (the guard would refuse it);
|
|
211
|
+
// `.env.local` still feeds it where it belongs (`develop:/ci`, `local`).
|
|
212
|
+
const skipDeploy = (key) => options.retired.has(key) || LOCAL_ONLY_KEYS.has(key);
|
|
213
|
+
const skip = (key) => options.retired.has(key) && !LOCAL_ONLY_KEYS.has(key);
|
|
214
|
+
const isResend = (key) => options.replaceResend && key === RESEND_KEY;
|
|
215
|
+
// main:/
|
|
216
|
+
const mainValues = new Map();
|
|
217
|
+
let mintedInMain = false;
|
|
218
|
+
const mintResend = (key) => {
|
|
219
|
+
if (mintedInMain)
|
|
220
|
+
return;
|
|
221
|
+
mintedInMain = true;
|
|
222
|
+
writes.push({ kind: 'minted-resend', location: MAIN_ROOT, key, origin: key });
|
|
223
|
+
};
|
|
224
|
+
for (const [key, value] of values.production) {
|
|
225
|
+
if (skipDeploy(key) || value === null)
|
|
226
|
+
continue;
|
|
227
|
+
if (isResend(key)) {
|
|
228
|
+
mintResend(key);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
mainValues.set(key, value);
|
|
232
|
+
writes.push({ kind: 'value', location: MAIN_ROOT, key, origin: key, value });
|
|
233
|
+
}
|
|
234
|
+
// main:/ci
|
|
235
|
+
const directMain = values.local.get(DATABASE_URL_MAIN_KEY);
|
|
236
|
+
if (directMain !== undefined && !skip(DATABASE_URL_MAIN_KEY)) {
|
|
237
|
+
writes.push({
|
|
238
|
+
kind: 'value',
|
|
239
|
+
location: MAIN_CI,
|
|
240
|
+
key: DATABASE_URL_KEY,
|
|
241
|
+
origin: DATABASE_URL_MAIN_KEY,
|
|
242
|
+
value: directMain,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// develop:/
|
|
246
|
+
const developOwn = new Map();
|
|
247
|
+
const developHas = new Set();
|
|
248
|
+
for (const [key, value] of values.preview) {
|
|
249
|
+
if (skipDeploy(key) || value === null)
|
|
250
|
+
continue;
|
|
251
|
+
developHas.add(key);
|
|
252
|
+
if (isResend(key) || mainValues.get(key) === value) {
|
|
253
|
+
// Preview holds the team key and production does not: the minted key goes to main first.
|
|
254
|
+
if (isResend(key))
|
|
255
|
+
mintResend(key);
|
|
256
|
+
writes.push({
|
|
257
|
+
kind: 'reference',
|
|
258
|
+
location: DEVELOP_ROOT,
|
|
259
|
+
key,
|
|
260
|
+
origin: key,
|
|
261
|
+
target: { ...MAIN_ROOT, key },
|
|
262
|
+
});
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
developOwn.set(key, value);
|
|
266
|
+
writes.push({ kind: 'value', location: DEVELOP_ROOT, key, origin: key, value });
|
|
267
|
+
}
|
|
268
|
+
// develop:/ci
|
|
269
|
+
for (const key of CI_KEYS_FROM_LOCAL) {
|
|
270
|
+
const value = values.local.get(key);
|
|
271
|
+
if (value === undefined || skip(key))
|
|
272
|
+
continue;
|
|
273
|
+
if (key === NEON_API_KEY && options.importLocalNeonKey === false)
|
|
274
|
+
continue;
|
|
275
|
+
writes.push({ kind: 'value', location: DEVELOP_CI, key, origin: key, value });
|
|
276
|
+
}
|
|
277
|
+
if (developHas.has(DATABASE_URL_KEY)) {
|
|
278
|
+
writes.push({
|
|
279
|
+
kind: 'reference',
|
|
280
|
+
location: DEVELOP_CI,
|
|
281
|
+
key: DATABASE_URL_KEY,
|
|
282
|
+
origin: DATABASE_URL_KEY,
|
|
283
|
+
target: { ...DEVELOP_ROOT, key: DATABASE_URL_KEY },
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
for (const write of options.extra ?? [])
|
|
287
|
+
writes.push(write);
|
|
288
|
+
// local
|
|
289
|
+
for (const [key, value] of values.local) {
|
|
290
|
+
if (skip(key) || key === DATABASE_URL_MAIN_KEY || CI_KEYS_FROM_LOCAL.includes(key))
|
|
291
|
+
continue;
|
|
292
|
+
if (isResend(key)) {
|
|
293
|
+
mintResend(key);
|
|
294
|
+
writes.push({
|
|
295
|
+
kind: 'reference',
|
|
296
|
+
location: LOCAL_ROOT,
|
|
297
|
+
key,
|
|
298
|
+
origin: key,
|
|
299
|
+
target: { ...MAIN_ROOT, key },
|
|
300
|
+
});
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (mainValues.get(key) === value) {
|
|
304
|
+
writes.push({
|
|
305
|
+
kind: 'reference',
|
|
306
|
+
location: LOCAL_ROOT,
|
|
307
|
+
key,
|
|
308
|
+
origin: key,
|
|
309
|
+
target: { ...MAIN_ROOT, key },
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (developOwn.get(key) === value) {
|
|
314
|
+
writes.push({
|
|
315
|
+
kind: 'reference',
|
|
316
|
+
location: LOCAL_ROOT,
|
|
317
|
+
key,
|
|
318
|
+
origin: key,
|
|
319
|
+
target: { ...DEVELOP_ROOT, key },
|
|
320
|
+
});
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
writes.push({ kind: 'value', location: LOCAL_ROOT, key, origin: key, value });
|
|
324
|
+
}
|
|
325
|
+
return writes;
|
|
326
|
+
}
|
|
327
|
+
/** How a planned write is named in the plan — never with its value. */
|
|
328
|
+
export function describeWrite(write) {
|
|
329
|
+
const where = formatLocation(write.location);
|
|
330
|
+
const renamed = write.key !== write.origin ? ` como \`${write.key}\`` : '';
|
|
331
|
+
if (write.kind === 'reference') {
|
|
332
|
+
return `${where}${renamed} (referencia a ${formatLocation(write.target)})`;
|
|
333
|
+
}
|
|
334
|
+
if (write.kind === 'minted-resend')
|
|
335
|
+
return `${where} (key propia del proyecto, nueva)`;
|
|
336
|
+
return `${where}${renamed}`;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Build the entries of the plan (one per key, sorted), the confirmations the apply needs and the
|
|
340
|
+
* blockers that stop it. Pure: values in, NAMES and hashes out.
|
|
341
|
+
*/
|
|
342
|
+
export function buildPlanEntries(input) {
|
|
343
|
+
const { values, writes, retired, hash } = input;
|
|
344
|
+
const names = new Set([
|
|
345
|
+
...values.local.keys(),
|
|
346
|
+
...values.production.keys(),
|
|
347
|
+
...values.preview.keys(),
|
|
348
|
+
]);
|
|
349
|
+
for (const source of values.railway.values())
|
|
350
|
+
for (const key of source.keys())
|
|
351
|
+
names.add(key);
|
|
352
|
+
const display = (value) => value === undefined ? undefined : value === null ? UNREADABLE_MARK : hash(value);
|
|
353
|
+
const entries = [];
|
|
354
|
+
const confirmations = [];
|
|
355
|
+
const unreadable = [];
|
|
356
|
+
for (const name of [...names].sort()) {
|
|
357
|
+
const state = classifyKey(name, values);
|
|
358
|
+
const railway = {};
|
|
359
|
+
for (const [label, source] of values.railway) {
|
|
360
|
+
if (source.has(name))
|
|
361
|
+
railway[label] = hash(source.get(name));
|
|
362
|
+
}
|
|
363
|
+
const sources = {
|
|
364
|
+
...(values.local.has(name) ? { local: display(values.local.get(name)) } : {}),
|
|
365
|
+
...(values.production.has(name) ? { production: display(values.production.get(name)) } : {}),
|
|
366
|
+
...(values.preview.has(name) ? { preview: display(values.preview.get(name)) } : {}),
|
|
367
|
+
...(Object.keys(railway).length > 0 ? { railway } : {}),
|
|
368
|
+
};
|
|
369
|
+
const targets = writes.filter((w) => w.origin === name).map(describeWrite);
|
|
370
|
+
const entry = { name, state, sources, targets };
|
|
371
|
+
if (retired.has(name)) {
|
|
372
|
+
entry.action = 'retirar';
|
|
373
|
+
entry.note =
|
|
374
|
+
input.notes?.get(name) ??
|
|
375
|
+
(RETIRED_UNREAD_KEYS.includes(name)
|
|
376
|
+
? 'retirada — no se importa: ningún lector del kit la usa'
|
|
377
|
+
: isRailKey(name)
|
|
378
|
+
? 'token del rail: no se importa a la bóveda (con huella igual o distinta a la del rail: ' +
|
|
379
|
+
'una copia no es del proyecto) y el sync lo quita del despliegue'
|
|
380
|
+
: 'clave sólo-local en un despliegue: no se importa ahí y el sync la quita del despliegue');
|
|
381
|
+
}
|
|
382
|
+
else if (input.notes?.has(name)) {
|
|
383
|
+
entry.note = input.notes.get(name);
|
|
384
|
+
}
|
|
385
|
+
else if (input.replaceResend && name === RESEND_KEY) {
|
|
386
|
+
entry.action = 'reemplazar';
|
|
387
|
+
entry.note =
|
|
388
|
+
'es la key de equipo del rail: se acuña una propia del proyecto (sólo envío, limitada a su dominio)';
|
|
389
|
+
}
|
|
390
|
+
else if (name === DATABASE_URL_MAIN_KEY && targets.length > 0) {
|
|
391
|
+
entry.action = 'renombrar';
|
|
392
|
+
entry.note =
|
|
393
|
+
'sin sufijo de entorno: la cadena directa de main vive en main:/ci como DATABASE_URL';
|
|
394
|
+
}
|
|
395
|
+
else if (targets.length === 0 && state !== 'ilegible') {
|
|
396
|
+
entry.note = isRailwayProvided(name)
|
|
397
|
+
? 'variable propia de Railway: no se importa ni la borra el sync'
|
|
398
|
+
: 'sólo en Railway, en un entorno sin igual en la bóveda: no se importa aquí; ' +
|
|
399
|
+
'`factory vault sync railway --railway-env <nombre>` la importa y crea su sync';
|
|
400
|
+
}
|
|
401
|
+
entries.push(entry);
|
|
402
|
+
if (state === 'ilegible') {
|
|
403
|
+
unreadable.push(name);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (entry.action === 'retirar') {
|
|
407
|
+
confirmations.push({
|
|
408
|
+
name,
|
|
409
|
+
question: `\`${name}\` (${entry.note}). ¿Confirmas que no se importa?`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
else if (entry.action === 'reemplazar') {
|
|
413
|
+
confirmations.push({
|
|
414
|
+
name,
|
|
415
|
+
question: `\`${name}\` ${entry.note}. ¿Confirmas el reemplazo?`,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
else if (targets.length > 0 && (state !== 'igual' || entry.action === 'renombrar')) {
|
|
419
|
+
confirmations.push({
|
|
420
|
+
name,
|
|
421
|
+
question: `\`${name}\` (${state}) → ${targets.join(', ')}. ¿Confirmas?`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return { entries, confirmations, unreadable };
|
|
426
|
+
}
|
|
427
|
+
/** Read the raw state object (either shape), or `null` without a file. Fail-closed when unreadable. */
|
|
428
|
+
function readRawState(rootDir) {
|
|
429
|
+
if (!hasProvisionState(rootDir))
|
|
430
|
+
return null;
|
|
431
|
+
if (isProvisionStateUnreadable(rootDir)) {
|
|
432
|
+
throw new CLIError(`No puedo leer \`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`: no guardo el vínculo con la bóveda ` +
|
|
433
|
+
`encima. ${UNREADABLE_STATE_REMEDY}`);
|
|
434
|
+
}
|
|
435
|
+
return JSON.parse(readFileSync(provisionStatePath(rootDir), 'utf8'));
|
|
436
|
+
}
|
|
437
|
+
/** Atomic write (temp file + rename), same discipline as `writeProvisionState`. */
|
|
438
|
+
function writeRawState(rootDir, state, what = 'el vínculo con la bóveda') {
|
|
439
|
+
const dir = path.join(rootDir, PROVISION_DIR);
|
|
440
|
+
mkdirSync(dir, { recursive: true });
|
|
441
|
+
const target = provisionStatePath(rootDir);
|
|
442
|
+
const tmp = path.join(dir, `.${PROVISION_STATE_FILE}.vault-adopt.tmp`);
|
|
443
|
+
try {
|
|
444
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
445
|
+
renameSync(tmp, target);
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
rmSync(tmp, { force: true });
|
|
449
|
+
throw new CLIError(`No se pudo guardar ${what} en \`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`: ` +
|
|
450
|
+
`${error?.message ?? 'error desconocido'}.`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Record the repo ↔ vault link in `.timekast/provision.json` — the shape ADOPT-004 (declined)
|
|
455
|
+
* and ADOPT-009 (the Factory, lifecycle `live`, without `provision --adopt`) reuse:
|
|
456
|
+
*
|
|
457
|
+
* · The file EXISTS (step-by-step or adopted shape): only the `vault` block is set — every other
|
|
458
|
+
* key stays byte-for-byte what it was — and a {@link VAULT_DECLINED_KEY} mark is removed.
|
|
459
|
+
* · There is NO file (a derivative born before `provision`): the file is born in the ADOPTED
|
|
460
|
+
* shape, the one `factory provision --adopt` writes (`adoptState`, `lifecycle: 'live'`), with
|
|
461
|
+
* what the adoption knows (slug, Vercel project, repo) and `unknown` for the rest, plus the
|
|
462
|
+
* `vault` block. Every reader already accepts that shape (`readDestroyTarget`,
|
|
463
|
+
* `readVaultLink`, the wrapper), `provision --adopt --force` completes the `unknown` ids later
|
|
464
|
+
* and CARRIES the `vault` block, and `lifecycle: 'live'` keeps `--destroy` refusing. A
|
|
465
|
+
* step-shaped file with only a `vault` block was the alternative, and `readProvisionState`
|
|
466
|
+
* would read it as corrupt.
|
|
467
|
+
*/
|
|
468
|
+
export function writeAdoptionLink(rootDir, input) {
|
|
469
|
+
let raw = readRawState(rootDir);
|
|
470
|
+
if (raw === null) {
|
|
471
|
+
adoptState(rootDir, {
|
|
472
|
+
slug: input.slug,
|
|
473
|
+
...(input.vercelProjectId ? { vercelProjectId: input.vercelProjectId } : {}),
|
|
474
|
+
...(input.githubRepo ? { githubRepo: input.githubRepo } : {}),
|
|
475
|
+
...(input.neonProjectId ? { neonProjectId: input.neonProjectId } : {}),
|
|
476
|
+
}, input.now ? { now: input.now } : {});
|
|
477
|
+
raw = readRawState(rootDir);
|
|
478
|
+
}
|
|
479
|
+
// A re-run CARRIES every field of the block that is not the link's own — above all
|
|
480
|
+
// `neonRevokePending` (ADOPT-008): dropping it would leave a replaced key alive with nobody to
|
|
481
|
+
// revoke it.
|
|
482
|
+
const previous = raw.vault !== null && typeof raw.vault === 'object' && !Array.isArray(raw.vault)
|
|
483
|
+
? raw.vault
|
|
484
|
+
: {};
|
|
485
|
+
const link = {
|
|
486
|
+
...previous,
|
|
487
|
+
...buildVaultState(input.projectId, { done: true }),
|
|
488
|
+
...(input.vercelProjectId ? { vercelProjectId: input.vercelProjectId } : {}),
|
|
489
|
+
};
|
|
490
|
+
raw.vault = link;
|
|
491
|
+
if (input.resend)
|
|
492
|
+
raw.resend = mergeResendBlock(raw.resend, input.resend);
|
|
493
|
+
delete raw[VAULT_DECLINED_KEY];
|
|
494
|
+
writeRawState(rootDir, raw);
|
|
495
|
+
return link;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The `resend` block after the adoption minted `next`: the key it REPLACES is never forgotten. The
|
|
499
|
+
* previous `apiKeyId` (when it is another key) and the previous `pendingRevokeIds` join `next`'s
|
|
500
|
+
* pending list — dropping them would leave live keys nobody can revoke by id. A previous delivery
|
|
501
|
+
* marker is NOT carried: it was taken before an older write, so a job after it proves nothing about
|
|
502
|
+
* the new key; without one, the settle takes the marker then (`settleResendRevokes`).
|
|
503
|
+
*/
|
|
504
|
+
export function mergeResendBlock(previous, next) {
|
|
505
|
+
const prev = previous !== null && typeof previous === 'object' && !Array.isArray(previous)
|
|
506
|
+
? previous
|
|
507
|
+
: {};
|
|
508
|
+
const ids = [
|
|
509
|
+
...(Array.isArray(prev.pendingRevokeIds) ? prev.pendingRevokeIds : []),
|
|
510
|
+
...(typeof prev.apiKeyId === 'string' && prev.apiKeyId !== '' ? [prev.apiKeyId] : []),
|
|
511
|
+
...(next.pendingRevokeIds ?? []),
|
|
512
|
+
].filter((id) => typeof id === 'string' && id !== '' && id !== next.apiKeyId);
|
|
513
|
+
const pending = [...new Set(ids)];
|
|
514
|
+
const merged = { apiKeyId: next.apiKeyId, domain: next.domain };
|
|
515
|
+
if (pending.length > 0) {
|
|
516
|
+
merged.pendingRevokeIds = pending;
|
|
517
|
+
if (next.syncJobsAtPublish)
|
|
518
|
+
merged.syncJobsAtPublish = next.syncJobsAtPublish;
|
|
519
|
+
}
|
|
520
|
+
return merged;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* ADOPT-004 — record that the repo DECLINES the vault, in the shape ADOPT-001 chose for the link
|
|
524
|
+
* (see {@link writeAdoptionLink}), and touch nothing else: no `.env.local`, no `package.json`, no
|
|
525
|
+
* network. Local only by construction — this function has no seam to reach a service.
|
|
526
|
+
*
|
|
527
|
+
* · The repo is already in the vault (a well-formed `vault` block) → refuses, writes nothing.
|
|
528
|
+
* · The mark is already there → nothing is written: a second `--decline` leaves the file
|
|
529
|
+
* byte-for-byte as it was (the first timestamp stays).
|
|
530
|
+
* · The file EXISTS (either shape) → only the {@link VAULT_DECLINED_KEY} key is added; every
|
|
531
|
+
* other key stays what it was.
|
|
532
|
+
* · There is NO file → it is born in the ADOPTED shape (`adoptState`, `lifecycle: 'live'`) with
|
|
533
|
+
* the slug and `unknown` for every provider id — the declining run asks no provider — plus
|
|
534
|
+
* the mark, and no `vault` block. `provision --adopt --force` completes the ids later and
|
|
535
|
+
* carries the mark.
|
|
536
|
+
*
|
|
537
|
+
* Fail-closed: an unreadable file and a malformed `vault` block stop it (never "no vault").
|
|
538
|
+
*
|
|
539
|
+
* @throws {CLIError} when the repo is adopted, or the state cannot be read or written.
|
|
540
|
+
*/
|
|
541
|
+
export function writeDeclineMark(rootDir, input) {
|
|
542
|
+
const now = input.now ?? (() => new Date());
|
|
543
|
+
let raw = readRawState(rootDir);
|
|
544
|
+
const born = raw === null;
|
|
545
|
+
if (raw !== null) {
|
|
546
|
+
// Throws on a malformed block (VaultLinkMalformedError) — never read as "no vault".
|
|
547
|
+
if (readVaultLink(rootDir) !== null) {
|
|
548
|
+
throw new CLIError('Este repo ya está en la bóveda (tiene bloque `vault` en ' +
|
|
549
|
+
`\`${PROVISION_DIR}/${PROVISION_STATE_FILE}\`): no hay nada que declinar. No escribí nada.`);
|
|
550
|
+
}
|
|
551
|
+
if (Object.prototype.hasOwnProperty.call(raw, VAULT_DECLINED_KEY))
|
|
552
|
+
return 'already-declined';
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
adoptState(rootDir, { slug: input.slug }, { now });
|
|
556
|
+
raw = readRawState(rootDir);
|
|
557
|
+
}
|
|
558
|
+
const mark = { at: now().toISOString() };
|
|
559
|
+
raw[VAULT_DECLINED_KEY] = mark;
|
|
560
|
+
try {
|
|
561
|
+
writeRawState(rootDir, raw, 'que el repo declinó la bóveda');
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
// A file this run created without its mark would read as "undecided": take it back.
|
|
565
|
+
if (born)
|
|
566
|
+
rmSync(provisionStatePath(rootDir), { force: true });
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
return 'declined';
|
|
570
|
+
}
|
|
571
|
+
/** The Vercel project id a previous adoption recorded inside the `vault` block, if any. */
|
|
572
|
+
export function recordedAdoptionVercelId(vault) {
|
|
573
|
+
const id = vault?.vercelProjectId;
|
|
574
|
+
return typeof id === 'string' && id.trim() !== '' ? id.trim() : null;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Rename `.env.local` to {@link ENV_LOCAL_BACKUP_FILE} — the LAST step of the apply. Refuses when
|
|
578
|
+
* the backup already exists (never overwritten) or when git would track it: a derivative's
|
|
579
|
+
* `.gitignore` is born frozen and may not cover it, and a secret in the history is permanent.
|
|
580
|
+
*/
|
|
581
|
+
export async function retireEnvLocal(rootDir, isGitIgnored) {
|
|
582
|
+
const source = path.join(rootDir, ENV_LOCAL_FILE);
|
|
583
|
+
if (!existsSync(source))
|
|
584
|
+
return 'absent';
|
|
585
|
+
const backup = path.join(rootDir, ENV_LOCAL_BACKUP_FILE);
|
|
586
|
+
if (existsSync(backup))
|
|
587
|
+
return 'backup-exists';
|
|
588
|
+
if (!(await isGitIgnored(ENV_LOCAL_BACKUP_FILE)))
|
|
589
|
+
return 'backup-not-ignored';
|
|
590
|
+
renameSync(source, backup);
|
|
591
|
+
return 'renamed';
|
|
592
|
+
}
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
// Railway — inventory, import and sync (API GraphQL with the rail's team token)
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
/** Railway's public GraphQL endpoint — re-exported from the client (RWAY-002), its only caller. */
|
|
597
|
+
export { RAILWAY_GRAPHQL_URL } from './railway-api.js';
|
|
598
|
+
/**
|
|
599
|
+
* Prefix of the variables Railway provides by itself to every service (`RAILWAY_ENVIRONMENT`,
|
|
600
|
+
* `RAILWAY_PUBLIC_DOMAIN`, …). They are the platform's, not the project's: never imported into the
|
|
601
|
+
* vault — `main:/` also feeds Vercel production, where they would be false — and never counted as
|
|
602
|
+
* something a sync would delete.
|
|
603
|
+
*/
|
|
604
|
+
export const RAILWAY_PROVIDED_PREFIX = 'RAILWAY_';
|
|
605
|
+
/** Whether `key` is one of the variables Railway provides by itself. */
|
|
606
|
+
export function isRailwayProvided(key) {
|
|
607
|
+
return key.startsWith(RAILWAY_PROVIDED_PREFIX);
|
|
608
|
+
}
|
|
609
|
+
/** The Railway client over `fetchImpl` with an already-read team token. */
|
|
610
|
+
function railwayClientOver(token, fetchImpl) {
|
|
611
|
+
return createRailwayClient({ token, transport: railwayFetchTransport(fetchImpl) });
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Every Railway project the TEAM token reaches, with its services and environments — through the
|
|
615
|
+
* client (RWAY-002). This is the token's proof of life too: a query over projects, NEVER `me`
|
|
616
|
+
* (`fx-secrets-vault §5`). Fail-closed: a missing list, or one with unread pages, throws.
|
|
617
|
+
*/
|
|
618
|
+
export async function listRailwayProjects(token, fetchImpl) {
|
|
619
|
+
return (await railwayClientOver(token, fetchImpl).listProjects()).map((p) => ({
|
|
620
|
+
id: p.id,
|
|
621
|
+
name: p.name,
|
|
622
|
+
services: p.services.map((s) => ({ id: s.id, name: s.name })),
|
|
623
|
+
environments: p.environments,
|
|
624
|
+
}));
|
|
625
|
+
}
|
|
626
|
+
/** The variables of one service in one environment — values in memory only (import first). */
|
|
627
|
+
export async function readRailwayVariables(token, where, fetchImpl) {
|
|
628
|
+
return railwayClientOver(token, fetchImpl).readVariableValues(where);
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Inventory the Railway project CONNECTED TO THE REPO (Decision 13: the platform decides the
|
|
632
|
+
* destination; a project's name need not match the repo): each service × environment with its
|
|
633
|
+
* variables, values in memory. `null` when no service is connected to `repo` — not an error: most
|
|
634
|
+
* repos only deploy to Vercel. 🔴 Two or more connected projects are several deploys of one code:
|
|
635
|
+
* `vault adopt` stops BEFORE writing anything and points at the several-deploys pattern — it never
|
|
636
|
+
* imports values of different deploys into one vault (`fx-secrets-vault §6`).
|
|
637
|
+
*/
|
|
638
|
+
export async function readRailwayInventory(repo, token, fetchImpl) {
|
|
639
|
+
const client = railwayClientOver(token, fetchImpl);
|
|
640
|
+
const matches = await client.findByConnectedRepo(repo);
|
|
641
|
+
const projects = connectedProjects(matches);
|
|
642
|
+
if (projects.length > 1) {
|
|
643
|
+
throw new CLIError(`Hay ${projects.length} proyectos de Railway conectados a \`${repo}\`: ` +
|
|
644
|
+
`${projects.map((p) => `\`${p.name}\` (${p.id})`).join(', ')}. Es un repo con varios despliegues: ` +
|
|
645
|
+
'`vault adopt` no importa a una sola bóveda valores de despliegues distintos.\n' +
|
|
646
|
+
' · Cada despliegue es su propio proyecto de la bóveda, con una plantilla y el registro ' +
|
|
647
|
+
'`.timekast/deploys.json`: se operan con `factory vault deploys` (fx-secrets-vault §6).\n' +
|
|
648
|
+
'No hice nada.');
|
|
649
|
+
}
|
|
650
|
+
const [connected] = projects;
|
|
651
|
+
if (!connected)
|
|
652
|
+
return null;
|
|
653
|
+
const project = await client.getProject(connected.id);
|
|
654
|
+
if (!project) {
|
|
655
|
+
throw new CLIError(`Railway dejó de encontrar el proyecto \`${connected.name}\` (${connected.id}) entre la búsqueda y la ` +
|
|
656
|
+
'lectura. Repite. No hice nada.');
|
|
657
|
+
}
|
|
658
|
+
const sources = [];
|
|
659
|
+
for (const service of project.services) {
|
|
660
|
+
for (const environment of project.environments) {
|
|
661
|
+
const values = await client.readVariableValues({
|
|
662
|
+
projectId: project.id,
|
|
663
|
+
environmentId: environment.id,
|
|
664
|
+
serviceId: service.id,
|
|
665
|
+
serviceName: service.name,
|
|
666
|
+
});
|
|
667
|
+
sources.push({
|
|
668
|
+
label: `${service.name}@${environment.name}`,
|
|
669
|
+
service: { id: service.id, name: service.name },
|
|
670
|
+
environment,
|
|
671
|
+
values,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return { project: { id: project.id, name: project.name }, sources };
|
|
676
|
+
}
|
|
677
|
+
/** The vault environments a Railway sync may read from (their root folder). */
|
|
678
|
+
export const RAILWAY_VAULT_ENVS = [VAULT_ENV_MAIN, VAULT_ENV_DEVELOP];
|
|
679
|
+
/**
|
|
680
|
+
* The Railway syncs `vault adopt` proposes (ADOPT-002): one per service × environment whose
|
|
681
|
+
* Railway environment has the SAME NAME as a vault deploy environment (`main`, `develop`) —
|
|
682
|
+
* "cada entorno a su igual en Railway". A Railway environment with another name (`production`) is
|
|
683
|
+
* never mapped by guessing: it is returned in `unmapped`, and the plan points at
|
|
684
|
+
* `factory vault sync railway --railway-env <nombre>` (the user's answer, 2026-09-24).
|
|
685
|
+
*/
|
|
686
|
+
export function proposeRailwaySyncs(inventory) {
|
|
687
|
+
const proposals = [];
|
|
688
|
+
const unmapped = [];
|
|
689
|
+
for (const source of inventory.sources) {
|
|
690
|
+
const vaultEnv = RAILWAY_VAULT_ENVS.find((env) => env === source.environment.name);
|
|
691
|
+
if (vaultEnv)
|
|
692
|
+
proposals.push({ vaultEnv, source });
|
|
693
|
+
else
|
|
694
|
+
unmapped.push(source);
|
|
695
|
+
}
|
|
696
|
+
return { proposals, unmapped };
|
|
697
|
+
}
|
|
698
|
+
/** How a Railway sync is named in plans, questions and messages. */
|
|
699
|
+
export function railwaySyncLabel(vaultEnv, source) {
|
|
700
|
+
return `${vaultEnv}:/ → Railway ${source.label}`;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* The name a created Railway sync gets: `<slug>-railway-<env>-<service>`, lowercase, only
|
|
704
|
+
* `[a-z0-9-]` (the instance takes slug-shaped names). The service's NAME, not its id: readable in
|
|
705
|
+
* the vault's list, and the id stays in the destination config.
|
|
706
|
+
*/
|
|
707
|
+
export function railwaySyncName(slug, vaultEnv, serviceName) {
|
|
708
|
+
const service = serviceName
|
|
709
|
+
.toLowerCase()
|
|
710
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
711
|
+
.replace(/^-+|-+$/g, '') || 'service';
|
|
712
|
+
return `${slug}-railway-${vaultEnv}-${service}`;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Plan the import of Railway into one deploy root (`main:/` or `develop:/`) — the user's answer
|
|
716
|
+
* for the first sync over a live destination (2026-09-24): "importar primero". Pure: values in,
|
|
717
|
+
* writes and key NAMES out.
|
|
718
|
+
*
|
|
719
|
+
* · A key the folder does not hold → written there (a reference to `main:/` when the folder is
|
|
720
|
+
* `develop:/` and `main:/` holds the same value — one copy of each shared value).
|
|
721
|
+
* · A key the folder holds with the same value → nothing.
|
|
722
|
+
* · With another value → `differing` (the vault stays the single writer; the sync replaces
|
|
723
|
+
* Railway's value — the person confirms it).
|
|
724
|
+
* · A local-only key or a retired rail token → `removed` (the guard refuses it in a deploy root;
|
|
725
|
+
* the sync removes it from Railway — the person confirms it).
|
|
726
|
+
* · Railway's own `RAILWAY_*` variables → ignored ({@link isRailwayProvided}).
|
|
727
|
+
*
|
|
728
|
+
* `folder` is what the folder holds (or will hold) with references RESOLVED; `mainFolder` the same
|
|
729
|
+
* for `main:/` when `location` is `develop:/`.
|
|
730
|
+
*/
|
|
731
|
+
export function planRailwayImport(input) {
|
|
732
|
+
const plan = { writes: [], differing: [], removed: [], conflicting: [] };
|
|
733
|
+
const seen = new Map();
|
|
734
|
+
const conflicting = new Set();
|
|
735
|
+
for (const source of input.sources) {
|
|
736
|
+
for (const [key, value] of source) {
|
|
737
|
+
if (isRailwayProvided(key))
|
|
738
|
+
continue;
|
|
739
|
+
const before = seen.get(key);
|
|
740
|
+
if (before !== undefined && before !== value)
|
|
741
|
+
conflicting.add(key);
|
|
742
|
+
if (before === undefined)
|
|
743
|
+
seen.set(key, value);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const develop = input.location.env === VAULT_ENV_DEVELOP;
|
|
747
|
+
for (const key of [...seen.keys()].sort()) {
|
|
748
|
+
const value = seen.get(key);
|
|
749
|
+
if (input.retired.has(key) || LOCAL_ONLY_KEYS.has(key)) {
|
|
750
|
+
plan.removed.push(key);
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (conflicting.has(key)) {
|
|
754
|
+
plan.conflicting.push(key);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const current = input.folder.get(key);
|
|
758
|
+
if (current !== undefined) {
|
|
759
|
+
if (current !== value)
|
|
760
|
+
plan.differing.push(key);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (develop && input.mainFolder?.get(key) === value) {
|
|
764
|
+
plan.writes.push({
|
|
765
|
+
kind: 'reference',
|
|
766
|
+
location: input.location,
|
|
767
|
+
key,
|
|
768
|
+
origin: key,
|
|
769
|
+
target: { ...MAIN_ROOT, key },
|
|
770
|
+
});
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
plan.writes.push({ kind: 'value', location: input.location, key, origin: key, value });
|
|
774
|
+
}
|
|
775
|
+
return plan;
|
|
776
|
+
}
|
|
777
|
+
/** Whether `sync` writes to this Railway service × environment (any source folder). */
|
|
778
|
+
export function isRailwaySyncTo(sync, where) {
|
|
779
|
+
const config = sync.destinationConfig;
|
|
780
|
+
return (sync.destination === 'railway' &&
|
|
781
|
+
config.projectId === where.projectId &&
|
|
782
|
+
config.environmentId === where.environmentId &&
|
|
783
|
+
config.serviceId === where.serviceId);
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Why an existing Railway sync of this destination is NOT the expected one — empty when it can be
|
|
787
|
+
* adopted as is. Same strictness as the repo syncs (ADR-005 §8): its source folder, deletion in the
|
|
788
|
+
* destination ON, auto-sync ON, no key schema. Never patched: named, and the run stops.
|
|
789
|
+
*/
|
|
790
|
+
export function railwaySyncDifferences(sync, expected) {
|
|
791
|
+
const differences = [];
|
|
792
|
+
if (!sync.source || sync.source.env !== expected.env || sync.source.path !== expected.path) {
|
|
793
|
+
differences.push(`su fuente es \`${sync.source ? formatLocation(sync.source) : 'desconocida'}\` ` +
|
|
794
|
+
`(espero \`${formatLocation(expected)}\`)`);
|
|
795
|
+
}
|
|
796
|
+
if (sync.disableSecretDeletion) {
|
|
797
|
+
differences.push('tiene desactivado el borrado en destino (espero activado)');
|
|
798
|
+
}
|
|
799
|
+
if (sync.isAutoSyncEnabled !== true) {
|
|
800
|
+
differences.push('no tiene el auto-sync encendido (espero encendido)');
|
|
801
|
+
}
|
|
802
|
+
if (sync.keySchema !== null && sync.keySchema !== '' && sync.keySchema !== '{{secretKey}}') {
|
|
803
|
+
differences.push(`renombra las claves con el esquema \`${sync.keySchema}\` (espero sin esquema)`);
|
|
804
|
+
}
|
|
805
|
+
return differences;
|
|
806
|
+
}
|
|
807
|
+
// ---------------------------------------------------------------------------
|
|
808
|
+
// Local-only keys found in a deploy
|
|
809
|
+
// ---------------------------------------------------------------------------
|
|
810
|
+
/**
|
|
811
|
+
* The keys to retire: every rail key ({@link isRailKey}) and every retired key nothing reads
|
|
812
|
+
* ({@link RETIRED_UNREAD_KEYS}) found in any source — `.env.local` or a deploy — and every
|
|
813
|
+
* local-only key (`LOCAL_ONLY_KEYS`) found in a Vercel or Railway deploy — which the vault's write guard
|
|
814
|
+
* would refuse in `main:/`/`develop:/` anyway, and which must never reach a deploy (ADR-005).
|
|
815
|
+
*/
|
|
816
|
+
export function retiredKeys(values) {
|
|
817
|
+
const retired = new Set();
|
|
818
|
+
const everywhere = [values.local, values.production, values.preview, ...values.railway.values()];
|
|
819
|
+
for (const source of everywhere) {
|
|
820
|
+
for (const key of source.keys()) {
|
|
821
|
+
if (isRailKey(key) || RETIRED_UNREAD_KEYS.includes(key))
|
|
822
|
+
retired.add(key);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
for (const key of LOCAL_ONLY_KEYS) {
|
|
826
|
+
if (values.production.has(key) || values.preview.has(key))
|
|
827
|
+
retired.add(key);
|
|
828
|
+
// ADOPT-002: a Railway sync reads a deploy root too, where the guard refuses these.
|
|
829
|
+
if ([...values.railway.values()].some((source) => source.has(key)))
|
|
830
|
+
retired.add(key);
|
|
831
|
+
}
|
|
832
|
+
return retired;
|
|
833
|
+
}
|
|
834
|
+
const SEALED_REASON = 'cifra datos guardados: una llave nueva los deja ilegibles para siempre';
|
|
835
|
+
/**
|
|
836
|
+
* 🔴 THE table of the kit — the only place that says which `sensitive` var belongs to which class.
|
|
837
|
+
* A key NOT here is `desconocida` (asked; without an answer, sealed), except the encryption-key
|
|
838
|
+
* shapes of {@link SEALED_KEY_PATTERN}, which are sealed without asking.
|
|
839
|
+
*/
|
|
840
|
+
export const SENSITIVE_KEY_TABLE = {
|
|
841
|
+
DATABASE_URL: {
|
|
842
|
+
class: 'derivable',
|
|
843
|
+
derive: 'neon-pooled',
|
|
844
|
+
source: 'la API de Neon (con `NEON_API_KEY` del rail): la cadena pooled del branch de ese entorno',
|
|
845
|
+
},
|
|
846
|
+
NEXT_PUBLIC_APP_URL: {
|
|
847
|
+
class: 'derivable',
|
|
848
|
+
derive: 'app-url',
|
|
849
|
+
source: 'el dominio de ese entorno en Vercel',
|
|
850
|
+
},
|
|
851
|
+
WEBAUTHN_RP_ID: {
|
|
852
|
+
class: 'derivable',
|
|
853
|
+
derive: 'rp-id',
|
|
854
|
+
source: 'el dominio de producción en Vercel (el rpID es uno solo y no cambia)',
|
|
855
|
+
},
|
|
856
|
+
WEBAUTHN_RELATED_ORIGINS: {
|
|
857
|
+
class: 'derivable',
|
|
858
|
+
derive: 'related-origins',
|
|
859
|
+
source: 'el dominio de develop en Vercel',
|
|
860
|
+
},
|
|
861
|
+
AUTH_SECRET: {
|
|
862
|
+
class: 'rotable',
|
|
863
|
+
rotate: 'auth-secret',
|
|
864
|
+
effect: 'cierra todas las sesiones de ese entorno en cuanto el sync lo escriba',
|
|
865
|
+
},
|
|
866
|
+
AUTH_GOOGLE_SECRET: {
|
|
867
|
+
class: 'rotable',
|
|
868
|
+
rotate: 'provider',
|
|
869
|
+
effect: 'se reemite en la consola de Google; el secreto viejo deja de servir',
|
|
870
|
+
},
|
|
871
|
+
AUTH_GITHUB_SECRET: {
|
|
872
|
+
class: 'rotable',
|
|
873
|
+
rotate: 'provider',
|
|
874
|
+
effect: 'se reemite en la configuración de la OAuth App de GitHub; el secreto viejo deja de servir',
|
|
875
|
+
},
|
|
876
|
+
RESEND_API_KEY: {
|
|
877
|
+
class: 'rotable',
|
|
878
|
+
rotate: 'provider',
|
|
879
|
+
effect: 'se reemite en Resend (una key propia del proyecto, sólo envío); la vieja deja de servir',
|
|
880
|
+
},
|
|
881
|
+
MFA_ENCRYPTION_KEY: { class: 'sellada', reason: SEALED_REASON },
|
|
882
|
+
};
|
|
883
|
+
/**
|
|
884
|
+
* The shape of an encryption key the kit does not list (`LEGACY_CIPHER_KEY`,
|
|
885
|
+
* `DATA_ENCRYPTION_KEY`…): sealed WITHOUT asking — asking could only end in the one answer that
|
|
886
|
+
* destroys data.
|
|
887
|
+
*/
|
|
888
|
+
export const SEALED_KEY_PATTERN = /(ENCRYPT|CIPHER|CRYPT_KEY|_KEK$|SEAL)/;
|
|
889
|
+
/**
|
|
890
|
+
* Classify one `sensitive` var — pure, on `{ key, type, target }`. The target is part of the
|
|
891
|
+
* contract (the same key may be sensitive in one environment and readable in another: each is
|
|
892
|
+
* classified where it is sensitive); the class depends on the key. A var that is NOT `sensitive`
|
|
893
|
+
* has no verdict here (`null`): it is read, or it blocks as unreadable.
|
|
894
|
+
*/
|
|
895
|
+
export function classifySensitive(input) {
|
|
896
|
+
if (input.type !== 'sensitive')
|
|
897
|
+
return null;
|
|
898
|
+
const known = SENSITIVE_KEY_TABLE[input.key];
|
|
899
|
+
if (known)
|
|
900
|
+
return known;
|
|
901
|
+
if (SEALED_KEY_PATTERN.test(input.key))
|
|
902
|
+
return { class: 'sellada', reason: SEALED_REASON };
|
|
903
|
+
return { class: 'desconocida' };
|
|
904
|
+
}
|
|
905
|
+
/** The vault folder a Vercel target is imported into (ADR-005 §1). */
|
|
906
|
+
export function folderOfTarget(target) {
|
|
907
|
+
return target === 'production' ? MAIN_ROOT : DEVELOP_ROOT;
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* The text `adopt` prints when it stops on a sealed key — written for the developer's AGENT to
|
|
911
|
+
* follow: (1) which var, (2) why it cannot be regenerated, (3) the bridge deploy, (4) that a person
|
|
912
|
+
* authorizes and supervises it step by step, (5) that `vault adopt` is run again afterwards.
|
|
913
|
+
*/
|
|
914
|
+
export function sealedStopExplanation(sealed, slug, vaultProjectExists = true) {
|
|
915
|
+
const lines = [
|
|
916
|
+
'🔴 Me detengo sin escribir nada: hay llaves SELLADAS marcadas como sensitive en Vercel, que no ' +
|
|
917
|
+
'devuelve su valor, y que no están en la bóveda.',
|
|
918
|
+
];
|
|
919
|
+
for (const { name, target } of sealed) {
|
|
920
|
+
const folder = formatLocation(folderOfTarget(target));
|
|
921
|
+
lines.push('', `\`${name}\` (Vercel ${target}):`, ` 1. Variable: \`${name}\`, en Vercel ${target}; su lugar en la bóveda es \`${folder}\` del proyecto \`${slug}\`.`, ' 2. Por qué no la regenero: cifra datos ya guardados (por ejemplo, los secretos de MFA de tus ' +
|
|
922
|
+
'usuarios). Una llave nueva los deja ilegibles para siempre, sin forma de volver atrás.', ` 3. Cómo se recupera: con un deploy puente — un build único de este proyecto en Vercel ${target} ` +
|
|
923
|
+
`que lee \`${name}\` y la escribe directo a \`${folder}\` de la bóveda, sin imprimirla en ningún log.`, ' 4. Ese deploy lo autoriza y lo supervisa paso a paso una persona: ni este comando ni un agente ' +
|
|
924
|
+
'lo disparan solos.', ' 5. Cuando la llave esté en la bóveda, vuelve a correr `factory vault adopt --apply`: la reconoce ' +
|
|
925
|
+
'y continúa sin detenerse.');
|
|
926
|
+
}
|
|
927
|
+
if (!vaultProjectExists) {
|
|
928
|
+
lines.push('', manualVaultProjectSteps(slug, sealed.map(({ target }) => formatLocation(folderOfTarget(target)))));
|
|
929
|
+
}
|
|
930
|
+
lines.push('', 'Nunca se genera una llave nueva para destrabar la adopción (fx-secrets-vault §8, paso 4).');
|
|
931
|
+
return lines.join('\n');
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* What to do when a key must be loaded into the vault BY HAND (a sealed key's bridge deploy, a
|
|
935
|
+
* credential re-issued at its provider) and the repo's vault project does not exist yet — the SAME
|
|
936
|
+
* text for both stops, concrete: create it with the slug and your own session, with the `main`,
|
|
937
|
+
* `develop` and `local` environments and the destination folder of the key; the later
|
|
938
|
+
* `factory vault adopt --apply` adopts it by slug and completes members, deletion protection and
|
|
939
|
+
* the import. (`provision` is not the way here: the repo is already deployed.)
|
|
940
|
+
*/
|
|
941
|
+
export function manualVaultProjectSteps(slug, folders) {
|
|
942
|
+
const unique = [...new Set(folders)].map((folder) => `\`${folder}\``).join(', ');
|
|
943
|
+
return (`El proyecto \`${slug}\` todavía no existe en la bóveda. Para cargar la clave a mano, créalo tú en ` +
|
|
944
|
+
`la bóveda, con tu sesión: nombre \`${slug}\`, entornos \`${VAULT_ENV_MAIN}\`, \`${VAULT_ENV_DEVELOP}\` y ` +
|
|
945
|
+
`\`${VAULT_ENV_LOCAL}\`, y la carpeta destino de la clave (${unique}). Después, ` +
|
|
946
|
+
'`factory vault adopt --apply` lo adopta por su slug y completa lo demás: miembros, protección ' +
|
|
947
|
+
'contra borrado e import.');
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Whether the vault is OFFERED in this repo — the same rule as `vault adopt` (ADOPT-001): not in
|
|
951
|
+
* `core`, detected by the lockfile's `profile: 'core'` OR by the absence of the wrapper
|
|
952
|
+
* (`scripts/tools/with-vault.mjs`), never by the absence of `package.json`.
|
|
953
|
+
*/
|
|
954
|
+
export function isVaultOffered(rootDir) {
|
|
955
|
+
return (detectCoreProfile(rootDir) === null && existsSync(path.join(rootDir, WITH_VAULT_SCRIPT_FILE)));
|
|
956
|
+
}
|
|
957
|
+
/** The mode from its three facts — pure. A link wins over a stale mark; a mark wins over `core`. */
|
|
958
|
+
export function vaultModeOf(input) {
|
|
959
|
+
if (input.linked)
|
|
960
|
+
return 'bóveda';
|
|
961
|
+
if (input.declined)
|
|
962
|
+
return 'declinado';
|
|
963
|
+
if (!input.offered)
|
|
964
|
+
return 'no aplica';
|
|
965
|
+
return 'sin decidir';
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Read the mode — local, synchronous, no network. A `provision.json` that cannot be read, or whose
|
|
969
|
+
* `vault` block is malformed, gives NO mode (never an invented one): `error` carries the same
|
|
970
|
+
* message every other reader gives, with its remedy.
|
|
971
|
+
*/
|
|
972
|
+
export function readVaultMode(rootDir) {
|
|
973
|
+
let link;
|
|
974
|
+
let declined;
|
|
975
|
+
try {
|
|
976
|
+
link = readVaultLink(rootDir);
|
|
977
|
+
declined = readVaultDeclined(rootDir);
|
|
978
|
+
}
|
|
979
|
+
catch (error) {
|
|
980
|
+
return {
|
|
981
|
+
mode: null,
|
|
982
|
+
link: null,
|
|
983
|
+
error: error instanceof CLIError
|
|
984
|
+
? error.message
|
|
985
|
+
: 'No pude leer `.timekast/provision.json` (error interno).',
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
mode: vaultModeOf({ linked: link !== null, declined, offered: isVaultOffered(rootDir) }),
|
|
990
|
+
link,
|
|
991
|
+
error: null,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
/** The Vercel target each deploy environment of the vault syncs to (ADR-005 §1). */
|
|
995
|
+
const DRIFT_PAIRS = [
|
|
996
|
+
{ env: 'main', target: 'production' },
|
|
997
|
+
{ env: 'develop', target: 'preview' },
|
|
998
|
+
];
|
|
999
|
+
/**
|
|
1000
|
+
* What a Vercel repo's report says about Railway: not compared there. A repo whose destination IS
|
|
1001
|
+
* Railway gets the `railway` form of the report instead (RWAY-006).
|
|
1002
|
+
*/
|
|
1003
|
+
export const DRIFT_RAILWAY_NOT_VERIFIED = 'no verificado';
|
|
1004
|
+
/**
|
|
1005
|
+
* Compare one vault folder (references RESOLVED — what its sync delivers) with its Vercel target.
|
|
1006
|
+
* Pure: values in, key NAMES + display hashes out. A `null` deploy value is unreadable.
|
|
1007
|
+
*/
|
|
1008
|
+
export function classifyDrift(input) {
|
|
1009
|
+
const { vault, deploy, hash } = input;
|
|
1010
|
+
const keys = [...new Set([...vault.keys(), ...deploy.keys()])].sort();
|
|
1011
|
+
return keys.map((key) => {
|
|
1012
|
+
const inVault = vault.has(key);
|
|
1013
|
+
const inDeploy = deploy.has(key);
|
|
1014
|
+
const deployValue = deploy.get(key);
|
|
1015
|
+
let state;
|
|
1016
|
+
if (inDeploy && deployValue === null)
|
|
1017
|
+
state = 'ilegible';
|
|
1018
|
+
else if (!inDeploy)
|
|
1019
|
+
state = 'sólo en la bóveda';
|
|
1020
|
+
else if (!inVault)
|
|
1021
|
+
state = 'sólo en el deploy';
|
|
1022
|
+
else
|
|
1023
|
+
state = vault.get(key) === deployValue ? 'igual' : 'distinta';
|
|
1024
|
+
return {
|
|
1025
|
+
key,
|
|
1026
|
+
env: input.env,
|
|
1027
|
+
target: input.target,
|
|
1028
|
+
state,
|
|
1029
|
+
...(inVault ? { vault: hash(vault.get(key)) } : {}),
|
|
1030
|
+
...(inDeploy
|
|
1031
|
+
? { deploy: deployValue === null ? UNREADABLE_MARK : hash(deployValue) }
|
|
1032
|
+
: {}),
|
|
1033
|
+
};
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
/** Time budget of the whole drift (vault + rail + Vercel) in `doctor`/`status`. */
|
|
1037
|
+
export const DRIFT_TIMEOUT_MS = 20_000;
|
|
1038
|
+
/**
|
|
1039
|
+
* Where there is never a vault session (CI, a Vercel build, Railway): the drift is not even
|
|
1040
|
+
* attempted — reported `no verificable`, which is informative and never a warning, so it does not
|
|
1041
|
+
* dirty every CI run (the wrapper passes through on the same signals, `fx-secrets-vault §7`).
|
|
1042
|
+
*/
|
|
1043
|
+
export const DRIFT_SKIP_SIGNALS = ['CI', 'VERCEL', 'RAILWAY_ENVIRONMENT'];
|
|
1044
|
+
/** RWAY-006 — the saved destination is Railway (an unreadable file reads as "not Railway"). */
|
|
1045
|
+
function isRailwayDestination(rootDir) {
|
|
1046
|
+
try {
|
|
1047
|
+
return readProvisionTarget(rootDir) === 'railway';
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/** The seams that reach a session, the network or the environment — REQUIRED under Vitest. */
|
|
1054
|
+
const REQUIRED_DRIFT_SEAMS = [
|
|
1055
|
+
'vaultClient',
|
|
1056
|
+
'rail',
|
|
1057
|
+
'fetchImpl',
|
|
1058
|
+
'env',
|
|
1059
|
+
];
|
|
1060
|
+
/** Why the drift could not be verified — the failure's own message (rail/vault/Vercel), never a value. */
|
|
1061
|
+
function driftFailureReason(error) {
|
|
1062
|
+
if (error instanceof RailError || error instanceof CLIError)
|
|
1063
|
+
return error.message;
|
|
1064
|
+
return 'No pude verificar el drift (error interno al leer la bóveda o Vercel).';
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* The drift between the vault and Vercel, by display hash (ADOPT-004), for a repo in the vault:
|
|
1068
|
+
* `main:/` against production and `develop:/` against preview, key by key. Reads only — the vault
|
|
1069
|
+
* with the person's session, Vercel with the rail's token; the project is found with the SAME
|
|
1070
|
+
* localizer `vault adopt` uses (recorded id first, then the project connected to the repo).
|
|
1071
|
+
*
|
|
1072
|
+
* Never throws for a missing session, network or project: those are `no verificable` with the
|
|
1073
|
+
* failure's own message (the rail's names the login). Bounded by `timeoutMs`. The one exception is
|
|
1074
|
+
* the test-isolation refusal: under Vitest, a call without its seams throws BEFORE any request.
|
|
1075
|
+
*/
|
|
1076
|
+
export async function computeVaultDrift(rootDir, link, deps = {}) {
|
|
1077
|
+
const railway = isRailwayDestination(rootDir);
|
|
1078
|
+
if (globalThis[VAULT_TEST_ISOLATION_KEY] !== undefined) {
|
|
1079
|
+
for (const seam of REQUIRED_DRIFT_SEAMS) {
|
|
1080
|
+
if (deps[seam] === undefined)
|
|
1081
|
+
throw new VaultSeamMissingError(seam);
|
|
1082
|
+
}
|
|
1083
|
+
if (railway && !deps.railwayClient && !deps.railwayTransport) {
|
|
1084
|
+
throw new RailwaySeamMissingError();
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
// The same failure, in the shape of the destination's report.
|
|
1088
|
+
const unverifiable = (reason) => railway
|
|
1089
|
+
? { status: 'railway', railway: { status: 'no verificable', reason } }
|
|
1090
|
+
: { status: 'no verificable', reason, railway: DRIFT_RAILWAY_NOT_VERIFIED };
|
|
1091
|
+
const env = deps.env ?? process.env;
|
|
1092
|
+
const signal = DRIFT_SKIP_SIGNALS.find((name) => env[name] !== undefined && env[name] !== '');
|
|
1093
|
+
if (signal) {
|
|
1094
|
+
return unverifiable(`aquí no hay sesión de la bóveda (\`${signal}\` está definida: CI o un despliegue). Corre el reporte en tu máquina.`);
|
|
1095
|
+
}
|
|
1096
|
+
const timeoutMs = deps.timeoutMs ?? DRIFT_TIMEOUT_MS;
|
|
1097
|
+
const platform = railway ? 'Railway' : 'Vercel';
|
|
1098
|
+
let timer;
|
|
1099
|
+
const timeout = new Promise((resolve) => {
|
|
1100
|
+
timer = setTimeout(() => resolve(unverifiable(`la bóveda o ${platform} no respondieron en ${Math.round(timeoutMs / 1000)} s. Revisa tu conexión y repite.`)), timeoutMs);
|
|
1101
|
+
timer.unref?.();
|
|
1102
|
+
});
|
|
1103
|
+
const work = (railway
|
|
1104
|
+
? readRailwayDrift(rootDir, link, deps, timeoutMs)
|
|
1105
|
+
: readDrift(rootDir, link, deps, timeoutMs)).catch((error) => unverifiable(driftFailureReason(error)));
|
|
1106
|
+
try {
|
|
1107
|
+
return await Promise.race([work, timeout]);
|
|
1108
|
+
}
|
|
1109
|
+
finally {
|
|
1110
|
+
if (timer)
|
|
1111
|
+
clearTimeout(timer);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
async function readDrift(rootDir, link, deps, timeoutMs) {
|
|
1115
|
+
const rail = deps.rail ?? createRailResolver({ timeoutMs });
|
|
1116
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
1117
|
+
const token = await rail.get('VERCEL_TOKEN');
|
|
1118
|
+
const teamId = await rail.getOptional('VERCEL_TEAM_ID');
|
|
1119
|
+
if (!teamId) {
|
|
1120
|
+
return {
|
|
1121
|
+
status: 'no verificable',
|
|
1122
|
+
reason: 'falta `VERCEL_TEAM_ID` en `rail-timekast`: no sé en qué team de Vercel buscar. Pide a un admin que la cargue.',
|
|
1123
|
+
railway: DRIFT_RAILWAY_NOT_VERIFIED,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
const target = readDestroyTarget(rootDir);
|
|
1127
|
+
const repo = target?.githubRepo ?? null;
|
|
1128
|
+
const location = await locateVercelProject({
|
|
1129
|
+
recordedId: recordedAdoptionVercelId(link) ?? target?.vercelProjectId ?? null,
|
|
1130
|
+
repo,
|
|
1131
|
+
token,
|
|
1132
|
+
teamId,
|
|
1133
|
+
deps: { fetchImpl },
|
|
1134
|
+
});
|
|
1135
|
+
if (location.kind === 'none') {
|
|
1136
|
+
return {
|
|
1137
|
+
status: 'no verificable',
|
|
1138
|
+
reason: 'este repo no tiene proyecto de Vercel: ni registrado en `.timekast/provision.json` ni conectado ' +
|
|
1139
|
+
`a \`${repo ?? '(repo sin registrar)'}\`. Sólo se compara la bóveda con Vercel.`,
|
|
1140
|
+
railway: DRIFT_RAILWAY_NOT_VERIFIED,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
if (location.kind === 'ambiguous') {
|
|
1144
|
+
return {
|
|
1145
|
+
status: 'no verificable',
|
|
1146
|
+
reason: `hay ${location.candidates.length} proyectos de Vercel conectados a \`${repo}\` ` +
|
|
1147
|
+
`(${location.candidates.map((p) => `\`${p.name}\``).join(', ')}) y no elijo uno a ciegas. ` +
|
|
1148
|
+
'Un repo con varios despliegues se opera con `factory vault deploys` (fx-secrets-vault §6).',
|
|
1149
|
+
railway: DRIFT_RAILWAY_NOT_VERIFIED,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
const project = location.project;
|
|
1153
|
+
const client = deps.vaultClient ?? createVaultClient({ timeoutMs });
|
|
1154
|
+
const vars = await listEnvVarsWithValues(project.id, token, teamId, { fetchImpl });
|
|
1155
|
+
const hash = deps.hash ?? createDisplayHasher();
|
|
1156
|
+
const entries = [];
|
|
1157
|
+
for (const pair of DRIFT_PAIRS) {
|
|
1158
|
+
const deploy = new Map();
|
|
1159
|
+
for (const v of vars) {
|
|
1160
|
+
// A preview var pinned to a git branch is not `develop:/`'s destination: not compared.
|
|
1161
|
+
if (v.target.includes(pair.target) && !(pair.target === 'preview' && v.gitBranch)) {
|
|
1162
|
+
deploy.set(v.key, v.value);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const vaultEnv = pair.env === 'main' ? link.envs.main : link.envs.develop;
|
|
1166
|
+
const vault = await client.readSecrets(link.projectId, { env: vaultEnv, path: VAULT_ROOT_PATH }, { expandReferences: true });
|
|
1167
|
+
entries.push(...classifyDrift({ env: pair.env, target: pair.target, vault, deploy, hash }));
|
|
1168
|
+
}
|
|
1169
|
+
return {
|
|
1170
|
+
status: 'verificado',
|
|
1171
|
+
vercelProject: { id: project.id, name: project.name },
|
|
1172
|
+
entries,
|
|
1173
|
+
railway: DRIFT_RAILWAY_NOT_VERIFIED,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
/** The four drift states from two maps of display hashes (Railway returns every value). */
|
|
1177
|
+
function compareHashes(vault, deploy) {
|
|
1178
|
+
const keys = [...new Set([...vault.keys(), ...deploy.keys()])].sort();
|
|
1179
|
+
return keys.map((key) => {
|
|
1180
|
+
const v = vault.get(key);
|
|
1181
|
+
const d = deploy.get(key);
|
|
1182
|
+
const state = v === undefined
|
|
1183
|
+
? 'sólo en el deploy'
|
|
1184
|
+
: d === undefined
|
|
1185
|
+
? 'sólo en la bóveda'
|
|
1186
|
+
: v === d
|
|
1187
|
+
? 'igual'
|
|
1188
|
+
: 'distinta';
|
|
1189
|
+
return {
|
|
1190
|
+
key,
|
|
1191
|
+
state,
|
|
1192
|
+
...(v !== undefined ? { vault: v } : {}),
|
|
1193
|
+
...(d !== undefined ? { deploy: d } : {}),
|
|
1194
|
+
};
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* RWAY-006 — the drift between the vault and Railway, by display hash: `main:/` against the
|
|
1199
|
+
* environment the block maps as `main`, `develop:/` against `develop`. It reads ONLY the ids of the
|
|
1200
|
+
* `railway` block (project, service, environments) — never a search by connected repo (a repo that
|
|
1201
|
+
* today is connected elsewhere must not move the comparison). Without a block → "no verificable,
|
|
1202
|
+
* corre `--adopt`" without asking Railway. Railway returns the VALUES: each one is reduced to its
|
|
1203
|
+
* display hash by the client as it arrives, and only hashes reach the report. `PORT` is compared like
|
|
1204
|
+
* any key (the vault writes it); only the platform's `RAILWAY_*` are left out.
|
|
1205
|
+
*/
|
|
1206
|
+
async function readRailwayDrift(rootDir, link, deps, timeoutMs) {
|
|
1207
|
+
const unverifiable = (reason) => ({
|
|
1208
|
+
status: 'railway',
|
|
1209
|
+
railway: { status: 'no verificable', reason },
|
|
1210
|
+
});
|
|
1211
|
+
const rw = readRailwayBlock(rootDir);
|
|
1212
|
+
const projectId = rw?.projectId;
|
|
1213
|
+
const serviceId = rw?.serviceId;
|
|
1214
|
+
if (!rw || !projectId || !serviceId || !rw.environments?.main) {
|
|
1215
|
+
return unverifiable('`.timekast/provision.json` no tiene el bloque `railway` con el proyecto, el ' +
|
|
1216
|
+
'servicio y el entorno `main`, y no busco el proyecto por mi cuenta. Corre ' +
|
|
1217
|
+
'`factory provision --adopt --force --target=railway` (sin `--force` si el repo todavía no ' +
|
|
1218
|
+
'tiene ese archivo).');
|
|
1219
|
+
}
|
|
1220
|
+
const rail = deps.rail ?? createRailResolver({ timeoutMs });
|
|
1221
|
+
const client = deps.railwayClient ??
|
|
1222
|
+
createRailwayClient({
|
|
1223
|
+
rail,
|
|
1224
|
+
...(deps.railwayTransport ? { transport: deps.railwayTransport } : {}),
|
|
1225
|
+
});
|
|
1226
|
+
const redo = 'No busco otro: rehaz el bloque con `factory provision --adopt --force --target=railway`.';
|
|
1227
|
+
const project = await client.getProject(projectId);
|
|
1228
|
+
if (!project) {
|
|
1229
|
+
return unverifiable(`Railway no tiene el proyecto \`${projectId}\` que registra el bloque \`railway\`. ${redo}`);
|
|
1230
|
+
}
|
|
1231
|
+
const service = project.services.find((s) => s.id === serviceId);
|
|
1232
|
+
if (!service) {
|
|
1233
|
+
return unverifiable(`el proyecto de Railway \`${project.name}\` ya no tiene el servicio \`${serviceId}\` que registra el bloque \`railway\`. ${redo}`);
|
|
1234
|
+
}
|
|
1235
|
+
const vaultClient = deps.vaultClient ?? createVaultClient({ timeoutMs });
|
|
1236
|
+
const hash = deps.hash ?? createDisplayHasher();
|
|
1237
|
+
const entries = [];
|
|
1238
|
+
const notes = [];
|
|
1239
|
+
for (const role of ['main', 'develop']) {
|
|
1240
|
+
const environmentId = rw.environments[role];
|
|
1241
|
+
if (!environmentId) {
|
|
1242
|
+
notes.push('sin entorno `develop` mapeado en el bloque `railway`: sólo se compara `main`.');
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
const environment = project.environments.find((e) => e.id === environmentId);
|
|
1246
|
+
if (!environment) {
|
|
1247
|
+
return unverifiable(`el proyecto de Railway \`${project.name}\` ya no tiene el entorno \`${environmentId}\` que el bloque mapea como \`${role}\`. ${redo}`);
|
|
1248
|
+
}
|
|
1249
|
+
const deployed = await client.readVariableHashes({ projectId, environmentId, serviceId, serviceName: service.name }, hash);
|
|
1250
|
+
const deploy = new Map([...deployed].filter(([key]) => !isRailwayProvided(key)));
|
|
1251
|
+
const values = await vaultClient.readSecrets(link.projectId, { env: role === 'main' ? link.envs.main : link.envs.develop, path: VAULT_ROOT_PATH }, { expandReferences: true });
|
|
1252
|
+
const vault = new Map([...values].map(([key, value]) => [key, hash(value)]));
|
|
1253
|
+
for (const entry of compareHashes(vault, deploy)) {
|
|
1254
|
+
entries.push({ ...entry, env: role, railwayEnv: environment.name });
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return {
|
|
1258
|
+
status: 'railway',
|
|
1259
|
+
railway: {
|
|
1260
|
+
status: 'verificado',
|
|
1261
|
+
project: { id: project.id, name: project.name },
|
|
1262
|
+
service: { id: service.id, name: service.name },
|
|
1263
|
+
entries,
|
|
1264
|
+
notes,
|
|
1265
|
+
},
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
/** The text of the Railway drift (RWAY-006) — names and display hashes only. */
|
|
1269
|
+
function renderRailwayDrift(drift) {
|
|
1270
|
+
const lines = [];
|
|
1271
|
+
if (drift.status === 'no verificable') {
|
|
1272
|
+
const [first, ...rest] = drift.reason.split('\n');
|
|
1273
|
+
lines.push(` Drift bóveda ↔ Railway: no verificable — ${first}`);
|
|
1274
|
+
for (const extra of rest)
|
|
1275
|
+
if (extra.trim())
|
|
1276
|
+
lines.push(` ${extra}`);
|
|
1277
|
+
}
|
|
1278
|
+
else {
|
|
1279
|
+
const off = drift.entries.filter((e) => e.state !== 'igual');
|
|
1280
|
+
const equal = drift.entries.length - off.length;
|
|
1281
|
+
lines.push(` Drift bóveda ↔ Railway (\`${drift.project.name}\` · servicio \`${drift.service.name}\`), por huella (HMAC con llave de esta corrida):`);
|
|
1282
|
+
if (off.length === 0) {
|
|
1283
|
+
lines.push(` ✔ Sin drift: ${equal} claves iguales.`);
|
|
1284
|
+
}
|
|
1285
|
+
else {
|
|
1286
|
+
for (const e of off) {
|
|
1287
|
+
const hashes = [
|
|
1288
|
+
e.vault ? `bóveda=${e.vault}` : null,
|
|
1289
|
+
e.deploy ? `deploy=${e.deploy}` : null,
|
|
1290
|
+
]
|
|
1291
|
+
.filter(Boolean)
|
|
1292
|
+
.join(' ');
|
|
1293
|
+
lines.push(` · ${e.key} — ${e.env} ↔ ${e.railwayEnv}: ${e.state}${hashes ? ` (${hashes})` : ''}`);
|
|
1294
|
+
}
|
|
1295
|
+
lines.push(` ${equal} claves iguales. Una clave distinta o sólo en el deploy es la huella de un segundo ` +
|
|
1296
|
+
'escritor: el valor se cambia en la bóveda y llega por el sync (fx-secrets-vault §7). ' +
|
|
1297
|
+
'Las variables `RAILWAY_*` son de la plataforma y no se comparan.');
|
|
1298
|
+
}
|
|
1299
|
+
for (const note of drift.notes)
|
|
1300
|
+
lines.push(` ${note}`);
|
|
1301
|
+
}
|
|
1302
|
+
lines.push(' Vercel: no aplica (el destino de este repo es Railway).');
|
|
1303
|
+
return lines;
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* The mode (local) + the drift (network, bounded, only in `bóveda`). Never throws for a missing
|
|
1307
|
+
* session, network or unreadable state — except the Vitest seam refusal of {@link computeVaultDrift}.
|
|
1308
|
+
*/
|
|
1309
|
+
export async function inspectRepoVault(rootDir, deps = {}) {
|
|
1310
|
+
return repoVaultReportFor(rootDir, readVaultMode(rootDir), deps);
|
|
1311
|
+
}
|
|
1312
|
+
/** {@link inspectRepoVault} over a mode already read (so `doctor` reads the file once). */
|
|
1313
|
+
export async function repoVaultReportFor(rootDir, reading, deps = {}) {
|
|
1314
|
+
const drift = reading.mode === 'bóveda' && reading.link
|
|
1315
|
+
? await computeVaultDrift(rootDir, reading.link, deps)
|
|
1316
|
+
: { status: 'no aplica' };
|
|
1317
|
+
return { mode: reading.mode, modeError: reading.error, drift };
|
|
1318
|
+
}
|
|
1319
|
+
/** The line that tells the person how to decide — only `doctor`, only in `sin decidir`. */
|
|
1320
|
+
export const ADOPT_SUGGESTION = ' Para pasarlo a la bóveda: `factory vault adopt` (sin `--apply` sólo enseña el plan). ' +
|
|
1321
|
+
'Si decides que no: `factory vault adopt --decline` y no te lo vuelvo a proponer.';
|
|
1322
|
+
/** One line per mode — plain es-MX. */
|
|
1323
|
+
function modeLine(report) {
|
|
1324
|
+
switch (report.mode) {
|
|
1325
|
+
case 'bóveda':
|
|
1326
|
+
return 'Bóveda de secretos: este repo vive en la bóveda (modo `bóveda`).';
|
|
1327
|
+
case 'declinado':
|
|
1328
|
+
return 'Bóveda de secretos: este repo declinó la bóveda (modo `declinado`); trabaja con `.env.local` y `env:push`.';
|
|
1329
|
+
case 'sin decidir':
|
|
1330
|
+
return 'Bóveda de secretos: sin decidir (modo `sin decidir`); este repo trabaja con `.env.local` y nadie ha decidido si pasa a la bóveda.';
|
|
1331
|
+
case 'no aplica':
|
|
1332
|
+
return 'Bóveda de secretos: no aplica (perfil `core` o sin `scripts/tools/with-vault.mjs`).';
|
|
1333
|
+
default:
|
|
1334
|
+
return `Bóveda de secretos: no pude leer el modo. ${report.modeError ?? ''}`.trimEnd();
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* The text of the vault section of `doctor`/`status` — key NAMES and display hashes only.
|
|
1339
|
+
* `suggestAdopt` adds the `vault adopt` line (only `doctor` asks for it, only in `sin decidir`).
|
|
1340
|
+
*/
|
|
1341
|
+
export function renderRepoVault(report, opts) {
|
|
1342
|
+
const lines = [modeLine(report)];
|
|
1343
|
+
if (opts.suggestAdopt && report.mode === 'sin decidir')
|
|
1344
|
+
lines.push(ADOPT_SUGGESTION);
|
|
1345
|
+
const drift = report.drift;
|
|
1346
|
+
if (drift.status === 'railway') {
|
|
1347
|
+
lines.push(...renderRailwayDrift(drift.railway));
|
|
1348
|
+
}
|
|
1349
|
+
else if (drift.status === 'no verificable') {
|
|
1350
|
+
const [first, ...rest] = drift.reason.split('\n');
|
|
1351
|
+
lines.push(` Drift bóveda ↔ Vercel: no verificable — ${first}`);
|
|
1352
|
+
for (const extra of rest)
|
|
1353
|
+
if (extra.trim())
|
|
1354
|
+
lines.push(` ${extra}`);
|
|
1355
|
+
lines.push(` Railway: ${DRIFT_RAILWAY_NOT_VERIFIED} (este reporte sólo compara con Vercel).`);
|
|
1356
|
+
}
|
|
1357
|
+
else if (drift.status === 'verificado') {
|
|
1358
|
+
const off = drift.entries.filter((e) => e.state !== 'igual');
|
|
1359
|
+
const equal = drift.entries.length - off.length;
|
|
1360
|
+
lines.push(` Drift bóveda ↔ Vercel (\`${drift.vercelProject.name}\`), por huella (HMAC con llave de esta corrida):`);
|
|
1361
|
+
if (off.length === 0) {
|
|
1362
|
+
lines.push(` ✔ Sin drift: ${equal} claves iguales entre main ↔ production y develop ↔ preview.`);
|
|
1363
|
+
}
|
|
1364
|
+
else {
|
|
1365
|
+
for (const e of off) {
|
|
1366
|
+
const hashes = [
|
|
1367
|
+
e.vault ? `bóveda=${e.vault}` : null,
|
|
1368
|
+
e.deploy ? `deploy=${e.deploy}` : null,
|
|
1369
|
+
]
|
|
1370
|
+
.filter(Boolean)
|
|
1371
|
+
.join(' ');
|
|
1372
|
+
lines.push(` · ${e.key} — ${e.env} ↔ ${e.target}: ${e.state}${hashes ? ` (${hashes})` : ''}`);
|
|
1373
|
+
}
|
|
1374
|
+
lines.push(` ${equal} claves iguales. Una clave distinta o sólo en el deploy es la huella de un segundo ` +
|
|
1375
|
+
'escritor: el valor se cambia en la bóveda y llega por el sync (fx-secrets-vault §7). ' +
|
|
1376
|
+
'`ilegible` = sensitive en Vercel (no devuelve su valor).');
|
|
1377
|
+
}
|
|
1378
|
+
lines.push(` Railway: ${DRIFT_RAILWAY_NOT_VERIFIED} (este reporte sólo compara con Vercel).`);
|
|
1379
|
+
}
|
|
1380
|
+
return lines;
|
|
1381
|
+
}
|
|
1382
|
+
// ---------------------------------------------------------------------------
|
|
1383
|
+
// ADOPT-009 — Factory mode, the connected-and-empty deploy, the project's Neon key
|
|
1384
|
+
// ---------------------------------------------------------------------------
|
|
1385
|
+
/**
|
|
1386
|
+
* Factory mode — the checkout of the kit's ORIGIN, which `vault adopt` adopts with the same command
|
|
1387
|
+
* as any derivative. TWO signals at once, never one: no `.timekast/lockfile.json` (ADR-003,
|
|
1388
|
+
* {@link isFactoryCheckout}) AND the `origin` remote is the Factory's GitHub repo (`owner/repo`
|
|
1389
|
+
* compared without case). An old derivative without a lockfile has another origin; a checkout
|
|
1390
|
+
* without `origin` has none — neither enters. The origin is read through the caller's seam.
|
|
1391
|
+
*/
|
|
1392
|
+
export async function isFactoryMode(rootDir, readOriginRepo) {
|
|
1393
|
+
if (!isFactoryCheckout(rootDir))
|
|
1394
|
+
return false;
|
|
1395
|
+
const origin = await readOriginRepo(rootDir);
|
|
1396
|
+
return origin !== null && origin.toLowerCase() === FACTORY_REPO.toLowerCase();
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* 🔴 A deploy is "connected and EMPTY" only when this is proven — fail-closed:
|
|
1400
|
+
* · there is a Vercel project and its env listing came back WITH the list, and the list is
|
|
1401
|
+
* empty (production and preview both come from that one listing): a response WITHOUT the list
|
|
1402
|
+
* (`null`), an error or ONE variable (of any target) is not empty;
|
|
1403
|
+
* · Railway was consulted and has no project: present, or not consulted (no `RAILWAY_TOKEN` in
|
|
1404
|
+
* the rail, an error of its API) is not empty.
|
|
1405
|
+
* Anything else → the normal rule (`main`/`develop` from the deploy).
|
|
1406
|
+
*/
|
|
1407
|
+
export function isConnectedEmptyDeploy(input) {
|
|
1408
|
+
return (input.hasVercelProject &&
|
|
1409
|
+
input.vercelEnvs !== null &&
|
|
1410
|
+
input.vercelEnvs.length === 0 &&
|
|
1411
|
+
input.railway === 'ausente');
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* The keys that vary per deploy environment (`fx-secrets-vault §7`): on a connected-and-empty
|
|
1415
|
+
* deploy they are generated or derived (`buildEnvPlan`), never read from `.env.local`.
|
|
1416
|
+
*/
|
|
1417
|
+
export const PER_ENV_KEYS = new Set([
|
|
1418
|
+
'DATABASE_URL',
|
|
1419
|
+
'AUTH_SECRET',
|
|
1420
|
+
'NEXT_PUBLIC_APP_URL',
|
|
1421
|
+
'NEXT_PUBLIC_APP_ENV',
|
|
1422
|
+
'WEBAUTHN_RP_ID',
|
|
1423
|
+
'WEBAUTHN_RELATED_ORIGINS',
|
|
1424
|
+
'NODE_ENV',
|
|
1425
|
+
]);
|
|
1426
|
+
/** The encryption key `buildEnvPlan` carries — shared, sealed: from `.env.local`, never generated. */
|
|
1427
|
+
export const MFA_KEY = 'MFA_ENCRYPTION_KEY';
|
|
1428
|
+
/**
|
|
1429
|
+
* On a connected-and-empty deploy, the keys of `.env.local` that MAY be the product's shared values
|
|
1430
|
+
* (`MFA_ENCRYPTION_KEY`, the sender, product flags…): everything but the per-environment keys,
|
|
1431
|
+
* the local-only ones, the rail's and the retired ones. Each one is confirmed by whoever runs —
|
|
1432
|
+
* a "no" keeps it only in `local`.
|
|
1433
|
+
*/
|
|
1434
|
+
export function sharedCandidatesOf(local) {
|
|
1435
|
+
return [...local.keys()]
|
|
1436
|
+
.filter((key) => !PER_ENV_KEYS.has(key) &&
|
|
1437
|
+
!LOCAL_ONLY_KEYS.has(key) &&
|
|
1438
|
+
!isRailKey(key) &&
|
|
1439
|
+
!RETIRED_UNREAD_KEYS.includes(key))
|
|
1440
|
+
.sort();
|
|
1441
|
+
}
|
|
1442
|
+
/** Neon's API (same base as `neon-api.ts`) — for the proof of a project-scoped key. */
|
|
1443
|
+
const NEON_PROOF_BASE = 'https://console.neon.tech/api/v2';
|
|
1444
|
+
const NEON_PROOF_TIMEOUT_MS = 15_000;
|
|
1445
|
+
/**
|
|
1446
|
+
* Whether `key` is PROVEN the project's own Neon key, by a NEGATIVE scope test:
|
|
1447
|
+
*
|
|
1448
|
+
* 1. the candidate answers 2xx on its project (`GET /projects/{id}`);
|
|
1449
|
+
* 2. the rail's key names the project's org (`GET /projects/{id}` → `org_id`) and lists the org's
|
|
1450
|
+
* projects, from which ONE sibling (another project of the same org) is taken;
|
|
1451
|
+
* 3. the candidate answers EXACTLY 404 on that sibling.
|
|
1452
|
+
*
|
|
1453
|
+
* Step 3 is what separates the three classes of key (verified live, 2026-09-12): a project-scoped
|
|
1454
|
+
* key 404s on any other project of its org; an org key and a member's personal key both see every
|
|
1455
|
+
* project of the org (2xx). `GET /users/me/organizations` does NOT separate them — an org key 404s
|
|
1456
|
+
* there exactly like a project key — so it is not asked.
|
|
1457
|
+
*
|
|
1458
|
+
* Any doubt → NOT proven: a non-2xx on step 1, a rail read that fails, an org without a sibling (the
|
|
1459
|
+
* test cannot run), and any answer other than 404 on step 3 (a 429, a 5xx, a 401, a timeout). The
|
|
1460
|
+
* caller then mints one; it never imports a key it could not prove. The key travels in a header
|
|
1461
|
+
* only; nothing of the responses is kept.
|
|
1462
|
+
*/
|
|
1463
|
+
export async function isProjectScopedNeonKey(key, projectId, deps) {
|
|
1464
|
+
const status = async (pathname) => {
|
|
1465
|
+
try {
|
|
1466
|
+
const response = await deps.fetchImpl(`${NEON_PROOF_BASE}${pathname}`, {
|
|
1467
|
+
method: 'GET',
|
|
1468
|
+
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
|
1469
|
+
signal: AbortSignal.timeout(NEON_PROOF_TIMEOUT_MS),
|
|
1470
|
+
});
|
|
1471
|
+
return response.status;
|
|
1472
|
+
}
|
|
1473
|
+
catch {
|
|
1474
|
+
return null;
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
const own = await status(`/projects/${encodeURIComponent(projectId)}`);
|
|
1478
|
+
if (own === null || own < 200 || own >= 300)
|
|
1479
|
+
return false;
|
|
1480
|
+
let sibling;
|
|
1481
|
+
try {
|
|
1482
|
+
const railKey = await deps.railKey();
|
|
1483
|
+
const orgId = await getNeonProjectOrg(railKey, projectId, { fetchImpl: deps.fetchImpl });
|
|
1484
|
+
if (!orgId)
|
|
1485
|
+
return false;
|
|
1486
|
+
const projects = await listNeonProjects(railKey, orgId, { fetchImpl: deps.fetchImpl });
|
|
1487
|
+
sibling = projects.find((project) => project.id !== projectId)?.id;
|
|
1488
|
+
}
|
|
1489
|
+
catch {
|
|
1490
|
+
return false;
|
|
1491
|
+
}
|
|
1492
|
+
if (!sibling)
|
|
1493
|
+
return false;
|
|
1494
|
+
return (await status(`/projects/${encodeURIComponent(sibling)}`)) === 404;
|
|
1495
|
+
}
|