@timekast/factory 1.12.0 → 1.14.2

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,2487 @@
1
+ /**
2
+ * `factory provision` — scaffold of the derived-project onboarding command (PROV-001).
3
+ *
4
+ * This issue ships ONLY the command skeleton: flag dispatch, the atomic state
5
+ * model (`provision-state.ts`), and the fail-closed preflight
6
+ * (`provision-preflight.ts`). The REAL resource creation lives in later issues —
7
+ * Neon (PROV-002), Vercel/GitHub (PROV-003/004), DNS+Resend (PROV-005) — so each
8
+ * service step here is a CLEAR stub that those issues fill in. The command is
9
+ * already wired end-to-end (flags → preflight → resumable per-step state) so the
10
+ * next issues only drop their REST call into the marked stub.
11
+ *
12
+ * Headless / flag-driven contract: NO interactive prompts (the interactivity is
13
+ * EPIC-17's `tk-provision` skill + `/provision`). Every input is a flag; `--json`
14
+ * emits a machine-readable result; `--dry-run` prints the plan and mutates
15
+ * nothing (no REST, no state write); without tokens it degrades cleanly via the
16
+ * preflight's typed errors.
17
+ *
18
+ * All expected failures throw `CLIError` / `PreflightError` — never a raw
19
+ * `throw new Error()` — so the top-level handler in `index.ts` prints a clean,
20
+ * actionable line and exits with the right code.
21
+ */
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { basename, dirname, join } from 'node:path';
24
+ import { execa } from 'execa';
25
+ import prompts from 'prompts';
26
+ import { FACTORY_ORG } from '../lib/constants.js';
27
+ import { CLIError } from '../lib/cli-error.js';
28
+ import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, ensureCnameRecord, findCnameRecord, resolveZoneId, resolveZoneIdForName, CNAME_VERCEL_TARGET, TIMEKAST_ZONE_NAME, provisionDomains, } from '../lib/cloudflare-api.js';
29
+ import { createDevelopBranch, createNeonProject, createProjectApiKey, deleteNeonApiKeysByName, deleteNeonProject, getConnectionUri, listNeonBranches, listNeonProjects, NeonApiError, NEON_API_BASE, NEON_DEVELOP_BRANCH, } from '../lib/neon-api.js';
30
+ import { defaultEnvPath, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
31
+ import { addVercelDomain, buildEnvPlan, createVercelProject, generateAuthSecret, getLinkedRepoId, getVercelDomainStatus, GitHubAppNotInstalledError, linkGitRepo, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, writeVercelProjectJson, } from '../lib/vercel-env.js';
32
+ import { adoptState, ADOPT_UNKNOWN, clearProvisionState, clearState, hasProvisionState, isServiceComplete, PROVISION_DIR, PROVISION_STATE_FILE, readDestroyTarget, readProvisionState, writeProvisionState, } from '../lib/provision-state.js';
33
+ /**
34
+ * The THREE GitHub Secrets the E2E runner (`pnpm test:e2e` → `e2e-runner.ts` +
35
+ * `setup:e2e`) reads to create an ISOLATED Neon branch per CI run. provision sets
36
+ * all three so a provisioned repo runs E2E with zero manual `setup:e2e`:
37
+ * - NEON_API_KEY — the project-scoped key (creates/deletes the per-run branch)
38
+ * - NEON_PROJECT_ID — the Neon project the per-run branch is created under
39
+ * - DATABASE_URL — the (non-prod develop) connection string the runner falls
40
+ * back to before it swaps in the per-run branch's own connection.
41
+ * Names MUST match what `e2e.yml` reads — a mismatch silently breaks CI E2E.
42
+ */
43
+ export const NEON_KEY_SECRET = 'NEON_API_KEY';
44
+ export const NEON_PROJECT_ID_SECRET = 'NEON_PROJECT_ID';
45
+ export const E2E_DATABASE_URL_SECRET = 'DATABASE_URL';
46
+ /**
47
+ * The label of the DEDICATED transactional-mail subdomain. Resend sends from
48
+ * `${MAIL_SUBDOMAIN}.<base>` — NEVER the apex `<base>`, which holds the org's (or a
49
+ * client's) corporate MX/SPF (Google Workspace, M365, …). Verifying Resend on the
50
+ * apex would collide with that mail; an isolated subdomain keeps both working. Same
51
+ * convention for the org (`updates.timekast.mx`) and every client (`updates.<client>`).
52
+ */
53
+ export const MAIL_SUBDOMAIN = 'updates';
54
+ /** Build the transactional-mail domain for a base domain: `updates.<base>`. */
55
+ export function mailDomainFor(base) {
56
+ return `${MAIL_SUBDOMAIN}.${base}`;
57
+ }
58
+ /**
59
+ * The canonical, ORG-LEVEL sender for transactional mail on provisioned stacks
60
+ * (issue §4 + §5). Sends from `noreply@updates.timekast.mx` — the dedicated subdomain
61
+ * verified in Resend, NOT the apex `timekast.mx` (which is on Google Workspace). The
62
+ * client's own sender is set in the `--domain` flow (PROV-006), using the same
63
+ * `updates.<client>` convention. The unit test pins this value.
64
+ */
65
+ export const PROVISION_EMAIL_FROM = `noreply@${mailDomainFor(TIMEKAST_ZONE_NAME)}`;
66
+ // `provisionDomains` moved to `cloudflare-api.ts` (next to TIMEKAST_ZONE_NAME) so
67
+ // `provision-state.ts`'s `--destroy` target can re-derive the dev domain from the
68
+ // slug without a circular import. Re-exported here to keep the public surface (and
69
+ // the existing `provision-slug` test import) stable.
70
+ export { provisionDomains };
71
+ /**
72
+ * Validate a slug as a DNS subdomain label BEFORE any REST call (issue §8). A
73
+ * label may contain only lowercase letters, digits and hyphens, may not start or
74
+ * end with a hyphen, and is ≤63 chars (the DNS label limit, with margin for the
75
+ * `-dev` suffix). An invalid slug (e.g. an underscore, uppercase, a leading dot)
76
+ * is rejected with a clear `CLIError` so no malformed domain ever reaches Vercel
77
+ * or Cloudflare.
78
+ *
79
+ * @throws {CLIError} when the slug cannot be a valid subdomain label.
80
+ */
81
+ export function assertValidSubdomainSlug(slug) {
82
+ if (!isValidSubdomainSlug(slug)) {
83
+ throw new CLIError(`El slug \`${slug}\` no es un subdominio válido para \`${TIMEKAST_ZONE_NAME}\`.\n` +
84
+ 'Usa solo minúsculas, dígitos y guiones (sin guion al inicio/fin, sin guion bajo ni puntos, ≤59 chars).');
85
+ }
86
+ }
87
+ /**
88
+ * Predicate form of {@link assertValidSubdomainSlug} — `true` when `slug` is a
89
+ * valid DNS subdomain label (lowercase/digits/hyphens, no leading/trailing hyphen,
90
+ * ≤59 chars so `-dev` fits the 63-char label limit). Used to GUARD a low-trust
91
+ * slug read from the state file before it reaches a shell command (FIX 2), without
92
+ * throwing — the caller skips + reports a malformed value instead.
93
+ */
94
+ export function isValidSubdomainSlug(slug) {
95
+ // A leading `-` is excluded by the regex anchor, but check explicitly too so the
96
+ // intent (anti flag-smuggling) is unmistakable.
97
+ if (slug.length === 0 || slug.length > 59 || slug.startsWith('-'))
98
+ return false;
99
+ return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug);
100
+ }
101
+ /**
102
+ * The services `--services` understands, in canonical provisioning order. `github`
103
+ * precedes `vercel`: PROV-004 (the `github` step) builds the git substrate AND
104
+ * creates the Vercel PROJECT, whose `projectId` PROV-003 (the `vercel` env-sync
105
+ * step) then consumes. The order is normalized regardless of `--services` input.
106
+ */
107
+ const KNOWN_SERVICES = ['neon', 'github', 'vercel', 'dns'];
108
+ /** The known `--dns` route tokens, for validation. */
109
+ const KNOWN_DNS_ROUTES = ['org', 'cliente-cf', 'registrar'];
110
+ /** Default verification-poll timeout (minutes) and interval (seconds) — issue §4. */
111
+ export const DEFAULT_VERIFY_TIMEOUT_MIN = 10;
112
+ export const VERIFY_POLL_INTERVAL_SEC = 30;
113
+ /** Read the value of a `--key=value` or `--key value` flag from an argv slice. */
114
+ function readValueFlag(argv, name) {
115
+ const prefix = `--${name}=`;
116
+ const inline = argv.find((arg) => arg.startsWith(prefix));
117
+ if (inline)
118
+ return inline.slice(prefix.length);
119
+ const idx = argv.indexOf(`--${name}`);
120
+ if (idx !== -1) {
121
+ const next = argv[idx + 1];
122
+ if (next && !next.startsWith('-'))
123
+ return next;
124
+ }
125
+ return undefined;
126
+ }
127
+ /**
128
+ * Parse `--services=a,b,c` into the typed service list, rejecting unknown tokens
129
+ * with a `CLIError` (a typo must not silently provision nothing). An empty/absent
130
+ * flag yields an empty list — the command turns that into a "nothing to do" error
131
+ * so the failure is explicit.
132
+ */
133
+ export function parseServices(raw) {
134
+ if (!raw)
135
+ return [];
136
+ const tokens = raw
137
+ .split(',')
138
+ .map((t) => t.trim())
139
+ .filter(Boolean);
140
+ const services = [];
141
+ for (const token of tokens) {
142
+ if (!KNOWN_SERVICES.includes(token)) {
143
+ throw new CLIError(`Servicio desconocido en \`--services\`: \`${token}\`. ` +
144
+ `Válidos: ${KNOWN_SERVICES.join(', ')}.`);
145
+ }
146
+ const service = token;
147
+ if (!services.includes(service))
148
+ services.push(service);
149
+ }
150
+ // Normalize to canonical provisioning order regardless of input order.
151
+ return KNOWN_SERVICES.filter((s) => services.includes(s));
152
+ }
153
+ /**
154
+ * Parse `--dns=<route>` into a typed {@link DnsRoute}, defaulting to `org` when
155
+ * absent and rejecting an unknown token with a `CLIError` (a typo must not silently
156
+ * pick the wrong route — `--dns=registar` would skip CNAME creation by accident).
157
+ */
158
+ export function parseDnsRoute(raw) {
159
+ if (!raw)
160
+ return 'org';
161
+ const token = raw.trim();
162
+ if (!KNOWN_DNS_ROUTES.includes(token)) {
163
+ throw new CLIError(`Ruta de DNS desconocida en \`--dns\`: \`${token}\`. ` +
164
+ `Válidas: ${KNOWN_DNS_ROUTES.join(', ')}.`);
165
+ }
166
+ return token;
167
+ }
168
+ /**
169
+ * Parse `--verify-timeout=<min>` into a positive integer of MINUTES, defaulting to
170
+ * {@link DEFAULT_VERIFY_TIMEOUT_MIN}. A non-numeric or non-positive value is a
171
+ * `CLIError` (a `0`/negative timeout would never poll).
172
+ */
173
+ export function parseVerifyTimeout(raw) {
174
+ if (raw === undefined)
175
+ return DEFAULT_VERIFY_TIMEOUT_MIN;
176
+ const minutes = Number(raw);
177
+ if (!Number.isFinite(minutes) || !Number.isInteger(minutes) || minutes <= 0) {
178
+ throw new CLIError(`\`--verify-timeout\` debe ser un entero positivo de minutos (recibí \`${raw}\`).`);
179
+ }
180
+ return minutes;
181
+ }
182
+ /** Parse `provision`'s argv slice into typed flags. */
183
+ export function parseProvisionFlags(argv) {
184
+ return {
185
+ services: parseServices(readValueFlag(argv, 'services')),
186
+ team: readValueFlag(argv, 'team'),
187
+ orgId: readValueFlag(argv, 'org-id'),
188
+ slug: readValueFlag(argv, 'slug'),
189
+ domain: readValueFlag(argv, 'domain'),
190
+ dns: parseDnsRoute(readValueFlag(argv, 'dns')),
191
+ verifyTimeout: parseVerifyTimeout(readValueFlag(argv, 'verify-timeout')),
192
+ confirm: argv.includes('--confirm'),
193
+ dryRun: argv.includes('--dry-run'),
194
+ ephemeral: argv.includes('--ephemeral'),
195
+ resume: argv.includes('--resume'),
196
+ force: argv.includes('--force'),
197
+ json: argv.includes('--json'),
198
+ adopt: argv.includes('--adopt'),
199
+ destroy: argv.includes('--destroy'),
200
+ };
201
+ }
202
+ /** Human label for a service (es-MX), for the plain-text plan. */
203
+ const SERVICE_LABEL = {
204
+ neon: 'Neon (proyecto + branch develop)',
205
+ vercel: 'Vercel (env sync por target)',
206
+ github: 'GitHub + Vercel (sustrato git + proyecto + env + E2E)',
207
+ dns: 'DNS + Resend (dominio del entorno)',
208
+ };
209
+ /** Build the ordered plan: each requested service is `skip` if complete, else `create`. */
210
+ function buildPlan(services, state) {
211
+ return services.map((service) => ({
212
+ service,
213
+ action: isServiceComplete(state, service) ? 'skip' : 'create',
214
+ }));
215
+ }
216
+ /** Print the plain-text plan (the `--dry-run` and pre-run narration). */
217
+ function printPlan(result) {
218
+ console.log(`Plan de provision para \`${result.projectName}\` (lifecycle: ${result.lifecycle}):`);
219
+ for (const step of result.steps) {
220
+ const mark = step.action === 'skip' ? '⏭ (ya completado)' : '+ crear';
221
+ console.log(` ${mark} ${SERVICE_LABEL[step.service]}`);
222
+ // For the DNS step, list the EXACT domains + records the run would create
223
+ // (issue §4 AC: `--dry-run` lists the two domains and the two DNS records).
224
+ if (step.service === 'dns' && step.action === 'create') {
225
+ const { dev, prod } = provisionDomains(result.projectName);
226
+ console.log(` · Vercel dominio ${dev} (gitBranch: develop)`);
227
+ console.log(` · Vercel dominio ${prod} (producción)`);
228
+ console.log(` · Cloudflare CNAME ${dev} → cname.vercel-dns.com (proxied:false)`);
229
+ console.log(` · Cloudflare CNAME ${prod} → cname.vercel-dns.com (proxied:false)`);
230
+ console.log(` · Resend env: RESEND_API_KEY [production, preview] + EMAIL_FROM=${PROVISION_EMAIL_FROM}`);
231
+ }
232
+ }
233
+ if (result.dryRun) {
234
+ console.log('\n(`--dry-run`) No se ejecutó ninguna llamada a API ni se escribió estado.');
235
+ }
236
+ }
237
+ /**
238
+ * Derive the project name for the state. Until a `--name` flag exists (out of
239
+ * scope here — the interactive layer supplies it, EPIC-17), default to the
240
+ * `--org-id`/`--team` scope or a placeholder so the scaffold has a stable key.
241
+ */
242
+ function deriveProjectName(flags, rootDir) {
243
+ // `--slug` is the canonical stack identifier (drives domains + resource names);
244
+ // it wins over the dir name. Validate it as a subdomain label when given,
245
+ // since it flows into `{slug}.timekast.mx` downstream (PROV-005).
246
+ if (flags.slug) {
247
+ const slug = flags.slug.toLowerCase(); // every resource is lowercase (repo/dir/domains)
248
+ assertValidSubdomainSlug(slug);
249
+ return slug;
250
+ }
251
+ // No `--slug`: the project name is the directory provision runs in (the scaffold
252
+ // dir), NOT the org/team scope. A team id like `team_ldqH...` is neither a real
253
+ // project name nor a valid subdomain, and falling back to it produced a confusing
254
+ // `repo TimeKast/team_ldqH... no existe` error. Validate it like an explicit slug.
255
+ // Lowercased so a dir created with mixed case (e.g. `Edmond/`) yields a valid slug
256
+ // instead of being rejected for the uppercase — matches the `new` normalization.
257
+ const fromDir = basename(rootDir).toLowerCase();
258
+ assertValidSubdomainSlug(fromDir);
259
+ return fromDir;
260
+ }
261
+ /**
262
+ * Read the VALUE of `NEON_API_KEY` (the shared org key) from the rail
263
+ * (`~/.claude/.env`). Mirrors `readVercelToken`: split on the FIRST `=`, strip a
264
+ * single matched pair of surrounding quotes, return `null` when absent. This is
265
+ * the ONE place the org key VALUE is read; it is handed straight to the Neon
266
+ * `Authorization` header and never logged.
267
+ */
268
+ export function readNeonApiKey(envPath = defaultEnvPath()) {
269
+ if (!existsSync(envPath))
270
+ return null;
271
+ let text;
272
+ try {
273
+ text = readFileSync(envPath, 'utf8');
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ for (const line of text.split('\n')) {
279
+ const trimmed = line.trimStart();
280
+ if (trimmed === '' || trimmed.startsWith('#'))
281
+ continue;
282
+ const eq = line.indexOf('=');
283
+ if (eq === -1)
284
+ continue;
285
+ if (line.slice(0, eq).trim() !== 'NEON_API_KEY')
286
+ continue;
287
+ let value = line.slice(eq + 1).replace(/\s+$/, '');
288
+ if (value.length >= 2 &&
289
+ ((value.startsWith('"') && value.endsWith('"')) ||
290
+ (value.startsWith("'") && value.endsWith("'")))) {
291
+ value = value.slice(1, -1);
292
+ }
293
+ return value === '' ? null : value;
294
+ }
295
+ return null;
296
+ }
297
+ /**
298
+ * Read the VALUE of a single rail key from the rail (`~/.claude/.env`), with the
299
+ * same first-`=` split + matched-quote strip as {@link readNeonApiKey} /
300
+ * `readVercelToken`. Returns `null` when the file or the key is absent/empty. The
301
+ * value is handed straight to an `Authorization` header by the caller and never
302
+ * logged (no-echo). Centralized so the Cloudflare token reader does not become a
303
+ * fourth copy of the same parse loop.
304
+ */
305
+ function readGateValue(key, envPath) {
306
+ if (!existsSync(envPath))
307
+ return null;
308
+ let text;
309
+ try {
310
+ text = readFileSync(envPath, 'utf8');
311
+ }
312
+ catch {
313
+ return null;
314
+ }
315
+ for (const line of text.split('\n')) {
316
+ const trimmed = line.trimStart();
317
+ if (trimmed === '' || trimmed.startsWith('#'))
318
+ continue;
319
+ const eq = line.indexOf('=');
320
+ if (eq === -1)
321
+ continue;
322
+ if (line.slice(0, eq).trim() !== key)
323
+ continue;
324
+ let value = line.slice(eq + 1).replace(/\s+$/, '');
325
+ if (value.length >= 2 &&
326
+ ((value.startsWith('"') && value.endsWith('"')) ||
327
+ (value.startsWith("'") && value.endsWith("'")))) {
328
+ value = value.slice(1, -1);
329
+ }
330
+ return value === '' ? null : value;
331
+ }
332
+ return null;
333
+ }
334
+ /**
335
+ * Read the VALUE of `CLOUDFLARE_API_TOKEN` (the shared org zone-scoped DNS-edit token)
336
+ * from the rail. This is the ONE place the Cloudflare token VALUE is read; it is
337
+ * handed straight to the Cloudflare `Authorization` header and never logged.
338
+ */
339
+ export function readCloudflareApiKey(envPath = defaultEnvPath()) {
340
+ return readGateValue('CLOUDFLARE_API_TOKEN', envPath);
341
+ }
342
+ /**
343
+ * Map a Neon REST failure to an actionable, es-MX `CLIError` (issue §8 edges).
344
+ * `context` names the step that failed so the dev knows where the run stopped.
345
+ */
346
+ function neonErrorToCliError(error, projectName, context) {
347
+ switch (error.status) {
348
+ case 401:
349
+ return new CLIError(`El \`NEON_API_KEY\` del archivo de secretos es inválido o expiró (Neon respondió 401 en ${context}).\n` +
350
+ 'Ejecuta `factory secrets <archivo>` para renovarlo y reintenta con `--resume`.');
351
+ case 402:
352
+ case 429:
353
+ return new CLIError(`Neon rechazó la operación por cuota/facturación (respondió ${error.status} en ${context}).\n` +
354
+ 'Revisa el plan/cuota de proyectos Neon de la org y reintenta con `--resume`.');
355
+ case 409:
356
+ return new CLIError(`Ya existe un proyecto Neon con un nombre que choca con \`${projectName}\` (Neon respondió 409 en ${context}).\n` +
357
+ 'Usa `--force` para descartar el estado y empezar de cero con otro nombre, o elimina el proyecto previo.');
358
+ default:
359
+ return new CLIError(`Falló la llamada a Neon en ${context}: ${error.message}`);
360
+ }
361
+ }
362
+ /** The GitHub repo (`owner/name`) the project-scoped secret is pushed to. */
363
+ function neonSecretRepo(state) {
364
+ // PROV-004 records the created repo in `state.github.repo`; before it runs we
365
+ // fall back to the canonical `org/projectName` so the secret push is attempted
366
+ // (a non-existent repo → §8 pending path, never an abort).
367
+ return state.github?.repo ?? `${FACTORY_ORG}/${state.projectName}`;
368
+ }
369
+ /**
370
+ * Push a GitHub Secret. `gh secret set` reads the VALUE from STDIN when `--body`
371
+ * is omitted (no-echo: the value never lands in argv / the process list / a log —
372
+ * same discipline as `secrets.ts`). NOTE: `gh secret set` has NO `--body-file`
373
+ * flag (only `--body`/`--env-file`); passing one makes gh exit 1 ("unknown flag"),
374
+ * which silently failed every secret push before — stdin is the correct seam.
375
+ */
376
+ async function setGithubSecret(key, value, repo) {
377
+ await execa('gh', ['secret', 'set', key, '--repo', repo], { input: value });
378
+ }
379
+ /**
380
+ * PROV-002 — provision Neon: create the project (its primary branch = production
381
+ * `main`), create a CLEAN `develop` branch, read POOLED connection strings for
382
+ * both, mint a PROJECT-SCOPED API key, and store the E2E GitHub Secrets
383
+ * (NEON_API_KEY + NEON_PROJECT_ID + DATABASE_URL) so CI E2E works with zero manual
384
+ * `setup:e2e`. State is written ATOMICALLY after the project create and again
385
+ * after `develop`, so a mid-step failure leaves a resumable partial state
386
+ * (issue §6 + §8 Gherkin).
387
+ *
388
+ * Endpoint order (asserted by the unit test): POST /projects → POST branches
389
+ * (develop) → GET connection_uri (main) → GET connection_uri (develop) →
390
+ * POST /api_keys → `gh secret set`.
391
+ *
392
+ * `--resume` short-circuits ENTIRELY when `state.neon.projectId` already exists
393
+ * (caller skips the step via `isServiceComplete`; this is a defensive guard too).
394
+ *
395
+ * @throws {CLIError} mapping the Neon §8 edges (401/402/409/429) and a `develop`
396
+ * failure (state saved partial → resumable). A failed `gh secret set` does NOT
397
+ * abort: it records `apiKeySecretPending` and reports (issue §8).
398
+ */
399
+ export async function provisionNeon(rootDir, state, deps = {}) {
400
+ // Fully complete only when the FINAL step landed (key minted + stored as the GH
401
+ // secret). developBranchId alone is NOT "done": re-entering must finish the
402
+ // remaining steps WITHOUT re-minting the key. Every sub-step below is idempotent.
403
+ if (state.neon?.apiKeySecretDone)
404
+ return;
405
+ const envPath = deps.envPath ?? defaultEnvPath();
406
+ const apiKey = readNeonApiKey(envPath);
407
+ if (!apiKey) {
408
+ throw new CLIError('No se pudo leer `NEON_API_KEY` de `~/.claude/.env`.\n' +
409
+ 'Ejecuta `factory secrets <archivo>` para bajar la key del archivo de secretos y reintenta.');
410
+ }
411
+ const setSecret = deps.setSecret ?? setGithubSecret;
412
+ const apiDeps = { fetchImpl: deps.fetchImpl };
413
+ // Neon org-id from the rail (`NEON_ORGANIZATION_ID`), like the Vercel team-id —
414
+ // org-fixed config, not hardcoded. Neon REQUIRES it for org-scoped API keys.
415
+ const orgId = readGateValue('NEON_ORGANIZATION_ID', envPath) ?? undefined;
416
+ // 1. Reuse the project from a partial run; create it only if missing. Persist it
417
+ // immediately so a branch failure leaves projectId + mainBranchId durable.
418
+ let projectId;
419
+ let mainBranchId;
420
+ if (state.neon?.projectId && state.neon?.mainBranchId) {
421
+ projectId = state.neon.projectId;
422
+ mainBranchId = state.neon.mainBranchId;
423
+ }
424
+ else {
425
+ let project;
426
+ try {
427
+ project = await createNeonProject(apiKey, state.projectName, orgId, apiDeps);
428
+ }
429
+ catch (error) {
430
+ if (error instanceof NeonApiError) {
431
+ throw neonErrorToCliError(error, state.projectName, 'crear el proyecto');
432
+ }
433
+ throw error;
434
+ }
435
+ projectId = project.projectId;
436
+ mainBranchId = project.mainBranchId;
437
+ state.neon = { projectId, mainBranchId, developBranchId: '' };
438
+ writeProvisionState(rootDir, state);
439
+ }
440
+ // 2. CLEAN `develop` branch — reuse the recorded id; else create it (idempotent
441
+ // on a 409: createDevelopBranch reuses an existing develop) and persist the id
442
+ // IMMEDIATELY (FIX 6 — it was saved only at step 3, so a connection-string
443
+ // failure lost it and the resume re-created → 409).
444
+ let developBranchId = state.neon?.developBranchId ?? '';
445
+ if (!developBranchId) {
446
+ try {
447
+ developBranchId = await createDevelopBranch(apiKey, projectId, mainBranchId, apiDeps);
448
+ }
449
+ catch (error) {
450
+ // Save the partial state (issue §8 Gherkin: "guarda el state parcial").
451
+ // IDs only — never a connection string (those are re-derived from Neon on demand).
452
+ state.neon = { projectId, mainBranchId, developBranchId: '' };
453
+ writeProvisionState(rootDir, state);
454
+ if (error instanceof NeonApiError) {
455
+ throw new CLIError(`El proyecto Neon se creó pero falló la creación del branch \`develop\` (${error.message}).\n` +
456
+ 'El estado parcial se guardó; reintenta con `--resume` para crear solo el branch develop.');
457
+ }
458
+ throw error;
459
+ }
460
+ state.neon = { projectId, mainBranchId, developBranchId };
461
+ writeProvisionState(rootDir, state);
462
+ }
463
+ // 3. Read the develop branch's POOLED connection string (idempotent GET) — the
464
+ // ONLY string this step needs, for the E2E `DATABASE_URL` secret below. provision
465
+ // writes NO credentials file: the runtime/.env.local/migrate strings are
466
+ // re-derived on demand from Neon by their own steps (`resolveNeonSecrets`), since
467
+ // Neon is the source of truth.
468
+ let developDatabaseUrl;
469
+ try {
470
+ developDatabaseUrl = await getConnectionUri(apiKey, projectId, developBranchId, true, apiDeps);
471
+ }
472
+ catch (error) {
473
+ if (error instanceof NeonApiError) {
474
+ throw neonErrorToCliError(error, state.projectName, 'leer el connection string del branch develop');
475
+ }
476
+ throw error;
477
+ }
478
+ // 4. Mint a PROJECT-SCOPED API key (blast radius = this project only). Only the
479
+ // `apiKeySecretDone` guard above prevents re-minting; a resume after a pending
480
+ // secret push DOES re-mint (the prior key value was never persisted — it is a
481
+ // credential) and the old key is left orphaned. Acceptable: it only happens
482
+ // when the GH secret push keeps failing (e.g. the repo does not exist yet).
483
+ let projectKey;
484
+ try {
485
+ projectKey = await createProjectApiKey(apiKey, projectId, `${state.projectName}-e2e`, apiDeps);
486
+ }
487
+ catch (error) {
488
+ if (error instanceof NeonApiError) {
489
+ throw neonErrorToCliError(error, state.projectName, 'crear la API key project-scoped');
490
+ }
491
+ throw error;
492
+ }
493
+ // 5. Store the THREE GitHub Secrets the E2E runner needs (NEON_API_KEY +
494
+ // NEON_PROJECT_ID + DATABASE_URL) so `pnpm test:e2e` works in CI with zero
495
+ // manual `setup:e2e`. On success mark Neon done so a resume short-circuits
496
+ // (and never re-mints). A failure is NON-fatal (issue §8): the repo may not
497
+ // exist yet — record pending and report; a `--resume` retries once it does.
498
+ try {
499
+ const repo = neonSecretRepo(state);
500
+ await setSecret(NEON_KEY_SECRET, projectKey, repo);
501
+ await setSecret(NEON_PROJECT_ID_SECRET, projectId, repo);
502
+ await setSecret(E2E_DATABASE_URL_SECRET, developDatabaseUrl, repo);
503
+ state.neon = { projectId, mainBranchId, developBranchId, apiKeySecretDone: true };
504
+ writeProvisionState(rootDir, state);
505
+ }
506
+ catch {
507
+ state.neon = { projectId, mainBranchId, developBranchId, apiKeySecretPending: true };
508
+ writeProvisionState(rootDir, state);
509
+ console.warn(`⚠ No se pudieron guardar los secrets de E2E ` +
510
+ `(\`${NEON_KEY_SECRET}\`/\`${NEON_PROJECT_ID_SECRET}\`/\`${E2E_DATABASE_URL_SECRET}\`) ` +
511
+ `en \`${neonSecretRepo(state)}\` (¿el repo no existe aún o falta permiso?).\n` +
512
+ ' La API key project-scoped se creó; reintenta con `--resume` cuando el repo exista.');
513
+ }
514
+ }
515
+ /**
516
+ * Resolve the four pooled+direct Neon connection strings, ALWAYS by re-deriving
517
+ * fresh from Neon — provision persists NO credentials file (Neon is the source of
518
+ * truth). Uses the rail `NEON_API_KEY` + the branch ids in the committed state.
519
+ * Returns `null` only when the key or the branch ids are missing, so the caller can
520
+ * raise an actionable error.
521
+ *
522
+ * The re-derive is the NORMAL path now (not a clone/resume fallback): every
523
+ * top-level step that needs the strings (vercel env + `.env.local`, migrate secrets)
524
+ * calls this independently, so a full run re-derives a few times — a handful of fast
525
+ * GETs, deliberately un-cached (KISS: no ambiguous in-memory cache lifetime).
526
+ *
527
+ * `deps` mirrors `provisionVercel`'s injectable seams so the unit test drives the
528
+ * re-derive with a mock `fetchImpl`.
529
+ */
530
+ async function resolveNeonSecrets(state, envPath, deps = {}) {
531
+ const projectId = state.neon?.projectId;
532
+ const mainBranchId = state.neon?.mainBranchId;
533
+ const developBranchId = state.neon?.developBranchId;
534
+ if (!projectId || !mainBranchId || !developBranchId)
535
+ return null;
536
+ const apiKey = readNeonApiKey(envPath);
537
+ if (!apiKey)
538
+ return null;
539
+ const apiDeps = { fetchImpl: deps.fetchImpl };
540
+ // pooled → serverless/concurrent runtime (Vercel app); direct → single-process
541
+ // migrations + tooling (`.env.local`, CI migrate). See `getConnectionUri`.
542
+ const mainPooled = await getConnectionUri(apiKey, projectId, mainBranchId, true, apiDeps);
543
+ const mainDirect = await getConnectionUri(apiKey, projectId, mainBranchId, false, apiDeps);
544
+ const developPooled = await getConnectionUri(apiKey, projectId, developBranchId, true, apiDeps);
545
+ const developDirect = await getConnectionUri(apiKey, projectId, developBranchId, false, apiDeps);
546
+ return { mainPooled, mainDirect, developPooled, developDirect };
547
+ }
548
+ /**
549
+ * Map a Vercel REST failure to an actionable, es-MX `CLIError` (issue §8 edges).
550
+ * `context` names what failed so the dev knows where the run stopped.
551
+ */
552
+ function vercelErrorToCliError(error, teamId, context) {
553
+ switch (error.status) {
554
+ case 401:
555
+ return new CLIError(`El \`VERCEL_TOKEN\` del archivo de secretos es inválido o expiró (Vercel respondió 401 en ${context}).\n` +
556
+ 'Ejecuta `factory secrets <archivo>` para renovarlo y reintenta con `--resume`.');
557
+ case 403:
558
+ return new CLIError(`El \`VERCEL_TOKEN\` no tiene acceso al team \`${teamId}\` (Vercel respondió 403 en ${context}).\n` +
559
+ 'Verifica que el token está scopeado al team del org y reintenta con `--resume`.');
560
+ case 402:
561
+ case 429:
562
+ return new CLIError(`Vercel rechazó la operación por cuota/facturación (respondió ${error.status} en ${context}).\n` +
563
+ 'Revisa el plan del team y reintenta con `--resume`.');
564
+ default:
565
+ return new CLIError(`Falló la llamada a Vercel en ${context}: ${error.message}`);
566
+ }
567
+ }
568
+ /** Read the shared, non-environment-specific env values from the rail (no-echo). */
569
+ function readSharedVercelEnv(envPath) {
570
+ const shared = {};
571
+ if (!existsSync(envPath))
572
+ return shared;
573
+ let text;
574
+ try {
575
+ text = readFileSync(envPath, 'utf8');
576
+ }
577
+ catch {
578
+ return shared;
579
+ }
580
+ // Shared (cross-environment) keys the provision step propagates when present.
581
+ const SHARED_KEYS = new Set(['RESEND_API_KEY', 'EMAIL_FROM', 'CLOUDFLARE_API_TOKEN']);
582
+ for (const line of text.split('\n')) {
583
+ const trimmed = line.trimStart();
584
+ if (trimmed === '' || trimmed.startsWith('#'))
585
+ continue;
586
+ // First-`=` split (env-merge.ts rule): a value may itself contain `=`.
587
+ const eq = line.indexOf('=');
588
+ if (eq === -1)
589
+ continue;
590
+ const key = line.slice(0, eq).trim();
591
+ if (!SHARED_KEYS.has(key))
592
+ continue;
593
+ let value = line.slice(eq + 1).replace(/\s+$/, '');
594
+ if (value.length >= 2 &&
595
+ ((value.startsWith('"') && value.endsWith('"')) ||
596
+ (value.startsWith("'") && value.endsWith("'")))) {
597
+ value = value.slice(1, -1);
598
+ }
599
+ if (value !== '')
600
+ shared[key] = value;
601
+ }
602
+ return shared;
603
+ }
604
+ /** Default `pnpm install` runner — `execa` in the derived dir, no shell. */
605
+ async function pnpmInstall(cwd) {
606
+ await execa('pnpm', ['install'], { cwd });
607
+ }
608
+ /**
609
+ * PROV-003 — sync env into the Vercel project: thread `teamId` on EVERY REST call,
610
+ * push variables PER TARGET (production vs preview), mint an INDEPENDENT
611
+ * `AUTH_SECRET` per environment, write `.vercel/project.json` (WITHOUT `vercel
612
+ * link`/`pull`), and `pnpm install` the derived dir.
613
+ *
614
+ * Requires `state.neon` (the pooled connection strings) and `state.vercel.projectId`
615
+ * — the project itself is created by PROV-004. When `projectId` is absent, this
616
+ * throws a clear `CLIError` telling the dev to run the Vercel project create first.
617
+ *
618
+ * No-echo: secret VALUES never reach stdout — only KEY NAMES + targets are printed.
619
+ */
620
+ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
621
+ const projectId = state.vercel?.projectId;
622
+ if (!projectId) {
623
+ throw new CLIError('Falta el `projectId` de Vercel en el estado: el proyecto Vercel lo crea PROV-004.\n' +
624
+ 'Corre el paso de creación del proyecto Vercel primero, luego reintenta con `--resume`.');
625
+ }
626
+ const envPath = deps.envPath ?? defaultEnvPath();
627
+ const token = readVercelToken(envPath);
628
+ if (!token) {
629
+ throw new CLIError('No se pudo leer `VERCEL_TOKEN` de `~/.claude/.env`.\n' +
630
+ 'Ejecuta `factory secrets <archivo>` para bajar el token del archivo de secretos y reintenta.');
631
+ }
632
+ // Re-derive the Neon connection strings fresh from Neon (provision keeps NO
633
+ // credentials file — Neon is the source of truth). Returns null only when the rail
634
+ // key or the branch ids are missing.
635
+ let neonSecrets;
636
+ try {
637
+ neonSecrets = await resolveNeonSecrets(state, envPath, { fetchImpl: deps.fetchImpl });
638
+ }
639
+ catch (error) {
640
+ if (error instanceof NeonApiError) {
641
+ throw neonErrorToCliError(error, state.projectName, 're-derivar los connection strings de Neon');
642
+ }
643
+ throw error;
644
+ }
645
+ if (!neonSecrets) {
646
+ throw new CLIError('No se pudieron re-derivar los connection strings de Neon: aprovisiona Neon primero ' +
647
+ '(`--services=neon,...`) con `NEON_API_KEY` en el rail y reintenta con `--resume`.');
648
+ }
649
+ // Write the project link FIRST (no vercel link/pull) so downstream steps and a
650
+ // `--resume` can read `{ projectId, orgId, teamId }` even if the push fails.
651
+ writeVercelProjectJson(rootDir, { projectId, orgId: teamId, teamId }, { force: deps.force });
652
+ // Mint an INDEPENDENT AUTH_SECRET per environment (issue §4 — never shared).
653
+ // POOLED strings → the Vercel runtime DATABASE_URL (serverless/concurrent).
654
+ const plan = buildEnvPlan({
655
+ mainDatabaseUrl: neonSecrets.mainPooled,
656
+ developDatabaseUrl: neonSecrets.developPooled,
657
+ authSecretProd: generateAuthSecret(),
658
+ authSecretPreview: generateAuthSecret(),
659
+ shared: readSharedVercelEnv(envPath),
660
+ });
661
+ let result;
662
+ try {
663
+ result = await pushEnvPlan(projectId, token, teamId, plan, { fetchImpl: deps.fetchImpl });
664
+ }
665
+ catch (error) {
666
+ if (error instanceof VercelApiError) {
667
+ throw vercelErrorToCliError(error, teamId, 'sincronizar las variables de entorno');
668
+ }
669
+ throw error;
670
+ }
671
+ // Record the Vercel state with envSynced — the env push (this step) is done, so a
672
+ // resume short-circuits here. `projectId` was set by the github step (PROV-004).
673
+ state.vercel = { projectId, envSynced: true };
674
+ writeProvisionState(rootDir, state);
675
+ // No-echo: print only KEY NAMES + targets, never the values.
676
+ for (const v of result.created) {
677
+ console.log(` + ${v.key} [${v.target.join(', ')}]`);
678
+ }
679
+ for (const v of result.updated) {
680
+ console.log(` ↻ ${v.key} [${v.target.join(', ')}]`);
681
+ }
682
+ // Generate the gitignored `.env.local` so the dev can run the app, migrations and
683
+ // seeds locally (`pnpm dev`/`db:migrate`/`db:seed:admin` read `.env.local`). Uses
684
+ // the DIRECT strings (single-process tooling/migrations, never the pooler).
685
+ // DATABASE_URL → develop (safe default), DATABASE_URL_MAIN → main (db:query:main).
686
+ const envLocal = writeEnvLocal(rootDir, neonSecrets, { force: deps.force });
687
+ if (envLocal === 'written') {
688
+ console.log(' + .env.local (DATABASE_URL=develop · DATABASE_URL_MAIN=main · AUTH_SECRET)');
689
+ }
690
+ else if (envLocal === 'skipped') {
691
+ console.log(' ⏭ .env.local ya existía (no se sobrescribe — usa --force para regenerarlo)');
692
+ }
693
+ // `pnpm install` the derived dir (issue §4). A missing package.json (non-Node
694
+ // derivative) is reported, NOT fatal (issue §8).
695
+ if (existsSync(join(rootDir, 'package.json'))) {
696
+ const runInstall = deps.runInstall ?? pnpmInstall;
697
+ try {
698
+ await runInstall(rootDir);
699
+ }
700
+ catch (error) {
701
+ console.warn(`⚠ \`pnpm install\` falló en el directorio del derivado (${error?.message ?? 'error'}).\n` +
702
+ ' Las variables de Vercel ya se sincronizaron; corre `pnpm install` a mano y reintenta el deploy.');
703
+ }
704
+ }
705
+ else {
706
+ console.warn('⚠ No hay `package.json` en el directorio del derivado — omito `pnpm install` ' +
707
+ '(derivado non-Node). Las variables de Vercel ya se sincronizaron.');
708
+ }
709
+ // First deployment. provision links the repo to Vercel AFTER `factory new`/
710
+ // provision already pushed `main` + `develop`, so Vercel sees no push event and
711
+ // builds nothing (the project sits at 0 deployments). Fire one production build
712
+ // (`main`) + one preview build (`develop`) from the git refs — AFTER the env
713
+ // sync above, so the build has its DATABASE_URL. NON-FATAL and we do NOT wait
714
+ // for the build: a failure must not abort a provision whose project + env are
715
+ // already done.
716
+ try {
717
+ const repoId = await getLinkedRepoId(projectId, token, teamId, { fetchImpl: deps.fetchImpl });
718
+ if (repoId === null) {
719
+ console.warn('⚠ El proyecto Vercel no está ligado a un repo — omito el primer deployment. ' +
720
+ 'Conéctalo y pushea, o redeploya a mano.');
721
+ }
722
+ else {
723
+ await triggerDeployment(token, teamId, { projectId, name: state.projectName, repoId, ref: 'main', production: true }, { fetchImpl: deps.fetchImpl });
724
+ await triggerDeployment(token, teamId, { projectId, name: state.projectName, repoId, ref: 'develop', production: false }, { fetchImpl: deps.fetchImpl });
725
+ console.log(' ↑ deployments disparados: main (production) · develop (preview)');
726
+ }
727
+ }
728
+ catch (error) {
729
+ console.warn(`⚠ No se pudo disparar el primer deployment (${error?.message ?? 'error'}). ` +
730
+ 'El proyecto + las env vars ya están listos; pushea o redeploya a mano.');
731
+ }
732
+ }
733
+ /**
734
+ * Generate the gitignored `.env.local` from the derivative's `.env.example`, filling
735
+ * the two Neon connection strings + a fresh AUTH_SECRET. Without this the dev cannot
736
+ * run `pnpm dev`/`db:migrate`/`db:seed:admin` locally — those read `.env.local`, which
737
+ * provision otherwise never creates (it only syncs env to Vercel).
738
+ *
739
+ * Uses the DIRECT (non-pooler) strings: `.env.local` drives single-process tooling
740
+ * (`drizzle-kit migrate`/`generate`, `db:seed`, `db:query`) + local `pnpm dev` — none
741
+ * of which want the PgBouncer pooler (the pooled host is only for the serverless
742
+ * runtime on Vercel). Do NOT swap these back to the pooled variants.
743
+ *
744
+ * DATABASE_URL → the develop branch (safe default for local work); DATABASE_URL_MAIN
745
+ * → the main branch (read by `pnpm db:query:main`). NEVER clobbers an existing
746
+ * `.env.local` (the dev may have edited it) unless `--force`. The `.env.example` stays
747
+ * the SSOT of which keys exist; the keys are appended if absent (older scaffolds).
748
+ */
749
+ export function writeEnvLocal(rootDir, neon, opts = {}) {
750
+ const examplePath = join(rootDir, '.env.example');
751
+ if (!existsSync(examplePath))
752
+ return 'no-example';
753
+ const localPath = join(rootDir, '.env.local');
754
+ if (existsSync(localPath) && !opts.force)
755
+ return 'skipped';
756
+ let body = readFileSync(examplePath, 'utf8');
757
+ body = setEnvLine(body, 'DATABASE_URL', neon.developDirect);
758
+ body = setEnvLine(body, 'DATABASE_URL_MAIN', neon.mainDirect);
759
+ body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
760
+ writeFileSync(localPath, body, 'utf8');
761
+ return 'written';
762
+ }
763
+ /**
764
+ * Replace the value of `KEY=...` in an env-file body, preserving every other line.
765
+ * Appends `KEY="value"` when the key is absent (so an older `.env.example` without
766
+ * `DATABASE_URL_MAIN` still gets it). The value is double-quoted — Neon URLs (pooled
767
+ * or direct) carry `?`/`&` query params that an unquoted dotenv value would truncate.
768
+ */
769
+ function setEnvLine(body, key, value) {
770
+ const re = new RegExp(`^${key}=.*$`, 'm');
771
+ const line = `${key}="${value}"`;
772
+ return re.test(body) ? body.replace(re, line) : `${body.trimEnd()}\n${line}\n`;
773
+ }
774
+ /**
775
+ * The HTTPS git URL of the repo `gh repo create` made (`new.ts` creates
776
+ * `${FACTORY_ORG}/${name}` — the canonical remote is its `.git` HTTPS URL). Never
777
+ * SSH: the remote is added with the dev's `gh`-authenticated HTTPS credential.
778
+ */
779
+ function repoHttpsUrl(repoSlug) {
780
+ return `https://github.com/${repoSlug}.git`;
781
+ }
782
+ /** Default git runner — `execa` in the derived dir, no shell. */
783
+ async function gitInDir(args, cwd) {
784
+ const { stdout } = await execa('git', args, { cwd });
785
+ return { stdout };
786
+ }
787
+ /**
788
+ * Read the current `origin` remote URL (or `null` when there is none). Tolerates
789
+ * the `git remote get-url` exit-1 of a repo without `origin` (the common fresh
790
+ * case — `new.ts` does `git init` but never adds a remote).
791
+ */
792
+ async function readOriginUrl(runGit, cwd) {
793
+ try {
794
+ const { stdout } = await runGit(['remote', 'get-url', 'origin'], cwd);
795
+ const url = stdout.trim();
796
+ return url === '' ? null : url;
797
+ }
798
+ catch {
799
+ return null;
800
+ }
801
+ }
802
+ /**
803
+ * Build the git substrate in the documented, asserted ORDER (issue §6):
804
+ * 1. rename `master` → `main` (only when the current branch is `master`)
805
+ * 2. `git remote add origin <https-url>` (idempotent — `factory new` usually set
806
+ * it already; skipped when present, reset only under `--force`)
807
+ * 3. create the `develop` branch off `main`
808
+ * 4. `git push origin main` — MUST run BEFORE the Vercel project create + link,
809
+ * so the repo has a `main` branch for Vercel to track as production (the git
810
+ * link reads the repo's default branch). A no-op when `factory new` already
811
+ * pushed `main`; the guarantee here.
812
+ * 5. `git push origin develop` — provision OWNS the develop branch (created when
813
+ * infra is set up); Vercel attaches the preview deployment to it.
814
+ *
815
+ * The first `main` push is `factory new`'s job (so the docs pipeline can push before
816
+ * provision); steps 2+4 are the completeness layer (no-ops when `new` succeeded),
817
+ * step 5 (`develop`) is provision's own responsibility.
818
+ *
819
+ * Edge case (issue §8): an `origin` already pointing at a DIFFERENT URL (directory
820
+ * reuse) → without `--force`, throw a clear `CLIError` (headless-safe). With
821
+ * `--force`, the remote is reset to the canonical URL.
822
+ */
823
+ async function buildGitSubstrate(rootDir, repoSlug, runGit, force) {
824
+ const url = repoHttpsUrl(repoSlug);
825
+ // 1. Normalize the default branch to `main` if the repo arrived on `master`.
826
+ let currentBranch = '';
827
+ try {
828
+ currentBranch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], rootDir)).stdout.trim();
829
+ }
830
+ catch {
831
+ currentBranch = '';
832
+ }
833
+ if (currentBranch === 'master') {
834
+ await runGit(['branch', '-m', 'master', 'main'], rootDir);
835
+ }
836
+ // 2. Add (or reset, under --force) the `origin` remote → the HTTPS repo URL.
837
+ const existingOrigin = await readOriginUrl(runGit, rootDir);
838
+ if (existingOrigin && existingOrigin !== url) {
839
+ // GitHub repo URLs are case-insensitive: an `origin` differing ONLY by case
840
+ // (e.g. `TimeKast/Edmond.git` vs the canonical lowercase `TimeKast/edmond.git`)
841
+ // points at the SAME repo. Re-case it to the canonical URL silently — no `--force`
842
+ // needed. A difference BEYOND case is a genuinely different repo → throw unless
843
+ // `--force` resets it.
844
+ const sameRepoDifferentCase = existingOrigin.toLowerCase() === url.toLowerCase();
845
+ if (!sameRepoDifferentCase && !force) {
846
+ throw new CLIError(`El repo ya tiene un remote \`origin\` apuntando a otro URL (\`${existingOrigin}\` ≠ \`${url}\`).\n` +
847
+ 'Usa `--force` para sobrescribirlo, o limpia el remote si reutilizas este directorio.');
848
+ }
849
+ await runGit(['remote', 'set-url', 'origin', url], rootDir);
850
+ }
851
+ else if (!existingOrigin) {
852
+ await runGit(['remote', 'add', 'origin', url], rootDir);
853
+ }
854
+ // 3. Create the `develop` branch off `main` (idempotent: ignore "already exists").
855
+ try {
856
+ await runGit(['branch', 'develop', 'main'], rootDir);
857
+ }
858
+ catch {
859
+ // `develop` may already exist (resume / reused dir) — non-fatal.
860
+ }
861
+ // 4. Push `main` to the remote — BEFORE the Vercel project create (§8 ordering).
862
+ // A no-op when `factory new` already pushed it; the guarantee main is on the
863
+ // remote (a fresh push uses the dev's gh-authenticated HTTPS credential).
864
+ await runGit(['push', 'origin', 'main'], rootDir);
865
+ // 5. Push `develop` — provision owns this branch (the dev domain tracks it for
866
+ // Vercel preview deployments). `-u` sets the upstream so the dev's coding-phase
867
+ // pushes to `develop` just work.
868
+ await runGit(['push', '-u', 'origin', 'develop'], rootDir);
869
+ }
870
+ /** Map a Vercel project-create/link/patch failure to an actionable es-MX `CLIError`. */
871
+ function vercelProjectErrorToCliError(error, teamId, projectName, context) {
872
+ if (error.status === 409) {
873
+ return new CLIError(`Ya existe un proyecto Vercel con el nombre \`${projectName}\` en el team \`${teamId}\` (409 en ${context}).\n` +
874
+ 'Con `--resume` el CLI adopta el `projectId` del estado existente; o usa `--force` para empezar de cero con otro nombre.');
875
+ }
876
+ return vercelErrorToCliError(error, teamId, context);
877
+ }
878
+ /** Shipped E2E workflow template + the activated target it overwrites (the disabled placeholder). */
879
+ export const E2E_WORKFLOW_EXAMPLE = '.github/workflows/e2e.yml.example';
880
+ export const E2E_WORKFLOW_PATH = '.github/workflows/e2e.yml';
881
+ /**
882
+ * Resolve the installed `@playwright/test` version for the E2E container tag —
883
+ * lockfile preferred (exact, matches a published container), package.json range
884
+ * as fallback. Returns null when Playwright is not a dependency (the project does
885
+ * not use E2E → activation is simply skipped). NEVER throws.
886
+ */
887
+ export function resolvePlaywrightVersion(rootDir) {
888
+ const lockfile = join(rootDir, 'pnpm-lock.yaml');
889
+ if (existsSync(lockfile)) {
890
+ // Resolved line ` '@playwright/test': 1.58.2` — NOT the `specifier:` range.
891
+ const match = readFileSync(lockfile, 'utf8').match(/^\s+'@playwright\/test':\s+(\d+\.\d+\.\d+(?:-[\w.]+)?)\s*$/m);
892
+ if (match)
893
+ return match[1];
894
+ }
895
+ const pkgPath = join(rootDir, 'package.json');
896
+ if (existsSync(pkgPath)) {
897
+ try {
898
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
899
+ const spec = pkg.devDependencies?.['@playwright/test'] ?? pkg.dependencies?.['@playwright/test'];
900
+ if (typeof spec === 'string') {
901
+ const stripped = spec.replace(/^[\^~]/, '');
902
+ if (/^\d+\.\d+\.\d+/.test(stripped))
903
+ return stripped;
904
+ }
905
+ }
906
+ catch {
907
+ // malformed package.json → treat as "no Playwright" (skip activation)
908
+ }
909
+ }
910
+ return null;
911
+ }
912
+ /**
913
+ * ACTIVATE the E2E workflow in the derived repo: read the shipped `e2e.yml.example`,
914
+ * substitute the Playwright container version, and PIN the trigger to `main` ONLY —
915
+ * so `develop` stays fast and unguarded and the E2E suite is the LAST line of
916
+ * defense on the way into production (push to `main` + PRs INTO `main`, which gate
917
+ * the merge). Overwrites the disabled placeholder. Returns a status the caller
918
+ * narrates. NON-fatal: a missing template or absent Playwright dep skips activation.
919
+ */
920
+ export function writeE2eWorkflow(rootDir) {
921
+ const templatePath = join(rootDir, E2E_WORKFLOW_EXAMPLE);
922
+ if (!existsSync(templatePath))
923
+ return 'no-template';
924
+ const version = resolvePlaywrightVersion(rootDir);
925
+ if (!version)
926
+ return 'no-playwright';
927
+ const activated = readFileSync(templatePath, 'utf8')
928
+ .replace(/__PLAYWRIGHT_VERSION__/g, version)
929
+ // Pin EVERY trigger to `main` (last line of defense) — never the working branch.
930
+ .replace(/branches: \[[^\]]*\] # __TRIGGER_BRANCH__/g, 'branches: [main]');
931
+ const target = join(rootDir, E2E_WORKFLOW_PATH);
932
+ mkdirSync(dirname(target), { recursive: true });
933
+ writeFileSync(target, activated, 'utf8');
934
+ return 'activated';
935
+ }
936
+ /**
937
+ * PROV-004 — the `github` step: build the git substrate, create the Vercel PROJECT,
938
+ * connect the GitHub repo (GitHub App gate), confirm `productionBranch: main`,
939
+ * write `.vercel/project.json`, and activate the E2E workflow. Migrations run in the
940
+ * Vercel build (`vercel-build`: `pnpm db:migrate && pnpm build`), NOT a GitHub Actions
941
+ * workflow. HIGH-risk: every failure is recoverable via `--resume`.
942
+ *
943
+ * Order (asserted by the unit test): git substrate (rename → remote add → push
944
+ * main) runs ENTIRELY before `POST /v9/projects`. The project create is skipped on
945
+ * `--resume` when `state.vercel.projectId` already exists (also the 409 path).
946
+ *
947
+ * Never runs `vercel link` / `vercel pull` — the `.vercel/project.json` is written
948
+ * directly (SK.md §7.1). `gh secret set` values go over STDIN (no-echo).
949
+ */
950
+ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
951
+ const envPath = deps.envPath ?? defaultEnvPath();
952
+ const token = readVercelToken(envPath);
953
+ if (!token) {
954
+ throw new CLIError('No se pudo leer `VERCEL_TOKEN` de `~/.claude/.env`.\n' +
955
+ 'Ejecuta `factory secrets <archivo>` para bajar el token del archivo de secretos y reintenta.');
956
+ }
957
+ const runGit = deps.runGit ?? gitInDir;
958
+ const apiDeps = { fetchImpl: deps.fetchImpl };
959
+ const repoSlug = `${FACTORY_ORG}/${state.projectName}`;
960
+ // Record the repo on the state up-front so the Neon secret push (PROV-002) and a
961
+ // `--resume` target it; this is durable even if a later REST step fails.
962
+ state.github = { repo: repoSlug };
963
+ writeProvisionState(rootDir, state);
964
+ // 1. Git substrate — push `main` BEFORE the Vercel project create (§8 ordering).
965
+ // Skipped on resume when the Vercel project already exists (substrate done).
966
+ if (!state.vercel?.projectId) {
967
+ await buildGitSubstrate(rootDir, repoSlug, runGit, deps.force ?? false);
968
+ }
969
+ // 2. Create the Vercel PROJECT (resume: adopt the existing projectId, skip POST).
970
+ let projectId = state.vercel?.projectId;
971
+ if (!projectId) {
972
+ try {
973
+ const project = await createVercelProject(token, teamId, { name: state.projectName, repo: repoSlug }, apiDeps);
974
+ projectId = project.projectId;
975
+ }
976
+ catch (error) {
977
+ if (error instanceof VercelApiError) {
978
+ throw vercelProjectErrorToCliError(error, teamId, state.projectName, 'crear el proyecto Vercel');
979
+ }
980
+ throw error;
981
+ }
982
+ // Persist the projectId ATOMICALLY right after the create (issue §6 + AC).
983
+ state.vercel = { projectId };
984
+ writeProvisionState(rootDir, state);
985
+ }
986
+ // 3. Connect the GitHub repo. A 403 means Vercel's GitHub App is not installed —
987
+ // print the install URL + pause (HITL), do NOT abort the whole run (§8).
988
+ try {
989
+ await linkGitRepo(projectId, token, teamId, repoSlug, apiDeps);
990
+ }
991
+ catch (error) {
992
+ if (error instanceof GitHubAppNotInstalledError) {
993
+ console.warn('⚠ La GitHub App de Vercel no está instalada sobre la org/repo (Vercel respondió 403 al conectar el git).\n' +
994
+ ` Instálala aquí: https://github.com/apps/vercel/installations/new (selecciona \`${repoSlug}\`),\n` +
995
+ ' y reintenta con `--resume` para terminar la conexión y el resto del paso.');
996
+ return; // HITL pause — the projectId is already durable for --resume.
997
+ }
998
+ if (error instanceof VercelApiError) {
999
+ throw vercelProjectErrorToCliError(error, teamId, state.projectName, 'conectar el git al proyecto');
1000
+ }
1001
+ throw error;
1002
+ }
1003
+ // 4. Apply the project SETTINGS via PATCH (ssoProtection:null + autoExposeSystemEnvs)
1004
+ // — POST /v9/projects refuses them as additional props. NOT productionBranch:
1005
+ // that one is not a property of this endpoint (Vercel 400s + drops the whole
1006
+ // PATCH); the git link already tracks the repo's `main` as production.
1007
+ // NON-FATAL: a rejected setting just means the default stands — warn and continue.
1008
+ try {
1009
+ await patchProjectSettings(projectId, token, teamId, apiDeps);
1010
+ }
1011
+ catch (error) {
1012
+ const detail = error instanceof VercelApiError
1013
+ ? `Vercel respondió ${error.status}: ${error.body || '(sin cuerpo)'}`
1014
+ : error?.message;
1015
+ console.warn(`⚠ No se pudieron aplicar los settings del proyecto Vercel vía PATCH (${detail}).\n` +
1016
+ ' Vercel usa defaults (producción = main del repo) — continúo.');
1017
+ }
1018
+ // 5. Write `.vercel/project.json` directly (NEVER vercel link/pull — SK.md §7.1).
1019
+ writeVercelProjectJson(rootDir, { projectId, orgId: teamId, teamId }, { force: deps.force });
1020
+ // 6. ACTIVATE the E2E workflow PINNED TO `main` (the last line of defense before
1021
+ // production; `develop` stays unguarded). Without this it ships disabled and
1022
+ // nobody turns it on. Non-fatal: skipped when the project has no Playwright or
1023
+ // the template is absent. It lands in the working tree and is committed below.
1024
+ //
1025
+ // Migrations are NOT a GitHub Actions workflow: they run in the Vercel build
1026
+ // (`vercel-build`: `pnpm db:migrate && pnpm build`, shipped in package.json),
1027
+ // so every deployment migrates ITS target's DB (production←main, preview←develop)
1028
+ // before building — no migrate.yml, no Deploy Hook, no manual step.
1029
+ const e2e = writeE2eWorkflow(rootDir);
1030
+ if (e2e === 'activated') {
1031
+ console.log(` + ${E2E_WORKFLOW_PATH} (E2E activado — dispara en main: push + PRs)`);
1032
+ }
1033
+ else if (e2e === 'no-playwright') {
1034
+ console.log(' ⏭ E2E no activado (el proyecto no usa @playwright/test)');
1035
+ }
1036
+ else {
1037
+ console.log(' ⏭ E2E no activado (no se encontró e2e.yml.example en el repo)');
1038
+ }
1039
+ // 7. Commit + push the activated E2E workflow. `factory new` pushed the initial
1040
+ // scaffold commit BEFORE this workflow existed, so it lives only in the working
1041
+ // tree until committed here. Idempotent: a `--resume` with it already committed
1042
+ // stages nothing → skips.
1043
+ if (e2e === 'activated') {
1044
+ await commitAndPushWorkflows(rootDir, runGit);
1045
+ }
1046
+ // 8. Mark the github step DONE — the substrate, Vercel project, git connect,
1047
+ // workflows and secrets are all in place. The early returns above (e.g. the
1048
+ // GitHub-App-not-installed HITL pause) never reach here, so a step that did
1049
+ // NOT fully complete stays resumable (isServiceComplete reads this flag).
1050
+ state.github = { repo: repoSlug, done: true };
1051
+ writeProvisionState(rootDir, state);
1052
+ }
1053
+ /**
1054
+ * Commit the activated E2E workflow (`e2e.yml`) and push it to BOTH `main` and
1055
+ * `develop` in the derived repo. `factory new` pushed the initial scaffold commit
1056
+ * before provision activated this workflow, so it lives only in the working tree
1057
+ * until committed here. (Migrations run in the Vercel build via `vercel-build`, not a
1058
+ * workflow — there is nothing to commit for them.) Idempotent — when a `--resume`
1059
+ * finds it already committed, `git add` stages nothing and the commit/push is skipped.
1060
+ */
1061
+ async function commitAndPushWorkflows(rootDir, runGit) {
1062
+ await runGit(['add', E2E_WORKFLOW_PATH], rootDir);
1063
+ const staged = (await runGit(['diff', '--cached', '--name-only'], rootDir)).stdout.trim();
1064
+ if (!staged) {
1065
+ console.log(' ⏭ e2e.yml ya commiteado (resume) — sin cambios que pushear');
1066
+ return;
1067
+ }
1068
+ await runGit(['commit', '-m', 'ci: wire the E2E workflow'], rootDir);
1069
+ // `main:develop` lands the same commit on develop too (fast-forward — both branches
1070
+ // sat on the initial commit).
1071
+ await runGit(['push', 'origin', 'main'], rootDir);
1072
+ await runGit(['push', 'origin', 'main:develop'], rootDir);
1073
+ console.log(' ↑ e2e.yml commiteado + pusheado (main + develop)');
1074
+ }
1075
+ /**
1076
+ * Commit `.timekast/provision.json` (the resource-ID state — no credentials ever
1077
+ * touch it) to main + develop, so a `--resume` from a fresh clone has the IDs (the
1078
+ * connection strings are re-derived from Neon). Runs at the END of provision, when
1079
+ * the full state (all service IDs) is on disk. Best-effort + idempotent: an unchanged
1080
+ * state stages nothing → skips.
1081
+ */
1082
+ async function commitProvisionState(rootDir) {
1083
+ const statePath = join(PROVISION_DIR, PROVISION_STATE_FILE);
1084
+ try {
1085
+ await gitInDir(['add', statePath], rootDir);
1086
+ const staged = (await gitInDir(['diff', '--cached', '--name-only'], rootDir)).stdout.trim();
1087
+ if (!staged)
1088
+ return;
1089
+ await gitInDir(['commit', '-m', 'chore: persist provision state'], rootDir);
1090
+ await gitInDir(['push', 'origin', 'main'], rootDir);
1091
+ await gitInDir(['push', 'origin', 'main:develop'], rootDir);
1092
+ console.log(' ↑ .timekast/provision.json commiteado (main + develop)');
1093
+ }
1094
+ catch {
1095
+ // Non-fatal: the local state is intact; the dev can commit it later.
1096
+ }
1097
+ }
1098
+ /** Map a Cloudflare REST failure to an actionable, es-MX `CLIError` (issue §8 edges). */
1099
+ function cloudflareErrorToCliError(error, context) {
1100
+ switch (error.status) {
1101
+ case 401:
1102
+ return new CLIError(`El \`CLOUDFLARE_API_TOKEN\` del archivo de secretos es inválido o expiró (Cloudflare respondió 401 en ${context}).\n` +
1103
+ 'Ejecuta `factory secrets <archivo>` para renovarlo y reintenta con `--resume`.');
1104
+ case 403:
1105
+ return new CLIError(`El \`CLOUDFLARE_API_TOKEN\` no tiene scope sobre la zona \`${TIMEKAST_ZONE_NAME}\` ` +
1106
+ `(Cloudflare respondió 403 en ${context}).\n` +
1107
+ 'El token del archivo de secretos debe estar scopeado a esa zona con permiso DNS-edit (no account-wide).\n' +
1108
+ 'Pídele al admin que lo re-minte con ese scope en el archivo de secretos compartido, re-impórtalo con `factory secrets <archivo>` y reintenta con `--resume`.');
1109
+ case 429:
1110
+ return new CLIError(`Cloudflare rechazó la operación por rate-limit (429 en ${context}). Reintenta en un momento con \`--resume\`.`);
1111
+ default:
1112
+ return new CLIError(`Falló la llamada a Cloudflare en ${context}: ${error.message}`);
1113
+ }
1114
+ }
1115
+ /**
1116
+ * PROV-005 — the `dns` step. Attach the two interim domains to the Vercel project,
1117
+ * create their `proxied: false` Cloudflare CNAMEs, and wire Resend's env vars
1118
+ * (`RESEND_API_KEY` from the rail + the fixed `EMAIL_FROM`) into the project.
1119
+ *
1120
+ * Order:
1121
+ * 1. Validate the slug as a subdomain BEFORE any REST (issue §8).
1122
+ * 2. Vercel: `POST /domains` for `{slug}-dev` (gitBranch: develop) then `{slug}`
1123
+ * (production, no gitBranch). A 409 (already attached) is idempotent.
1124
+ * 3. Cloudflare: resolve the `timekast.mx` zone id, then ensure a `proxied:false`
1125
+ * CNAME for BOTH domains (create, or PATCH a manually-proxied record).
1126
+ * 4. Resend: upsert `RESEND_API_KEY` (production+preview) + the org
1127
+ * `EMAIL_FROM=noreply@updates.timekast.mx` (mail subdomain) — no-echo.
1128
+ * 5. Record `state.dns = { domain, devDomain }` (prod + the dev domain) and persist.
1129
+ *
1130
+ * Requires `state.vercel.projectId` (PROV-004). No-echo: the Cloudflare token and
1131
+ * the Resend key never reach stdout — only domain NAMES + key NAMES are printed.
1132
+ * Vercel's SSL provisioning can take minutes; this step does NOT block on it (§8).
1133
+ *
1134
+ * @throws {CLIError} on a missing projectId / invalid slug / missing token, and on
1135
+ * any mapped Vercel/Cloudflare REST failure (issue §8). On any throw, `state.dns`
1136
+ * is left unwritten (rollback of the step — issue §5 Gherkin).
1137
+ */
1138
+ export async function provisionDns(rootDir, state, teamId, deps = {}) {
1139
+ const projectId = state.vercel?.projectId;
1140
+ if (!projectId) {
1141
+ throw new CLIError('Falta el `projectId` de Vercel en el estado: el proyecto Vercel lo crea PROV-004.\n' +
1142
+ 'Corre el paso de Vercel/GitHub primero (`--services=github,...`) y reintenta con `--resume`.');
1143
+ }
1144
+ // 1. Validate the slug as a subdomain label BEFORE any REST call (issue §8).
1145
+ const slug = state.projectName;
1146
+ assertValidSubdomainSlug(slug);
1147
+ const { dev, prod } = provisionDomains(slug);
1148
+ const envPath = deps.envPath ?? defaultEnvPath();
1149
+ const vercelToken = readVercelToken(envPath);
1150
+ if (!vercelToken) {
1151
+ throw new CLIError('No se pudo leer `VERCEL_TOKEN` de `~/.claude/.env`.\n' +
1152
+ 'Ejecuta `factory secrets <archivo>` para bajar el token del archivo de secretos y reintenta.');
1153
+ }
1154
+ const cloudflareToken = readCloudflareApiKey(envPath);
1155
+ if (!cloudflareToken) {
1156
+ throw new CLIError('No se pudo leer `CLOUDFLARE_API_TOKEN` de `~/.claude/.env`.\n' +
1157
+ 'Ejecuta `factory secrets <archivo>` para bajar el token del archivo de secretos (scoped a la zona) y reintenta.');
1158
+ }
1159
+ const vercelDeps = { fetchImpl: deps.fetchImpl };
1160
+ const cloudflareDeps = { fetchImpl: deps.fetchImpl };
1161
+ // 2. Vercel — attach the DEV domain (gitBranch: develop) then PROD (no gitBranch).
1162
+ try {
1163
+ const devOutcome = await addVercelDomain(projectId, vercelToken, teamId, { name: dev, gitBranch: 'develop' }, vercelDeps);
1164
+ console.log(` ${devOutcome === 'exists' ? '↻ (ya existía)' : '+'} dominio Vercel ${dev} → branch develop`);
1165
+ const prodOutcome = await addVercelDomain(projectId, vercelToken, teamId, { name: prod }, vercelDeps);
1166
+ console.log(` ${prodOutcome === 'exists' ? '↻ (ya existía)' : '+'} dominio Vercel ${prod} → producción`);
1167
+ }
1168
+ catch (error) {
1169
+ if (error instanceof VercelApiError) {
1170
+ throw vercelErrorToCliError(error, teamId, 'agregar el dominio al proyecto Vercel');
1171
+ }
1172
+ throw error;
1173
+ }
1174
+ // 3. Cloudflare — resolve the zone, then ensure a `proxied:false` CNAME for both.
1175
+ let zoneId;
1176
+ try {
1177
+ zoneId = await resolveZoneId(cloudflareToken, cloudflareDeps);
1178
+ }
1179
+ catch (error) {
1180
+ if (error instanceof CloudflareApiError) {
1181
+ throw cloudflareErrorToCliError(error, `resolver la zona \`${TIMEKAST_ZONE_NAME}\``);
1182
+ }
1183
+ throw error; // resolveZoneId's own CLIError (zone not found) propagates as-is.
1184
+ }
1185
+ for (const name of [dev, prod]) {
1186
+ try {
1187
+ const outcome = await ensureCnameRecord(zoneId, name, cloudflareToken, cloudflareDeps);
1188
+ const mark = outcome === 'created'
1189
+ ? '+'
1190
+ : outcome === 'unproxied'
1191
+ ? '↻ (proxied→false)'
1192
+ : '⏭ (ya estaba)';
1193
+ console.log(` ${mark} CNAME ${name} → cname.vercel-dns.com (proxied:false)`);
1194
+ }
1195
+ catch (error) {
1196
+ if (error instanceof CloudflareApiError) {
1197
+ throw cloudflareErrorToCliError(error, `crear el CNAME de \`${name}\``);
1198
+ }
1199
+ throw error;
1200
+ }
1201
+ }
1202
+ // 4. Resend — wire RESEND_API_KEY (rail) + the FIXED EMAIL_FROM into Vercel env.
1203
+ await wireResendEnv(projectId, vercelToken, teamId, envPath, vercelDeps);
1204
+ // 5. Record BOTH interim domains in state — prod is canonical, dev tracks `develop`.
1205
+ // The dev domain is derivable from the slug, but persisting it keeps the state
1206
+ // file transparent about every domain provision created (not just prod).
1207
+ state.dns = { domain: prod, devDomain: dev };
1208
+ writeProvisionState(rootDir, state);
1209
+ }
1210
+ /**
1211
+ * Wire the Resend env vars into the Vercel project (issue §4 + §5), idempotently
1212
+ * and per-target, with `?teamId=` on every call (via `upsertEnvVar`). No-echo: the
1213
+ * `RESEND_API_KEY` VALUE never reaches stdout — only the KEY NAME + targets print.
1214
+ *
1215
+ * - `RESEND_API_KEY` ← the rail value → targets `production` + `preview`
1216
+ * - `EMAIL_FROM` ← the org `noreply@updates.timekast.mx` → `production` + `preview`
1217
+ *
1218
+ * `RESEND_API_KEY` absent from the rail is a WARNING (mail just stays unconfigured
1219
+ * — `isEmailReady()` guards it in the kit), never an abort (the domains are the
1220
+ * critical path; Resend is additive and re-runnable with `--resume`).
1221
+ */
1222
+ async function wireResendEnv(projectId, token, teamId, envPath, deps) {
1223
+ const plan = [];
1224
+ const resendKey = readGateValue('RESEND_API_KEY', envPath);
1225
+ if (resendKey) {
1226
+ plan.push({ key: 'RESEND_API_KEY', value: resendKey, target: ['production', 'preview'] });
1227
+ }
1228
+ else {
1229
+ console.warn('⚠ No hay `RESEND_API_KEY` en `~/.claude/.env` — omito esa env var (el correo queda sin ' +
1230
+ 'configurar hasta que la bajes con `factory secrets <archivo>` y reintentes con `--resume`).');
1231
+ }
1232
+ // EMAIL_FROM is the FIXED org sender — never read from the rail, never echoed value-wise.
1233
+ plan.push({ key: 'EMAIL_FROM', value: PROVISION_EMAIL_FROM, target: ['production', 'preview'] });
1234
+ const existing = await listEnvVars(projectId, token, teamId, deps);
1235
+ for (const entry of plan) {
1236
+ const outcome = await upsertEnvVar(projectId, token, teamId, entry, existing, deps);
1237
+ // No-echo: print the KEY NAME + targets only (EMAIL_FROM's value is the public
1238
+ // literal, but RESEND_API_KEY's must never be printed — so we print neither).
1239
+ console.log(` ${outcome === 'created' ? '+' : '↻'} ${entry.key} [${entry.target.join(', ')}]`);
1240
+ }
1241
+ }
1242
+ // ─────────────────────────────────────────────────────────────────────────────
1243
+ // PROV-006 — the deferrable `--domain` (client's definitive domain)
1244
+ // ─────────────────────────────────────────────────────────────────────────────
1245
+ /** Base URL of the Resend REST API (named constant — never an inline literal). */
1246
+ export const RESEND_API_BASE = 'https://api.resend.com';
1247
+ /** Timeout (ms) for the Resend REST call — a dead network must not hang provision. */
1248
+ const RESEND_API_TIMEOUT_MS = 30_000;
1249
+ /** Default poll delay — a real `setTimeout`-backed sleep (tests inject a no-op). */
1250
+ function realSleep(ms) {
1251
+ return new Promise((resolve) => setTimeout(resolve, ms));
1252
+ }
1253
+ /** Default interim-retire confirm — a `prompts` y/n (aborts/Esc → false, never retire). */
1254
+ async function promptConfirm(message) {
1255
+ const { ok } = await prompts({ type: 'confirm', name: 'ok', message, initial: false });
1256
+ return ok === true;
1257
+ }
1258
+ /**
1259
+ * Register the client domain in Resend — `POST /domains { name }` (PROV-006 §4),
1260
+ * reusing the rail's `RESEND_API_KEY`. Idempotency (issue §8): Resend returns an
1261
+ * error when the domain is already verified on ANOTHER account; that is a WARNING,
1262
+ * not an abort — `EMAIL_FROM` is updated regardless. An absent `RESEND_API_KEY` is
1263
+ * also a warning (mail stays unconfigured). No-echo: the key never reaches stdout.
1264
+ */
1265
+ async function registerResendDomain(domain, envPath, fetchImpl) {
1266
+ const resendKey = readGateValue('RESEND_API_KEY', envPath);
1267
+ if (!resendKey) {
1268
+ console.warn('⚠ No hay `RESEND_API_KEY` en `~/.claude/.env` — omito el alta del dominio en Resend ' +
1269
+ '(bájala con `factory secrets <archivo>` y reintenta con `--resume`).');
1270
+ return;
1271
+ }
1272
+ try {
1273
+ const response = await fetchImpl(`${RESEND_API_BASE}/domains`, {
1274
+ method: 'POST',
1275
+ headers: {
1276
+ Authorization: `Bearer ${resendKey}`,
1277
+ 'Content-Type': 'application/json',
1278
+ },
1279
+ body: JSON.stringify({ name: domain }),
1280
+ signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
1281
+ });
1282
+ if (!response.ok) {
1283
+ // Already verified elsewhere / validation error → warn + continue (issue §8).
1284
+ console.warn(`⚠ Resend rechazó el alta de \`${domain}\` (status ${response.status}). ` +
1285
+ 'Quizá ya está verificado en otra cuenta — `EMAIL_FROM` se actualiza igual; ' +
1286
+ 'verifica el dominio a mano en Resend si hace falta.');
1287
+ return;
1288
+ }
1289
+ console.log(` + Resend: dominio ${domain} registrado (verifica los records DNS en Resend)`);
1290
+ }
1291
+ catch (error) {
1292
+ // A network/timeout failure on Resend is non-fatal — the domain switch already
1293
+ // happened on Vercel; Resend verification is re-runnable with `--resume`.
1294
+ console.warn(`⚠ No se pudo dar de alta \`${domain}\` en Resend (${error?.message ?? 'error'}). ` +
1295
+ 'Reintenta con `--resume`; `EMAIL_FROM` ya se actualizó.');
1296
+ }
1297
+ }
1298
+ /** Print the exact DNS records the owner must create for the `--dns=registrar` path. */
1299
+ function printManualRecords(domain, verification) {
1300
+ console.log(`\n Crea estos registros DNS para \`${domain}\` en tu registrar:`);
1301
+ if (verification.length === 0) {
1302
+ // Vercel has not yet returned verification records (e.g. first poll) — print the
1303
+ // canonical CNAME the kit always uses so the dev is never left without guidance.
1304
+ console.log(` · CNAME ${domain} → ${CNAME_VERCEL_TARGET}`);
1305
+ }
1306
+ else {
1307
+ for (const v of verification) {
1308
+ console.log(` · ${v.type.toUpperCase()} ${v.domain} → ${v.value}`);
1309
+ }
1310
+ }
1311
+ console.log(' (El CLI NO crea estos registros en modo `--dns=registrar`.)\n');
1312
+ }
1313
+ /**
1314
+ * Poll `GET /v9/projects/{id}/domains/{name}` until Vercel reports `verified:true`
1315
+ * or the timeout budget runs out (PROV-006 §4). Returns `true` once verified; on
1316
+ * timeout returns `false` (the caller records `pending-verification` — resumable).
1317
+ * The `sleep`/`now` seams are injected so the unit test never waits 30s for real.
1318
+ */
1319
+ async function pollDomainVerification(projectId, domain, token, teamId, deps, fetchImpl) {
1320
+ const sleep = deps.sleep ?? realSleep;
1321
+ const now = deps.now ?? Date.now;
1322
+ const timeoutMs = (deps.verifyTimeoutMin ?? DEFAULT_VERIFY_TIMEOUT_MIN) * 60_000;
1323
+ const intervalMs = VERIFY_POLL_INTERVAL_SEC * 1_000;
1324
+ const deadline = now() + timeoutMs;
1325
+ const vercelDeps = { fetchImpl };
1326
+ // First check immediately (the domain may already be verified — idempotent re-run).
1327
+ for (;;) {
1328
+ const status = await getVercelDomainStatus(projectId, domain, token, teamId, vercelDeps);
1329
+ if (status.verified)
1330
+ return true;
1331
+ // For the registrar path, surface the exact records every poll while pending.
1332
+ if (deps.dnsRoute === 'registrar')
1333
+ printManualRecords(domain, status.verification);
1334
+ if (now() >= deadline)
1335
+ return false;
1336
+ console.log(` … esperando verificación de \`${domain}\` (reintento en ${VERIFY_POLL_INTERVAL_SEC}s)`);
1337
+ await sleep(intervalMs);
1338
+ }
1339
+ }
1340
+ /**
1341
+ * Resolve which Cloudflare token + zone the client-domain CNAME is created under
1342
+ * (PROV-006 §4 tree). `registrar` returns `null` (no CNAME created — manual). For
1343
+ * `org` the shared org token (`CLOUDFLARE_API_TOKEN`) is scoped to the client zone; for
1344
+ * `cliente-cf` the CLIENT's own key is used (read from the rail under
1345
+ * `CLIENT_CLOUDFLARE_API_TOKEN`, or prompted when absent and a TTY is present).
1346
+ */
1347
+ async function resolveDnsToken(route, envPath, headless) {
1348
+ if (route === 'registrar')
1349
+ return null;
1350
+ if (route === 'org') {
1351
+ const token = readCloudflareApiKey(envPath);
1352
+ if (!token) {
1353
+ throw new CLIError('No se pudo leer `CLOUDFLARE_API_TOKEN` de `~/.claude/.env` para `--dns=org`.\n' +
1354
+ 'Ejecuta `factory secrets <archivo>` para bajar el token del archivo de secretos (scoped a la zona) y reintenta.');
1355
+ }
1356
+ return token;
1357
+ }
1358
+ // cliente-cf — the client's own key. Prefer the rail; prompt only with a TTY.
1359
+ const fromRail = readGateValue('CLIENT_CLOUDFLARE_API_TOKEN', envPath);
1360
+ if (fromRail)
1361
+ return fromRail;
1362
+ if (headless) {
1363
+ throw new CLIError('`--dns=cliente-cf` requiere la API key de Cloudflare del cliente, pero no está en ' +
1364
+ '`~/.claude/.env` (`CLIENT_CLOUDFLARE_API_TOKEN`) y no hay terminal interactiva.\n' +
1365
+ 'Agrégala al rail o corre con TTY para que el CLI la pida.');
1366
+ }
1367
+ const { key } = await prompts({
1368
+ type: 'password',
1369
+ name: 'key',
1370
+ message: 'API key de Cloudflare del cliente (scoped a su zona, DNS-edit):',
1371
+ });
1372
+ const value = typeof key === 'string' ? key.trim() : '';
1373
+ if (value === '') {
1374
+ throw new CLIError('No se proporcionó la API key de Cloudflare del cliente. Operación cancelada.');
1375
+ }
1376
+ return value;
1377
+ }
1378
+ /**
1379
+ * PROV-006 — attach the client's DEFINITIVE domain to the Vercel project, create
1380
+ * (or print, for `registrar`) its DNS record, poll Vercel until verified, and on
1381
+ * success: retire the interim prod domain (with explicit confirmation), update
1382
+ * `EMAIL_FROM` + `AUTH_URL`/`NEXTAUTH_URL`, and register the domain in Resend.
1383
+ *
1384
+ * Standalone-safe: runnable as a PATCH over an existing provision (the `dns` step
1385
+ * of PROV-005 already attached the interim domains) or inside the initial run. It
1386
+ * requires `state.vercel.projectId` (PROV-004).
1387
+ *
1388
+ * Idempotency (issue §8):
1389
+ * - domain already attached to Vercel (409) → only polls, never re-adds.
1390
+ * - interim retire fails → WARNING, the step is NOT marked failed (interim stays).
1391
+ * - Resend already verified elsewhere → warning + continue.
1392
+ * - `AUTH_URL`/`NEXTAUTH_URL` already correct → the upsert PATCH is omitted.
1393
+ *
1394
+ * On timeout the state is left `clientDomainStatus: pending-verification` (resumable);
1395
+ * on success it is `live`.
1396
+ *
1397
+ * @throws {CLIError} on a missing projectId / token, a mapped Vercel/Cloudflare
1398
+ * REST failure, or a verification timeout (the partial state is persisted first).
1399
+ */
1400
+ export async function provisionClientDomain(rootDir, state, teamId, domain, deps = {}) {
1401
+ const projectId = state.vercel?.projectId;
1402
+ if (!projectId) {
1403
+ throw new CLIError('Falta el `projectId` de Vercel en el estado: el proyecto Vercel lo crea PROV-004.\n' +
1404
+ 'Corre el paso de Vercel/GitHub primero (`--services=github,...`) y reintenta con `--resume`.');
1405
+ }
1406
+ const route = deps.dnsRoute ?? 'org';
1407
+ const envPath = deps.envPath ?? defaultEnvPath();
1408
+ const fetchImpl = deps.fetchImpl ?? fetch;
1409
+ const vercelToken = readVercelToken(envPath);
1410
+ if (!vercelToken) {
1411
+ throw new CLIError('No se pudo leer `VERCEL_TOKEN` de `~/.claude/.env`.\n' +
1412
+ 'Ejecuta `factory secrets <archivo>` para bajar el token del archivo de secretos y reintenta.');
1413
+ }
1414
+ const vercelDeps = { fetchImpl };
1415
+ // 1. Attach the client domain to Vercel (409 → already attached, idempotent §8).
1416
+ try {
1417
+ const outcome = await addVercelDomain(projectId, vercelToken, teamId, { name: domain }, vercelDeps);
1418
+ console.log(` ${outcome === 'exists' ? '↻ (ya existía)' : '+'} dominio Vercel ${domain} → producción`);
1419
+ }
1420
+ catch (error) {
1421
+ if (error instanceof VercelApiError) {
1422
+ throw vercelErrorToCliError(error, teamId, 'agregar el dominio del cliente a Vercel');
1423
+ }
1424
+ throw error;
1425
+ }
1426
+ // 2. DNS record: org/cliente-cf CREATE the CNAME; registrar only PRINTS it (§4).
1427
+ if (route === 'registrar') {
1428
+ // Print the canonical record now; the poll loop re-prints Vercel's verification.
1429
+ printManualRecords(domain, []);
1430
+ }
1431
+ else {
1432
+ const cfToken = await resolveDnsToken(route, envPath, deps.headless ?? false);
1433
+ const zoneName = apexZoneOf(domain);
1434
+ const cloudflareDeps = { fetchImpl };
1435
+ let zoneId;
1436
+ try {
1437
+ zoneId = await resolveZoneIdForName(zoneName, cfToken, cloudflareDeps);
1438
+ }
1439
+ catch (error) {
1440
+ if (error instanceof CloudflareApiError) {
1441
+ throw cloudflareErrorToCliError(error, `resolver la zona \`${zoneName}\``);
1442
+ }
1443
+ throw error; // resolveZoneIdForName's own CLIError (zone not found) propagates.
1444
+ }
1445
+ try {
1446
+ const cnameOutcome = await ensureCnameRecord(zoneId, domain, cfToken, cloudflareDeps);
1447
+ const mark = cnameOutcome === 'created'
1448
+ ? '+'
1449
+ : cnameOutcome === 'unproxied'
1450
+ ? '↻ (proxied→false)'
1451
+ : '⏭ (ya estaba)';
1452
+ console.log(` ${mark} CNAME ${domain} → ${CNAME_VERCEL_TARGET} (proxied:false, vía ${route})`);
1453
+ }
1454
+ catch (error) {
1455
+ if (error instanceof CloudflareApiError) {
1456
+ throw cloudflareErrorToCliError(error, `crear el CNAME de \`${domain}\``);
1457
+ }
1458
+ throw error;
1459
+ }
1460
+ }
1461
+ // 3. Poll Vercel for verification (seam-injected sleep/clock — never 30s for real).
1462
+ const verified = await pollDomainVerification(projectId, domain, vercelToken, teamId, { ...deps, dnsRoute: route }, fetchImpl);
1463
+ if (!verified) {
1464
+ // Timeout: persist the pending state (resumable) and stop with a clear error (§5).
1465
+ state.dns = {
1466
+ ...(state.dns ?? {
1467
+ domain: provisionDomains(state.projectName).prod,
1468
+ devDomain: provisionDomains(state.projectName).dev,
1469
+ }),
1470
+ clientDomain: domain,
1471
+ clientDomainStatus: 'pending-verification',
1472
+ };
1473
+ writeProvisionState(rootDir, state);
1474
+ throw new CLIError(`\`${domain}\` no se verificó dentro del timeout (${deps.verifyTimeoutMin ?? DEFAULT_VERIFY_TIMEOUT_MIN} min).\n` +
1475
+ 'El estado quedó en `domain: pending-verification`; reintenta con `--resume` para seguir sondeando.');
1476
+ }
1477
+ console.log(` ✔ ${domain} verificado por Vercel`);
1478
+ // 4. Retire the interim prod domain — ONLY with explicit confirmation (§4 + §8).
1479
+ await retireInterimDomain(projectId, vercelToken, teamId, state, deps, fetchImpl);
1480
+ // 5. Update EMAIL_FROM + AUTH callbacks in Vercel env (idempotent — §8).
1481
+ await updateDomainEnv(projectId, vercelToken, teamId, domain, vercelDeps);
1482
+ // 6. Register the client's MAIL SUBDOMAIN (`updates.<domain>`) in Resend — NOT the
1483
+ // apex, which holds the client's corporate MX/SPF (Google Workspace, M365, …).
1484
+ // Verifying Resend on a dedicated subdomain keeps the client's own mail intact.
1485
+ await registerResendDomain(mailDomainFor(domain), envPath, fetchImpl);
1486
+ // 7. Record the client domain as LIVE and persist.
1487
+ state.dns = {
1488
+ ...(state.dns ?? {
1489
+ domain: provisionDomains(state.projectName).prod,
1490
+ devDomain: provisionDomains(state.projectName).dev,
1491
+ }),
1492
+ clientDomain: domain,
1493
+ clientDomainStatus: 'live',
1494
+ };
1495
+ writeProvisionState(rootDir, state);
1496
+ }
1497
+ /**
1498
+ * Retire the interim prod domain (`{slug}.timekast.mx`) AFTER the client domain
1499
+ * verifies — but ONLY with explicit confirmation (PROV-006 §4). `--confirm`
1500
+ * pre-approves; otherwise a TTY prompt asks (headless without `--confirm` → NOT
1501
+ * retired). The DEV domain (`{slug}-dev.timekast.mx`) is ALWAYS conserved. A
1502
+ * removal failure is a WARNING, never a step failure (issue §8 — interim stays up).
1503
+ */
1504
+ async function retireInterimDomain(projectId, token, teamId, state, deps, fetchImpl) {
1505
+ const interim = provisionDomains(state.projectName).prod;
1506
+ let approved = deps.preConfirmed === true;
1507
+ if (!approved) {
1508
+ if (deps.headless) {
1509
+ console.warn(`⚠ No se retira el dominio interino \`${interim}\` en headless sin \`--confirm\` ` +
1510
+ '(queda activo; retíralo a mano o reintenta con `--confirm`).');
1511
+ return;
1512
+ }
1513
+ const confirm = deps.confirm ?? promptConfirm;
1514
+ approved = await confirm(`¿Retirar el dominio interino \`${interim}\` de Vercel?`);
1515
+ }
1516
+ if (!approved) {
1517
+ console.log(` ⏭ Conservo el dominio interino \`${interim}\` (sin confirmación de retiro).`);
1518
+ return;
1519
+ }
1520
+ try {
1521
+ const outcome = await removeVercelDomain(projectId, interim, token, teamId, { fetchImpl });
1522
+ console.log(` ${outcome === 'absent' ? '⏭ (ya no estaba)' : '-'} dominio interino ${interim} retirado`);
1523
+ }
1524
+ catch (error) {
1525
+ // §8: a 5xx (or any non-404) on the retire is a WARNING — the interim stays up,
1526
+ // the step is NOT marked failed, the dev retires it manually later.
1527
+ console.warn(`⚠ No se pudo retirar el dominio interino \`${interim}\` (${error?.message ?? 'error'}).\n` +
1528
+ ' Queda activo; retíralo a mano en Vercel o reintenta con `--resume`. El paso NO falla por esto.');
1529
+ }
1530
+ // The DEV domain is intentionally never touched (issue §4 — conserved).
1531
+ }
1532
+ /**
1533
+ * Update `EMAIL_FROM` + `AUTH_URL`/`NEXTAUTH_URL` to the client domain in the
1534
+ * Vercel env, per-target and idempotently (PROV-006 §4). Lists the existing vars
1535
+ * once; an `AUTH_URL`/`NEXTAUTH_URL`/`EMAIL_FROM` already set to the target value
1536
+ * has its PATCH omitted (issue §8 — second run is a no-op). No-echo: only KEY
1537
+ * NAMES + targets print (the values here are public, but the discipline holds).
1538
+ */
1539
+ async function updateDomainEnv(projectId, token, teamId, domain, deps) {
1540
+ const authUrl = `https://${domain}`;
1541
+ const plan = [
1542
+ // EMAIL_FROM uses the dedicated mail subdomain (`updates.<domain>`), NOT the apex
1543
+ // — the apex carries the client's corporate mail. AUTH_URL stays the app domain.
1544
+ {
1545
+ key: 'EMAIL_FROM',
1546
+ value: `noreply@${mailDomainFor(domain)}`,
1547
+ target: ['production', 'preview'],
1548
+ },
1549
+ { key: 'AUTH_URL', value: authUrl, target: ['production', 'preview'] },
1550
+ { key: 'NEXTAUTH_URL', value: authUrl, target: ['production', 'preview'] },
1551
+ ];
1552
+ const existing = await listEnvVars(projectId, token, teamId, deps);
1553
+ for (const entry of plan) {
1554
+ const outcome = await upsertEnvVar(projectId, token, teamId, entry, existing, deps);
1555
+ console.log(` ${outcome === 'created' ? '+' : '↻'} ${entry.key} [${entry.target.join(', ')}]`);
1556
+ }
1557
+ }
1558
+ /**
1559
+ * Print the resolved client-domain operation tree for `--domain --dry-run`
1560
+ * (PROV-006 §8): the exact domain, the DNS route, whether the CNAME is created or
1561
+ * printed, the interim-retire intent, and the env updates — mutating NOTHING.
1562
+ */
1563
+ function printClientDomainPlan(domain, interim, route, confirm) {
1564
+ console.log(`\nPlan de \`--domain=${domain}\` (ruta DNS: ${route}):`);
1565
+ console.log(` + dominio Vercel ${domain} → producción`);
1566
+ if (route === 'registrar') {
1567
+ console.log(` · (registrar) imprime CNAME ${domain} → ${CNAME_VERCEL_TARGET} — NO lo crea`);
1568
+ }
1569
+ else {
1570
+ const zone = apexZoneOf(domain);
1571
+ console.log(` + Cloudflare CNAME ${domain} → ${CNAME_VERCEL_TARGET} (proxied:false, zona ${zone}, vía ${route})`);
1572
+ }
1573
+ console.log(` … sondea GET /v9/projects/.../domains/${domain} hasta verified:true (o timeout)`);
1574
+ console.log(` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim} ` +
1575
+ `(el dev ${domain}-dev se conserva)`);
1576
+ console.log(` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · AUTH_URL=https://${domain} · NEXTAUTH_URL=https://${domain}`);
1577
+ console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
1578
+ console.log('\n(`--dry-run`) No se ejecutó ninguna llamada a API ni se escribió estado.');
1579
+ }
1580
+ /**
1581
+ * Execute one service's create step. `neon` (PROV-002), `github` (PROV-004),
1582
+ * `vercel` (PROV-003) and `dns` (PROV-005) are implemented. `teamId` is required
1583
+ * for the Vercel + GitHub + DNS steps (validated upstream).
1584
+ */
1585
+ async function runServiceStep(service, rootDir, state, flags) {
1586
+ switch (service) {
1587
+ case 'neon':
1588
+ await provisionNeon(rootDir, state);
1589
+ break;
1590
+ case 'vercel':
1591
+ // PROV-003: push env (teamId on every call) + write .vercel/project.json +
1592
+ // pnpm install. The Vercel PROJECT itself is created by PROV-004.
1593
+ await provisionVercel(rootDir, state, flags.team, { force: flags.force });
1594
+ break;
1595
+ case 'github':
1596
+ // PROV-004: git substrate (rename → remote add → push main) + create the
1597
+ // Vercel project + connect git (GitHub App) + PATCH production-branch +
1598
+ // .vercel/project.json + activate E2E. Migrations run in vercel-build, not a workflow.
1599
+ await provisionGithubVercel(rootDir, state, flags.team, { force: flags.force });
1600
+ break;
1601
+ case 'dns':
1602
+ // PROV-005: attach the interim domains to Vercel (dev → develop, prod →
1603
+ // production) + create their Cloudflare CNAMEs (proxied:false) + wire Resend
1604
+ // (RESEND_API_KEY + the fixed EMAIL_FROM), then `state.dns = { domain, devDomain }`.
1605
+ await provisionDns(rootDir, state, flags.team);
1606
+ break;
1607
+ default:
1608
+ throw new CLIError(`Paso de provision no implementado: \`${service}\`.`);
1609
+ }
1610
+ }
1611
+ /** Print the optional neonctl warning when the preflight flagged it (non-fatal). */
1612
+ function warnNeonctl(report) {
1613
+ if (report.status === 'warn')
1614
+ console.warn(`⚠ ${report.message}`);
1615
+ }
1616
+ /** Default interactive disambiguation — a numbered `prompts` select (name + id + url). */
1617
+ async function promptSelectCandidate(provider, candidates) {
1618
+ console.log(`\nVarios candidatos para ${provider}:`);
1619
+ candidates.forEach((c, i) => {
1620
+ console.log(` ${i + 1}. ${c.name} · ${c.id} · ${c.url}`);
1621
+ });
1622
+ const { idx } = await prompts({
1623
+ type: 'select',
1624
+ name: 'idx',
1625
+ message: `¿Cuál es el ${provider} de este proyecto?`,
1626
+ choices: candidates.map((c, i) => ({ title: `${c.name} (${c.id})`, value: i })),
1627
+ });
1628
+ if (typeof idx !== 'number')
1629
+ return null; // cancelled (Esc) → unknown
1630
+ return candidates[idx] ?? null;
1631
+ }
1632
+ /** Default `gh` runner — read-only `gh` via execa (never a write subcommand). */
1633
+ async function ghRun(args) {
1634
+ const { stdout } = await execa('gh', args);
1635
+ return { stdout };
1636
+ }
1637
+ /**
1638
+ * Resolve ONE provider's candidate list down to a single id (issue §4):
1639
+ * - 0 candidates → `null` (caller records `unknown`)
1640
+ * - 1 candidate → that id (no prompt — but the AMBIGUITY edge always prompts:
1641
+ * the caller decides whether to force a prompt; here a lone match is taken)
1642
+ * - 2+ candidates → prompt (interactive) or record the candidate list for the
1643
+ * `--json`/headless path and return `null` (→ `unknown` until resolved).
1644
+ *
1645
+ * `forcePrompt` (issue §8: "el CLI siempre presenta el prompt si hay más de un
1646
+ * candidato, incluso si parece obvio") makes the caller pass the full list through
1647
+ * even for a single near-obvious match when there is genuine ambiguity upstream.
1648
+ */
1649
+ async function resolveCandidate(provider, candidates, deps, recordAmbiguity) {
1650
+ if (candidates.length === 0)
1651
+ return null;
1652
+ if (candidates.length === 1)
1653
+ return candidates[0].id;
1654
+ // More than one candidate → ambiguity. In `--json`/headless we cannot prompt:
1655
+ // surface the list for external resolution and leave the field `unknown`.
1656
+ recordAmbiguity(candidates);
1657
+ if (deps.headless)
1658
+ return null;
1659
+ const select = deps.selectCandidate ?? promptSelectCandidate;
1660
+ const chosen = await select(provider, candidates);
1661
+ return chosen?.id ?? null;
1662
+ }
1663
+ /** Reconstruct the Vercel project id (read-only) — filter by name/slug. */
1664
+ async function adoptVercel(slug, teamId, token, deps, recordAmbiguity) {
1665
+ const projects = await listVercelProjects(token, teamId, { fetchImpl: deps.fetchImpl });
1666
+ const candidates = projectsToCandidates(projects, slug, teamId);
1667
+ return resolveCandidate('Vercel', candidates, deps, recordAmbiguity);
1668
+ }
1669
+ /** Map Vercel project summaries to candidates, keeping only slug-name matches. */
1670
+ function projectsToCandidates(projects, slug, teamId) {
1671
+ const matches = projects.filter((p) => p.name === slug || p.name.includes(slug) || slug.includes(p.name));
1672
+ // Prefer an EXACT name match alone (unambiguous); otherwise surface all matches.
1673
+ const exact = matches.filter((p) => p.name === slug);
1674
+ const pool = exact.length === 1 ? exact : matches;
1675
+ return pool.map((p) => ({
1676
+ id: p.id,
1677
+ name: p.name,
1678
+ url: `https://vercel.com/${teamId}/${p.name}`,
1679
+ }));
1680
+ }
1681
+ /**
1682
+ * Reconstruct the Neon project id + `develop` branch id (read-only). Filters the
1683
+ * org's projects by slug; once the project resolves, lists its branches to find
1684
+ * the `develop` one. A branch lookup failure leaves the branch id `unknown`
1685
+ * without failing the whole adopt (issue §4).
1686
+ */
1687
+ async function adoptNeon(slug, apiKey, deps, recordAmbiguity) {
1688
+ const projects = await listNeonProjects(apiKey, { fetchImpl: deps.fetchImpl });
1689
+ const matches = projects.filter((p) => p.name === slug || p.name.includes(slug) || slug.includes(p.name));
1690
+ const exact = matches.filter((p) => p.name === slug);
1691
+ const pool = exact.length === 1 ? exact : matches;
1692
+ const candidates = pool.map((p) => ({
1693
+ id: p.id,
1694
+ name: p.name,
1695
+ url: `https://console.neon.tech/app/projects/${p.id}`,
1696
+ }));
1697
+ const projectId = await resolveCandidate('Neon', candidates, deps, recordAmbiguity);
1698
+ if (!projectId)
1699
+ return { projectId: null, branchDevId: null };
1700
+ // Resolve the develop branch (best-effort — a failure → unknown, not a hard stop).
1701
+ let branchDevId = null;
1702
+ try {
1703
+ const branches = await listNeonBranches(apiKey, projectId, { fetchImpl: deps.fetchImpl });
1704
+ branchDevId = branches.find((b) => b.name === NEON_DEVELOP_BRANCH)?.id ?? null;
1705
+ }
1706
+ catch {
1707
+ branchDevId = null;
1708
+ }
1709
+ return { projectId, branchDevId };
1710
+ }
1711
+ /**
1712
+ * Reconstruct the GitHub repo (read-only) via `gh repo view {org}/{slug} --json
1713
+ * name,url`. A missing repo (`gh` exit ≠ 0) → `null` (recorded `unknown`), never a
1714
+ * hard failure (issue §4). The canonical org/slug is the only candidate, so no
1715
+ * disambiguation is needed here.
1716
+ */
1717
+ async function adoptGithub(slug, deps) {
1718
+ const runGh = deps.runGh ?? ghRun;
1719
+ const repo = `${FACTORY_ORG}/${slug}`;
1720
+ try {
1721
+ const { stdout } = await runGh(['repo', 'view', repo, '--json', 'name,url']);
1722
+ const parsed = JSON.parse(stdout);
1723
+ // A successful view confirms the repo exists; record the canonical `org/slug`.
1724
+ return parsed.name ? repo : null;
1725
+ }
1726
+ catch {
1727
+ return null;
1728
+ }
1729
+ }
1730
+ /**
1731
+ * Reconstruct the interim domains' DNS presence (read-only). The domain NAMES are
1732
+ * always derivable from the slug (never `unknown`); this only CONFIRMS the CNAMEs
1733
+ * exist (a missing/unauthorized Cloudflare token logs a notice and continues — the
1734
+ * domains are still recorded by name). Returns the `{ dev, prod }` names regardless.
1735
+ */
1736
+ async function adoptDns(slug, deps) {
1737
+ const domains = provisionDomains(slug);
1738
+ const cfToken = readCloudflareApiKey(deps.envPath ?? defaultEnvPath());
1739
+ if (!cfToken) {
1740
+ console.warn('⚠ No hay `CLOUDFLARE_API_TOKEN` en el rail — registro los dominios por nombre sin ' +
1741
+ 'confirmar sus CNAMEs (es read-only; corre `factory secrets <archivo>` si quieres la verificación).');
1742
+ return domains;
1743
+ }
1744
+ try {
1745
+ const cfDeps = { fetchImpl: deps.fetchImpl };
1746
+ const zoneId = await resolveZoneId(cfToken, cfDeps);
1747
+ for (const name of [domains.dev, domains.prod]) {
1748
+ const record = await findCnameRecord(zoneId, name, cfToken, cfDeps);
1749
+ console.log(record
1750
+ ? ` ✔ CNAME ${name} encontrado (${record.content})`
1751
+ : ` · CNAME ${name} no encontrado (lo registro por nombre igual)`);
1752
+ }
1753
+ }
1754
+ catch {
1755
+ // A read-only DNS check failure must never fail the adopt (issue §4).
1756
+ console.warn(`⚠ No se pudo verificar los CNAMEs en Cloudflare (read-only) — continúo.`);
1757
+ }
1758
+ return domains;
1759
+ }
1760
+ /** Print the reconstructed adopt state in plain es-MX (the non-`--json` narration). */
1761
+ function printAdoptState(result) {
1762
+ const s = result.state;
1763
+ console.log(`\nEstado reconstruido para \`${s.slug}\` (lifecycle: ${s.lifecycle}):`);
1764
+ console.log(` Vercel project: ${s.vercelProjectId}`);
1765
+ console.log(` Neon project: ${s.neonProjectId}`);
1766
+ console.log(` Neon develop: ${s.neonBranchDevId}`);
1767
+ console.log(` GitHub repo: ${s.githubRepo}`);
1768
+ console.log(` Dominio dev: ${s.domains.dev}`);
1769
+ console.log(` Dominio prod: ${s.domains.prod}`);
1770
+ if (result.dryRun) {
1771
+ console.log('\n(`--dry-run`) No se escribió `.timekast/provision.json`.');
1772
+ }
1773
+ }
1774
+ /**
1775
+ * `factory provision --adopt` (PROV-007). Reconstruct `.timekast/provision.json`
1776
+ * for an EXISTING derived project by querying the providers STRICTLY read-only —
1777
+ * never a POST/DELETE/PATCH. The slug is the project dir name; each provider is
1778
+ * queried via its GET endpoint, ambiguities are resolved by a numbered prompt
1779
+ * (interactive) or surfaced in `--json` (external resolution), a provider with no
1780
+ * token/access/response is recorded `unknown` and the run continues, and the final
1781
+ * state is written with `lifecycle: 'live'` + `adoptedAt`. `--dry-run` runs all the
1782
+ * GETs and prints the state WITHOUT writing the file.
1783
+ *
1784
+ * Existence guard (issue §4): a state file already present → without `--force`,
1785
+ * throw a clear `CLIError`; with `--force`, overwrite.
1786
+ *
1787
+ * @throws {CLIError} when a state already exists and `--force` was not passed.
1788
+ */
1789
+ export async function runAdopt(flags, deps = {}) {
1790
+ const rootDir = process.cwd();
1791
+ // Existence guard up-front (friendlier message than the writer's last-line guard).
1792
+ if (!flags.dryRun && hasProvisionState(rootDir) && !flags.force) {
1793
+ throw new CLIError('Ya existe `.timekast/provision.json` (este proyecto ya fue adoptado o provisionado). ' +
1794
+ 'Usa `--force` para sobrescribirlo con un nuevo adopt, o déjalo como está.');
1795
+ }
1796
+ // The slug is the dir name — a LOW-TRUST identifier that flows into provider
1797
+ // lookups + the persisted state. Re-validate it as a subdomain label BEFORE any
1798
+ // use (FIX 2): a malformed dir name (leading `-`, underscore, …) is rejected,
1799
+ // never passed to a REST query or recorded.
1800
+ const slug = basename(rootDir);
1801
+ assertValidSubdomainSlug(slug);
1802
+ const envPath = deps.envPath ?? defaultEnvPath();
1803
+ // Headless detection keys off STDIN (whether a human can answer), not STDOUT
1804
+ // (which is redirected by pipes) — unify with `--destroy` (FIX 3).
1805
+ const headless = deps.headless ?? !process.stdin.isTTY;
1806
+ const adeps = { ...deps, envPath, headless };
1807
+ const candidates = {};
1808
+ // Vercel (GET /v9/projects) — needs the team scope + the rail token. No token /
1809
+ // no team / a REST failure → `unknown`, continue (issue §4).
1810
+ let vercelProjectId = null;
1811
+ const vercelToken = readVercelToken(envPath);
1812
+ if (vercelToken && flags.team) {
1813
+ try {
1814
+ vercelProjectId = await adoptVercel(slug, flags.team, vercelToken, adeps, (list) => {
1815
+ candidates.vercel = list;
1816
+ });
1817
+ }
1818
+ catch {
1819
+ console.warn('⚠ No se pudo consultar Vercel (read-only) — registro `vercelProjectId: unknown`.');
1820
+ }
1821
+ }
1822
+ else {
1823
+ console.warn('⚠ Sin `VERCEL_TOKEN` en el rail o sin `--team` — omito Vercel (`vercelProjectId: unknown`). ' +
1824
+ 'Corre `factory secrets <archivo>` y pasa `--team` para completarlo con otro `--adopt`.');
1825
+ }
1826
+ // Neon (GET /projects + branches) — needs the rail key. Absent → `unknown` (the
1827
+ // Gherkin "Provider Neon sin token disponible" path).
1828
+ let neonProjectId = null;
1829
+ let neonBranchDevId = null;
1830
+ const neonApiKey = readNeonApiKey(envPath);
1831
+ if (neonApiKey) {
1832
+ try {
1833
+ const neon = await adoptNeon(slug, neonApiKey, adeps, (list) => {
1834
+ candidates.neon = list;
1835
+ });
1836
+ neonProjectId = neon.projectId;
1837
+ neonBranchDevId = neon.branchDevId;
1838
+ }
1839
+ catch {
1840
+ console.warn('⚠ No se pudo consultar Neon (read-only) — registro `neonProjectId: unknown`.');
1841
+ }
1842
+ }
1843
+ else {
1844
+ console.warn('⚠ Sin `NEON_API_KEY` en el rail — omito Neon (`neonProjectId: unknown`). ' +
1845
+ 'Completa con `factory provision --adopt` después de `factory secrets <archivo>`.');
1846
+ }
1847
+ // GitHub (gh repo view) — uses the dev's gh auth (no rail token needed).
1848
+ const githubRepo = await adoptGithub(slug, adeps);
1849
+ // DNS (GET dns_records) — domain names always derivable; CNAME check best-effort.
1850
+ const domains = await adoptDns(slug, adeps);
1851
+ const partial = {
1852
+ slug,
1853
+ vercelProjectId: vercelProjectId ?? ADOPT_UNKNOWN,
1854
+ neonProjectId: neonProjectId ?? ADOPT_UNKNOWN,
1855
+ neonBranchDevId: neonBranchDevId ?? ADOPT_UNKNOWN,
1856
+ githubRepo: githubRepo ?? ADOPT_UNKNOWN,
1857
+ domains,
1858
+ };
1859
+ // Build the state object (the writer also stamps these; for `--dry-run` we build
1860
+ // it here so the printed/returned shape matches exactly what WOULD be written).
1861
+ const now = deps.now ?? (() => new Date());
1862
+ const state = {
1863
+ lifecycle: 'live',
1864
+ slug,
1865
+ vercelProjectId: partial.vercelProjectId,
1866
+ neonProjectId: partial.neonProjectId,
1867
+ neonBranchDevId: partial.neonBranchDevId,
1868
+ githubRepo: partial.githubRepo,
1869
+ domains,
1870
+ adoptedAt: now().toISOString(),
1871
+ };
1872
+ const result = { state, dryRun: flags.dryRun, candidates };
1873
+ // `--dry-run`: print/emit the reconstructed state, write NOTHING (issue §4).
1874
+ if (flags.dryRun) {
1875
+ if (flags.json)
1876
+ console.log(JSON.stringify(result, null, 2));
1877
+ else
1878
+ printAdoptState(result);
1879
+ return result;
1880
+ }
1881
+ // Persist atomically with lifecycle:'live' + adoptedAt (the writer re-stamps the
1882
+ // clock; pass the same `now` so the written state matches the returned one).
1883
+ const written = adoptState(rootDir, partial, { force: flags.force, now });
1884
+ result.state = written;
1885
+ if (flags.json) {
1886
+ console.log(JSON.stringify(result, null, 2));
1887
+ }
1888
+ else {
1889
+ printAdoptState(result);
1890
+ console.log('\n✔ `.timekast/provision.json` escrito (lifecycle: live).');
1891
+ }
1892
+ return result;
1893
+ }
1894
+ /** Default `gh` runner — the dev's gh session (never a shared org token) via execa. */
1895
+ async function ghDelete(args) {
1896
+ const { stdout } = await execa('gh', args);
1897
+ return { stdout };
1898
+ }
1899
+ /** Default `vercel` runner — the dev's vercel CLI (never a shared org token) via execa. */
1900
+ async function vercelDelete(args) {
1901
+ const { stdout } = await execa('vercel', args);
1902
+ return { stdout };
1903
+ }
1904
+ /** Default type-to-confirm — a `prompts` text input (empty/Esc → '', i.e. a mismatch). */
1905
+ async function promptText(message) {
1906
+ const { answer } = await prompts({ type: 'text', name: 'answer', message });
1907
+ return typeof answer === 'string' ? answer : '';
1908
+ }
1909
+ /** Build the read-only list of what `--destroy` WOULD remove (the `--dry-run` plan). */
1910
+ function describeDestroyPlan(target) {
1911
+ const lines = [];
1912
+ if (target.neonProjectId) {
1913
+ if (target.lifecycle === 'ephemeral') {
1914
+ lines.push(` - Neon project ${target.neonProjectId} (NUKE completo: branches + data) — DELETE /api/v2`);
1915
+ }
1916
+ else if (target.neonBranchDevId) {
1917
+ lines.push(` - Neon branch develop ${target.neonBranchDevId} (proyecto ${target.neonProjectId} se conserva) — DELETE /api/v2`);
1918
+ }
1919
+ }
1920
+ for (const domain of target.domains) {
1921
+ lines.push(` - Dominio Vercel ${domain} — DELETE /v9/projects/.../domains`);
1922
+ lines.push(` - CNAME Cloudflare ${domain} — DELETE /zones/.../dns_records`);
1923
+ }
1924
+ if (target.vercelProjectId) {
1925
+ lines.push(` - Proyecto Vercel ${target.vercelProjectId} — \`vercel remove\` (sesión del dev)`);
1926
+ }
1927
+ if (target.githubRepo) {
1928
+ lines.push(` - Repo GitHub ${target.githubRepo} — \`gh repo delete\` (sesión del dev)`);
1929
+ }
1930
+ if (lines.length === 0) {
1931
+ lines.push(' (No hay recursos registrados que destruir — el estado no tiene ids.)');
1932
+ }
1933
+ return lines;
1934
+ }
1935
+ /**
1936
+ * Delete the Neon `develop` branch with the dev's NEON_API_KEY — the
1937
+ * `deleteE2EBranch` pattern from `scripts/tools/neon-branch.ts` (DELETE
1938
+ * /api/v2/projects/{id}/branches/{branchId}). A 404 (already gone) is "already
1939
+ * clean" (§8). The shared org create-only key is NEVER used to delete.
1940
+ */
1941
+ async function destroyNeonBranch(target, apiKey, fetchImpl) {
1942
+ const resource = `Neon branch develop (${target.neonBranchDevId})`;
1943
+ if (!target.neonProjectId || !target.neonBranchDevId) {
1944
+ return { resource: 'Neon branch develop', status: 'skipped', detail: 'sin id en el estado' };
1945
+ }
1946
+ try {
1947
+ const response = await fetchImpl(`${NEON_API_BASE}/projects/${encodeURIComponent(target.neonProjectId)}/branches/${encodeURIComponent(target.neonBranchDevId)}`, {
1948
+ method: 'DELETE',
1949
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
1950
+ signal: AbortSignal.timeout(30_000),
1951
+ });
1952
+ if (response.ok || response.status === 404) {
1953
+ return {
1954
+ resource,
1955
+ status: 'removed',
1956
+ detail: response.status === 404 ? 'ya estaba borrado (404)' : 'eliminado',
1957
+ };
1958
+ }
1959
+ return { resource, status: 'failed', detail: `Neon respondió ${response.status}` };
1960
+ }
1961
+ catch (error) {
1962
+ return { resource, status: 'failed', detail: error?.message ?? 'error de red' };
1963
+ }
1964
+ }
1965
+ /**
1966
+ * Nuke the ENTIRE Neon project (`deleteNeonProject` → DELETE /projects/{id}) — the
1967
+ * `ephemeral` teardown blast radius (FIX 8). Unlike {@link destroyNeonBranch}
1968
+ * (which removes only `develop` and preserves production `main` for a `live`
1969
+ * stack), this removes the project + all its branches + data. It needs ONLY the
1970
+ * project id, so it also tears down a PARTIAL stack whose develop branch never got
1971
+ * created (FIX 9). A 404 → already gone (§8). The dev's key does this, never the
1972
+ * shared org create-only key.
1973
+ */
1974
+ async function destroyNeonProject(target, apiKey, fetchImpl) {
1975
+ const resource = `Neon project (${target.neonProjectId})`;
1976
+ if (!target.neonProjectId) {
1977
+ return { resource: 'Neon project', status: 'skipped', detail: 'sin id en el estado' };
1978
+ }
1979
+ try {
1980
+ await deleteNeonProject(apiKey, target.neonProjectId, { fetchImpl });
1981
+ // Project gone — clean the project-scoped API key(s) it minted (`{slug}-e2e`)
1982
+ // so the Neon org does not accumulate dead keys across provisions/tests.
1983
+ // Best-effort (never throws): the project nuke already succeeded.
1984
+ const keysDeleted = await deleteNeonApiKeysByName(apiKey, `${target.slug}-e2e`, { fetchImpl });
1985
+ return {
1986
+ resource,
1987
+ status: 'removed',
1988
+ detail: keysDeleted > 0
1989
+ ? `proyecto + ${keysDeleted} API key(s) eliminados (nuke)`
1990
+ : 'proyecto eliminado (nuke)',
1991
+ };
1992
+ }
1993
+ catch (error) {
1994
+ const detail = error instanceof NeonApiError
1995
+ ? `Neon respondió ${error.status}`
1996
+ : (error?.message ?? 'error de red');
1997
+ return { resource, status: 'failed', detail };
1998
+ }
1999
+ }
2000
+ /**
2001
+ * Remove a domain from the Vercel project via the REST API (the dev's
2002
+ * `VERCEL_TOKEN`). 404 → already gone (§8). A team scope is required; absent →
2003
+ * skipped (the domain stays, reported). Mirrors {@link removeVercelDomain}'s 404
2004
+ * tolerance.
2005
+ */
2006
+ async function destroyVercelDomain(domain, projectId, teamId, token, fetchImpl) {
2007
+ const resource = `Dominio Vercel ${domain}`;
2008
+ try {
2009
+ const outcome = await removeVercelDomain(projectId, domain, token, teamId, { fetchImpl });
2010
+ return {
2011
+ resource,
2012
+ status: 'removed',
2013
+ detail: outcome === 'absent' ? 'ya estaba removido (404)' : 'removido',
2014
+ };
2015
+ }
2016
+ catch (error) {
2017
+ return { resource, status: 'failed', detail: error?.message ?? 'error' };
2018
+ }
2019
+ }
2020
+ /**
2021
+ * Delete the Cloudflare CNAME for a domain with the dev's own `CLOUDFLARE_API_TOKEN`
2022
+ * (the shared org token is DNS-EDIT but create-only by policy; the dev's token does the
2023
+ * delete). A missing record (no CNAME) → "already clean", never a failure (§8 —
2024
+ * a derivative with no domain configured simply has nothing to remove).
2025
+ */
2026
+ async function destroyCloudflareRecord(domain, zoneId, token, fetchImpl) {
2027
+ const resource = `CNAME Cloudflare ${domain}`;
2028
+ try {
2029
+ const record = await findCnameRecord(zoneId, domain, token, {
2030
+ fetchImpl,
2031
+ });
2032
+ if (!record) {
2033
+ return { resource, status: 'removed', detail: 'no había registro (ya limpio)' };
2034
+ }
2035
+ const response = await fetchImpl(`${CLOUDFLARE_API_BASE}/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(record.id)}`, {
2036
+ method: 'DELETE',
2037
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
2038
+ signal: AbortSignal.timeout(30_000),
2039
+ });
2040
+ if (response.ok || response.status === 404) {
2041
+ return { resource, status: 'removed', detail: 'eliminado' };
2042
+ }
2043
+ return { resource, status: 'failed', detail: `Cloudflare respondió ${response.status}` };
2044
+ }
2045
+ catch (error) {
2046
+ return { resource, status: 'failed', detail: error?.message ?? 'error' };
2047
+ }
2048
+ }
2049
+ /**
2050
+ * Remove the Vercel PROJECT via the dev's `vercel` CLI (`vercel remove <slug>
2051
+ * --yes`) — issue §4: the project delete uses the DEV's session, never a shared org
2052
+ * token (the shared org Vercel token is create/configure-only). A "not found" stderr
2053
+ * is treated as already-removed (§8).
2054
+ */
2055
+ async function destroyVercelProject(target, runVercel) {
2056
+ const resource = `Proyecto Vercel ${target.slug}`;
2057
+ // FIX 2: the slug came from the low-trust state file and is about to become a
2058
+ // positional arg to `vercel remove`. A malformed slug (leading `-` = flag
2059
+ // smuggling, or a non-subdomain shape) is NEVER passed to the command. It is a
2060
+ // `failed` (not a silent skip) because a Vercel project IS recorded but cannot be
2061
+ // torn down — so `hadFailures` keeps the state (no orphan), per FIX 3.
2062
+ if (!isValidSubdomainSlug(target.slug)) {
2063
+ return {
2064
+ resource: 'Proyecto Vercel',
2065
+ status: 'failed',
2066
+ detail: `slug malformado en el estado (\`${target.slug}\`) — bórralo a mano en Vercel`,
2067
+ };
2068
+ }
2069
+ try {
2070
+ // `--` ends option parsing so the slug can never be read as a flag.
2071
+ await runVercel(['remove', '--yes', '--', target.slug]);
2072
+ return { resource, status: 'removed', detail: 'eliminado vía `vercel remove`' };
2073
+ }
2074
+ catch (error) {
2075
+ const message = error?.message ?? '';
2076
+ if (/not found|no such|does not exist|404/i.test(message)) {
2077
+ return { resource, status: 'removed', detail: 'ya no existía (vercel)' };
2078
+ }
2079
+ return { resource, status: 'failed', detail: message || 'error en `vercel remove`' };
2080
+ }
2081
+ }
2082
+ /**
2083
+ * Delete the GitHub repo via the dev's `gh` session (`gh repo delete {org/slug}
2084
+ * --yes`) — issue §4: NEVER a shared org token (shared org tokens are create-only). A
2085
+ * "not found" is already-deleted (§8).
2086
+ */
2087
+ async function destroyGithubRepo(target, runGh) {
2088
+ const resource = `Repo GitHub ${target.githubRepo}`;
2089
+ if (!target.githubRepo) {
2090
+ return { resource: 'Repo GitHub', status: 'skipped', detail: 'sin repo en el estado' };
2091
+ }
2092
+ // FIX 2 defense-in-depth: `readDestroyTarget` already dropped a malformed repo
2093
+ // to `null` (so `!target.githubRepo` caught it), but re-check the `owner/repo`
2094
+ // shape here — a value starting with `-` could smuggle a flag into `gh`.
2095
+ if (target.githubRepo.startsWith('-') ||
2096
+ !/^[A-Za-z0-9_.][A-Za-z0-9_.-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/.test(target.githubRepo)) {
2097
+ return {
2098
+ resource: 'Repo GitHub',
2099
+ status: 'skipped',
2100
+ detail: `repo malformado en el estado (\`${target.githubRepo}\`) — bórralo a mano`,
2101
+ };
2102
+ }
2103
+ try {
2104
+ // `--` ends option parsing so the repo can never be read as a flag.
2105
+ await runGh(['repo', 'delete', '--yes', '--', target.githubRepo]);
2106
+ return { resource, status: 'removed', detail: 'eliminado vía `gh repo delete`' };
2107
+ }
2108
+ catch (error) {
2109
+ const message = error?.message ?? '';
2110
+ if (/not found|could not resolve|404/i.test(message)) {
2111
+ return { resource, status: 'removed', detail: 'ya no existía (gh)' };
2112
+ }
2113
+ return { resource, status: 'failed', detail: message || 'error en `gh repo delete`' };
2114
+ }
2115
+ }
2116
+ /**
2117
+ * Orchestrate the per-provider teardown (issue §4 + §8). Each provider failure is
2118
+ * logged and the run CONTINUES with the others; the outcomes are collected so the
2119
+ * caller can report what was NOT removed. Order: Neon branch → (per domain: Vercel
2120
+ * domain + Cloudflare CNAME) → Vercel project → GitHub repo. Resource-delete tokens
2121
+ * are the DEV's (Neon/Cloudflare from the rail, Vercel/GitHub from the dev's CLI
2122
+ * session) — NEVER the shared org create-only tokens.
2123
+ */
2124
+ async function teardownProviders(target, deps) {
2125
+ const outcomes = [];
2126
+ // 1. Neon — blast radius BY LIFECYCLE (FIX 8/9/10):
2127
+ // · ephemeral → nuke the WHOLE project (needs only neonProjectId, so it also
2128
+ // cleans a PARTIAL stack whose develop branch never got created).
2129
+ // · live → remove ONLY the develop branch, preserving production `main`.
2130
+ // A missing Neon key is a `failed` outcome (keeps the state + flags the orphan),
2131
+ // NEVER a silent skip that drops the state with a project still alive.
2132
+ const neonKey = readNeonApiKey(deps.envPath);
2133
+ if (target.neonProjectId) {
2134
+ if (!neonKey) {
2135
+ outcomes.push({
2136
+ resource: target.lifecycle === 'ephemeral'
2137
+ ? `Neon project (${target.neonProjectId})`
2138
+ : `Neon branch develop (${target.neonBranchDevId})`,
2139
+ status: 'failed',
2140
+ detail: 'sin `NEON_API_KEY` en el rail — bórralo a mano',
2141
+ });
2142
+ }
2143
+ else if (target.lifecycle === 'ephemeral') {
2144
+ outcomes.push(await destroyNeonProject(target, neonKey, deps.fetchImpl));
2145
+ }
2146
+ else if (target.neonBranchDevId) {
2147
+ outcomes.push(await destroyNeonBranch(target, neonKey, deps.fetchImpl));
2148
+ }
2149
+ // live + no develop branch → nothing to remove (production `main` is preserved).
2150
+ }
2151
+ // 2. Per domain — remove from Vercel AND delete the Cloudflare CNAME (§8: no
2152
+ // domain configured → nothing to remove; a single provider failure continues).
2153
+ const vercelToken = readVercelToken(deps.envPath);
2154
+ const cfToken = readCloudflareApiKey(deps.envPath);
2155
+ let zoneId = null;
2156
+ for (const domain of target.domains) {
2157
+ // The domain is registered in the state, so there IS a resource to remove. If
2158
+ // a prerequisite is missing (FIX 3: e.g. `scope` null on an adopted stack, or
2159
+ // no token), emit `failed` — NOT a silent skip — so `hadFailures` keeps the
2160
+ // state and the dev knows a live domain was left behind, not orphaned silently.
2161
+ if (!target.vercelProjectId) {
2162
+ outcomes.push({
2163
+ resource: `Dominio Vercel ${domain}`,
2164
+ status: 'failed',
2165
+ detail: 'sin `vercelProjectId` en el estado — retíralo a mano en Vercel',
2166
+ });
2167
+ }
2168
+ else if (!target.scope) {
2169
+ outcomes.push({
2170
+ resource: `Dominio Vercel ${domain}`,
2171
+ status: 'failed',
2172
+ detail: 'sin `scope`/team en el estado (¿stack adoptado?) — retíralo a mano en Vercel',
2173
+ });
2174
+ }
2175
+ else if (!vercelToken) {
2176
+ outcomes.push({
2177
+ resource: `Dominio Vercel ${domain}`,
2178
+ status: 'failed',
2179
+ detail: 'sin `VERCEL_TOKEN` en el rail — retíralo a mano en Vercel',
2180
+ });
2181
+ }
2182
+ else {
2183
+ outcomes.push(await destroyVercelDomain(domain, target.vercelProjectId, target.scope, vercelToken, deps.fetchImpl));
2184
+ }
2185
+ if (!cfToken) {
2186
+ outcomes.push({
2187
+ resource: `CNAME Cloudflare ${domain}`,
2188
+ status: 'failed',
2189
+ detail: 'sin `CLOUDFLARE_API_TOKEN` en el rail — borra el CNAME a mano',
2190
+ });
2191
+ }
2192
+ else {
2193
+ try {
2194
+ if (zoneId === null) {
2195
+ zoneId = await resolveZoneIdForName(apexZoneOf(domain), cfToken, {
2196
+ fetchImpl: deps.fetchImpl,
2197
+ });
2198
+ }
2199
+ outcomes.push(await destroyCloudflareRecord(domain, zoneId, cfToken, deps.fetchImpl));
2200
+ }
2201
+ catch (error) {
2202
+ outcomes.push({
2203
+ resource: `CNAME Cloudflare ${domain}`,
2204
+ status: 'failed',
2205
+ detail: error?.message ?? 'no se resolvió la zona',
2206
+ });
2207
+ }
2208
+ }
2209
+ }
2210
+ // 3. Vercel PROJECT — the dev's `vercel remove` (NEVER a shared org token).
2211
+ if (target.vercelProjectId) {
2212
+ outcomes.push(await destroyVercelProject(target, deps.runVercel));
2213
+ }
2214
+ // 4. GitHub repo — the dev's `gh repo delete` (NEVER a shared org token).
2215
+ if (target.githubRepo) {
2216
+ outcomes.push(await destroyGithubRepo(target, deps.runGh));
2217
+ }
2218
+ return outcomes;
2219
+ }
2220
+ /** Print the per-provider teardown report (es-MX) and flag the not-removed ones. */
2221
+ function printDestroyReport(outcomes) {
2222
+ for (const o of outcomes) {
2223
+ const mark = o.status === 'removed' ? '✔' : o.status === 'skipped' ? '⏭' : '✖';
2224
+ console.log(` ${mark} ${o.resource}: ${o.detail}`);
2225
+ }
2226
+ const failed = outcomes.filter((o) => o.status === 'failed');
2227
+ if (failed.length > 0) {
2228
+ console.warn(`\n⚠ ${failed.length} recurso(s) NO se eliminaron — bórralos a mano:\n` +
2229
+ failed.map((o) => ` · ${o.resource} (${o.detail})`).join('\n'));
2230
+ }
2231
+ }
2232
+ /**
2233
+ * `factory provision --destroy` (PROV-008). Tear down a derived stack, reading the
2234
+ * state's `lifecycle` as the single authority:
2235
+ *
2236
+ * - No state file → `CLIError` ("ejecuta `--adopt` primero").
2237
+ * - Headless (no TTY / `--json`) → `CLIError` BEFORE any op (agente-only).
2238
+ * - `lifecycle: live` WITHOUT `--force` → refuse, zero deletes.
2239
+ * - `lifecycle: live` WITH `--force` → type-to-confirm the slug; mismatch → abort.
2240
+ * - `lifecycle: ephemeral` → auto-nuke (no `--force` needed; `--force`
2241
+ * is a no-op — no type-to-confirm).
2242
+ * - `--dry-run` → list the resources + tools, mutate NOTHING.
2243
+ *
2244
+ * Resource-delete tokens are the DEV's (Neon/Cloudflare from the rail; Vercel/GitHub
2245
+ * from the dev's CLI session) — NEVER the shared org create-only tokens. A provider
2246
+ * failure is logged and the teardown continues; the state file is cleared ONLY when
2247
+ * every provider succeeded (issue §4 + §8).
2248
+ *
2249
+ * @throws {CLIError} on no-state, headless, a `live` refusal, or a confirm mismatch.
2250
+ */
2251
+ export async function runDestroy(flags, deps = {}) {
2252
+ // 1. 🔴 Guard no-headless — BEFORE any op (issue §4 + §6). `--json` OR no TTY is
2253
+ // headless; `--destroy` is agente-only (never the TUI/SaaS).
2254
+ const isTty = deps.isTty ?? Boolean(process.stdin.isTTY);
2255
+ if (!isTty || flags.json) {
2256
+ throw new CLIError('--destroy requiere sesión interactiva (no headless)', 1);
2257
+ }
2258
+ const rootDir = deps.rootDir ?? process.cwd();
2259
+ // 2. Read the state — the SINGLE authority. Absent → run `--adopt` first (§4).
2260
+ const target = readDestroyTarget(rootDir);
2261
+ if (!target) {
2262
+ throw new CLIError('Sin provision state. Ejecuta --adopt primero.', 1);
2263
+ }
2264
+ // 3. Lifecycle guard (issue §4). `live` refuses unless `--force`; `live` + `--force`
2265
+ // requires type-to-confirm. `ephemeral` auto-nukes (`--force` has no extra effect).
2266
+ if (target.lifecycle === 'live') {
2267
+ if (!flags.force) {
2268
+ throw new CLIError('Este derivado está marcado como live. Usa `--force` solo si estás seguro.', 1);
2269
+ }
2270
+ if (!flags.dryRun) {
2271
+ const prompt = deps.prompt ?? promptText;
2272
+ const answer = await prompt(`Escribe el slug del proyecto (\`${target.slug}\`) para confirmar la destrucción: `);
2273
+ if (answer.trim() !== target.slug) {
2274
+ throw new CLIError('Confirmación incorrecta. Operación cancelada.', 1);
2275
+ }
2276
+ }
2277
+ }
2278
+ // 4. `--dry-run` — list what WOULD be destroyed, mutate NOTHING (issue §4).
2279
+ if (flags.dryRun) {
2280
+ console.log(`Plan de \`--destroy\` para \`${target.slug}\` (lifecycle: ${target.lifecycle}):`);
2281
+ for (const line of describeDestroyPlan(target))
2282
+ console.log(line);
2283
+ console.log('\n(`--dry-run`) No se ejecutó ninguna eliminación ni se borró el estado.');
2284
+ return {
2285
+ slug: target.slug,
2286
+ lifecycle: target.lifecycle,
2287
+ dryRun: true,
2288
+ outcomes: [],
2289
+ hadFailures: false,
2290
+ };
2291
+ }
2292
+ // 5. Teardown — per provider, continuing past failures (issue §8).
2293
+ console.log(`Destruyendo \`${target.slug}\` (lifecycle: ${target.lifecycle})…`);
2294
+ const outcomes = await teardownProviders(target, {
2295
+ fetchImpl: deps.fetchImpl ?? fetch,
2296
+ envPath: deps.envPath ?? defaultEnvPath(),
2297
+ runGh: deps.runGh ?? ghDelete,
2298
+ runVercel: deps.runVercel ?? vercelDelete,
2299
+ });
2300
+ printDestroyReport(outcomes);
2301
+ const hadFailures = outcomes.some((o) => o.status === 'failed');
2302
+ // 6. Clear the state ONLY on a fully-successful teardown (issue §4). A partial
2303
+ // failure keeps the state so a `--resume`-style re-run can finish the job.
2304
+ if (!hadFailures) {
2305
+ clearState(rootDir);
2306
+ console.log('\n✔ Destrucción completa. `.timekast/provision.json` eliminado.');
2307
+ }
2308
+ else {
2309
+ console.warn('\n⚠ Quedaron recursos sin eliminar — conservo `.timekast/provision.json` ' +
2310
+ 'para que puedas reintentar tras borrarlos a mano.');
2311
+ }
2312
+ return { slug: target.slug, lifecycle: target.lifecycle, dryRun: false, outcomes, hadFailures };
2313
+ }
2314
+ /**
2315
+ * `factory provision` entrypoint. Flag-driven, headless-safe scaffold:
2316
+ *
2317
+ * 1. Validate flags (at least one service; sane combos).
2318
+ * 2. Resolve state: `--force` discards it; `--resume` reads it (tolerating
2319
+ * corruption with a `--force` hint); a fresh run refuses to clobber an
2320
+ * existing state without `--resume`/`--force`.
2321
+ * 3. `--dry-run` prints the plan and returns — NO preflight network call, NO
2322
+ * state write.
2323
+ * 4. Preflight (fail-closed: gh + rail tokens + Vercel team; neonctl optional).
2324
+ * 5. Run each pending step (today: stubs) and persist state atomically after
2325
+ * each — so a mid-run failure leaves completed steps durable for `--resume`.
2326
+ *
2327
+ * @throws {CLIError | PreflightError} on any expected failure.
2328
+ */
2329
+ export async function runProvision(flags) {
2330
+ // Resolve the Vercel team-id from the rail (`VERCEL_TEAM_ID` in `~/.claude/.env`)
2331
+ // when `--team` is omitted. The team-id is org-fixed config, NOT a per-run flag —
2332
+ // it lives in the rail alongside the tokens, so devs never pass
2333
+ // it. `--team` stays as an explicit override. Never hardcoded in source (CODING.md §5).
2334
+ if (!flags.team) {
2335
+ const railTeam = readGateValue('VERCEL_TEAM_ID', defaultEnvPath());
2336
+ if (railTeam)
2337
+ flags.team = railTeam;
2338
+ }
2339
+ // `--destroy` (PROV-008) tears a stack down. It is agente-only (refuses headless)
2340
+ // and reads the state's `lifecycle` as the single authority. It wins over every
2341
+ // other mode (a `--destroy --adopt` is destroy, not a re-adopt).
2342
+ if (flags.destroy) {
2343
+ return runDestroy(flags);
2344
+ }
2345
+ // `--adopt` (PROV-007) is a read-only backfill mode that bypasses the entire
2346
+ // service-create path: it reconstructs the state from existing resources and
2347
+ // never creates/deletes anything. It wins over `--services` (mutual-exclusive).
2348
+ if (flags.adopt) {
2349
+ return runAdopt(flags);
2350
+ }
2351
+ // `--domain` is a valid STANDALONE operation (PROV-006 patch mode): it may run
2352
+ // with no `--services` (a deferred go-live over an existing provision) or
2353
+ // alongside the initial run. It always runs AFTER the service steps.
2354
+ const hasDomain = Boolean(flags.domain);
2355
+ // 1. Flag validation.
2356
+ if (flags.services.length === 0 && !hasDomain) {
2357
+ throw new CLIError('No seleccionaste servicios. Usa `--services=neon,vercel,github,dns` ' +
2358
+ `(válidos: ${KNOWN_SERVICES.join(', ')}), o \`--domain=<dominio>\` para el dominio del cliente.`);
2359
+ }
2360
+ // The Vercel env-sync (`vercel`), the project-create (`github`, PROV-004), the
2361
+ // domain attach (`dns`, PROV-005) AND the client domain (`--domain`, PROV-006) all
2362
+ // hit the team-scoped Vercel API — the project lives on the team, not the account.
2363
+ if ((flags.services.includes('vercel') ||
2364
+ flags.services.includes('github') ||
2365
+ flags.services.includes('dns') ||
2366
+ hasDomain) &&
2367
+ !flags.team) {
2368
+ throw new CLIError('El provision de Vercel/GitHub/DNS y `--domain` requieren el team del org. ' +
2369
+ 'Agrégalo al rail como `VERCEL_TEAM_ID=<teamId>` en `~/.claude/.env` (impórtalo con `factory secrets <archivo>`) ' +
2370
+ 'o pásalo con `--team=<teamId>`.');
2371
+ }
2372
+ const rootDir = process.cwd();
2373
+ const lifecycle = flags.ephemeral ? 'ephemeral' : 'live';
2374
+ // A STANDALONE `--domain` patch (no services) requires an existing state to
2375
+ // patch — it cannot clobber, so it is treated like `--resume` for state-reading.
2376
+ const standaloneDomain = hasDomain && flags.services.length === 0;
2377
+ // 2. Resolve state. `--force` wins over `--resume` (issue §8): discard + restart.
2378
+ if (flags.force) {
2379
+ clearProvisionState(rootDir);
2380
+ }
2381
+ let state = null;
2382
+ if ((flags.resume || standaloneDomain) && !flags.force) {
2383
+ state = readProvisionState(rootDir); // throws CLIError on corrupt → hints --force
2384
+ if (!state) {
2385
+ throw new CLIError(standaloneDomain
2386
+ ? '`--domain` en modo patch requiere un provision existente (`.timekast/provision.json`), ' +
2387
+ 'pero no hay estado. Corre el provision inicial (`--services=...`) primero.'
2388
+ : 'No hay estado de provision para retomar (`--resume`). Corre sin `--resume` para empezar.');
2389
+ }
2390
+ }
2391
+ else if (!flags.force && flags.services.length > 0 && hasProvisionState(rootDir)) {
2392
+ // A fresh (non-resume, non-force) service run must not clobber a prior state.
2393
+ throw new CLIError('Ya existe un estado de provision (`.timekast/provision.json`). ' +
2394
+ 'Usa `--resume` para retomarlo o `--force` para descartarlo y empezar de cero.');
2395
+ }
2396
+ // In patch mode the projectName comes from the loaded state (the interim slug);
2397
+ // a fresh run derives it from flags (the scaffold default).
2398
+ const projectName = state?.projectName ?? deriveProjectName(flags, rootDir);
2399
+ const steps = buildPlan(flags.services, state);
2400
+ const result = {
2401
+ projectName,
2402
+ lifecycle: state?.lifecycle ?? lifecycle,
2403
+ dryRun: flags.dryRun,
2404
+ resumed: (flags.resume || standaloneDomain) && !flags.force,
2405
+ steps,
2406
+ };
2407
+ if (hasDomain) {
2408
+ result.clientDomain = { domain: flags.domain, route: flags.dns };
2409
+ }
2410
+ // 3. Dry-run: print the plan, touch nothing (no preflight network, no state).
2411
+ if (flags.dryRun) {
2412
+ if (flags.json) {
2413
+ console.log(JSON.stringify(result, null, 2));
2414
+ }
2415
+ else {
2416
+ if (steps.length > 0)
2417
+ printPlan(result);
2418
+ if (hasDomain) {
2419
+ const interim = provisionDomains(projectName).prod;
2420
+ printClientDomainPlan(flags.domain, interim, flags.dns, flags.confirm);
2421
+ }
2422
+ }
2423
+ return result;
2424
+ }
2425
+ // 4. Preflight — fail-closed BEFORE any mutation. A standalone `--domain` patch
2426
+ // preflights as `dns` (it needs the Vercel token + team + Cloudflare token).
2427
+ const preflightServices = flags.services.length > 0 ? flags.services : hasDomain ? ['dns'] : [];
2428
+ const preflight = await runProvisionPreflight({ services: preflightServices, teamId: flags.team, repoSlug: `${FACTORY_ORG}/${projectName}` }, {});
2429
+ if (preflight.neonctl.status === 'warn') {
2430
+ result.neonctlWarning = preflight.neonctl.message;
2431
+ }
2432
+ // 5. Initialize / carry the state, then run each pending step, persisting
2433
+ // atomically after each (resumability — issue §6 + Gherkin).
2434
+ const runState = state ?? {
2435
+ projectName,
2436
+ lifecycle,
2437
+ scope: flags.team ?? flags.orgId ?? '',
2438
+ createdAt: new Date().toISOString(),
2439
+ };
2440
+ // Ensure the state dir exists + a baseline file before the first step, so a
2441
+ // step failure on the very first service still leaves a resumable state.
2442
+ writeProvisionState(rootDir, runState);
2443
+ for (const step of steps) {
2444
+ if (step.action === 'skip')
2445
+ continue;
2446
+ await runServiceStep(step.service, rootDir, runState, flags);
2447
+ // Persist after each create. PROV-002 (neon) already writes its state
2448
+ // atomically inside the step; the remaining stubs record nothing yet, and
2449
+ // this write keeps the baseline durable for them too.
2450
+ writeProvisionState(rootDir, runState);
2451
+ }
2452
+ // 5b. Client domain (PROV-006) — runs AFTER the services so the Vercel project
2453
+ // (and the interim domains) already exist. Standalone patch or post-services.
2454
+ if (hasDomain) {
2455
+ // Headless = no STDIN to answer the interim-retire prompt (not STDOUT, which a
2456
+ // pipe redirects) — unify with `--destroy` / `--adopt` (FIX 3).
2457
+ const headless = !process.stdin.isTTY;
2458
+ await provisionClientDomain(rootDir, runState, flags.team, flags.domain, {
2459
+ dnsRoute: flags.dns,
2460
+ verifyTimeoutMin: flags.verifyTimeout,
2461
+ preConfirmed: flags.confirm,
2462
+ headless,
2463
+ });
2464
+ }
2465
+ // 5c. Persist the provision state to the repo (resource IDs) so a `--resume` from
2466
+ // a fresh clone has them. Only when the git substrate exists; best-effort.
2467
+ if (runState.github?.done) {
2468
+ await commitProvisionState(rootDir);
2469
+ }
2470
+ // 6. Report.
2471
+ if (flags.json) {
2472
+ console.log(JSON.stringify(result, null, 2));
2473
+ }
2474
+ else {
2475
+ warnNeonctl(preflight.neonctl);
2476
+ if (steps.length > 0)
2477
+ printPlan(result);
2478
+ if (hasDomain) {
2479
+ console.log(`\n✔ Dominio del cliente listo: ${flags.domain} (ruta ${flags.dns}).`);
2480
+ }
2481
+ else {
2482
+ console.log(`\n✔ Scaffold de provision listo (lifecycle: ${lifecycle}). ` +
2483
+ 'Estado en `.timekast/provision.json`.');
2484
+ }
2485
+ }
2486
+ return result;
2487
+ }