@timekast/factory 1.12.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 +19 -3
- 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 +10 -9
- 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,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neon REST API v2 client for `factory provision` (PROV-002).
|
|
3
|
+
*
|
|
4
|
+
* A THIN, project-level Neon client that mirrors the proven fetch shape of
|
|
5
|
+
* `scripts/tools/neon-branch.ts` (`Authorization: Bearer ${apiKey}` +
|
|
6
|
+
* `https://console.neon.tech/api/v2/...`) — we reuse that shape rather than
|
|
7
|
+
* reinvent an HTTP client (issue §4, AC5). Where `neon-branch.ts` only creates a
|
|
8
|
+
* single E2E branch, this client operates at the PROJECT level: create a project
|
|
9
|
+
* (its primary branch becomes production `main`), create a clean `develop`
|
|
10
|
+
* branch, read POOLED connection strings, and mint a project-scoped API key.
|
|
11
|
+
*
|
|
12
|
+
* Every call surfaces a typed {@link NeonApiError} on a non-2xx response that
|
|
13
|
+
* carries the HTTP status, so the caller (`provision.ts`) can map the Neon edge
|
|
14
|
+
* cases the issue calls out (§8) to actionable, es-MX `CLIError` messages:
|
|
15
|
+
* - 401 → `NEON_API_KEY` invalid/rotated → re-import the rail (`factory secrets <archivo>`)
|
|
16
|
+
* - 402 / 429 → quota / billing → billing message
|
|
17
|
+
* - 409 → project name already exists → suggest `--force` or another name
|
|
18
|
+
*
|
|
19
|
+
* The `fetch` impl is an INJECTABLE seam (`NeonApiDeps.fetchImpl`) so the unit
|
|
20
|
+
* tests drive endpoint order + payload shape with a mock — the live REST flow is
|
|
21
|
+
* exercised e2e by PROV-010, never here (issue §7).
|
|
22
|
+
*/
|
|
23
|
+
/** Neon REST v2 base — same host the E2E branch tool uses (`neon-branch.ts`). */
|
|
24
|
+
export const NEON_API_BASE = 'https://console.neon.tech/api/v2';
|
|
25
|
+
/** Timeout (ms) for every Neon REST call — a dead network must not hang provision. */
|
|
26
|
+
const NEON_API_TIMEOUT_MS = 30_000;
|
|
27
|
+
/** Production branch name — the project's primary branch is named/used as this. */
|
|
28
|
+
export const NEON_MAIN_BRANCH = 'main';
|
|
29
|
+
/** Name of the isolated, data-clean second branch this step creates. */
|
|
30
|
+
export const NEON_DEVELOP_BRANCH = 'develop';
|
|
31
|
+
/**
|
|
32
|
+
* Neon returns 423 (Locked) while a freshly-created project runs its post-create
|
|
33
|
+
* operations ("project already has running conflicting operations"). The develop
|
|
34
|
+
* branch create retries with linear backoff until that lock clears.
|
|
35
|
+
*/
|
|
36
|
+
const NEON_LOCK_MAX_RETRIES = 5;
|
|
37
|
+
const NEON_LOCK_BACKOFF_MS = 3_000;
|
|
38
|
+
/**
|
|
39
|
+
* Default database + owning role Neon auto-creates for an API-provisioned project
|
|
40
|
+
* (POST /projects without a custom db/role). Stable Neon platform defaults — same
|
|
41
|
+
* category as the branch-name constants above (a provision convention, NOT org
|
|
42
|
+
* config). The `connection_uri` endpoint requires both query params.
|
|
43
|
+
*/
|
|
44
|
+
const NEON_DEFAULT_DATABASE = 'neondb';
|
|
45
|
+
const NEON_DEFAULT_ROLE = 'neondb_owner';
|
|
46
|
+
/** A non-2xx response from the Neon REST API. Carries the HTTP status for mapping. */
|
|
47
|
+
export class NeonApiError extends Error {
|
|
48
|
+
status;
|
|
49
|
+
/** The raw response body (best-effort) — useful context, never a secret. */
|
|
50
|
+
body;
|
|
51
|
+
constructor(status, body, context) {
|
|
52
|
+
super(`Neon API ${status} en ${context}: ${body || '(sin cuerpo)'}`);
|
|
53
|
+
this.name = 'NeonApiError';
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.body = body;
|
|
56
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Shared headers for every Neon REST call (Bearer auth + JSON). */
|
|
60
|
+
function neonHeaders(apiKey) {
|
|
61
|
+
return {
|
|
62
|
+
Authorization: `Bearer ${apiKey}`,
|
|
63
|
+
'Content-Type': 'application/json',
|
|
64
|
+
Accept: 'application/json',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Read a response body as text (best-effort) for error context — never throws. */
|
|
68
|
+
async function safeBody(response) {
|
|
69
|
+
try {
|
|
70
|
+
return (await response.text()).slice(0, 500);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create a Neon project. The project is born WITH its primary branch — we name
|
|
78
|
+
* that branch `main` and use it as production (issue §4: do NOT create an extra
|
|
79
|
+
* `main` branch). Returns the project id + the primary branch id.
|
|
80
|
+
*
|
|
81
|
+
* @throws {NeonApiError} on any non-2xx (caller maps 401/402/409/429 → CLIError).
|
|
82
|
+
*/
|
|
83
|
+
export async function createNeonProject(apiKey, projectName, orgId, deps = {}) {
|
|
84
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
85
|
+
const response = await fetchImpl(`${NEON_API_BASE}/projects`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: neonHeaders(apiKey),
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
project: {
|
|
90
|
+
name: projectName,
|
|
91
|
+
// org_id targets the project at the org. Neon REQUIRES it when the API key
|
|
92
|
+
// is org-scoped; omitted when absent so a personal-account key still works.
|
|
93
|
+
...(orgId ? { org_id: orgId } : {}),
|
|
94
|
+
// Name the auto-created primary branch `main` so production reads cleanly.
|
|
95
|
+
branch: { name: NEON_MAIN_BRANCH },
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
99
|
+
});
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw new NeonApiError(response.status, await safeBody(response), 'POST /projects');
|
|
102
|
+
}
|
|
103
|
+
const data = (await response.json());
|
|
104
|
+
const projectId = data.project?.id;
|
|
105
|
+
// The create-project response returns the primary branch under `branch`
|
|
106
|
+
// (and/or the first of `branches`); accept either shape defensively.
|
|
107
|
+
const mainBranchId = data.branch?.id ?? data.branches?.[0]?.id;
|
|
108
|
+
if (!projectId || !mainBranchId) {
|
|
109
|
+
throw new NeonApiError(response.status, JSON.stringify(data).slice(0, 500), 'POST /projects (respuesta sin projectId/branchId)');
|
|
110
|
+
}
|
|
111
|
+
return { projectId, mainBranchId };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Create a CLEAN `develop` branch off the project's primary branch. The branch
|
|
115
|
+
* carries NO snapshot of another project's data — we pass `parent_id` of the
|
|
116
|
+
* primary `main` (a branch of the SAME new project), which Neon copies as an
|
|
117
|
+
* empty schema base (issue §4 + §8: not a fork of production data of some other
|
|
118
|
+
* project; the new project starts empty anyway). A `read_write` endpoint is
|
|
119
|
+
* attached so the branch is immediately connectable.
|
|
120
|
+
*
|
|
121
|
+
* @returns the new `develop` branch id.
|
|
122
|
+
* @throws {NeonApiError} on any non-2xx.
|
|
123
|
+
*/
|
|
124
|
+
export async function createDevelopBranch(apiKey, projectId, parentBranchId, deps = {}) {
|
|
125
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
126
|
+
const sleep = deps.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
127
|
+
const postBranch = () => fetchImpl(`${NEON_API_BASE}/projects/${encodeURIComponent(projectId)}/branches`, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: neonHeaders(apiKey),
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
branch: { name: NEON_DEVELOP_BRANCH, parent_id: parentBranchId },
|
|
132
|
+
endpoints: [{ type: 'read_write' }],
|
|
133
|
+
}),
|
|
134
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
135
|
+
});
|
|
136
|
+
// Retry on 423 (project locked by post-create operations) with linear backoff.
|
|
137
|
+
let response = await postBranch();
|
|
138
|
+
for (let attempt = 0; response.status === 423 && attempt < NEON_LOCK_MAX_RETRIES; attempt++) {
|
|
139
|
+
await sleep(NEON_LOCK_BACKOFF_MS * (attempt + 1));
|
|
140
|
+
response = await postBranch();
|
|
141
|
+
}
|
|
142
|
+
// Idempotent resume (409): the branch already exists from a prior run that died
|
|
143
|
+
// before persisting its id, so the resume re-entered here. Reuse the existing
|
|
144
|
+
// `develop` branch instead of failing — re-running provision must converge.
|
|
145
|
+
if (response.status === 409) {
|
|
146
|
+
const existing = await listNeonBranches(apiKey, projectId, deps);
|
|
147
|
+
const develop = existing.find((b) => b.name === NEON_DEVELOP_BRANCH);
|
|
148
|
+
if (develop)
|
|
149
|
+
return develop.id;
|
|
150
|
+
}
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
throw new NeonApiError(response.status, await safeBody(response), 'POST /projects/{id}/branches (develop)');
|
|
153
|
+
}
|
|
154
|
+
const data = (await response.json());
|
|
155
|
+
const branchId = data.branch?.id;
|
|
156
|
+
if (!branchId) {
|
|
157
|
+
throw new NeonApiError(response.status, JSON.stringify(data).slice(0, 500), 'POST branches/develop (respuesta sin branchId)');
|
|
158
|
+
}
|
|
159
|
+
return branchId;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Read a branch's connection string for the requested mode via Neon's
|
|
163
|
+
* `GET /projects/{id}/connection_uri`:
|
|
164
|
+
* - `pooled: true` → the PgBouncer-pooled URI (the `-pooler` host) for
|
|
165
|
+
* serverless/concurrent runtime (the Drizzle Neon pool — sk-db §7).
|
|
166
|
+
* - `pooled: false` → the DIRECT URI (no `-pooler`) for single-process
|
|
167
|
+
* migrations + tooling (`drizzle-kit migrate`, `db:seed`/`db:query`, local dev).
|
|
168
|
+
*
|
|
169
|
+
* The discriminator is serverless/concurrency, NOT "runtime": local `pnpm dev` is
|
|
170
|
+
* runtime but uses the direct URI (one connection + drizzle-kit wants direct).
|
|
171
|
+
* Returns the connection string value.
|
|
172
|
+
*
|
|
173
|
+
* @throws {NeonApiError} on any non-2xx.
|
|
174
|
+
*/
|
|
175
|
+
export async function getConnectionUri(apiKey, projectId, branchId, pooled, deps = {}) {
|
|
176
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
177
|
+
// Neon's connection_uri requires database_name + role_name (the project's default
|
|
178
|
+
// db + owner role). Without them it 400s "database_name not set".
|
|
179
|
+
const query = new URLSearchParams({
|
|
180
|
+
branch_id: branchId,
|
|
181
|
+
database_name: NEON_DEFAULT_DATABASE,
|
|
182
|
+
role_name: NEON_DEFAULT_ROLE,
|
|
183
|
+
pooled: pooled ? 'true' : 'false',
|
|
184
|
+
});
|
|
185
|
+
const response = await fetchImpl(`${NEON_API_BASE}/projects/${encodeURIComponent(projectId)}/connection_uri?${query.toString()}`, {
|
|
186
|
+
method: 'GET',
|
|
187
|
+
headers: neonHeaders(apiKey),
|
|
188
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
189
|
+
});
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
throw new NeonApiError(response.status, await safeBody(response), 'GET /projects/{id}/connection_uri');
|
|
192
|
+
}
|
|
193
|
+
const data = (await response.json());
|
|
194
|
+
if (!data.uri) {
|
|
195
|
+
throw new NeonApiError(response.status, JSON.stringify(data).slice(0, 500), 'GET connection_uri (respuesta sin uri)');
|
|
196
|
+
}
|
|
197
|
+
return data.uri;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* List the org's Neon projects — `GET /projects` (PROV-007 `--adopt`). STRICTLY
|
|
201
|
+
* read-only: it never POSTs/PATCHes/DELETEs. The caller filters by slug/name to
|
|
202
|
+
* reconstruct the adopted state and disambiguates when more than one matches
|
|
203
|
+
* (issue §4). Neon paginates with `?limit=`; one page (default 100) covers the
|
|
204
|
+
* org's project list for the adopt use case.
|
|
205
|
+
*
|
|
206
|
+
* @throws {NeonApiError} on any non-2xx (caller records the field as `unknown`).
|
|
207
|
+
*/
|
|
208
|
+
export async function listNeonProjects(apiKey, deps = {}) {
|
|
209
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
210
|
+
const response = await fetchImpl(`${NEON_API_BASE}/projects`, {
|
|
211
|
+
method: 'GET',
|
|
212
|
+
headers: neonHeaders(apiKey),
|
|
213
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
214
|
+
});
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
throw new NeonApiError(response.status, await safeBody(response), 'GET /projects');
|
|
217
|
+
}
|
|
218
|
+
const data = (await response.json());
|
|
219
|
+
return (data.projects ?? [])
|
|
220
|
+
.filter((p) => Boolean(p.id) && Boolean(p.name))
|
|
221
|
+
.map((p) => ({ id: p.id, name: p.name }));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* List a Neon project's branches — `GET /projects/{id}/branches` (PROV-007
|
|
225
|
+
* `--adopt`). STRICTLY read-only. The caller picks the `develop` branch id to
|
|
226
|
+
* record in the adopted state.
|
|
227
|
+
*
|
|
228
|
+
* @throws {NeonApiError} on any non-2xx (caller records the field as `unknown`).
|
|
229
|
+
*/
|
|
230
|
+
export async function listNeonBranches(apiKey, projectId, deps = {}) {
|
|
231
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
232
|
+
const response = await fetchImpl(`${NEON_API_BASE}/projects/${encodeURIComponent(projectId)}/branches`, {
|
|
233
|
+
method: 'GET',
|
|
234
|
+
headers: neonHeaders(apiKey),
|
|
235
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
236
|
+
});
|
|
237
|
+
if (!response.ok) {
|
|
238
|
+
throw new NeonApiError(response.status, await safeBody(response), 'GET /projects/{id}/branches');
|
|
239
|
+
}
|
|
240
|
+
const data = (await response.json());
|
|
241
|
+
return (data.branches ?? [])
|
|
242
|
+
.filter((b) => Boolean(b.id) && Boolean(b.name))
|
|
243
|
+
.map((b) => ({ id: b.id, name: b.name }));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Mint a PROJECT-SCOPED Neon API key (issue §4 + plan: the GH secret stores a
|
|
247
|
+
* project-scoped key, NOT the org key — blast radius is one project's infra).
|
|
248
|
+
* Neon's `POST /api_keys` with a `project_id` scopes the key to that project.
|
|
249
|
+
* Returns the key value (the ONE place this value is read — it is handed
|
|
250
|
+
* straight to `gh secret set` over STDIN, never logged).
|
|
251
|
+
*
|
|
252
|
+
* @throws {NeonApiError} on any non-2xx.
|
|
253
|
+
*/
|
|
254
|
+
export async function createProjectApiKey(apiKey, projectId, keyName, deps = {}) {
|
|
255
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
256
|
+
const mint = () => fetchImpl(`${NEON_API_BASE}/api_keys`, {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: neonHeaders(apiKey),
|
|
259
|
+
body: JSON.stringify({ key_name: keyName, project_id: projectId }),
|
|
260
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
261
|
+
});
|
|
262
|
+
let response = await mint();
|
|
263
|
+
// Idempotent resume (409): a key with this name already exists from a prior run
|
|
264
|
+
// whose value we never persisted (it is a secret — unreadable after creation).
|
|
265
|
+
// The value can't be reused, so delete the stale key(s) by name and re-mint — a
|
|
266
|
+
// resume must converge, not 409. (Only the secret-pending path re-enters here;
|
|
267
|
+
// the happy path sets apiKeySecretDone and short-circuits before minting again.)
|
|
268
|
+
if (response.status === 409) {
|
|
269
|
+
await deleteNeonApiKeysByName(apiKey, keyName, { fetchImpl });
|
|
270
|
+
response = await mint();
|
|
271
|
+
}
|
|
272
|
+
if (!response.ok) {
|
|
273
|
+
throw new NeonApiError(response.status, await safeBody(response), 'POST /api_keys');
|
|
274
|
+
}
|
|
275
|
+
const data = (await response.json());
|
|
276
|
+
if (!data.key) {
|
|
277
|
+
throw new NeonApiError(response.status, JSON.stringify(data).slice(0, 500), 'POST /api_keys (respuesta sin key)');
|
|
278
|
+
}
|
|
279
|
+
return data.key;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Best-effort delete of every account/org API key whose name matches `keyName`
|
|
283
|
+
* (GET /api_keys → DELETE /api_keys/{id}). Two callers:
|
|
284
|
+
* - the idempotent re-mint (clear a stale key before re-minting on resume), and
|
|
285
|
+
* - the ephemeral `--destroy` nuke (delete the project-scoped key it minted so
|
|
286
|
+
* the org does not accumulate dead keys across provisions/tests).
|
|
287
|
+
* Returns the count deleted. NEVER throws — a cleanup failure must not abort the
|
|
288
|
+
* caller (the re-mint surfaces its own error; the destroy already nuked the
|
|
289
|
+
* project).
|
|
290
|
+
*/
|
|
291
|
+
export async function deleteNeonApiKeysByName(apiKey, keyName, deps = {}) {
|
|
292
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
293
|
+
let deleted = 0;
|
|
294
|
+
try {
|
|
295
|
+
const listResp = await fetchImpl(`${NEON_API_BASE}/api_keys`, {
|
|
296
|
+
method: 'GET',
|
|
297
|
+
headers: neonHeaders(apiKey),
|
|
298
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
299
|
+
});
|
|
300
|
+
if (!listResp.ok)
|
|
301
|
+
return 0;
|
|
302
|
+
const data = (await listResp.json());
|
|
303
|
+
// Neon returns a bare array; tolerate a `{ api_keys: [...] }` wrapper too.
|
|
304
|
+
const list = Array.isArray(data) ? data : (data?.api_keys ?? []);
|
|
305
|
+
const ids = list
|
|
306
|
+
.filter((k) => k?.name === keyName && k?.id != null)
|
|
307
|
+
.map((k) => String(k.id));
|
|
308
|
+
for (const id of ids) {
|
|
309
|
+
const del = await fetchImpl(`${NEON_API_BASE}/api_keys/${encodeURIComponent(id)}`, {
|
|
310
|
+
method: 'DELETE',
|
|
311
|
+
headers: neonHeaders(apiKey),
|
|
312
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
313
|
+
});
|
|
314
|
+
if (del.ok || del.status === 404)
|
|
315
|
+
deleted++;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// best-effort — never throws; the caller does not depend on cleanup succeeding
|
|
320
|
+
}
|
|
321
|
+
return deleted;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Delete an ENTIRE Neon project — `DELETE /projects/{id}`. `--destroy` calls this
|
|
325
|
+
* on an `ephemeral` stack to nuke the whole project (not just the `develop`
|
|
326
|
+
* branch), which is the actual blast radius an ephemeral teardown promises. A 404
|
|
327
|
+
* means the project is already gone → treated as success (idempotent teardown).
|
|
328
|
+
* The DEV's key does this, NEVER the shared org create-only key from the rail.
|
|
329
|
+
*
|
|
330
|
+
* @throws {NeonApiError} on any non-2xx other than 404.
|
|
331
|
+
*/
|
|
332
|
+
export async function deleteNeonProject(apiKey, projectId, deps = {}) {
|
|
333
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
334
|
+
const response = await fetchImpl(`${NEON_API_BASE}/projects/${encodeURIComponent(projectId)}`, {
|
|
335
|
+
method: 'DELETE',
|
|
336
|
+
headers: neonHeaders(apiKey),
|
|
337
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
338
|
+
});
|
|
339
|
+
if (response.ok || response.status === 404)
|
|
340
|
+
return;
|
|
341
|
+
throw new NeonApiError(response.status, await safeBody(response), 'DELETE /projects/{id}');
|
|
342
|
+
}
|
package/dist/lib/preflight.js
CHANGED
|
@@ -8,15 +8,57 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Any failure throws a `PreflightError` with an actionable message — never a
|
|
10
10
|
* cryptic spawn error or a stack trace.
|
|
11
|
+
*
|
|
12
|
+
* The launcher preflight (`runLauncherPreflight`, LTUI-002) reuses these same
|
|
13
|
+
* checks but prepends a Node-version gate and, instead of failing fast on the
|
|
14
|
+
* first throw, returns a structured per-check report so `factory doctor
|
|
15
|
+
* --launcher` can render every step with ✅ / ❌ across all platforms (the
|
|
16
|
+
* cross-platform equivalent of the macOS launcher's `checkPrereqs`).
|
|
11
17
|
*/
|
|
12
18
|
import { execa } from 'execa';
|
|
13
19
|
import { PreflightError } from './cli-error.js';
|
|
14
20
|
import { FACTORY_ORG } from './constants.js';
|
|
21
|
+
/** Minimum Node major version the kit requires (mirrors `engines.node` + the launcher AppleScript). */
|
|
22
|
+
export const MIN_NODE_MAJOR = 22;
|
|
23
|
+
/** Timeout (ms) for the GitHub API calls in the org-membership check — a slow / dead network must not hang. */
|
|
24
|
+
const GH_API_TIMEOUT_MS = 10_000;
|
|
15
25
|
/** True when the thrown error is execa's "command not found" (ENOENT). */
|
|
16
26
|
function isCommandNotFound(error) {
|
|
17
27
|
const code = error?.code;
|
|
18
28
|
return code === 'ENOENT';
|
|
19
29
|
}
|
|
30
|
+
/** True when the thrown error is execa's timeout (the spawned command was killed for taking too long). */
|
|
31
|
+
function isTimeout(error) {
|
|
32
|
+
return error?.timedOut === true;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse a Node version string (`process.version`, e.g. `v22.3.1` or `22.3.1`)
|
|
36
|
+
* into its major number. Returns `NaN` when the string is unparseable.
|
|
37
|
+
*/
|
|
38
|
+
export function parseNodeMajor(version) {
|
|
39
|
+
const match = /^v?(\d+)\./.exec(version.trim());
|
|
40
|
+
return match ? Number(match[1]) : NaN;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Step 0 (launcher only) — the running Node is at least `minMajor` (default 22).
|
|
44
|
+
*
|
|
45
|
+
* Reads the version from the running process (`process.version` by default —
|
|
46
|
+
* always present, the process is already running). Mirrors the launcher
|
|
47
|
+
* AppleScript's `checkPrereqs` Node gate. `nodeVersion` is injectable so tests
|
|
48
|
+
* can drive both the pass and the fail branch without spawning a sub-process.
|
|
49
|
+
*
|
|
50
|
+
* @throws {PreflightError} when the major is below `minMajor` or unparseable.
|
|
51
|
+
*/
|
|
52
|
+
export function assertNodeVersion(minMajor = MIN_NODE_MAJOR, nodeVersion = process.version) {
|
|
53
|
+
const major = parseNodeMajor(nodeVersion);
|
|
54
|
+
if (Number.isNaN(major)) {
|
|
55
|
+
throw new PreflightError(`No se pudo determinar la versión de Node (\`${nodeVersion}\`).\n` +
|
|
56
|
+
`Instala Node ${minMajor} o superior desde https://nodejs.org`);
|
|
57
|
+
}
|
|
58
|
+
if (major < minMajor) {
|
|
59
|
+
throw new PreflightError(`Node ${nodeVersion} — requiere ≥ ${minMajor}. Instala Node ${minMajor} o superior desde https://nodejs.org`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
20
62
|
/** Step 1 — `gh` is installed on PATH. */
|
|
21
63
|
async function assertGhInstalled() {
|
|
22
64
|
try {
|
|
@@ -50,10 +92,16 @@ async function assertGhAuthenticated() {
|
|
|
50
92
|
async function assertOrgMembership() {
|
|
51
93
|
let login;
|
|
52
94
|
try {
|
|
53
|
-
const { stdout } = await execa('gh', ['api', '/user', '--jq', '.login']
|
|
95
|
+
const { stdout } = await execa('gh', ['api', '/user', '--jq', '.login'], {
|
|
96
|
+
timeout: GH_API_TIMEOUT_MS,
|
|
97
|
+
});
|
|
54
98
|
login = stdout.trim();
|
|
55
99
|
}
|
|
56
|
-
catch {
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (isTimeout(error)) {
|
|
102
|
+
throw new PreflightError('No se pudo verificar tu usuario de GitHub: la API no respondió (¿sin conexión?).\n' +
|
|
103
|
+
'Revisa tu red y vuelve a intentar.');
|
|
104
|
+
}
|
|
57
105
|
throw new PreflightError('No se pudo determinar tu usuario de GitHub con `gh api /user`.\n' +
|
|
58
106
|
'Verifica tu sesión con `gh auth status`.');
|
|
59
107
|
}
|
|
@@ -61,9 +109,15 @@ async function assertOrgMembership() {
|
|
|
61
109
|
throw new PreflightError('No se pudo determinar tu usuario de GitHub. Verifica tu sesión con `gh auth status`.');
|
|
62
110
|
}
|
|
63
111
|
try {
|
|
64
|
-
await execa('gh', ['api', `/orgs/${FACTORY_ORG}/members/${login}`]
|
|
112
|
+
await execa('gh', ['api', `/orgs/${FACTORY_ORG}/members/${login}`], {
|
|
113
|
+
timeout: GH_API_TIMEOUT_MS,
|
|
114
|
+
});
|
|
65
115
|
}
|
|
66
|
-
catch {
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (isTimeout(error)) {
|
|
118
|
+
throw new PreflightError(`No se pudo verificar tu membresía en el org \`${FACTORY_ORG}\`: la API no respondió (¿sin conexión?).\n` +
|
|
119
|
+
'Revisa tu red y vuelve a intentar.');
|
|
120
|
+
}
|
|
67
121
|
throw new PreflightError(`No se pudo verificar que \`${login}\` sea miembro del org \`${FACTORY_ORG}\`.\n` +
|
|
68
122
|
`Si ya eres miembro, tu token puede carecer del scope \`read:org\` (la membresía privada queda invisible): ejecuta \`gh auth refresh -s read:org\` y vuelve a intentar.\n` +
|
|
69
123
|
`Si no eres miembro, solicita acceso al org \`${FACTORY_ORG}\` o verifica tu sesión con \`gh auth status\`.`);
|
|
@@ -78,3 +132,84 @@ export async function runPreflight() {
|
|
|
78
132
|
await assertGhAuthenticated();
|
|
79
133
|
await assertOrgMembership();
|
|
80
134
|
}
|
|
135
|
+
/** The ordered launcher labels (single source so render + tests agree). */
|
|
136
|
+
const LAUNCHER_LABELS = {
|
|
137
|
+
node: `Node ≥ ${MIN_NODE_MAJOR}`,
|
|
138
|
+
gh: 'GitHub CLI (gh) instalado',
|
|
139
|
+
auth: 'Sesión de GitHub activa',
|
|
140
|
+
org: `Membresía del org ${FACTORY_ORG}`,
|
|
141
|
+
};
|
|
142
|
+
/** A confirmation message for a passing check (the `ok` branch). */
|
|
143
|
+
const OK_MESSAGES = {
|
|
144
|
+
node: `Node ${process.version} cumple el mínimo (≥ ${MIN_NODE_MAJOR}).`,
|
|
145
|
+
gh: 'GitHub CLI (gh) está instalado.',
|
|
146
|
+
auth: 'Tu sesión de GitHub CLI está activa.',
|
|
147
|
+
org: `Eres miembro del org ${FACTORY_ORG}.`,
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Run the launcher preflight (LTUI-002): the cross-platform equivalent of the
|
|
151
|
+
* macOS launcher's `checkPrereqs`. Verifies, IN ORDER:
|
|
152
|
+
*
|
|
153
|
+
* 1. Node ≥ 22 (`process.version`).
|
|
154
|
+
* 2. `gh` installed.
|
|
155
|
+
* 3. `gh auth status` valid.
|
|
156
|
+
* 4. Membership in `FACTORY_ORG`.
|
|
157
|
+
*
|
|
158
|
+
* Steps 2-4 reuse the SAME functions as `runPreflight()` (no reimplementation of
|
|
159
|
+
* the org-membership logic — LTUI-002 §6). Unlike `runPreflight`, this does NOT
|
|
160
|
+
* throw on the first failure: it stops running further checks (each later check
|
|
161
|
+
* depends on the previous — no auth without gh, no org without auth) but still
|
|
162
|
+
* RETURNS a full ordered report, marking the checks after the failure as
|
|
163
|
+
* `skipped`. The caller decides exit code (0 = all ok, 1 = any fail).
|
|
164
|
+
*/
|
|
165
|
+
export async function runLauncherPreflight(deps = {}) {
|
|
166
|
+
const ghInstalled = deps.ghInstalled ?? assertGhInstalled;
|
|
167
|
+
const ghAuthenticated = deps.ghAuthenticated ?? assertGhAuthenticated;
|
|
168
|
+
const orgMembership = deps.orgMembership ?? assertOrgMembership;
|
|
169
|
+
// The ordered pipeline. Each step is the assertion that throws PreflightError
|
|
170
|
+
// on failure; the runner turns that into a structured result.
|
|
171
|
+
const steps = [
|
|
172
|
+
{
|
|
173
|
+
id: 'node',
|
|
174
|
+
run: () => assertNodeVersion(deps.minNodeMajor ?? MIN_NODE_MAJOR, deps.nodeVersion),
|
|
175
|
+
},
|
|
176
|
+
{ id: 'gh', run: ghInstalled },
|
|
177
|
+
{ id: 'auth', run: ghAuthenticated },
|
|
178
|
+
{ id: 'org', run: orgMembership },
|
|
179
|
+
];
|
|
180
|
+
const results = [];
|
|
181
|
+
let failed = false;
|
|
182
|
+
for (const step of steps) {
|
|
183
|
+
if (failed) {
|
|
184
|
+
results.push({
|
|
185
|
+
id: step.id,
|
|
186
|
+
label: LAUNCHER_LABELS[step.id],
|
|
187
|
+
status: 'skipped',
|
|
188
|
+
message: 'Se omitió: depende de un check anterior que falló.',
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
await step.run();
|
|
194
|
+
results.push({
|
|
195
|
+
id: step.id,
|
|
196
|
+
label: LAUNCHER_LABELS[step.id],
|
|
197
|
+
status: 'ok',
|
|
198
|
+
message: OK_MESSAGES[step.id],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
failed = true;
|
|
203
|
+
const message = error instanceof PreflightError
|
|
204
|
+
? error.message
|
|
205
|
+
: `Falló la verificación (${error?.message ?? 'error desconocido'}).`;
|
|
206
|
+
results.push({
|
|
207
|
+
id: step.id,
|
|
208
|
+
label: LAUNCHER_LABELS[step.id],
|
|
209
|
+
status: 'fail',
|
|
210
|
+
message,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return results;
|
|
215
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared end-pointer for the `new` / `update` success tails (PVSKILL-002).
|
|
3
|
+
*
|
|
4
|
+
* `new` and `update` both finish by telling the dev the next step — `/provision` —
|
|
5
|
+
* to complete the environment setup. The copy lives in `PROVISION_POINTER`
|
|
6
|
+
* (constants.ts) so it is a single named string; the *gating* (when NOT to print)
|
|
7
|
+
* lives here so both commands share one rule and it is unit-testable without a real
|
|
8
|
+
* terminal (`isTTY` is injectable, mirroring `maybeOfferAdopt`).
|
|
9
|
+
*
|
|
10
|
+
* Contract (issue §4 AC): print ONLY in the successful flow, AFTER the last step.
|
|
11
|
+
* The caller is responsible for placing the call after its final success log and
|
|
12
|
+
* before returning — an error path throws earlier and never reaches it (so error /
|
|
13
|
+
* Ctrl-C / `--dry-run` aborts naturally skip it). This helper only adds the CI-noise
|
|
14
|
+
* guard: suppress when running headless (no TTY) under an auto-confirm flag (`--yes`),
|
|
15
|
+
* which marks a non-interactive launcher / pipeline run.
|
|
16
|
+
*/
|
|
17
|
+
import { PROVISION_POINTER } from './constants.js';
|
|
18
|
+
/**
|
|
19
|
+
* Print the `/provision` end-pointer unless the run is a headless auto-confirm
|
|
20
|
+
* (`--yes` AND no TTY) — that combination is a CI / launcher run where the extra
|
|
21
|
+
* line is noise. An interactive run (TTY) prints even with `--yes`; a plain
|
|
22
|
+
* headless pipe (no `--yes`) still prints (the message is informative, not a
|
|
23
|
+
* prompt — issue §8 "output capturado en pipelines").
|
|
24
|
+
*/
|
|
25
|
+
export function printProvisionPointer(opts = {}) {
|
|
26
|
+
const assumeYes = opts.assumeYes ?? false;
|
|
27
|
+
const isTTY = opts.isTTY ?? Boolean(process.stdout.isTTY);
|
|
28
|
+
const log = opts.log ?? ((m) => console.log(m));
|
|
29
|
+
if (assumeYes && !isTTY)
|
|
30
|
+
return; // headless auto-confirm → suppress (CI noise)
|
|
31
|
+
log(`\n${PROVISION_POINTER}`);
|
|
32
|
+
}
|