@timekast/factory 1.11.0 → 1.13.0

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.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Preflight checks for `factory provision` (PROV-001).
3
+ *
4
+ * A DEDICATED preflight, independent of `lib/preflight.ts` (whose scope is the
5
+ * `new`/`add`/`update` org-membership gate). Provision mutates external infra
6
+ * (Neon, Vercel, DNS) with the shared org tokens imported into the rail
7
+ * (`factory secrets <archivo>`, SEC-001), so it must fail CLOSED if any credential
8
+ * it will use is missing or invalid — BEFORE a single resource is created.
9
+ *
10
+ * What is FAIL-CLOSED (aborts with `PreflightError`):
11
+ * 1. `gh` installed + a valid `gh auth status` session (the git substrate of
12
+ * PROV-004 needs it; and it is the org identity that gates the secrets import).
13
+ * 2. The rail tokens for the requested services are PRESENT in `~/.claude/.env`
14
+ * (`VERCEL_TOKEN`, `NEON_API_KEY`, …). Absent → re-run `factory secrets <archivo>`.
15
+ * 3. `VERCEL_TOKEN` is valid AND team-scoped: `GET /v2/teams/{teamId}` (NOT
16
+ * `/v2/user` — a personal token passes `/v2/user` yet 403s on the team).
17
+ * 401 = invalid token; 403 = valid token without team access — distinct
18
+ * messages so the dev knows whether to re-import or check team membership.
19
+ *
20
+ * What is OPTIONAL (a WARNING, never an abort — frozen plan decision):
21
+ * - `neonctl`. Provision mutates Neon over REST with the rail `NEON_API_KEY`,
22
+ * so a missing or un-authenticated `neonctl` is a convenience gap, not a
23
+ * blocker (issue §1, §8). Absent → warn with the install hint and continue.
24
+ *
25
+ * The token reader, the `fetch`, and the `gh` spawns are all injectable seams so
26
+ * the unit tests drive every branch (401/403/missing-token/no-gh/no-neonctl)
27
+ * without touching the real `~/.claude/.env`, the real `gh`, or the network.
28
+ */
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { homedir } from 'node:os';
31
+ import path from 'node:path';
32
+ import { execa } from 'execa';
33
+ import { PreflightError } from './cli-error.js';
34
+ /** Timeout (ms) for the Vercel team-check fetch — a dead network must not hang. */
35
+ const VERCEL_API_TIMEOUT_MS = 10_000;
36
+ /** Base URL of the Vercel REST API (named constant — never an inline literal). */
37
+ export const VERCEL_API_BASE = 'https://api.vercel.com';
38
+ /** The gate token each provision service needs present in `~/.claude/.env`. */
39
+ const SERVICE_TOKENS = {
40
+ neon: 'NEON_API_KEY',
41
+ vercel: 'VERCEL_TOKEN',
42
+ // `github` (PROV-004) uses the dev's `gh` session for the substrate AND the
43
+ // gate `VERCEL_TOKEN` to create the Vercel project; `dns` rides the Cloudflare
44
+ // token but only when a `--domain` is requested (PROV-005), so it is not gated
45
+ // at the scaffold stage.
46
+ github: 'VERCEL_TOKEN',
47
+ dns: 'CLOUDFLARE_API_TOKEN',
48
+ };
49
+ /** True when the thrown error is execa's "command not found" (ENOENT). */
50
+ function isCommandNotFound(error) {
51
+ return error?.code === 'ENOENT';
52
+ }
53
+ /** Absolute path to the per-user secrets rail (`~/.claude/.env`). */
54
+ export function defaultEnvPath() {
55
+ return path.join(homedir(), '.claude', '.env');
56
+ }
57
+ /**
58
+ * Read the SET of KEY NAMES present in a `KEY=value` rail file — NAMES ONLY, the
59
+ * value of every line is discarded immediately so a secret never leaves this
60
+ * function (no-echo discipline, mirrors `secrets.ts`). Split on the FIRST `=`;
61
+ * comment / blank / no-`=` lines are skipped. A missing file → empty set.
62
+ *
63
+ * `envPath` is injectable so tests point at a temp file (or a non-existent path).
64
+ */
65
+ export function readEnvKeyNames(envPath = defaultEnvPath()) {
66
+ if (!existsSync(envPath))
67
+ return new Set();
68
+ let text;
69
+ try {
70
+ text = readFileSync(envPath, 'utf8');
71
+ }
72
+ catch {
73
+ return new Set(); // unreadable → treat as "no keys" (preflight will report missing)
74
+ }
75
+ const names = new Set();
76
+ for (const line of text.split('\n')) {
77
+ const trimmed = line.trimStart();
78
+ if (trimmed === '' || trimmed.startsWith('#'))
79
+ continue;
80
+ const eq = line.indexOf('=');
81
+ if (eq === -1)
82
+ continue;
83
+ const key = line.slice(0, eq).trim();
84
+ if (key !== '')
85
+ names.add(key);
86
+ }
87
+ return names;
88
+ }
89
+ /** Step 1a — `gh` is installed on PATH. */
90
+ async function assertGhInstalled(spawn) {
91
+ try {
92
+ await spawn('gh', ['--version']);
93
+ }
94
+ catch (error) {
95
+ if (isCommandNotFound(error)) {
96
+ throw new PreflightError('GitHub CLI (`gh`) no está instalado o no está en el PATH.\n' +
97
+ 'Instálalo desde https://cli.github.com y vuelve a intentar.');
98
+ }
99
+ throw new PreflightError('No se pudo ejecutar `gh`. Verifica tu instalación de GitHub CLI (https://cli.github.com).');
100
+ }
101
+ }
102
+ /** Step 1b — `gh auth status` returns exit 0 (a valid, non-expired session). */
103
+ async function assertGhAuthenticated(spawn) {
104
+ try {
105
+ await spawn('gh', ['auth', 'status']);
106
+ }
107
+ catch {
108
+ throw new PreflightError('Tu sesión de GitHub CLI no es válida o expiró.\n' +
109
+ 'Ejecuta `gh auth login` para autenticarte y vuelve a intentar.');
110
+ }
111
+ }
112
+ /**
113
+ * Step 1b' — the `gh` token carries the `workflow` scope. The derived scaffold ships
114
+ * `.github/workflows/*.yml` (ci, e2e, …) and provision generates more (migrate.yml);
115
+ * GitHub REJECTS an HTTPS push that creates/updates a workflow file unless the OAuth
116
+ * token has the `workflow` scope ("refusing to allow an OAuth App to create or update
117
+ * workflow … without `workflow` scope"). Parses `gh auth status` for the scope; the
118
+ * fix is a one-time `gh auth refresh -h github.com -s workflow`.
119
+ */
120
+ async function assertGhWorkflowScope(spawn) {
121
+ let output = '';
122
+ try {
123
+ const res = (await spawn('gh', ['auth', 'status']));
124
+ output = `${res.stdout ?? ''}\n${res.stderr ?? ''}`;
125
+ }
126
+ catch {
127
+ return; // a failed `gh auth status` is already surfaced by assertGhAuthenticated
128
+ }
129
+ if (!/\bworkflow\b/.test(output)) {
130
+ throw new PreflightError('Tu sesión de `gh` no tiene el scope `workflow`, necesario para pushear los archivos\n' +
131
+ '`.github/workflows/` del scaffold (GitHub rechaza el push sin él).\n' +
132
+ 'Concédelo con: gh auth refresh -h github.com -s workflow\ny vuelve a intentar.');
133
+ }
134
+ }
135
+ /**
136
+ * Step 1c — the target repo EXISTS on GitHub (`gh repo view {owner/repo}`). provision
137
+ * does NOT create the repo: `factory new` does (`gh repo create`). A missing repo
138
+ * here means `new` was never run (or the repo was deleted), so we abort with a
139
+ * pointer to `factory new` instead of letting the `github` step fail on an opaque
140
+ * `git push` to a non-existent remote. A 404/no-access both surface the same hint.
141
+ */
142
+ async function assertRepoExists(repoSlug, spawn) {
143
+ try {
144
+ await spawn('gh', ['repo', 'view', repoSlug, '--json', 'name']);
145
+ }
146
+ catch (error) {
147
+ // `gh` itself broken was already caught by step 1a; here a failure means the
148
+ // repo is not viewable (does not exist or no access).
149
+ if (isCommandNotFound(error))
150
+ return;
151
+ const slug = repoSlug.split('/')[1] ?? repoSlug;
152
+ throw new PreflightError(`El repo \`${repoSlug}\` no existe en GitHub (o no tienes acceso).\n` +
153
+ `provision liga la infra a un repo que YA existe — créalo primero con \`factory new ${slug}\` ` +
154
+ '(que hace `gh repo create` + el scaffold) y vuelve a intentar.');
155
+ }
156
+ }
157
+ /**
158
+ * Step 2 — the rail tokens for the requested services are present.
159
+ *
160
+ * Maps each requested service to its required token (`SERVICE_TOKENS`) and
161
+ * asserts every one is a key in `~/.claude/.env`. A missing token → fail-closed:
162
+ * the dev must re-run `factory secrets <archivo>` (SEC-001) to import them. Services
163
+ * with no required token (e.g. `github`) are skipped.
164
+ */
165
+ function assertGateTokensPresent(services, present) {
166
+ const required = new Set();
167
+ for (const service of services) {
168
+ const token = SERVICE_TOKENS[service];
169
+ if (token)
170
+ required.add(token);
171
+ }
172
+ const missing = [...required].filter((token) => !present.has(token));
173
+ if (missing.length > 0) {
174
+ throw new PreflightError(`Faltan tokens del archivo de secretos en \`~/.claude/.env\`: ${missing.join(', ')}.\n` +
175
+ 'Ejecuta `factory secrets <archivo>` para bajarlos del archivo de secretos del org y vuelve a intentar.');
176
+ }
177
+ }
178
+ /**
179
+ * Optional `neonctl` check — NEVER aborts (frozen plan decision: provision mutates
180
+ * Neon over REST with the rail `NEON_API_KEY`). Returns a structured report the
181
+ * caller turns into a warning when `status === 'warn'`:
182
+ * - not on PATH → warn ("install neonctl")
183
+ * - on PATH, `me` fails → warn ("run neonctl auth") — issue §8 edge case
184
+ * - on PATH + authed → ok
185
+ */
186
+ async function checkNeonctlOptional(spawn) {
187
+ try {
188
+ await spawn('neonctl', ['--version']);
189
+ }
190
+ catch (error) {
191
+ if (isCommandNotFound(error)) {
192
+ return {
193
+ status: 'warn',
194
+ message: '`neonctl` no está instalado (opcional — provision muta Neon por REST con el `NEON_API_KEY` del archivo de secretos).\n' +
195
+ 'Si lo quieres para inspección manual: `npm i -g neonctl`.',
196
+ };
197
+ }
198
+ return {
199
+ status: 'warn',
200
+ message: 'No se pudo ejecutar `neonctl` (opcional). Continúo con la mutación por REST.',
201
+ };
202
+ }
203
+ // Installed → check it is authenticated (`neonctl me`). Un-authed is still only
204
+ // a warning: provision does not depend on the neonctl session.
205
+ try {
206
+ await spawn('neonctl', ['me']);
207
+ }
208
+ catch {
209
+ return {
210
+ status: 'warn',
211
+ message: '`neonctl` está instalado pero no autenticado (opcional).\n' +
212
+ 'Autentícalo con `neonctl auth` si lo quieres para inspección; provision no lo necesita.',
213
+ };
214
+ }
215
+ return { status: 'ok', message: '`neonctl` instalado y autenticado.' };
216
+ }
217
+ /**
218
+ * Step 3 — `VERCEL_TOKEN` is valid and scoped to `teamId` via `GET /v2/teams/{teamId}`.
219
+ *
220
+ * Distinguishes the two failure modes the issue calls out (§8):
221
+ * - 401 → the token itself is invalid/expired → re-pull
222
+ * - 403 → a valid token WITHOUT access to this team (e.g. a personal-scoped
223
+ * token) → check the team id / membership
224
+ *
225
+ * Any other non-2xx, or a network/timeout failure, also aborts (fail-closed —
226
+ * provision must not proceed on an unverifiable token).
227
+ */
228
+ async function assertVercelTeamAccess(token, teamId, fetchImpl) {
229
+ const url = `${VERCEL_API_BASE}/v2/teams/${encodeURIComponent(teamId)}`;
230
+ let response;
231
+ try {
232
+ response = await fetchImpl(url, {
233
+ method: 'GET',
234
+ headers: { Authorization: `Bearer ${token}` },
235
+ signal: AbortSignal.timeout(VERCEL_API_TIMEOUT_MS),
236
+ });
237
+ }
238
+ catch {
239
+ throw new PreflightError(`No se pudo verificar el acceso al team de Vercel \`${teamId}\`: la API no respondió (¿sin conexión?).\n` +
240
+ 'Revisa tu red y vuelve a intentar.');
241
+ }
242
+ if (response.ok)
243
+ return; // 2xx → token válido y con acceso al team.
244
+ if (response.status === 401) {
245
+ throw new PreflightError('El `VERCEL_TOKEN` es inválido o expiró (Vercel respondió 401).\n' +
246
+ 'Vuelve a bajar los tokens del archivo de secretos con `factory secrets <archivo>` y reintenta.');
247
+ }
248
+ if (response.status === 403) {
249
+ throw new PreflightError(`El \`VERCEL_TOKEN\` no tiene acceso al team \`${teamId}\` (Vercel respondió 403).\n` +
250
+ 'El token es válido pero no alcanza ese team; verifica el `--team` y la membresía del token en el team.');
251
+ }
252
+ throw new PreflightError(`No se pudo verificar el team de Vercel \`${teamId}\`: Vercel respondió ${response.status}.\n` +
253
+ 'Reintenta; si persiste, revisa el `--team` y el `VERCEL_TOKEN`.');
254
+ }
255
+ /**
256
+ * Run the provision preflight (PROV-001). Fail-closed on `gh` / gate tokens /
257
+ * Vercel team access; returns a `neonctl` report (warning, never fatal). Reusable
258
+ * by the `provision` command and unit-testable via the injectable deps.
259
+ *
260
+ * @throws {PreflightError} when any fail-closed check fails — no resource is
261
+ * created (the command runs this BEFORE any mutation).
262
+ */
263
+ export async function runProvisionPreflight(input, deps = {}) {
264
+ const spawn = deps.spawn ?? execa;
265
+ const fetchImpl = deps.fetchImpl ?? fetch;
266
+ const envPath = deps.envPath ?? defaultEnvPath();
267
+ // 1. gh installed + authenticated.
268
+ await assertGhInstalled(spawn);
269
+ await assertGhAuthenticated(spawn);
270
+ // 1c. github-specific gh checks (only when `github` will push): the token carries
271
+ // the `workflow` scope (the scaffold + provision push `.github/workflows/*`,
272
+ // which GitHub REJECTS without it) AND the target repo exists (provision is
273
+ // downstream of `factory new`). Both abort BEFORE any resource is created.
274
+ if (input.services.includes('github')) {
275
+ await assertGhWorkflowScope(spawn);
276
+ if (input.repoSlug)
277
+ await assertRepoExists(input.repoSlug, spawn);
278
+ }
279
+ // 2. Gate tokens for the requested services present in the rail.
280
+ const present = readEnvKeyNames(envPath);
281
+ assertGateTokensPresent(input.services, present);
282
+ // 3. Vercel team-scoped token check — when provisioning Vercel (env sync), GitHub
283
+ // (PROV-004 creates the team-scoped Vercel project), OR DNS (PROV-005 attaches
284
+ // the interim domains to the team-scoped project).
285
+ if (input.services.includes('vercel') ||
286
+ input.services.includes('github') ||
287
+ input.services.includes('dns')) {
288
+ if (!input.teamId) {
289
+ throw new PreflightError('Falta `--team=<teamId>`: el provision de Vercel/GitHub/DNS requiere el team del org.\n' +
290
+ 'Pásalo como `factory provision --services=github,vercel,dns --team=team_xxx`.');
291
+ }
292
+ const token = readVercelToken(envPath);
293
+ if (!token) {
294
+ // Defensive: step 2 already asserted presence, but guard the read path.
295
+ throw new PreflightError('No se pudo leer `VERCEL_TOKEN` de `~/.claude/.env`.\n' +
296
+ 'Ejecuta `factory secrets <archivo>` y vuelve a intentar.');
297
+ }
298
+ await assertVercelTeamAccess(token, input.teamId, fetchImpl);
299
+ }
300
+ // 4. Optional neonctl — never fatal; surfaced as a warning by the caller.
301
+ const neonctl = await checkNeonctlOptional(spawn);
302
+ return { neonctl };
303
+ }
304
+ /**
305
+ * Read the VALUE of `VERCEL_TOKEN` from the rail (the team-check needs the value,
306
+ * not just the name). Split on the FIRST `=`; strip a single matched pair of
307
+ * surrounding quotes (the rail may store quoted values — `env-merge.ts`
308
+ * preserves them). Returns `null` when absent/unreadable.
309
+ *
310
+ * This is the ONE place a token VALUE is read; it is handed straight to the
311
+ * Authorization header and never logged.
312
+ */
313
+ export function readVercelToken(envPath = defaultEnvPath()) {
314
+ if (!existsSync(envPath))
315
+ return null;
316
+ let text;
317
+ try {
318
+ text = readFileSync(envPath, 'utf8');
319
+ }
320
+ catch {
321
+ return null;
322
+ }
323
+ for (const line of text.split('\n')) {
324
+ const trimmed = line.trimStart();
325
+ if (trimmed === '' || trimmed.startsWith('#'))
326
+ continue;
327
+ const eq = line.indexOf('=');
328
+ if (eq === -1)
329
+ continue;
330
+ const key = line.slice(0, eq).trim();
331
+ if (key !== 'VERCEL_TOKEN')
332
+ continue;
333
+ let value = line.slice(eq + 1).replace(/\s+$/, '');
334
+ if (value.length >= 2 &&
335
+ ((value.startsWith('"') && value.endsWith('"')) ||
336
+ (value.startsWith("'") && value.endsWith("'")))) {
337
+ value = value.slice(1, -1);
338
+ }
339
+ return value === '' ? null : value;
340
+ }
341
+ return null;
342
+ }