@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
package/dist/lib/preflight.js
CHANGED
|
@@ -15,12 +15,11 @@
|
|
|
15
15
|
* --launcher` can render every step with ✅ / ❌ across all platforms (the
|
|
16
16
|
* cross-platform equivalent of the macOS launcher's `checkPrereqs`).
|
|
17
17
|
*/
|
|
18
|
-
import { readFileSync } from 'node:fs';
|
|
19
|
-
import { homedir } from 'node:os';
|
|
20
|
-
import path from 'node:path';
|
|
21
18
|
import { execa } from 'execa';
|
|
22
19
|
import { PreflightError } from './cli-error.js';
|
|
23
20
|
import { FACTORY_ORG } from './constants.js';
|
|
21
|
+
import { RailError, createRailResolver } from './rail.js';
|
|
22
|
+
import { VAULT_CONFIG } from './vault-config.js';
|
|
24
23
|
/** Minimum Node major version the kit requires (mirrors `engines.node` + the launcher AppleScript). */
|
|
25
24
|
export const MIN_NODE_MAJOR = 22;
|
|
26
25
|
/** Timeout (ms) for the GitHub API calls in the org-membership check — a slow / dead network must not hang. */
|
|
@@ -136,9 +135,10 @@ export async function runPreflight() {
|
|
|
136
135
|
await assertOrgMembership();
|
|
137
136
|
}
|
|
138
137
|
/**
|
|
139
|
-
* The methodology-rail keys the launcher expects in
|
|
140
|
-
*
|
|
141
|
-
* proposal tooling keys. Single source so the check + tests
|
|
138
|
+
* The methodology-rail keys the launcher expects in the vault rail (`rail-timekast`,
|
|
139
|
+
* read with the person's session — `fx-secrets-vault §3`). Provisioning tokens +
|
|
140
|
+
* their team/org ids + the proposal tooling keys. Single source so the check + tests
|
|
141
|
+
* agree.
|
|
142
142
|
*
|
|
143
143
|
* `NEON_ORG` is deliberately NOT here: it only names a default organization, and
|
|
144
144
|
* its absence is a valid configuration (a key that reaches exactly one org needs
|
|
@@ -153,14 +153,26 @@ export const REQUIRED_SECRET_KEYS = [
|
|
|
153
153
|
'SHORTIO_API_KEY',
|
|
154
154
|
'GAMMA_API_KEY',
|
|
155
155
|
'VERCEL_TEAM_ID',
|
|
156
|
+
// RWAY-003: `provision` asks Railway for the connected repo to discover a repo's destination
|
|
157
|
+
// (and to refuse a `--target=vercel` over a repo that lives in Railway) — in Vercel repos too.
|
|
158
|
+
'RAILWAY_TOKEN',
|
|
156
159
|
];
|
|
160
|
+
/**
|
|
161
|
+
* Time budget (ms) of the launcher's vault read. Deliberately SHORTER than the
|
|
162
|
+
* resolver's default (`RAIL_READ_TIMEOUT_MS`, 30 s): the desktop launcher waits 30 s
|
|
163
|
+
* IN TOTAL for the whole `doctor --launcher --json` document
|
|
164
|
+
* (`desktop/renderer/panels/home.tsx`), and `npx @latest` already eats part of that.
|
|
165
|
+
* A vault that does not answer must end as a `warn` "could not be determined" inside
|
|
166
|
+
* the document — not as a launcher-side "connection error" because the JSON never came.
|
|
167
|
+
*/
|
|
168
|
+
export const LAUNCHER_RAIL_TIMEOUT_MS = 10_000;
|
|
157
169
|
/** The ordered launcher labels (single source so render + tests agree). */
|
|
158
170
|
const LAUNCHER_LABELS = {
|
|
159
171
|
node: `Node ≥ ${MIN_NODE_MAJOR}`,
|
|
160
172
|
gh: 'GitHub CLI (gh) instalado',
|
|
161
173
|
auth: 'Sesión de GitHub activa',
|
|
162
174
|
org: `Membresía del org ${FACTORY_ORG}`,
|
|
163
|
-
secrets:
|
|
175
|
+
secrets: `Secrets del org en la bóveda (${VAULT_CONFIG.rail.name})`,
|
|
164
176
|
};
|
|
165
177
|
/** A confirmation message for a passing check (the `ok` branch). */
|
|
166
178
|
const OK_MESSAGES = {
|
|
@@ -168,43 +180,81 @@ const OK_MESSAGES = {
|
|
|
168
180
|
gh: 'GitHub CLI (gh) está instalado.',
|
|
169
181
|
auth: 'Tu sesión de GitHub CLI está activa.',
|
|
170
182
|
org: `Eres miembro del org ${FACTORY_ORG}.`,
|
|
171
|
-
secrets: `Las ${REQUIRED_SECRET_KEYS.length}
|
|
183
|
+
secrets: `Las ${REQUIRED_SECRET_KEYS.length} claves requeridas están en ` +
|
|
184
|
+
`\`${VAULT_CONFIG.rail.name}\` y tu sesión de la bóveda está activa.`,
|
|
172
185
|
};
|
|
186
|
+
/** The vault state skeleton — coordinates always present, everything else undetermined. */
|
|
187
|
+
function vaultState(overrides) {
|
|
188
|
+
return {
|
|
189
|
+
domain: VAULT_CONFIG.domain,
|
|
190
|
+
project: VAULT_CONFIG.rail.name,
|
|
191
|
+
session: null,
|
|
192
|
+
access: null,
|
|
193
|
+
missingKeys: null,
|
|
194
|
+
unknownReason: null,
|
|
195
|
+
...overrides,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** Map a rail failure (other than a missing key) to the published vault state. */
|
|
199
|
+
function vaultStateForFailure(kind) {
|
|
200
|
+
switch (kind) {
|
|
201
|
+
case 'no-session':
|
|
202
|
+
return vaultState({ session: false });
|
|
203
|
+
case 'no-access':
|
|
204
|
+
return vaultState({ session: true, access: false });
|
|
205
|
+
case 'not-installed':
|
|
206
|
+
case 'timeout':
|
|
207
|
+
case 'unrecognized':
|
|
208
|
+
return vaultState({ unknownReason: kind });
|
|
209
|
+
case 'key-missing':
|
|
210
|
+
// Never reaches here (`getOptional` turns it into `null`), kept for exhaustiveness.
|
|
211
|
+
return vaultState({ session: true, access: true });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
173
214
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
215
|
+
* Build the secrets check against the vault rail: session → access → presence of
|
|
216
|
+
* each {@link REQUIRED_SECRET_KEYS}, in that order (the resolver reads the rail
|
|
217
|
+
* project ONCE; a session/access failure surfaces on the first key). NAMES ONLY —
|
|
218
|
+
* each value is dropped the moment the resolver hands it over.
|
|
219
|
+
*
|
|
220
|
+
* Never `fail`, never throws: every outcome is `ok` or `warn`. The message on a
|
|
221
|
+
* failure is the resolver's own (built from the classification, never from what
|
|
222
|
+
* the vault printed), so it names the login command with the domain, the rail
|
|
223
|
+
* project, or how to install `infisical`.
|
|
177
224
|
*/
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
225
|
+
async function secretsCheck(rail) {
|
|
226
|
+
const base = { id: 'secrets', label: LAUNCHER_LABELS.secrets };
|
|
227
|
+
const missing = [];
|
|
228
|
+
try {
|
|
229
|
+
for (const key of REQUIRED_SECRET_KEYS) {
|
|
230
|
+
// Presence only: the value is compared to null and discarded right here.
|
|
231
|
+
if ((await rail.getOptional(key)) === null)
|
|
232
|
+
missing.push(key);
|
|
186
233
|
}
|
|
187
234
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
235
|
+
catch (error) {
|
|
236
|
+
const kind = error instanceof RailError ? error.kind : 'unrecognized';
|
|
237
|
+
return {
|
|
238
|
+
...base,
|
|
239
|
+
status: 'warn',
|
|
240
|
+
// A non-RailError is not ours to echo (its text could carry anything).
|
|
241
|
+
message: error instanceof RailError
|
|
242
|
+
? error.message
|
|
243
|
+
: 'No se pudo determinar el estado de la bóveda (error interno al leerla).',
|
|
244
|
+
vault: vaultStateForFailure(kind),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const vault = vaultState({ session: true, access: true, missingKeys: missing });
|
|
248
|
+
if (missing.length === 0) {
|
|
249
|
+
return { ...base, status: 'ok', message: OK_MESSAGES.secrets, vault };
|
|
193
250
|
}
|
|
194
|
-
return names;
|
|
195
|
-
}
|
|
196
|
-
/** Build the secrets check (`ok` if all required keys present, else `warn`). */
|
|
197
|
-
function secretsCheck(railContent) {
|
|
198
|
-
const present = readRailKeyNames(railContent);
|
|
199
|
-
const missing = REQUIRED_SECRET_KEYS.filter((key) => !present.has(key));
|
|
200
251
|
return {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
`${missing.join(', ')}. Impórtalos desde el card Secrets.`,
|
|
252
|
+
...base,
|
|
253
|
+
status: 'warn',
|
|
254
|
+
message: `Faltan ${missing.length} de ${REQUIRED_SECRET_KEYS.length} claves en ` +
|
|
255
|
+
`\`${VAULT_CONFIG.rail.name}\`: ${missing.join(', ')}.\n` +
|
|
256
|
+
'Pide a un admin de la bóveda que las cargue.',
|
|
257
|
+
vault,
|
|
208
258
|
};
|
|
209
259
|
}
|
|
210
260
|
/**
|
|
@@ -222,6 +272,10 @@ function secretsCheck(railContent) {
|
|
|
222
272
|
* depends on the previous — no auth without gh, no org without auth) but still
|
|
223
273
|
* RETURNS a full ordered report, marking the checks after the failure as
|
|
224
274
|
* `skipped`. The caller decides exit code (0 = all ok, 1 = any fail).
|
|
275
|
+
*
|
|
276
|
+
* Then, always and independently of 1-4, the `secrets` check (5): session → access
|
|
277
|
+
* → key presence in the vault rail. It is `ok` or `warn`, never `fail`, so it never
|
|
278
|
+
* trips the exit code.
|
|
225
279
|
*/
|
|
226
280
|
export async function runLauncherPreflight(deps = {}) {
|
|
227
281
|
const ghInstalled = deps.ghInstalled ?? assertGhInstalled;
|
|
@@ -274,6 +328,6 @@ export async function runLauncherPreflight(deps = {}) {
|
|
|
274
328
|
}
|
|
275
329
|
// Secrets check — independent of the gh/auth/org chain (runs even if those
|
|
276
330
|
// failed) and non-blocking: resolves to `ok` or `warn`, never `fail`/`skipped`.
|
|
277
|
-
results.push(secretsCheck(deps.
|
|
331
|
+
results.push(await secretsCheck(deps.rail ?? createRailResolver({ timeoutMs: LAUNCHER_RAIL_TIMEOUT_MS })));
|
|
278
332
|
return results;
|
|
279
333
|
}
|
|
@@ -3,19 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A DEDICATED preflight, independent of `lib/preflight.ts` (whose scope is the
|
|
5
5
|
* `new`/`add`/`update` org-membership gate). Provision mutates external infra
|
|
6
|
-
* (Neon, Vercel, DNS) with the
|
|
7
|
-
* (`
|
|
8
|
-
*
|
|
6
|
+
* (Neon, Vercel, DNS) with the org tokens of the rail — read from the vault
|
|
7
|
+
* (`rail-timekast`) with the person's session through the single resolver
|
|
8
|
+
* (`rail.ts`, ADR-006) — so it must fail CLOSED if any credential it will use is
|
|
9
|
+
* missing or invalid — BEFORE a single resource is created.
|
|
9
10
|
*
|
|
10
|
-
* What is FAIL-CLOSED (aborts with `PreflightError`):
|
|
11
|
+
* What is FAIL-CLOSED (aborts with `PreflightError` / a typed `RailError`):
|
|
11
12
|
* 1. `gh` installed + a valid `gh auth status` session (the git substrate of
|
|
12
|
-
* PROV-004 needs it
|
|
13
|
-
* 2. The rail tokens for the requested services are PRESENT in
|
|
14
|
-
* (`VERCEL_TOKEN`, `NEON_API_KEY`, …). Absent →
|
|
13
|
+
* PROV-004 needs it).
|
|
14
|
+
* 2. The rail tokens for the requested services are PRESENT in the vault
|
|
15
|
+
* (`VERCEL_TOKEN`, `NEON_API_KEY`, …). Absent → a vault admin loads them. No
|
|
16
|
+
* session / no access / an unrecognized answer is the resolver's own error —
|
|
17
|
+
* never read as "absent".
|
|
15
18
|
* 3. `VERCEL_TOKEN` is valid AND team-scoped: `GET /v2/teams/{teamId}` (NOT
|
|
16
19
|
* `/v2/user` — a personal token passes `/v2/user` yet 403s on the team).
|
|
17
20
|
* 401 = invalid token; 403 = valid token without team access — distinct
|
|
18
|
-
* messages so the dev knows whether to
|
|
21
|
+
* messages so the dev knows whether to check the session / ask for a rotation
|
|
22
|
+
* or check team membership.
|
|
19
23
|
*
|
|
20
24
|
* What is OPTIONAL (a WARNING, never an abort — frozen plan decision):
|
|
21
25
|
* - `neonctl`. Provision mutates Neon over REST with the rail `NEON_API_KEY`,
|
|
@@ -24,28 +28,39 @@
|
|
|
24
28
|
* The session is detected by its credentials FILE, never by running
|
|
25
29
|
* `neonctl me` — that command starts a login instead of failing.
|
|
26
30
|
*
|
|
27
|
-
* The
|
|
31
|
+
* The rail resolver, the `fetch`, and the `gh` spawns are all injectable seams so
|
|
28
32
|
* the unit tests drive every branch (401/403/missing-token/no-gh/no-neonctl)
|
|
29
|
-
* without touching the real
|
|
33
|
+
* without touching the real vault, the real `gh`, or the network.
|
|
30
34
|
*/
|
|
31
|
-
import { existsSync
|
|
35
|
+
import { existsSync } from 'node:fs';
|
|
32
36
|
import { homedir } from 'node:os';
|
|
33
37
|
import path from 'node:path';
|
|
34
38
|
import { execa } from 'execa';
|
|
35
39
|
import { PreflightError } from './cli-error.js';
|
|
40
|
+
import { WITH_VAULT_SCRIPT_FILE } from './constants.js';
|
|
41
|
+
import { hasLockfile, readLockfile } from './lockfile.js';
|
|
42
|
+
import { loginCommand, RAIL_ENV, RAIL_TIMEKAST, readOptionalRailSecret, readRailSecret, } from './rail.js';
|
|
43
|
+
import { RAILWAY_TOKEN_KEY } from './railway-api.js';
|
|
36
44
|
/** Timeout (ms) for the Vercel team-check fetch — a dead network must not hang. */
|
|
37
45
|
const VERCEL_API_TIMEOUT_MS = 10_000;
|
|
38
46
|
/** Base URL of the Vercel REST API (named constant — never an inline literal). */
|
|
39
47
|
export const VERCEL_API_BASE = 'https://api.vercel.com';
|
|
40
|
-
/** The gate token each provision service needs present in
|
|
48
|
+
/** The gate token each provision service needs present in the vault rail. */
|
|
41
49
|
const SERVICE_TOKENS = {
|
|
50
|
+
// `vault` (VAULT-002) reads no rail token: it talks to the vault with the person's own
|
|
51
|
+
// session, which the vault client obtains (and names `infisical login` when it is missing).
|
|
52
|
+
vault: null,
|
|
42
53
|
neon: 'NEON_API_KEY',
|
|
43
54
|
vercel: 'VERCEL_TOKEN',
|
|
44
55
|
// `github` (PROV-004) uses the dev's `gh` session for the substrate AND the
|
|
45
56
|
// gate `VERCEL_TOKEN` to create the Vercel project; `dns` rides the Cloudflare
|
|
46
57
|
// token but only when a `--domain` is requested (PROV-005), so it is not gated
|
|
47
58
|
// at the scaffold stage.
|
|
59
|
+
// RWAY-003: ONLY with destination Vercel — see `gateTokenFor`: with destination Railway the
|
|
60
|
+
// `github` step creates no Vercel project and never reads `VERCEL_TOKEN`.
|
|
48
61
|
github: 'VERCEL_TOKEN',
|
|
62
|
+
// RWAY-003 — the Railway step (RWAY-004) talks to Railway with the team token of the rail.
|
|
63
|
+
railway: RAILWAY_TOKEN_KEY,
|
|
49
64
|
dns: 'CLOUDFLARE_API_TOKEN',
|
|
50
65
|
// `backlog` (BSYNC-003) registers the project in backlog.timekast.mx with the org
|
|
51
66
|
// `admin` token. NOTE: unlike the other gate tokens (fail-closed here), a missing
|
|
@@ -56,46 +71,54 @@ const SERVICE_TOKENS = {
|
|
|
56
71
|
// entry is the defense-in-depth double-check.
|
|
57
72
|
backlog: 'BACKLOG_ADMIN_TOKEN',
|
|
58
73
|
};
|
|
74
|
+
/**
|
|
75
|
+
* The gate token of `service` for this run's destination (RWAY-003). The Vercel coupling of the
|
|
76
|
+
* `github` step exists only with destination Vercel: with Railway, `github` needs no
|
|
77
|
+
* `VERCEL_TOKEN`. An unresolved destination keeps today's (Vercel) behavior.
|
|
78
|
+
*/
|
|
79
|
+
function gateTokenFor(service, target) {
|
|
80
|
+
if (service === 'github' && target === 'railway')
|
|
81
|
+
return null;
|
|
82
|
+
return SERVICE_TOKENS[service];
|
|
83
|
+
}
|
|
59
84
|
/** True when the thrown error is execa's "command not found" (ENOENT). */
|
|
60
85
|
function isCommandNotFound(error) {
|
|
61
86
|
return error?.code === 'ENOENT';
|
|
62
87
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
88
|
+
/**
|
|
89
|
+
* The process-wide rail resolver (`rail.ts`): one vault read per rail project per
|
|
90
|
+
* process, shared by every step of a run. The default of every `rail` seam in
|
|
91
|
+
* `provision` / `env push`; tests inject `createRailResolver({ runner })` instead.
|
|
92
|
+
*/
|
|
93
|
+
export function defaultRail() {
|
|
94
|
+
return { get: readRailSecret, getOptional: readOptionalRailSecret };
|
|
66
95
|
}
|
|
67
96
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* `envPath` is injectable so tests point at a temp file (or a non-existent path).
|
|
97
|
+
* Which of `keys` are PRESENT in the vault rail — NAMES ONLY. Each value is dropped
|
|
98
|
+
* the moment it is read, so no secret leaves this function (no-echo). Only a missing
|
|
99
|
+
* key counts as absent: no session, no access, a timeout or an unrecognized answer
|
|
100
|
+
* propagate as the resolver's typed error — a required token is never skipped
|
|
101
|
+
* because the vault could not be read.
|
|
74
102
|
*/
|
|
75
|
-
export function readEnvKeyNames(
|
|
76
|
-
if (!existsSync(envPath))
|
|
77
|
-
return new Set();
|
|
78
|
-
let text;
|
|
79
|
-
try {
|
|
80
|
-
text = readFileSync(envPath, 'utf8');
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
return new Set(); // unreadable → treat as "no keys" (preflight will report missing)
|
|
84
|
-
}
|
|
103
|
+
export async function readEnvKeyNames(keys, rail = defaultRail()) {
|
|
85
104
|
const names = new Set();
|
|
86
|
-
for (const
|
|
87
|
-
|
|
88
|
-
if (trimmed === '' || trimmed.startsWith('#'))
|
|
89
|
-
continue;
|
|
90
|
-
const eq = line.indexOf('=');
|
|
91
|
-
if (eq === -1)
|
|
92
|
-
continue;
|
|
93
|
-
const key = line.slice(0, eq).trim();
|
|
94
|
-
if (key !== '')
|
|
105
|
+
for (const key of keys) {
|
|
106
|
+
if ((await rail.getOptional(key)) !== null)
|
|
95
107
|
names.add(key);
|
|
96
108
|
}
|
|
97
109
|
return names;
|
|
98
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* The action for a rail token a provider REJECTED (401): first the vault session —
|
|
113
|
+
* it expires, and a dead session is not a dead token — and only if it is alive, a
|
|
114
|
+
* vault admin rotates the token in `rail-timekast` under the SAME name
|
|
115
|
+
* (`fx-secrets-vault §9`). Nobody re-imports anything: there is no local copy.
|
|
116
|
+
*/
|
|
117
|
+
export function railTokenRejectedHint(key) {
|
|
118
|
+
return (`Comprueba primero tu sesión de la bóveda (\`${loginCommand()}\`); si está viva, avisa a un ` +
|
|
119
|
+
`admin de la bóveda para que rote \`${key}\` en \`${RAIL_TIMEKAST.name}\` ` +
|
|
120
|
+
'(el valor nuevo entra con el mismo nombre).');
|
|
121
|
+
}
|
|
99
122
|
/** Step 1a — `gh` is installed on PATH. */
|
|
100
123
|
async function assertGhInstalled(spawn) {
|
|
101
124
|
try {
|
|
@@ -165,24 +188,25 @@ async function assertRepoExists(repoSlug, spawn) {
|
|
|
165
188
|
}
|
|
166
189
|
}
|
|
167
190
|
/**
|
|
168
|
-
* Step 2 — the rail tokens for the requested services are present.
|
|
191
|
+
* Step 2 — the rail tokens for the requested services are present in the vault.
|
|
169
192
|
*
|
|
170
|
-
* Maps each requested service to its required token (`SERVICE_TOKENS`) and
|
|
171
|
-
*
|
|
172
|
-
* the
|
|
173
|
-
*
|
|
193
|
+
* Maps each requested service to its required token (`SERVICE_TOKENS`) and asserts
|
|
194
|
+
* every one is a key of `rail-timekast`. A missing token → fail-closed, naming ALL
|
|
195
|
+
* the missing ones at once (a vault admin loads them). A vault that cannot be read
|
|
196
|
+
* (no session, no access, …) is the resolver's error, not a "missing token".
|
|
174
197
|
*/
|
|
175
|
-
function assertGateTokensPresent(services,
|
|
198
|
+
async function assertGateTokensPresent(services, rail, target) {
|
|
176
199
|
const required = new Set();
|
|
177
200
|
for (const service of services) {
|
|
178
|
-
const token =
|
|
201
|
+
const token = gateTokenFor(service, target);
|
|
179
202
|
if (token)
|
|
180
203
|
required.add(token);
|
|
181
204
|
}
|
|
205
|
+
const present = await readEnvKeyNames([...required], rail);
|
|
182
206
|
const missing = [...required].filter((token) => !present.has(token));
|
|
183
207
|
if (missing.length > 0) {
|
|
184
|
-
throw new PreflightError(`Faltan tokens
|
|
185
|
-
'
|
|
208
|
+
throw new PreflightError(`Faltan tokens en \`${RAIL_TIMEKAST.name}\` (bóveda, entorno ${RAIL_ENV}): ${missing.join(', ')}.\n` +
|
|
209
|
+
'Pide a un admin de la bóveda que los cargue y vuelve a intentar.');
|
|
186
210
|
}
|
|
187
211
|
}
|
|
188
212
|
/**
|
|
@@ -209,7 +233,7 @@ async function checkNeonctlOptional(spawn) {
|
|
|
209
233
|
if (isCommandNotFound(error)) {
|
|
210
234
|
return {
|
|
211
235
|
status: 'warn',
|
|
212
|
-
message: '`neonctl` no está instalado (opcional — provision muta Neon por REST con el `NEON_API_KEY` del
|
|
236
|
+
message: '`neonctl` no está instalado (opcional — provision muta Neon por REST con el `NEON_API_KEY` del rail).\n' +
|
|
213
237
|
'Si lo quieres para inspección manual: `npm i -g neonctl`.',
|
|
214
238
|
};
|
|
215
239
|
}
|
|
@@ -226,7 +250,7 @@ async function checkNeonctlOptional(spawn) {
|
|
|
226
250
|
// depend on the neonctl session") therefore had two side effects it had no business
|
|
227
251
|
// having: it popped an OAuth consent in the dev's browser mid-provision, and, if
|
|
228
252
|
// approved, wrote an OAuth session to their host. Worse where nobody can see it: on
|
|
229
|
-
// a headless
|
|
253
|
+
// a headless run there is no browser and no one to approve, so the spawn
|
|
230
254
|
// sits waiting and hangs the preflight of a run that never needed it.
|
|
231
255
|
//
|
|
232
256
|
// Reading the credentials file is the same answer with no side effects. It can go
|
|
@@ -246,7 +270,8 @@ async function checkNeonctlOptional(spawn) {
|
|
|
246
270
|
* Step 3 — `VERCEL_TOKEN` is valid and scoped to `teamId` via `GET /v2/teams/{teamId}`.
|
|
247
271
|
*
|
|
248
272
|
* Distinguishes the two failure modes the issue calls out (§8):
|
|
249
|
-
* - 401 → the token itself is invalid/expired →
|
|
273
|
+
* - 401 → the token itself is invalid/expired → check the vault session, then
|
|
274
|
+
* ask a vault admin to rotate it
|
|
250
275
|
* - 403 → a valid token WITHOUT access to this team (e.g. a personal-scoped
|
|
251
276
|
* token) → check the team id / membership
|
|
252
277
|
*
|
|
@@ -271,7 +296,7 @@ async function assertVercelTeamAccess(token, teamId, fetchImpl) {
|
|
|
271
296
|
return; // 2xx → token válido y con acceso al team.
|
|
272
297
|
if (response.status === 401) {
|
|
273
298
|
throw new PreflightError('El `VERCEL_TOKEN` es inválido o expiró (Vercel respondió 401).\n' +
|
|
274
|
-
'
|
|
299
|
+
railTokenRejectedHint('VERCEL_TOKEN'));
|
|
275
300
|
}
|
|
276
301
|
if (response.status === 403) {
|
|
277
302
|
throw new PreflightError(`El \`VERCEL_TOKEN\` no tiene acceso al team \`${teamId}\` (Vercel respondió 403).\n` +
|
|
@@ -291,7 +316,12 @@ async function assertVercelTeamAccess(token, teamId, fetchImpl) {
|
|
|
291
316
|
export async function runProvisionPreflight(input, deps = {}) {
|
|
292
317
|
const spawn = deps.spawn ?? execa;
|
|
293
318
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
294
|
-
const
|
|
319
|
+
const rail = deps.rail ?? defaultRail();
|
|
320
|
+
// 0. VAULT-004 — the brain carries what the vault needs locally. Local reads only, so it runs
|
|
321
|
+
// first: a repo that cannot use the vault stops before any network call and before the
|
|
322
|
+
// vault step creates the project.
|
|
323
|
+
if (input.rootDir && input.services.includes('vault'))
|
|
324
|
+
assertVaultBrainPresent(input.rootDir);
|
|
295
325
|
// 1. gh installed + authenticated.
|
|
296
326
|
await assertGhInstalled(spawn);
|
|
297
327
|
await assertGhAuthenticated(spawn);
|
|
@@ -304,25 +334,21 @@ export async function runProvisionPreflight(input, deps = {}) {
|
|
|
304
334
|
if (input.repoSlug)
|
|
305
335
|
await assertRepoExists(input.repoSlug, spawn);
|
|
306
336
|
}
|
|
307
|
-
// 2. Gate tokens for the requested services present in the rail.
|
|
308
|
-
|
|
309
|
-
assertGateTokensPresent(input.services, present);
|
|
337
|
+
// 2. Gate tokens for the requested services present in the vault rail.
|
|
338
|
+
await assertGateTokensPresent(input.services, rail, input.target);
|
|
310
339
|
// 3. Vercel team-scoped token check — when provisioning Vercel (env sync), GitHub
|
|
311
340
|
// (PROV-004 creates the team-scoped Vercel project), OR DNS (PROV-005 attaches
|
|
312
|
-
// the interim domains to the team-scoped project).
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
input.services.includes('
|
|
341
|
+
// the interim domains to the team-scoped project). RWAY-003: only with destination
|
|
342
|
+
// Vercel — a Railway run never asks for the team nor reads `VERCEL_TOKEN`.
|
|
343
|
+
if (input.target !== 'railway' &&
|
|
344
|
+
(input.services.includes('vercel') ||
|
|
345
|
+
input.services.includes('github') ||
|
|
346
|
+
input.services.includes('dns'))) {
|
|
316
347
|
if (!input.teamId) {
|
|
317
348
|
throw new PreflightError('Falta `--team=<teamId>`: el provision de Vercel/GitHub/DNS requiere el team del org.\n' +
|
|
318
349
|
'Pásalo como `factory provision --services=github,vercel,dns --team=team_xxx`.');
|
|
319
350
|
}
|
|
320
|
-
const token = readVercelToken(
|
|
321
|
-
if (!token) {
|
|
322
|
-
// Defensive: step 2 already asserted presence, but guard the read path.
|
|
323
|
-
throw new PreflightError('No se pudo leer `VERCEL_TOKEN` de `~/.claude/.env`.\n' +
|
|
324
|
-
'Ejecuta `factory secrets <archivo>` y vuelve a intentar.');
|
|
325
|
-
}
|
|
351
|
+
const token = await readVercelToken(rail);
|
|
326
352
|
await assertVercelTeamAccess(token, input.teamId, fetchImpl);
|
|
327
353
|
}
|
|
328
354
|
// 4. Optional neonctl — never fatal; surfaced as a warning by the caller.
|
|
@@ -330,41 +356,73 @@ export async function runProvisionPreflight(input, deps = {}) {
|
|
|
330
356
|
return { neonctl };
|
|
331
357
|
}
|
|
332
358
|
/**
|
|
333
|
-
* Read the VALUE of `VERCEL_TOKEN` from the rail (the team-check
|
|
334
|
-
* not just the name).
|
|
335
|
-
*
|
|
336
|
-
* preserves them). Returns `null` when absent/unreadable.
|
|
359
|
+
* Read the VALUE of `VERCEL_TOKEN` from the vault rail (the team-check and every
|
|
360
|
+
* Vercel call need the value, not just the name). A missing key is the resolver's
|
|
361
|
+
* `key-missing` error (it names the key); no session / no access propagate as-is.
|
|
337
362
|
*
|
|
338
|
-
*
|
|
339
|
-
* Authorization header and never logged.
|
|
363
|
+
* The value is handed straight to an Authorization header and never logged.
|
|
340
364
|
*/
|
|
341
|
-
export function readVercelToken(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
365
|
+
export function readVercelToken(rail = defaultRail()) {
|
|
366
|
+
return rail.get('VERCEL_TOKEN');
|
|
367
|
+
}
|
|
368
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
369
|
+
// VAULT-002 — is this repo on the `core` profile? (the vault is not offered there)
|
|
370
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
371
|
+
/**
|
|
372
|
+
* The local wrapper that injects the vault's `local` environment into the kit's scripts
|
|
373
|
+
* (VAULT-003). The `core` profile does not ship it, so a repo without it cannot work from the
|
|
374
|
+
* vault — that is the fallback signal when there is no lockfile to read.
|
|
375
|
+
*/
|
|
376
|
+
export const VAULT_WRAPPER_PATH = WITH_VAULT_SCRIPT_FILE;
|
|
377
|
+
/**
|
|
378
|
+
* The org vault's coordinates as the repo's brain carries them — what the wrapper reads to find
|
|
379
|
+
* the instance. Shipped by the kit (`.claude/policy/`); `factory update` brings it when missing.
|
|
380
|
+
*/
|
|
381
|
+
export const VAULT_POLICY_PATH = '.claude/policy/vault.json';
|
|
382
|
+
/**
|
|
383
|
+
* Detect the `core` profile, where `provision` does not offer the vault (VAULT-002). One
|
|
384
|
+
* function, reused by VAULT-004's preflight:
|
|
385
|
+
*
|
|
386
|
+
* 1. A READABLE lockfile decides: `profile === 'core'` → core; `full` → not core, even if the
|
|
387
|
+
* wrapper is missing (an outdated brain is VAULT-004's `factory update`, not a profile).
|
|
388
|
+
* 2. Only when there is no lockfile to read (absent, or present but unparseable): core when
|
|
389
|
+
* `scripts/tools/with-vault.mjs` is absent.
|
|
390
|
+
*
|
|
391
|
+
* 🔴 NEVER by the absence of `package.json`: a `full` derivative always has one, but so can a
|
|
392
|
+
* `core` repo of another stack that happens to use Node tooling — the lockfile is the authority.
|
|
393
|
+
* `lockfile.ts` is only READ here.
|
|
394
|
+
*/
|
|
395
|
+
export function detectCoreProfile(rootDir) {
|
|
396
|
+
if (hasLockfile(rootDir)) {
|
|
397
|
+
try {
|
|
398
|
+
return readLockfile(rootDir).profile === 'core' ? 'lockfile' : null;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// Unreadable lockfile → no profile to read; fall through to the wrapper signal.
|
|
366
402
|
}
|
|
367
|
-
return value === '' ? null : value;
|
|
368
403
|
}
|
|
369
|
-
return null;
|
|
404
|
+
return existsSync(path.join(rootDir, VAULT_WRAPPER_PATH)) ? null : 'no-wrapper';
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* VAULT-004 — in a `full` repo about to get the vault, the files the vault needs locally must be
|
|
408
|
+
* on disk: the wrapper that injects it into the scripts and `vault.json`, the coordinates it
|
|
409
|
+
* reads. Without them the project would be created and `pnpm dev` would start with no secrets.
|
|
410
|
+
* The fix is `factory update` (it brings a missing kit file), or `--no-vault` to go on without.
|
|
411
|
+
*
|
|
412
|
+
* `core` never reaches the check (the vault is not offered there): detected by the ONE function
|
|
413
|
+
* {@link detectCoreProfile}, shared with VAULT-002.
|
|
414
|
+
*
|
|
415
|
+
* @throws {PreflightError} naming every missing file.
|
|
416
|
+
*/
|
|
417
|
+
export function assertVaultBrainPresent(rootDir) {
|
|
418
|
+
if (detectCoreProfile(rootDir) !== null)
|
|
419
|
+
return;
|
|
420
|
+
const missing = [VAULT_WRAPPER_PATH, VAULT_POLICY_PATH].filter((rel) => !existsSync(path.join(rootDir, rel)));
|
|
421
|
+
if (missing.length === 0)
|
|
422
|
+
return;
|
|
423
|
+
throw new PreflightError(`Este repo no trae lo que la bóveda necesita en local: falta ${missing
|
|
424
|
+
.map((rel) => `\`${rel}\``)
|
|
425
|
+
.join(' y ')}. No creé nada.\n` +
|
|
426
|
+
' · Corre `factory update` para traer el cerebro al día y repite el comando.\n' +
|
|
427
|
+
' · O sigue sin bóveda, como hasta hoy: repite el comando con `--no-vault`.');
|
|
370
428
|
}
|