@timekast/factory 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/doctor.js +83 -0
- package/dist/commands/new.js +28 -1
- package/dist/commands/provision.js +2469 -0
- package/dist/commands/secrets.js +4 -0
- package/dist/commands/status.js +21 -2
- package/dist/commands/update.js +47 -0
- package/dist/index.js +79 -23
- package/dist/lib/cloudflare-api.js +243 -0
- package/dist/lib/constants.js +11 -0
- package/dist/lib/env-merge.js +40 -3
- package/dist/lib/neon-api.js +342 -0
- package/dist/lib/preflight.js +139 -4
- package/dist/lib/provision-pointer.js +32 -0
- package/dist/lib/provision-preflight.js +342 -0
- package/dist/lib/provision-state.js +384 -0
- package/dist/lib/unpack.js +47 -3
- package/dist/lib/vercel-env.js +490 -0
- package/dist/tui/index.js +8 -0
- package/dist/tui/menu.js +135 -0
- package/dist/tui/provision-flow.js +166 -0
- package/package.json +2 -2
- package/dist/commands/bootstrap-version.js +0 -43
- package/dist/commands/launcher-path.js +0 -31
- package/dist/launcher.applescript +0 -239
|
@@ -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';
|
package/dist/tui/menu.js
ADDED
|
@@ -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
|
+
}
|