@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,490 @@
1
+ /**
2
+ * Vercel REST helper for `factory provision` (PROV-003).
3
+ *
4
+ * A THIN, reusable Vercel Environment-Variables client that mirrors the proven
5
+ * fetch shape of `provision-preflight.ts` (`Authorization: Bearer ${token}` +
6
+ * `https://api.vercel.com/...`). Its single non-negotiable contract: EVERY call
7
+ * to the Vercel API carries `?teamId={teamId}`. A Vercel project that lives in a
8
+ * Pro team 403s any env-var call made WITHOUT the team scope (the resource is on
9
+ * the team, not the personal account), so `teamId` is threaded into one central
10
+ * URL builder — there is no code path here that hits Vercel without it.
11
+ *
12
+ * What this module does NOT do:
13
+ * - Create the Vercel PROJECT — that is PROV-004 (`projectId` arrives via the
14
+ * provision state, or the caller is told PROV-004 must run first).
15
+ * - Run `vercel link` / `vercel pull` — NEVER. It writes `.vercel/project.json`
16
+ * itself (`{ projectId, orgId, teamId }`) so downstream steps never depend on
17
+ * the Vercel CLI rewriting `.env.local` (SK.md §7.1 — overwrite locks devs out).
18
+ *
19
+ * Per-target push (issue §4): production-only secrets push to `["production"]`,
20
+ * preview/develop ones to `["preview"]`, and shared values to both. `AUTH_SECRET`
21
+ * is minted INDEPENDENTLY per environment (`randomBytes(32).toString('hex')`) —
22
+ * prod and preview must never share a secret. The push is idempotent: a var that
23
+ * already exists for a target is PATCHed (not re-POSTed → no `ENV_ALREADY_EXISTS`).
24
+ *
25
+ * No-echo discipline (issue §4 + `factory secrets` pattern): a secret VALUE never
26
+ * reaches stdout. The public surface returns only KEY NAMES + targets; values
27
+ * live solely inside the request bodies handed to `fetchImpl`.
28
+ *
29
+ * `fetchImpl` is an injectable seam so the unit tests drive endpoint order +
30
+ * payload shape (and assert the `?teamId=` invariant on every URL) with a mock —
31
+ * the live REST flow is exercised e2e by PROV-010, never here (issue §7).
32
+ */
33
+ import { randomBytes } from 'node:crypto';
34
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
35
+ import path from 'node:path';
36
+ import { CLIError } from './cli-error.js';
37
+ /** Base URL of the Vercel REST API (named constant — never an inline literal). */
38
+ export const VERCEL_API_BASE = 'https://api.vercel.com';
39
+ /** Timeout (ms) for every Vercel REST call — a dead network must not hang provision. */
40
+ const VERCEL_API_TIMEOUT_MS = 30_000;
41
+ /** Directory (repo-root relative) Vercel's project link lives in. */
42
+ export const VERCEL_DIR = '.vercel';
43
+ /** Filename of the Vercel project-link document inside {@link VERCEL_DIR}. */
44
+ export const VERCEL_PROJECT_FILE = 'project.json';
45
+ /** A non-2xx response from the Vercel REST API. Carries the HTTP status for mapping. */
46
+ export class VercelApiError extends Error {
47
+ status;
48
+ /** The raw response body (best-effort) — useful context, never a secret. */
49
+ body;
50
+ constructor(status, body, context) {
51
+ super(`Vercel API ${status} en ${context}: ${body || '(sin cuerpo)'}`);
52
+ this.name = 'VercelApiError';
53
+ this.status = status;
54
+ this.body = body;
55
+ Object.setPrototypeOf(this, new.target.prototype);
56
+ }
57
+ }
58
+ /**
59
+ * Raised when `POST /v9/projects/{id}/link` 403s because Vercel's GitHub App is
60
+ * NOT installed on the org/repo (issue §8). It is NOT a hard failure: the caller
61
+ * prints the install URL and pauses with an HITL checkpoint, then the dev re-runs
62
+ * with `--resume` after installing the app. A subclass of {@link VercelApiError}
63
+ * so it still carries the 403 status for any generic handler.
64
+ */
65
+ export class GitHubAppNotInstalledError extends VercelApiError {
66
+ constructor(body, context) {
67
+ super(403, body, context);
68
+ this.name = 'GitHubAppNotInstalledError';
69
+ Object.setPrototypeOf(this, new.target.prototype);
70
+ }
71
+ }
72
+ /** Build the shared headers for every Vercel REST call (Bearer auth + JSON). */
73
+ function vercelHeaders(token) {
74
+ return {
75
+ Authorization: `Bearer ${token}`,
76
+ 'Content-Type': 'application/json',
77
+ };
78
+ }
79
+ /** Read a response body as text (best-effort) for error context — never throws. */
80
+ async function safeBody(response) {
81
+ try {
82
+ return (await response.text()).slice(0, 500);
83
+ }
84
+ catch {
85
+ return '';
86
+ }
87
+ }
88
+ /**
89
+ * Build a Vercel API URL with `teamId` ALWAYS threaded in. This is the ONE place
90
+ * a Vercel URL is assembled, so the `?teamId=` invariant cannot be forgotten on
91
+ * any call — extra query params are merged in alongside it.
92
+ */
93
+ export function vercelUrl(pathname, teamId, extraQuery = {}) {
94
+ const query = new URLSearchParams({ teamId, ...extraQuery });
95
+ return `${VERCEL_API_BASE}${pathname}?${query.toString()}`;
96
+ }
97
+ /**
98
+ * Make an authenticated Vercel API call. `teamId` is threaded into the URL by
99
+ * {@link vercelUrl} for EVERY method — a personal-account call (no team) would
100
+ * 403 on a team-owned project. Surfaces a typed {@link VercelApiError} on non-2xx.
101
+ */
102
+ async function vercelApi(method, pathname, token, teamId, deps, body, extraQuery) {
103
+ const fetchImpl = deps.fetchImpl ?? fetch;
104
+ const url = vercelUrl(pathname, teamId, extraQuery);
105
+ const init = {
106
+ method,
107
+ headers: vercelHeaders(token),
108
+ signal: AbortSignal.timeout(VERCEL_API_TIMEOUT_MS),
109
+ };
110
+ if (body !== undefined)
111
+ init.body = JSON.stringify(body);
112
+ const response = await fetchImpl(url, init);
113
+ if (!response.ok) {
114
+ throw new VercelApiError(response.status, await safeBody(response), `${method} ${pathname}`);
115
+ }
116
+ // A 204 (no body) is valid for DELETE/PATCH — tolerate an empty body.
117
+ try {
118
+ return await response.json();
119
+ }
120
+ catch {
121
+ return {};
122
+ }
123
+ }
124
+ /** Framework Vercel records for a derived project — the kit is always Next.js. */
125
+ export const VERCEL_FRAMEWORK = 'nextjs';
126
+ /**
127
+ * Create the Vercel PROJECT (PROV-004 — `POST /v9/projects?teamId=`). The create
128
+ * body is MINIMAL — `name`, `framework: nextjs`, and the GitHub `gitRepository`
129
+ * link. Vercel's `POST /v9/projects` REJECTS the project SETTINGS as "additional
130
+ * properties" (`productionBranch`, `autoExposeSystemEnvs`, `ssoProtection`), so
131
+ * those are applied AFTER create via {@link patchProjectSettings} (best-effort).
132
+ *
133
+ * @throws {VercelApiError} on any non-2xx (caller maps 401/402/403/409/429).
134
+ */
135
+ export async function createVercelProject(token, teamId, input, deps = {}) {
136
+ const data = (await vercelApi('POST', '/v9/projects', token, teamId, deps, {
137
+ name: input.name,
138
+ framework: VERCEL_FRAMEWORK,
139
+ gitRepository: { type: 'github', repo: input.repo },
140
+ }));
141
+ if (!data.id) {
142
+ throw new VercelApiError(201, JSON.stringify(data).slice(0, 500), 'POST /v9/projects (respuesta sin id)');
143
+ }
144
+ return { projectId: data.id };
145
+ }
146
+ /**
147
+ * Connect the GitHub repo to the Vercel project (PROV-004 — `POST
148
+ * /v9/projects/{id}/link?teamId=`). Vercel's GitHub App must be installed on the
149
+ * org/repo first; if it is not, Vercel 403s — surfaced as a typed
150
+ * {@link GitHubAppNotInstalledError} so the caller can show the install URL and
151
+ * pause (HITL) instead of aborting (issue §8). Any OTHER non-2xx is a plain
152
+ * {@link VercelApiError}.
153
+ */
154
+ export async function linkGitRepo(projectId, token, teamId, repo, deps = {}) {
155
+ try {
156
+ await vercelApi('POST', `/v9/projects/${encodeURIComponent(projectId)}/link`, token, teamId, deps, {
157
+ type: 'github',
158
+ repo,
159
+ });
160
+ }
161
+ catch (error) {
162
+ if (error instanceof VercelApiError && error.status === 403) {
163
+ throw new GitHubAppNotInstalledError(error.body, `POST /v9/projects/${encodeURIComponent(projectId)}/link`);
164
+ }
165
+ throw error;
166
+ }
167
+ }
168
+ /**
169
+ * Apply the project SETTINGS via `PATCH /v9/projects/{id}?teamId=` — the kit's
170
+ * contract that `POST /v9/projects` refuses at create time:
171
+ * - `ssoProtection: null` — preview deployments must be PUBLIC (issue §4)
172
+ * - `autoExposeSystemEnvs: true` — the env pill reads VERCEL_GIT_* (branch+sha)
173
+ *
174
+ * `productionBranch` is NOT sent here: it is not a property of this endpoint —
175
+ * Vercel 400s with "should NOT have additional property `productionBranch`", and
176
+ * that rejection drops the whole PATCH (so `ssoProtection`/`autoExposeSystemEnvs`
177
+ * silently never apply). The git link already tracks the repo's default branch
178
+ * (`main`) as production, so confirming it here is both redundant and invalid.
179
+ *
180
+ * The CALLER treats this as NON-FATAL: a rejected setting must not abort the
181
+ * whole provision.
182
+ *
183
+ * @throws {VercelApiError} on any non-2xx (the caller catches + warns).
184
+ */
185
+ export async function patchProjectSettings(projectId, token, teamId, deps = {}) {
186
+ await vercelApi('PATCH', `/v9/projects/${encodeURIComponent(projectId)}`, token, teamId, deps, {
187
+ ssoProtection: null,
188
+ autoExposeSystemEnvs: true,
189
+ });
190
+ }
191
+ /**
192
+ * Read the NUMERIC GitHub repo id Vercel recorded when the project was linked
193
+ * (`GET /v9/projects/{id}` → `link.repoId`). A git deployment's `gitSource` needs
194
+ * this numeric id, NOT the `owner/name` slug. Returns `null` when the project is
195
+ * not git-linked yet, so the caller skips the first-deployment trigger instead of
196
+ * throwing.
197
+ *
198
+ * @throws {VercelApiError} on any non-2xx (the caller catches + warns).
199
+ */
200
+ export async function getLinkedRepoId(projectId, token, teamId, deps = {}) {
201
+ const data = (await vercelApi('GET', `/v9/projects/${encodeURIComponent(projectId)}`, token, teamId, deps));
202
+ const repoId = data.link?.repoId;
203
+ return typeof repoId === 'number' ? repoId : null;
204
+ }
205
+ /**
206
+ * Trigger the FIRST git deployment for ONE branch (`POST /v13/deployments`).
207
+ * provision links the repo to the Vercel project AFTER `factory new`/provision
208
+ * already pushed `main` + `develop`, so Vercel never receives a push event and
209
+ * builds NOTHING — the project sits at 0 deployments. This fires the build
210
+ * explicitly from a git ref.
211
+ *
212
+ * `production: true` (the `main` branch) sends `target: 'production'`; `develop`
213
+ * omits it, so Vercel files it as a PREVIEW deployment — the kit contract
214
+ * (production ← main, preview ← develop). Returns as soon as the deployment is
215
+ * QUEUED; it does NOT wait for the build (minutes), mirroring the domain-SSL
216
+ * non-wait.
217
+ *
218
+ * @throws {VercelApiError} on any non-2xx (the caller treats it as NON-FATAL —
219
+ * the project + env are already provisioned, a build failure must not abort).
220
+ */
221
+ export async function triggerDeployment(token, teamId, input, deps = {}) {
222
+ const body = {
223
+ name: input.name,
224
+ project: input.projectId,
225
+ gitSource: { type: 'github', repoId: input.repoId, ref: input.ref },
226
+ };
227
+ if (input.production)
228
+ body.target = 'production';
229
+ await vercelApi('POST', '/v13/deployments', token, teamId, deps, body);
230
+ }
231
+ /**
232
+ * Attach a domain to the Vercel project — `POST /v9/projects/{id}/domains?teamId=`
233
+ * (PROV-005, issue §4). The DEV domain (`{slug}-dev.timekast.mx`) carries
234
+ * `gitBranch: 'develop'` so Vercel routes the develop branch's preview to it; the
235
+ * PROD domain (`{slug}.timekast.mx`) is sent WITHOUT `gitBranch`, which makes it a
236
+ * production domain. The shape difference is the whole point — the unit test
237
+ * asserts the dev body has `gitBranch: 'develop'` and the prod body has none.
238
+ *
239
+ * Idempotency (issue §8): a domain already attached to the project returns 409.
240
+ * That is NOT an error — provision is resumable, so a 409 resolves to a no-op and
241
+ * this returns `'exists'` instead of throwing. Any OTHER non-2xx is a typed
242
+ * {@link VercelApiError} the caller maps. SSL provisioning by Vercel can take
243
+ * minutes; this call does NOT wait for it (issue §8) — it only registers the
244
+ * domain and returns; the e2e flow (PROV-010) waits for the cert.
245
+ *
246
+ * @throws {VercelApiError} on any non-2xx EXCEPT 409 (which → `'exists'`).
247
+ */
248
+ export async function addVercelDomain(projectId, token, teamId, input, deps = {}) {
249
+ const body = { name: input.name };
250
+ // Only the DEV domain carries gitBranch; the prod domain MUST omit it.
251
+ if (input.gitBranch)
252
+ body.gitBranch = input.gitBranch;
253
+ try {
254
+ await vercelApi('POST', `/v9/projects/${encodeURIComponent(projectId)}/domains`, token, teamId, deps, body);
255
+ return 'added';
256
+ }
257
+ catch (error) {
258
+ if (error instanceof VercelApiError && error.status === 409) {
259
+ // Already attached (resume / re-run) — idempotent, continue (issue §8).
260
+ return 'exists';
261
+ }
262
+ throw error;
263
+ }
264
+ }
265
+ /**
266
+ * Read a single domain's verification status — `GET
267
+ * /v9/projects/{id}/domains/{name}?teamId=` (PROV-006, issue §4). The provision
268
+ * verification loop polls this until `verified: true`; the `verification` array
269
+ * carries the exact records the owner must create when the domain is not yet
270
+ * verified (the `--dns=registrar` manual path prints them).
271
+ *
272
+ * @throws {VercelApiError} on any non-2xx (caller maps it).
273
+ */
274
+ export async function getVercelDomainStatus(projectId, name, token, teamId, deps = {}) {
275
+ const data = (await vercelApi('GET', `/v9/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(name)}`, token, teamId, deps));
276
+ return {
277
+ verified: data.verified === true,
278
+ verification: data.verification ?? [],
279
+ };
280
+ }
281
+ /**
282
+ * Remove a domain from the Vercel project — `DELETE
283
+ * /v9/projects/{id}/domains/{name}?teamId=` (PROV-006, issue §4). Used ONLY to
284
+ * retire an interim prod domain (`{slug}.timekast.mx`) AFTER the client domain
285
+ * verifies, and ONLY with the dev's explicit confirmation (the caller gates it).
286
+ *
287
+ * Idempotency (issue §8): a 404 (the domain is already gone) is NOT an error —
288
+ * it resolves to `'absent'` so a re-run is a no-op. Any OTHER non-2xx is a typed
289
+ * {@link VercelApiError} the caller treats as a non-fatal warning (the interim
290
+ * domain stays attached, never aborts the run).
291
+ *
292
+ * @throws {VercelApiError} on any non-2xx EXCEPT 404 (which → `'absent'`).
293
+ */
294
+ export async function removeVercelDomain(projectId, name, token, teamId, deps = {}) {
295
+ try {
296
+ await vercelApi('DELETE', `/v9/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(name)}`, token, teamId, deps);
297
+ return 'removed';
298
+ }
299
+ catch (error) {
300
+ if (error instanceof VercelApiError && error.status === 404) {
301
+ return 'absent';
302
+ }
303
+ throw error;
304
+ }
305
+ }
306
+ /**
307
+ * List the team's Vercel projects — `GET /v9/projects?teamId=&limit=20` (PROV-007
308
+ * `--adopt`). STRICTLY read-only: it never POSTs/PATCHes/DELETEs anything. The
309
+ * caller filters the returned summaries by slug/name to reconstruct the adopted
310
+ * state; an exact-name match is preferred, but every project name that CONTAINS
311
+ * the slug is returned as a candidate so the caller can disambiguate (issue §4).
312
+ *
313
+ * `limit` is threaded as a query param (default 20 — issue §6); `teamId` flows
314
+ * through {@link vercelUrl} like every other call. Used by `--adopt` only.
315
+ *
316
+ * @throws {VercelApiError} on any non-2xx (caller maps it / records `unknown`).
317
+ */
318
+ export async function listVercelProjects(token, teamId, deps = {}, limit = 20) {
319
+ const data = (await vercelApi('GET', '/v9/projects', token, teamId, deps, undefined, {
320
+ limit: String(limit),
321
+ }));
322
+ return (data.projects ?? [])
323
+ .filter((p) => Boolean(p.id) && Boolean(p.name))
324
+ .map((p) => ({ id: p.id, name: p.name }));
325
+ }
326
+ /**
327
+ * List the project's existing env vars (issue §8 idempotency: we PATCH a var that
328
+ * already covers a target instead of POSTing a duplicate). `decrypt=false` keeps
329
+ * the response value-free where Vercel allows it — we only ever read `id`/`key`/
330
+ * `target`, never the stored value.
331
+ */
332
+ export async function listEnvVars(projectId, token, teamId, deps = {}) {
333
+ const data = (await vercelApi('GET', `/v9/projects/${encodeURIComponent(projectId)}/env`, token, teamId, deps));
334
+ return data.envs ?? [];
335
+ }
336
+ /**
337
+ * Idempotently set ONE env var for its targets (issue §4 + §8). If an existing
338
+ * var already covers any of the requested targets, it is PATCHed
339
+ * (`/v10/projects/{id}/env/{envId}`); otherwise it is POSTed
340
+ * (`/v10/projects/{id}/env`). Either way the call threads `?teamId=`.
341
+ *
342
+ * Matching is by `key` + target overlap — a var pushed for `production` does not
343
+ * clobber the same key's `preview` entry (they are distinct Vercel env records,
344
+ * which is exactly how `AUTH_SECRET` can differ per environment).
345
+ */
346
+ export async function upsertEnvVar(projectId, token, teamId, plan, existing, deps = {}) {
347
+ const match = existing.find((e) => e.key === plan.key && (e.target ?? []).some((t) => plan.target.includes(t)));
348
+ if (match) {
349
+ await vercelApi('PATCH', `/v10/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(match.id)}`, token, teamId, deps, {
350
+ value: plan.value,
351
+ target: plan.target,
352
+ type: 'encrypted',
353
+ });
354
+ return 'updated';
355
+ }
356
+ await vercelApi('POST', `/v10/projects/${encodeURIComponent(projectId)}/env`, token, teamId, deps, {
357
+ key: plan.key,
358
+ value: plan.value,
359
+ target: plan.target,
360
+ type: 'encrypted',
361
+ });
362
+ return 'created';
363
+ }
364
+ /**
365
+ * Generate a fresh, cryptographically-random `AUTH_SECRET` (issue §4: prod and
366
+ * preview MUST be distinct). `randomBytes(32)` → 64 hex chars (≥32). Called once
367
+ * per environment, so two calls return two different values.
368
+ */
369
+ export function generateAuthSecret() {
370
+ return randomBytes(32).toString('hex');
371
+ }
372
+ /** Absolute path to `.vercel/project.json` under a repo root. */
373
+ export function vercelProjectPath(rootDir) {
374
+ return path.join(rootDir, VERCEL_DIR, VERCEL_PROJECT_FILE);
375
+ }
376
+ /**
377
+ * Read an existing `.vercel/project.json`, returning its `projectId` (best-effort)
378
+ * or `null` when absent/unparsable. Used by {@link writeVercelProjectJson} to
379
+ * detect a directory being reused with a DIFFERENT project (issue §8).
380
+ */
381
+ export function readVercelProjectId(rootDir) {
382
+ const file = vercelProjectPath(rootDir);
383
+ if (!existsSync(file))
384
+ return null;
385
+ try {
386
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
387
+ return typeof parsed.projectId === 'string' ? parsed.projectId : null;
388
+ }
389
+ catch {
390
+ return null;
391
+ }
392
+ }
393
+ /**
394
+ * Write `.vercel/project.json` with `{ projectId, orgId, teamId }` — WITHOUT ever
395
+ * running `vercel link`/`vercel pull` (SK.md §7.1: those overwrite `.env.local`
396
+ * and have locked devs out). The write is atomic (temp file + `renameSync`), so a
397
+ * kill mid-write leaves the previous link intact.
398
+ *
399
+ * Reuse guard (issue §8): if a link already exists for a DIFFERENT `projectId`,
400
+ * the dev may be reusing the directory. Without `force`, this throws a `CLIError`
401
+ * pointing at `--force` (headless-safe: a clear failure, never a silent clobber).
402
+ */
403
+ export function writeVercelProjectJson(rootDir, link, options = {}) {
404
+ const existingProjectId = readVercelProjectId(rootDir);
405
+ if (existingProjectId && existingProjectId !== link.projectId && !options.force) {
406
+ throw new CLIError(`Ya existe \`${VERCEL_DIR}/${VERCEL_PROJECT_FILE}\` apuntando a otro proyecto Vercel ` +
407
+ `(\`${existingProjectId}\` ≠ \`${link.projectId}\`).\n` +
408
+ 'Usa `--force` para sobrescribir el link, o elimina el archivo si reutilizas este directorio.');
409
+ }
410
+ const dir = path.join(rootDir, VERCEL_DIR);
411
+ mkdirSync(dir, { recursive: true });
412
+ const target = vercelProjectPath(rootDir);
413
+ const tmp = path.join(dir, `.${VERCEL_PROJECT_FILE}.tmp`);
414
+ const body = `${JSON.stringify({
415
+ projectId: link.projectId,
416
+ orgId: link.orgId,
417
+ teamId: link.teamId,
418
+ }, null, 2)}\n`;
419
+ try {
420
+ writeFileSync(tmp, body, 'utf8');
421
+ renameSync(tmp, target);
422
+ }
423
+ catch (error) {
424
+ rmSync(tmp, { force: true });
425
+ throw new CLIError(`No se pudo escribir \`${VERCEL_DIR}/${VERCEL_PROJECT_FILE}\`: ` +
426
+ `${error?.message ?? 'error desconocido'}.`);
427
+ }
428
+ }
429
+ /**
430
+ * Build the per-target env-var plan for a provisioned stack (issue §4).
431
+ *
432
+ * Production-bound values (the `main` DATABASE_URL, the prod `AUTH_SECRET`,
433
+ * `NEXT_PUBLIC_APP_ENV=production`) → `["production"]`. Preview/develop values
434
+ * (the `develop` DATABASE_URL, the preview `AUTH_SECRET`, `NEXT_PUBLIC_APP_ENV=
435
+ * preview`) → `["preview"]`. Shared, non-secret values (e.g. `EMAIL_FROM`,
436
+ * `RESEND_API_KEY`) apply to both. Each `AUTH_SECRET` is minted independently so
437
+ * the two environments never share a secret.
438
+ *
439
+ * `shared` values arrive already split on the first `=` (the caller reuses the
440
+ * `env-merge.ts` first-`=` rule); this builder does no parsing of its own.
441
+ */
442
+ export function buildEnvPlan(input) {
443
+ const plan = [
444
+ // Database — distinct connection string per environment.
445
+ { key: 'DATABASE_URL', value: input.mainDatabaseUrl, target: ['production'] },
446
+ { key: 'DATABASE_URL', value: input.developDatabaseUrl, target: ['preview'] },
447
+ // Auth secret — distinct value per environment (never shared).
448
+ { key: 'AUTH_SECRET', value: input.authSecretProd, target: ['production'] },
449
+ { key: 'AUTH_SECRET', value: input.authSecretPreview, target: ['preview'] },
450
+ // Deployment identity — drives the env-aware PWA manifest (see .env.example).
451
+ { key: 'NEXT_PUBLIC_APP_ENV', value: 'production', target: ['production'] },
452
+ { key: 'NEXT_PUBLIC_APP_ENV', value: 'preview', target: ['preview'] },
453
+ ];
454
+ for (const [key, value] of Object.entries(input.shared ?? {})) {
455
+ if (!value)
456
+ continue; // skip empty shared values
457
+ plan.push({ key, value, target: ['production', 'preview'] });
458
+ }
459
+ return plan;
460
+ }
461
+ /**
462
+ * Push an env-var plan to a Vercel project, idempotently and per-target, with
463
+ * `?teamId=` on EVERY call (issue §4). Lists existing vars ONCE up front, then
464
+ * upserts each plan entry. Returns KEY NAMES + targets only (no-echo): a secret
465
+ * value never crosses this boundary back to the caller, so it can never be logged.
466
+ *
467
+ * @throws {VercelApiError} on any non-2xx (the caller maps it to a `CLIError`).
468
+ */
469
+ export async function pushEnvPlan(projectId, token, teamId, plan, deps = {}) {
470
+ const existing = await listEnvVars(projectId, token, teamId, deps);
471
+ const result = { created: [], updated: [] };
472
+ // Track records we create during this run so a second plan entry for the same
473
+ // key+target (shouldn't happen, but defensive) PATCHes instead of duplicating.
474
+ const live = [...existing];
475
+ for (const entry of plan) {
476
+ const outcome = await upsertEnvVar(projectId, token, teamId, entry, live, deps);
477
+ if (outcome === 'created') {
478
+ result.created.push({ key: entry.key, target: entry.target });
479
+ live.push({
480
+ id: `pending-${entry.key}-${entry.target.join('-')}`,
481
+ key: entry.key,
482
+ target: entry.target,
483
+ });
484
+ }
485
+ else {
486
+ result.updated.push({ key: entry.key, target: entry.target });
487
+ }
488
+ }
489
+ return result;
490
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `cli/src/tui` — interactive terminal UI for the `factory` binary.
3
+ *
4
+ * Re-exports the public surface so callers import from `../tui/index.js`
5
+ * (the barrel) rather than reaching into individual modules.
6
+ */
7
+ export { runTuiMenu } from './menu.js';
8
+ export { provisionFlow, buildEphemeralFlags, validateSlug, TUI_PROVISION_SERVICES, TUI_PROVISION_DEFAULT_SERVICES, } from './provision-flow.js';
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `factory` (no args) — interactive TUI menu (LTUI-001).
3
+ *
4
+ * Replaces the old static HELP print on the no-args path with a cross-platform
5
+ * (pure TypeScript) menu: Nuevo / Actualizar / Aprovisionar / Secrets. The menu
6
+ * is a thin dispatcher — each option delegates to the existing command function
7
+ * (`runNew` / `runUpdate` / `secretsCommand`), which keep their own contracts.
8
+ * No OS branching: one binary serves Mac, Windows and Linux.
9
+ *
10
+ * Edge cases this module owns:
11
+ * · No TTY (CI / pipe): `process.stdout.isTTY` is falsy → degrade to printing
12
+ * HELP and return; never block waiting for input that can't arrive.
13
+ * · SIGINT / cancel (Ctrl+C / Esc): `prompts` `onCancel` → clean `exit(0)`,
14
+ * no stack trace.
15
+ * · Aprovisionar (ephemeral) delegates to the PROV-009 sub-flow
16
+ * (`provision-flow.ts`) — an ephemeral-only, cross-platform provision; the
17
+ * menu never reaches a destructive or live path.
18
+ *
19
+ * `factory -h` / `--help` still print HELP — those are handled in `index.ts`
20
+ * before this menu is ever reached, so this path is genuinely "no args".
21
+ */
22
+ import prompts from 'prompts';
23
+ import { parseNewFlags, runNew } from '../commands/new.js';
24
+ import { secretsCommand } from '../commands/secrets.js';
25
+ import { parseUpdateFlags, runUpdate } from '../commands/update.js';
26
+ import { provisionFlow } from './provision-flow.js';
27
+ /** Prompt for the top-level action. Returns `undefined` if the user cancels. */
28
+ async function promptAction() {
29
+ const { action } = await prompts({
30
+ type: 'select',
31
+ name: 'action',
32
+ message: 'TimeKast Factory — ¿qué quieres hacer?',
33
+ choices: [
34
+ { title: 'Nuevo proyecto', value: 'new' },
35
+ { title: 'Actualizar proyecto', value: 'update' },
36
+ { title: 'Aprovisionar (ephemeral)', value: 'provision' },
37
+ { title: 'Secrets', value: 'secrets' },
38
+ ],
39
+ initial: 0,
40
+ }, { onCancel: () => true } // let the value come back undefined; we exit cleanly below
41
+ );
42
+ return action;
43
+ }
44
+ /** Sub-flow: gather a project name + profile, then delegate to `runNew`. */
45
+ async function dispatchNew(deps) {
46
+ const { name } = await prompts({
47
+ type: 'text',
48
+ name: 'name',
49
+ message: '¿Cómo se va a llamar el proyecto?',
50
+ }, { onCancel: () => true });
51
+ if (!name || typeof name !== 'string' || name.trim() === '') {
52
+ // Cancel or empty name — back out cleanly, no error.
53
+ return;
54
+ }
55
+ const { profile } = await prompts({
56
+ type: 'select',
57
+ name: 'profile',
58
+ message: `¿Qué quieres crear para «${name.trim()}»?`,
59
+ choices: [
60
+ { title: 'App completa (starter kit + cerebro)', value: '--full' },
61
+ { title: 'Solo el cerebro', value: '--core' },
62
+ ],
63
+ initial: 0,
64
+ }, { onCancel: () => true });
65
+ if (!profile) {
66
+ return;
67
+ }
68
+ await deps.newProject(name.trim(), parseNewFlags([profile]));
69
+ }
70
+ /** Sub-flow: refresh the brain of the current repo via `runUpdate`. */
71
+ async function dispatchUpdate(deps) {
72
+ await deps.updateProject(parseUpdateFlags([]));
73
+ }
74
+ /**
75
+ * Sub-flow: Aprovisionar (ephemeral). Delegates to the PROV-009 provision flow,
76
+ * which gathers the slug (+ team/services), runs `--dry-run` first, confirms, and
77
+ * only then runs the real `--ephemeral` provision. Strictly ephemeral-only — the
78
+ * menu never exposes `--destroy`/`--force`/live from here.
79
+ */
80
+ async function dispatchProvision(deps) {
81
+ await deps.provision();
82
+ }
83
+ /** Sub-flow: pick a secrets master file, then delegate to `secretsCommand`. */
84
+ async function dispatchSecrets(deps) {
85
+ const { file } = await prompts({
86
+ type: 'text',
87
+ name: 'file',
88
+ message: 'Ruta del archivo master de secrets (KEY=value):',
89
+ }, { onCancel: () => true });
90
+ if (!file || typeof file !== 'string' || file.trim() === '') {
91
+ // Cancel or empty — back out cleanly.
92
+ return;
93
+ }
94
+ await deps.secrets(file.trim());
95
+ }
96
+ /**
97
+ * Run the interactive top-level menu. Called from `index.ts` when `factory` is
98
+ * invoked with no command (and not `-h`/`--help`).
99
+ *
100
+ * @param deps Injectable seams (TTY flag, HELP printer, command dispatchers).
101
+ * Defaults wire the real commands and `process.stdout.isTTY`.
102
+ */
103
+ export async function runTuiMenu(deps = {}) {
104
+ const isTTY = deps.isTTY ?? Boolean(process.stdout.isTTY);
105
+ const printHelp = deps.printHelp ?? (() => { });
106
+ const newProject = deps.newProject ?? runNew;
107
+ const updateProject = deps.updateProject ?? runUpdate;
108
+ const secrets = deps.secrets ?? secretsCommand;
109
+ const provision = deps.provision ?? provisionFlow;
110
+ // No TTY (CI / pipe): can't read interactive input. Degrade to HELP and return
111
+ // immediately — never hang waiting for input that can't arrive.
112
+ if (!isTTY) {
113
+ printHelp();
114
+ return;
115
+ }
116
+ const action = await promptAction();
117
+ // Cancel (Ctrl+C / Esc): `prompts` returns no value. Exit cleanly, no trace.
118
+ if (action === undefined) {
119
+ process.exit(0);
120
+ }
121
+ switch (action) {
122
+ case 'new':
123
+ await dispatchNew({ newProject });
124
+ return;
125
+ case 'update':
126
+ await dispatchUpdate({ updateProject });
127
+ return;
128
+ case 'provision':
129
+ await dispatchProvision({ provision });
130
+ return;
131
+ case 'secrets':
132
+ await dispatchSecrets({ secrets });
133
+ return;
134
+ }
135
+ }