@timekast/factory 1.26.0 → 1.28.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 +5 -4
- package/dist/commands/env.js +110 -24
- package/dist/commands/provision.js +384 -90
- package/dist/commands/ticket.js +487 -0
- package/dist/index.js +29 -0
- package/dist/lib/constants.js +133 -0
- package/dist/lib/env-file.js +264 -11
- package/dist/lib/env-wizard.js +110 -21
- package/dist/lib/initial-migration.js +12 -6
- package/dist/lib/neon-api.js +186 -21
- package/dist/lib/package-json.js +66 -2
- package/dist/lib/preflight.js +5 -1
- package/dist/lib/provision-preflight.js +29 -9
- package/dist/lib/provision-state.js +44 -0
- package/dist/lib/public-env-keys.json +27 -0
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* WHY THIS MODULE EXISTS. `factory provision` never ran `db:generate`, so a fresh
|
|
6
6
|
* derivative was born with a schema and no migrations while its `vercel-build`
|
|
7
|
-
* (`pnpm
|
|
7
|
+
* (`pnpm build && pnpm db:migrate`) migrated an empty database on the first deploy.
|
|
8
8
|
* The provision step that closes that gap has to decide, from disk alone, whether
|
|
9
9
|
* generating and committing a `0000` is the right thing to do — and the cost of a
|
|
10
10
|
* wrong "yes" is asymmetric.
|
|
@@ -12,11 +12,17 @@
|
|
|
12
12
|
* 🔴 THE SIGNAL FAILS TOWARD "DO NOT TOUCH". `.timekast/lockfile.json` is written by
|
|
13
13
|
* `new`/`add`/`update` in every derivative and does NOT exist in the Factory, so its
|
|
14
14
|
* ABSENCE means "this is the Factory, or a checkout that cannot be identified" and the
|
|
15
|
-
* predicate answers `false`. A false positive INSIDE the Factory would commit a
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* predicate answers `false`. A false positive INSIDE the Factory would commit a SECOND
|
|
16
|
+
* `0000` on top of the one the Factory now maintains for itself — a duplicate against the
|
|
17
|
+
* same schema, which `drizzle-kit check` then reports as a broken journal. A false
|
|
18
|
+
* negative only leaves the migration ungenerated, which the dev fixes with one
|
|
19
|
+
* `pnpm db:generate`.
|
|
20
|
+
*
|
|
21
|
+
* ℹ️ This used to warn that a `0000` committed here would RIDE THE TARBALL into every new
|
|
22
|
+
* derivative, because `profiles.full` was `include: ["**"]` with no exclusion for
|
|
23
|
+
* `src/lib/db/migrations/`. That hole is closed: the exclusion now exists (and is pinned
|
|
24
|
+
* by a test over the REAL profiles.json), which is precisely what let the Factory adopt
|
|
25
|
+
* migrations without breaking the born-clean rule. A derivative still generates its own.
|
|
20
26
|
*
|
|
21
27
|
* If this signal ever disagrees with the markdown detection `/deploy` uses (`is_factory`
|
|
22
28
|
* in `project/planning/project-config.md`), the ABSENCE OF THE LOCKFILE WINS and nothing
|
package/dist/lib/neon-api.js
CHANGED
|
@@ -95,6 +95,113 @@ async function safeBody(response) {
|
|
|
95
95
|
return '';
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* List the organizations the API key can reach — `GET /users/me/organizations`.
|
|
100
|
+
*
|
|
101
|
+
* This is the ONLY way to learn an org id from a key, and it is why the rail no
|
|
102
|
+
* longer carries `NEON_ORGANIZATION_ID`: the id is derived, never configured.
|
|
103
|
+
*
|
|
104
|
+
* Only a PERSONAL key answers here (an org key gets `404 not allowed for
|
|
105
|
+
* organization API keys`), which is exactly the shape the rail now holds: the
|
|
106
|
+
* personal key of a service account that is a member of the TimeKast orgs and
|
|
107
|
+
* of nothing else. A service account that belongs to ONE org therefore returns a
|
|
108
|
+
* single entry, and {@link resolveNeonOrg} needs no configuration at all.
|
|
109
|
+
*
|
|
110
|
+
* @throws {NeonApiError} on any non-2xx.
|
|
111
|
+
*/
|
|
112
|
+
export async function listNeonOrgs(apiKey, deps = {}) {
|
|
113
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
114
|
+
const response = await fetchImpl(`${NEON_API_BASE}/users/me/organizations`, {
|
|
115
|
+
method: 'GET',
|
|
116
|
+
headers: neonHeaders(apiKey),
|
|
117
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
118
|
+
});
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new NeonApiError(response.status, await safeBody(response), 'GET /users/me/organizations');
|
|
121
|
+
}
|
|
122
|
+
const data = (await response.json());
|
|
123
|
+
return (data.organizations ?? [])
|
|
124
|
+
.filter((o) => Boolean(o.id))
|
|
125
|
+
.map((o) => ({ id: o.id, name: o.name ?? o.id, handle: o.handle ?? '' }));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Resolve WHICH organization a new project is created in. Three tiers, in order
|
|
129
|
+
* (the caller passes tiers 1 and 2; this function owns the fallback):
|
|
130
|
+
*
|
|
131
|
+
* 1. `--neon-org <name|id>` — the explicit ask, for the deliberate detour
|
|
132
|
+
* (an overflow org once the default one fills up, a reserved one).
|
|
133
|
+
* 2. `NEON_ORG` from the rail — the durable default, a NAME not an id, set once.
|
|
134
|
+
* 3. Neither: a key that sees exactly ONE org uses it; a key that sees several
|
|
135
|
+
* THROWS, naming them. Fail-closed on purpose — guessing here would create a
|
|
136
|
+
* client's database in the wrong organization, and there is no prompt to fall
|
|
137
|
+
* back on (headless Agent Server runs have nobody to answer it).
|
|
138
|
+
*
|
|
139
|
+
* `selector` matches the `org-...` id verbatim, or the name/handle
|
|
140
|
+
* case-insensitively, so both `--neon-org TimeKast` and `--neon-org org-abc123`
|
|
141
|
+
* work. An unknown selector throws rather than falling through to a default: the
|
|
142
|
+
* caller asked for a specific org and silently using another is the failure mode
|
|
143
|
+
* this whole resolution exists to prevent.
|
|
144
|
+
*
|
|
145
|
+
* @throws {CLIError-ready Error} with an es-MX message the caller surfaces as-is.
|
|
146
|
+
* @throws {NeonApiError} if the listing itself fails.
|
|
147
|
+
*/
|
|
148
|
+
export async function resolveNeonOrg(apiKey, selector, deps = {}) {
|
|
149
|
+
const orgs = await listNeonOrgs(apiKey, deps);
|
|
150
|
+
if (orgs.length === 0) {
|
|
151
|
+
throw new Error('La `NEON_API_KEY` del archivo de secretos no ve ninguna organización de Neon.\n' +
|
|
152
|
+
' Debe ser la key PERSONAL de la cuenta de servicio, y esa cuenta tiene que ser ' +
|
|
153
|
+
'Admin de la organización (solo un Admin puede acuñar API keys project-scoped).');
|
|
154
|
+
}
|
|
155
|
+
if (selector) {
|
|
156
|
+
const needle = selector.trim().toLowerCase();
|
|
157
|
+
const hit = orgs.find((o) => o.id === selector.trim() ||
|
|
158
|
+
o.name.toLowerCase() === needle ||
|
|
159
|
+
o.handle.toLowerCase() === needle);
|
|
160
|
+
if (!hit) {
|
|
161
|
+
throw new Error(`La key no ve ninguna organización de Neon llamada \`${selector}\`.\n` +
|
|
162
|
+
` Organizaciones visibles: ${orgs.map((o) => `${o.name} (${o.id})`).join(', ')}.`);
|
|
163
|
+
}
|
|
164
|
+
return hit;
|
|
165
|
+
}
|
|
166
|
+
if (orgs.length === 1)
|
|
167
|
+
return orgs[0];
|
|
168
|
+
throw new Error(`La key ve ${orgs.length} organizaciones de Neon y no dijiste en cuál crear el proyecto.\n` +
|
|
169
|
+
` Visibles: ${orgs.map((o) => `${o.name} (${o.id})`).join(', ')}.\n` +
|
|
170
|
+
' Elige una con `--neon-org "<nombre>"`, o fija el default del rail con ' +
|
|
171
|
+
'`NEON_ORG=<nombre>` en `~/.claude/.env`.');
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Find an EXISTING project by name across every org the key can reach, and
|
|
175
|
+
* return it together with the org that owns it.
|
|
176
|
+
*
|
|
177
|
+
* This is how every operation on an already-created project (`--adopt`,
|
|
178
|
+
* `--destroy`, `--remint`, a `--resume` over a state written before `orgId`
|
|
179
|
+
* existed) learns its org: read it off the project, never from a flag or a
|
|
180
|
+
* default. Using the default here would point a teardown at the wrong org.
|
|
181
|
+
*
|
|
182
|
+
* A name that exists in TWO orgs THROWS instead of picking one — an ambiguous
|
|
183
|
+
* teardown target is precisely what must not resolve silently.
|
|
184
|
+
*
|
|
185
|
+
* @returns the match, or `null` when no org holds a project with that name.
|
|
186
|
+
*/
|
|
187
|
+
export async function findNeonProjectAcrossOrgs(apiKey, projectName, deps = {}) {
|
|
188
|
+
const orgs = await listNeonOrgs(apiKey, deps);
|
|
189
|
+
const needle = projectName.trim().toLowerCase();
|
|
190
|
+
const hits = [];
|
|
191
|
+
for (const org of orgs) {
|
|
192
|
+
const projects = await listNeonProjects(apiKey, org.id, deps);
|
|
193
|
+
for (const project of projects) {
|
|
194
|
+
if (project.name.trim().toLowerCase() === needle)
|
|
195
|
+
hits.push({ org, project });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (hits.length > 1) {
|
|
199
|
+
throw new Error(`Hay un proyecto de Neon llamado \`${projectName}\` en más de una organización: ` +
|
|
200
|
+
`${hits.map((h) => `${h.org.name} (${h.project.id})`).join(', ')}.\n` +
|
|
201
|
+
' No elijo por ti: resuélvelo a mano en la consola de Neon (renombra uno) y reintenta.');
|
|
202
|
+
}
|
|
203
|
+
return hits[0] ?? null;
|
|
204
|
+
}
|
|
98
205
|
/**
|
|
99
206
|
* Create a Neon project. The project is born WITH its primary branch — we name
|
|
100
207
|
* that branch `main` and use it as production (issue §4: do NOT create an extra
|
|
@@ -187,6 +294,39 @@ export async function createDevelopBranch(apiKey, projectId, parentBranchId, dep
|
|
|
187
294
|
}
|
|
188
295
|
return branchId;
|
|
189
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* The database a branch actually holds, plus its owning role.
|
|
299
|
+
*
|
|
300
|
+
* 🔴 These were hardcoded to Neon's defaults for an API-provisioned project
|
|
301
|
+
* (`neondb` / `neondb_owner`), which is right for a project this CLI created and WRONG
|
|
302
|
+
* for every project it merely adopted. A real client project whose database is named
|
|
303
|
+
* `adi_capital` got a `DATABASE_URL` secret pointing at a `neondb` that does not exist
|
|
304
|
+
* there — CI would have run against a database nobody had. Asking the branch costs one
|
|
305
|
+
* GET and is right in both cases.
|
|
306
|
+
*
|
|
307
|
+
* Falls back to Neon's defaults when the branch reports no database (or the lookup
|
|
308
|
+
* fails), so a freshly-created project behaves exactly as before.
|
|
309
|
+
*/
|
|
310
|
+
export async function getBranchDatabase(apiKey, projectId, branchId, deps = {}) {
|
|
311
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
312
|
+
try {
|
|
313
|
+
const response = await fetchImpl(`${NEON_API_BASE}/projects/${encodeURIComponent(projectId)}/branches/${encodeURIComponent(branchId)}/databases`, {
|
|
314
|
+
method: 'GET',
|
|
315
|
+
headers: neonHeaders(apiKey),
|
|
316
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
317
|
+
});
|
|
318
|
+
if (!response.ok)
|
|
319
|
+
return { database: NEON_DEFAULT_DATABASE, role: NEON_DEFAULT_ROLE };
|
|
320
|
+
const data = (await response.json());
|
|
321
|
+
const first = (data.databases ?? []).find((d) => d.name);
|
|
322
|
+
if (!first?.name)
|
|
323
|
+
return { database: NEON_DEFAULT_DATABASE, role: NEON_DEFAULT_ROLE };
|
|
324
|
+
return { database: first.name, role: first.owner_name ?? NEON_DEFAULT_ROLE };
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return { database: NEON_DEFAULT_DATABASE, role: NEON_DEFAULT_ROLE };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
190
330
|
/**
|
|
191
331
|
* Read a branch's connection string for the requested mode via Neon's
|
|
192
332
|
* `GET /projects/{id}/connection_uri`:
|
|
@@ -203,12 +343,15 @@ export async function createDevelopBranch(apiKey, projectId, parentBranchId, dep
|
|
|
203
343
|
*/
|
|
204
344
|
export async function getConnectionUri(apiKey, projectId, branchId, pooled, deps = {}) {
|
|
205
345
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
206
|
-
// Neon's connection_uri requires database_name + role_name
|
|
207
|
-
//
|
|
346
|
+
// Neon's connection_uri requires database_name + role_name. They come from the
|
|
347
|
+
// BRANCH (see `getBranchDatabase`), not from a constant: an adopted project's
|
|
348
|
+
// database is whatever its team named it, and assuming `neondb` there hands out a
|
|
349
|
+
// connection string to a database that does not exist.
|
|
350
|
+
const { database, role } = await getBranchDatabase(apiKey, projectId, branchId, deps);
|
|
208
351
|
const query = new URLSearchParams({
|
|
209
352
|
branch_id: branchId,
|
|
210
|
-
database_name:
|
|
211
|
-
role_name:
|
|
353
|
+
database_name: database,
|
|
354
|
+
role_name: role,
|
|
212
355
|
pooled: pooled ? 'true' : 'false',
|
|
213
356
|
});
|
|
214
357
|
const response = await fetchImpl(`${NEON_API_BASE}/projects/${encodeURIComponent(projectId)}/connection_uri?${query.toString()}`, {
|
|
@@ -300,20 +443,34 @@ export async function listNeonBranches(apiKey, projectId, deps = {}) {
|
|
|
300
443
|
const data = (await response.json());
|
|
301
444
|
return (data.branches ?? [])
|
|
302
445
|
.filter((b) => Boolean(b.id) && Boolean(b.name))
|
|
303
|
-
.map((b) => ({ id: b.id, name: b.name }));
|
|
446
|
+
.map((b) => ({ id: b.id, name: b.name, isDefault: b.default === true }));
|
|
304
447
|
}
|
|
305
448
|
/**
|
|
306
|
-
* Mint a PROJECT-SCOPED Neon API key
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
* Returns the key value (the ONE place
|
|
310
|
-
* straight to `gh secret set` over STDIN, never
|
|
449
|
+
* Mint a PROJECT-SCOPED Neon API key: `POST /organizations/{org_id}/api_keys`
|
|
450
|
+
* with a `project_id`. The result is an ORG-OWNED key restricted to that one
|
|
451
|
+
* project (Editor level), which is what the GH secret and the derivative's
|
|
452
|
+
* `.env.local` store — never the rail key. Returns the key value (the ONE place
|
|
453
|
+
* this value is read — handed straight to `gh secret set` over STDIN, never
|
|
454
|
+
* logged).
|
|
455
|
+
*
|
|
456
|
+
* 🔴 The endpoint matters, and getting it wrong was a real incident. This used to
|
|
457
|
+
* call `POST /api_keys` with the same `project_id` in the body. That endpoint
|
|
458
|
+
* takes `key_name` and NOTHING else, so Neon ignored the scope and handed back a
|
|
459
|
+
* PERSONAL key with access to every organization its owner belonged to — which
|
|
460
|
+
* provision then distributed to every repo's GitHub Secrets and `.env.local`.
|
|
461
|
+
* Verified 2026-09-12: the key this endpoint returns 404s on a sibling project in
|
|
462
|
+
* the same org and 404s on `DELETE /projects/{id}`, while still covering the whole
|
|
463
|
+
* E2E branch cycle (create branch, connection_uri, reveal_password, delete).
|
|
464
|
+
*
|
|
465
|
+
* Requires a PERSONAL key belonging to an ADMIN of the org: an org key gets
|
|
466
|
+
* `404 This endpoint requires a personal API key`, and there is no alternative
|
|
467
|
+
* route (`POST /projects/{id}/api_keys` does not exist).
|
|
311
468
|
*
|
|
312
469
|
* @throws {NeonApiError} on any non-2xx.
|
|
313
470
|
*/
|
|
314
|
-
export async function createProjectApiKey(apiKey, projectId, keyName, deps = {}) {
|
|
471
|
+
export async function createProjectApiKey(apiKey, orgId, projectId, keyName, deps = {}) {
|
|
315
472
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
316
|
-
const mint = () => fetchImpl(`${NEON_API_BASE}/api_keys`, {
|
|
473
|
+
const mint = () => fetchImpl(`${NEON_API_BASE}/organizations/${encodeURIComponent(orgId)}/api_keys`, {
|
|
317
474
|
method: 'POST',
|
|
318
475
|
headers: neonHeaders(apiKey),
|
|
319
476
|
body: JSON.stringify({ key_name: keyName, project_id: projectId }),
|
|
@@ -326,33 +483,41 @@ export async function createProjectApiKey(apiKey, projectId, keyName, deps = {})
|
|
|
326
483
|
// resume must converge, not 409. (Only the secret-pending path re-enters here;
|
|
327
484
|
// the happy path sets apiKeySecretDone and short-circuits before minting again.)
|
|
328
485
|
if (response.status === 409) {
|
|
329
|
-
await deleteNeonApiKeysByName(apiKey, keyName, { fetchImpl });
|
|
486
|
+
await deleteNeonApiKeysByName(apiKey, orgId, keyName, { fetchImpl });
|
|
330
487
|
response = await mint();
|
|
331
488
|
}
|
|
332
489
|
if (!response.ok) {
|
|
333
|
-
throw new NeonApiError(response.status, await safeBody(response), 'POST /api_keys');
|
|
490
|
+
throw new NeonApiError(response.status, await safeBody(response), 'POST /organizations/{org_id}/api_keys');
|
|
334
491
|
}
|
|
335
492
|
const data = (await response.json());
|
|
336
493
|
if (!data.key) {
|
|
337
|
-
throw new NeonApiError(response.status, JSON.stringify(data).slice(0, 500), 'POST /api_keys (respuesta sin key)');
|
|
494
|
+
throw new NeonApiError(response.status, JSON.stringify(data).slice(0, 500), 'POST /organizations/{org_id}/api_keys (respuesta sin key)');
|
|
338
495
|
}
|
|
339
496
|
return data.key;
|
|
340
497
|
}
|
|
341
498
|
/**
|
|
342
|
-
* Best-effort delete of every
|
|
343
|
-
* (GET /api_keys → DELETE
|
|
344
|
-
*
|
|
499
|
+
* Best-effort delete of every ORG API key whose name matches `keyName`
|
|
500
|
+
* (`GET /organizations/{org_id}/api_keys` → `DELETE .../api_keys/{id}`). Three
|
|
501
|
+
* callers:
|
|
502
|
+
* - the idempotent re-mint (clear a stale key before re-minting on resume),
|
|
503
|
+
* - `--remint` (revoke the live key before issuing its replacement), and
|
|
345
504
|
* - the ephemeral `--destroy` nuke (delete the project-scoped key it minted so
|
|
346
505
|
* the org does not accumulate dead keys across provisions/tests).
|
|
506
|
+
*
|
|
507
|
+
* Org-scoped for the same reason the mint is (see {@link createProjectApiKey}):
|
|
508
|
+
* the keys provision issues are owned by the org, so the personal `/api_keys`
|
|
509
|
+
* listing never saw them — and with a service-account key it 404s outright.
|
|
510
|
+
*
|
|
347
511
|
* Returns the count deleted. NEVER throws — a cleanup failure must not abort the
|
|
348
512
|
* caller (the re-mint surfaces its own error; the destroy already nuked the
|
|
349
513
|
* project).
|
|
350
514
|
*/
|
|
351
|
-
export async function deleteNeonApiKeysByName(apiKey, keyName, deps = {}) {
|
|
515
|
+
export async function deleteNeonApiKeysByName(apiKey, orgId, keyName, deps = {}) {
|
|
352
516
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
517
|
+
const keysBase = `${NEON_API_BASE}/organizations/${encodeURIComponent(orgId)}/api_keys`;
|
|
353
518
|
let deleted = 0;
|
|
354
519
|
try {
|
|
355
|
-
const listResp = await fetchImpl(
|
|
520
|
+
const listResp = await fetchImpl(keysBase, {
|
|
356
521
|
method: 'GET',
|
|
357
522
|
headers: neonHeaders(apiKey),
|
|
358
523
|
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
@@ -366,7 +531,7 @@ export async function deleteNeonApiKeysByName(apiKey, keyName, deps = {}) {
|
|
|
366
531
|
.filter((k) => k?.name === keyName && k?.id != null)
|
|
367
532
|
.map((k) => String(k.id));
|
|
368
533
|
for (const id of ids) {
|
|
369
|
-
const del = await fetchImpl(`${
|
|
534
|
+
const del = await fetchImpl(`${keysBase}/${encodeURIComponent(id)}`, {
|
|
370
535
|
method: 'DELETE',
|
|
371
536
|
headers: neonHeaders(apiKey),
|
|
372
537
|
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
package/dist/lib/package-json.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { CLIError } from './cli-error.js';
|
|
11
|
-
import { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, VERIFY_QUICK_SCRIPT_CMD, VERIFY_QUICK_SCRIPT_NAME, VERIFY_SCRIPT_CMD, VERIFY_SCRIPT_FILE, VERIFY_SCRIPT_NAME, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, RETIRED_SCRIPT_CMDS, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, } from './constants.js';
|
|
11
|
+
import { DEV_NEXT_GATE_FILE, DEV_NEXT_SCRIPT_CMD, DEV_NEXT_SCRIPT_NAME, E2E_SETUP_SCRIPT_CMD, E2E_SETUP_SCRIPT_FILE, E2E_SETUP_SCRIPT_NAME, EVIDENCE_VISUAL_SCRIPT_CMD, EVIDENCE_VISUAL_SCRIPT_FILE, EVIDENCE_VISUAL_SCRIPT_NAME, FACTORY_SCRIPTS, GENERATE_REFERENCE_CMD, GENERATE_REFERENCE_GATE_FILE, GENERATE_REFERENCE_NAME, GENERATOR_SCRIPTS, INVITE_ADMIN_SCRIPT_CMD, INVITE_ADMIN_SCRIPT_FILE, INVITE_ADMIN_SCRIPT_NAME, OBSOLETE_SCRIPT_CMDS, VERIFY_QUICK_SCRIPT_CMD, VERIFY_QUICK_SCRIPT_NAME, VERIFY_SCRIPT_CMD, VERIFY_SCRIPT_FILE, VERIFY_SCRIPT_NAME, PORTS_E2E_COMMENT, PORTS_E2E_COMMENT_KEY, PORTS_E2E_KEY, PORTS_KEY, PREFLIGHT_SCRIPT_CMD, PREFLIGHT_SCRIPT_FILE, PREFLIGHT_SCRIPT_NAME, PROFILES, RETIRED_SCRIPT_CMDS, SETUP_E2E_VARS_SCRIPT_CMD, SETUP_E2E_VARS_SCRIPT_FILE, SETUP_E2E_VARS_SCRIPT_NAME, SHIPPED_E2E_PORT, UPDATE_SCRIPT_NAME, VERCEL_BUILD_OBSOLETE_CMDS, VERCEL_BUILD_SCRIPT_CMD, VERCEL_BUILD_SCRIPT_NAME, } from './constants.js';
|
|
12
12
|
/**
|
|
13
13
|
* Auto-detect the profile to install into an existing repo from its
|
|
14
14
|
* `package.json`: a `factoryVersion` field means the repo was born from the
|
|
@@ -111,6 +111,18 @@ function ensureFactoryScripts(pkg, extraScripts = {}) {
|
|
|
111
111
|
* backing file lands.
|
|
112
112
|
*
|
|
113
113
|
* - `preflight` — when its sweep script ships (full brain).
|
|
114
|
+
* - `setup:e2e:vars` — publishes the CI E2E auth env vars (Fase 1 of the
|
|
115
|
+
* `e2e-ci-auth-env` retrofit), when `scripts/tools/setup-e2e.ts` ships (full brain).
|
|
116
|
+
* - `evidence:visual` — the visual-evidence harness (`fx-visual-evidence`), gated on the
|
|
117
|
+
* harness itself (`scripts/tools/visual-evidence/`) rather than on the runner: it is what
|
|
118
|
+
* this alias needs, and a `core` derivative receives neither. Forwards to `test:e2e`, so the
|
|
119
|
+
* capture pass goes through the ephemeral Neon branch like every other phase.
|
|
120
|
+
* - `e2e:setup` — installs the Playwright browsers, gated on the e2e runner that
|
|
121
|
+
* refuses to start without them and names this alias in its message. A repo
|
|
122
|
+
* whose runner ships but whose `package.json` lacks the alias would be told to
|
|
123
|
+
* run a script it does not have — which is why it is maintained here rather
|
|
124
|
+
* than only in the Factory's own `package.json` (frozen at bootstrap in a
|
|
125
|
+
* derivative, so it is unreachable any other way).
|
|
114
126
|
* - `generate:*` — one alias per reference generator whose `scripts/tools/
|
|
115
127
|
* generate-*.mjs` ships (full brain). These arrive via the `scripts/**`
|
|
116
128
|
* tracked set on `update`/`beta`, so an existing derivative self-heals the
|
|
@@ -140,6 +152,12 @@ export function kitLocalScripts(rootDir) {
|
|
|
140
152
|
scripts[PREFLIGHT_SCRIPT_NAME] = PREFLIGHT_SCRIPT_CMD;
|
|
141
153
|
if (has(INVITE_ADMIN_SCRIPT_FILE))
|
|
142
154
|
scripts[INVITE_ADMIN_SCRIPT_NAME] = INVITE_ADMIN_SCRIPT_CMD;
|
|
155
|
+
if (has(SETUP_E2E_VARS_SCRIPT_FILE))
|
|
156
|
+
scripts[SETUP_E2E_VARS_SCRIPT_NAME] = SETUP_E2E_VARS_SCRIPT_CMD;
|
|
157
|
+
if (has(E2E_SETUP_SCRIPT_FILE))
|
|
158
|
+
scripts[E2E_SETUP_SCRIPT_NAME] = E2E_SETUP_SCRIPT_CMD;
|
|
159
|
+
if (has(EVIDENCE_VISUAL_SCRIPT_FILE))
|
|
160
|
+
scripts[EVIDENCE_VISUAL_SCRIPT_NAME] = EVIDENCE_VISUAL_SCRIPT_CMD;
|
|
143
161
|
for (const gen of GENERATOR_SCRIPTS) {
|
|
144
162
|
if (has(gen.file))
|
|
145
163
|
scripts[gen.name] = gen.cmd;
|
|
@@ -355,6 +373,48 @@ export function removeRetiredScripts(pkgPath) {
|
|
|
355
373
|
}
|
|
356
374
|
return report;
|
|
357
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Reorder a derivative's `vercel-build` so the production build runs BEFORE the migration.
|
|
378
|
+
*
|
|
379
|
+
* WHY THIS IS ITS OWN FUNCTION AND NOT AN ENTRY IN `FACTORY_SCRIPTS`. Everything in that
|
|
380
|
+
* registry is INSERTED when missing, because those are the CLI's own `factory:*` aliases and a
|
|
381
|
+
* repo without them is a repo that is missing them. `vercel-build` is the opposite kind of key:
|
|
382
|
+
* it belongs to the boilerplate, arrives frozen with `src/` at `factory new` (BR-FACTORY-006),
|
|
383
|
+
* and in a `core`/non-Factory repo its absence is CORRECT — inserting it there would point a
|
|
384
|
+
* build command at a stack that repo does not have. So this heals in place and never inserts,
|
|
385
|
+
* the same shape as `emptyShippedE2EPort`.
|
|
386
|
+
*
|
|
387
|
+
* The damage it repairs is in `VERCEL_BUILD_SCRIPT_NAME`'s docstring: with the migration first,
|
|
388
|
+
* a build that fails AFTER it leaves the production database migrated while Vercel keeps
|
|
389
|
+
* serving the previous build — new schema, old code, and a deployment rollback cannot undo it.
|
|
390
|
+
*
|
|
391
|
+
* Surgical like the rest of this module: reads, mutates a single key, re-serializes with the
|
|
392
|
+
* original indentation + trailing newline, and writes ONLY on a real change — so a second run
|
|
393
|
+
* finds `already-correct` and writes nothing.
|
|
394
|
+
*
|
|
395
|
+
* @param pkgPath Absolute path to the derived project's `package.json`.
|
|
396
|
+
*/
|
|
397
|
+
export function healVercelBuildOrder(pkgPath) {
|
|
398
|
+
const raw = readFileSync(pkgPath, 'utf8');
|
|
399
|
+
const indent = detectIndent(raw);
|
|
400
|
+
const pkg = parsePackageJson(raw);
|
|
401
|
+
const scripts = pkg.scripts;
|
|
402
|
+
if (typeof scripts !== 'object' || scripts === null || Array.isArray(scripts))
|
|
403
|
+
return 'absent';
|
|
404
|
+
const current = scripts[VERCEL_BUILD_SCRIPT_NAME];
|
|
405
|
+
if (current === undefined)
|
|
406
|
+
return 'absent';
|
|
407
|
+
if (current === VERCEL_BUILD_SCRIPT_CMD)
|
|
408
|
+
return 'already-correct';
|
|
409
|
+
// Exact match against what the kit actually shipped, byte for byte. A dev who appended a flag
|
|
410
|
+
// — or wrote the same two halves with different spacing — keeps their command through every
|
|
411
|
+
// future update: an unlisted variant erring toward "leave it alone" is the safe side here.
|
|
412
|
+
if (!VERCEL_BUILD_OBSOLETE_CMDS.includes(current))
|
|
413
|
+
return 'preserved';
|
|
414
|
+
scripts[VERCEL_BUILD_SCRIPT_NAME] = VERCEL_BUILD_SCRIPT_CMD;
|
|
415
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
|
|
416
|
+
return 'reordered';
|
|
417
|
+
}
|
|
358
418
|
/**
|
|
359
419
|
* The kit-owned maintenance of a derivative's `package.json`, in the ONE order that is correct.
|
|
360
420
|
*
|
|
@@ -395,6 +455,10 @@ export function maintainKitScripts(pkgPath) {
|
|
|
395
455
|
// `scripts/**` can derive one per checkout. Conditional: it only ever touches that exact
|
|
396
456
|
// value, so a dev-chosen port survives untouched.
|
|
397
457
|
const portAction = emptyShippedE2EPort(pkgPath);
|
|
458
|
+
// Reorder `vercel-build` so the build runs before the migration. Conditional in the same way:
|
|
459
|
+
// it only ever rewrites a value the kit itself shipped, and never creates the key — a repo
|
|
460
|
+
// without it is a repo that should not have one.
|
|
461
|
+
const vercelBuildAction = healVercelBuildOrder(pkgPath);
|
|
398
462
|
const removedKeys = new Set(Object.entries(retiredReport)
|
|
399
463
|
.filter(([, action]) => action === 'removed')
|
|
400
464
|
.map(([name]) => name));
|
|
@@ -402,7 +466,7 @@ export function maintainKitScripts(pkgPath) {
|
|
|
402
466
|
.filter(([name, r]) => (r.action === 'added' || r.action === 'healed') && !removedKeys.has(name))
|
|
403
467
|
.map(([name]) => name);
|
|
404
468
|
const retired = [...removedKeys].filter((name) => insert.perScript[name]?.action !== 'added');
|
|
405
|
-
return { insert, retiredReport, portAction, inserted, retired };
|
|
469
|
+
return { insert, retiredReport, portAction, vercelBuildAction, inserted, retired };
|
|
406
470
|
}
|
|
407
471
|
/**
|
|
408
472
|
* Surgically set the derived project's `package.json.agentKitVersion` to the
|
package/dist/lib/preflight.js
CHANGED
|
@@ -139,6 +139,11 @@ export async function runPreflight() {
|
|
|
139
139
|
* The methodology-rail keys the launcher expects in `~/.claude/.env` (imported
|
|
140
140
|
* via `factory secrets <file>`). Provisioning tokens + their team/org ids + the
|
|
141
141
|
* proposal tooling keys. Single source so the check + tests agree.
|
|
142
|
+
*
|
|
143
|
+
* `NEON_ORG` is deliberately NOT here: it only names a default organization, and
|
|
144
|
+
* its absence is a valid configuration (a key that reaches exactly one org needs
|
|
145
|
+
* no default). Listing it would warn every dev about a key they may never need.
|
|
146
|
+
* The Neon ORG ID is not here either — it is derived from the key, never stored.
|
|
142
147
|
*/
|
|
143
148
|
export const REQUIRED_SECRET_KEYS = [
|
|
144
149
|
'NEON_API_KEY',
|
|
@@ -148,7 +153,6 @@ export const REQUIRED_SECRET_KEYS = [
|
|
|
148
153
|
'SHORTIO_API_KEY',
|
|
149
154
|
'GAMMA_API_KEY',
|
|
150
155
|
'VERCEL_TEAM_ID',
|
|
151
|
-
'NEON_ORGANIZATION_ID',
|
|
152
156
|
];
|
|
153
157
|
/** The ordered launcher labels (single source so render + tests agree). */
|
|
154
158
|
const LAUNCHER_LABELS = {
|
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
*
|
|
20
20
|
* What is OPTIONAL (a WARNING, never an abort — frozen plan decision):
|
|
21
21
|
* - `neonctl`. Provision mutates Neon over REST with the rail `NEON_API_KEY`,
|
|
22
|
-
* so a missing or
|
|
22
|
+
* so a missing or session-less `neonctl` is a convenience gap, not a
|
|
23
23
|
* blocker (issue §1, §8). Absent → warn with the install hint and continue.
|
|
24
|
+
* The session is detected by its credentials FILE, never by running
|
|
25
|
+
* `neonctl me` — that command starts a login instead of failing.
|
|
24
26
|
*
|
|
25
27
|
* The token reader, the `fetch`, and the `gh` spawns are all injectable seams so
|
|
26
28
|
* the unit tests drive every branch (401/403/missing-token/no-gh/no-neonctl)
|
|
@@ -183,6 +185,14 @@ function assertGateTokensPresent(services, present) {
|
|
|
183
185
|
'Ejecuta `factory secrets <archivo>` para bajarlos del archivo de secretos del org y vuelve a intentar.');
|
|
184
186
|
}
|
|
185
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Where `neonctl` keeps its OAuth session. Honors `NEONCTL_CONFIG_DIR`, which the CLI
|
|
190
|
+
* itself reads, so a dev who moved its config is not reported as session-less.
|
|
191
|
+
*/
|
|
192
|
+
export function neonctlCredentialsPath() {
|
|
193
|
+
const base = process.env.NEONCTL_CONFIG_DIR ?? path.join(homedir(), '.config', 'neonctl');
|
|
194
|
+
return path.join(base, 'credentials.json');
|
|
195
|
+
}
|
|
186
196
|
/**
|
|
187
197
|
* Optional `neonctl` check — NEVER aborts (frozen plan decision: provision mutates
|
|
188
198
|
* Neon over REST with the rail `NEON_API_KEY`). Returns a structured report the
|
|
@@ -208,19 +218,29 @@ async function checkNeonctlOptional(spawn) {
|
|
|
208
218
|
message: 'No se pudo ejecutar `neonctl` (opcional). Continúo con la mutación por REST.',
|
|
209
219
|
};
|
|
210
220
|
}
|
|
211
|
-
// Installed →
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
221
|
+
// Installed → is there a session? Answered by looking for the credentials file,
|
|
222
|
+
// NOT by running `neonctl me`.
|
|
223
|
+
//
|
|
224
|
+
// 🔴 `neonctl me` without a session does not fail — it STARTS A LOGIN and opens a
|
|
225
|
+
// browser. A check the plan froze as optional diagnostics ("provision does not
|
|
226
|
+
// depend on the neonctl session") therefore had two side effects it had no business
|
|
227
|
+
// having: it popped an OAuth consent in the dev's browser mid-provision, and, if
|
|
228
|
+
// approved, wrote an OAuth session to their host. Worse where nobody can see it: on
|
|
229
|
+
// a headless Agent Server there is no browser and no one to approve, so the spawn
|
|
230
|
+
// sits waiting and hangs the preflight of a run that never needed it.
|
|
231
|
+
//
|
|
232
|
+
// Reading the credentials file is the same answer with no side effects. It can go
|
|
233
|
+
// stale (an expired session still leaves the file), which is why the `ok` message
|
|
234
|
+
// says "con sesión" and not "authenticated" — and being wrong in that direction
|
|
235
|
+
// costs a wrong word in a warning, while the old one cost a credential.
|
|
236
|
+
if (!existsSync(neonctlCredentialsPath())) {
|
|
217
237
|
return {
|
|
218
238
|
status: 'warn',
|
|
219
|
-
message: '`neonctl` está instalado pero
|
|
239
|
+
message: '`neonctl` está instalado pero sin sesión (opcional).\n' +
|
|
220
240
|
'Autentícalo con `neonctl auth` si lo quieres para inspección; provision no lo necesita.',
|
|
221
241
|
};
|
|
222
242
|
}
|
|
223
|
-
return { status: 'ok', message: '`neonctl` instalado y
|
|
243
|
+
return { status: 'ok', message: '`neonctl` instalado y con sesión.' };
|
|
224
244
|
}
|
|
225
245
|
/**
|
|
226
246
|
* Step 3 — `VERCEL_TOKEN` is valid and scoped to `teamId` via `GET /v2/teams/{teamId}`.
|
|
@@ -199,6 +199,7 @@ export function adoptState(rootDir, partial, options = {}) {
|
|
|
199
199
|
slug,
|
|
200
200
|
vercelProjectId: partial.vercelProjectId ?? ADOPT_UNKNOWN,
|
|
201
201
|
neonProjectId: partial.neonProjectId ?? ADOPT_UNKNOWN,
|
|
202
|
+
neonOrgId: partial.neonOrgId ?? ADOPT_UNKNOWN,
|
|
202
203
|
neonBranchDevId: partial.neonBranchDevId ?? ADOPT_UNKNOWN,
|
|
203
204
|
githubRepo: partial.githubRepo ?? ADOPT_UNKNOWN,
|
|
204
205
|
domains: partial.domains ?? { dev: ADOPT_UNKNOWN, prod: ADOPT_UNKNOWN },
|
|
@@ -305,6 +306,47 @@ function isAdoptedShape(obj) {
|
|
|
305
306
|
// `projectName` + nested `vercel.projectId`. `slug` present = adopt.
|
|
306
307
|
return typeof obj.slug === 'string';
|
|
307
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Read the state as a {@link ProvisionState} for a `--remint`, tolerating BOTH shapes
|
|
311
|
+
* the way {@link readDestroyTarget} does.
|
|
312
|
+
*
|
|
313
|
+
* 🔴 Why this exists: the ADOPTED shape is what the whole existing fleet has — a
|
|
314
|
+
* derivative provisioned before this state file existed gets one from `--adopt`, and
|
|
315
|
+
* that shape has `slug`/`neonProjectId` instead of `projectName`/`neon`. So
|
|
316
|
+
* `readProvisionState` rejects it, and `--remint` (the command whose entire purpose is
|
|
317
|
+
* re-issuing the key of an already-provisioned project) failed on precisely the
|
|
318
|
+
* projects that need it, suggesting `--force` — which with `--services=neon` would
|
|
319
|
+
* discard the state and try to CREATE A SECOND Neon project for a derivative that
|
|
320
|
+
* already has one.
|
|
321
|
+
*
|
|
322
|
+
* Returns `null` when there is no state at all, and throws the same typed error as
|
|
323
|
+
* `readDestroyTarget` on a corrupt file. `mainBranchId` comes back empty for an
|
|
324
|
+
* adopted stack (that shape never recorded it), and so does `developBranchId` when the
|
|
325
|
+
* adopt found no branch by that name — the normal case for a project whose working
|
|
326
|
+
* branch is called something else (the Factory's is `production`). A re-mint resolves
|
|
327
|
+
* the branch it needs from Neon, honoring the repo's pinned E2E parent, and never
|
|
328
|
+
* creates one.
|
|
329
|
+
*/
|
|
330
|
+
export function readRemintState(rootDir) {
|
|
331
|
+
const target = readDestroyTarget(rootDir);
|
|
332
|
+
if (!target)
|
|
333
|
+
return null;
|
|
334
|
+
if (!target.neonProjectId)
|
|
335
|
+
return null;
|
|
336
|
+
return {
|
|
337
|
+
projectName: target.slug,
|
|
338
|
+
lifecycle: target.lifecycle,
|
|
339
|
+
scope: target.scope ?? '',
|
|
340
|
+
neon: {
|
|
341
|
+
projectId: target.neonProjectId,
|
|
342
|
+
orgId: target.neonOrgId ?? undefined,
|
|
343
|
+
mainBranchId: '',
|
|
344
|
+
developBranchId: target.neonBranchDevId ?? '',
|
|
345
|
+
},
|
|
346
|
+
github: target.githubRepo ? { repo: target.githubRepo } : undefined,
|
|
347
|
+
createdAt: new Date(0).toISOString(),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
308
350
|
/**
|
|
309
351
|
* Read the provision state file as a {@link DestroyTarget}, tolerating BOTH the
|
|
310
352
|
* step-by-step {@link ProvisionState} and the flat {@link AdoptedState} shapes
|
|
@@ -351,6 +393,7 @@ export function readDestroyTarget(rootDir) {
|
|
|
351
393
|
lifecycle: adopted.lifecycle === 'live' ? 'live' : 'live', // adopt is always live
|
|
352
394
|
scope: null, // the adopt shape does not persist the team scope
|
|
353
395
|
neonProjectId: realId(adopted.neonProjectId),
|
|
396
|
+
neonOrgId: realId(adopted.neonOrgId),
|
|
354
397
|
neonBranchDevId: realId(adopted.neonBranchDevId),
|
|
355
398
|
vercelProjectId: realId(adopted.vercelProjectId),
|
|
356
399
|
githubRepo: realRepo(adopted.githubRepo),
|
|
@@ -388,6 +431,7 @@ export function readDestroyTarget(rootDir) {
|
|
|
388
431
|
lifecycle,
|
|
389
432
|
scope: realId(step.scope),
|
|
390
433
|
neonProjectId: realId(step.neon?.projectId),
|
|
434
|
+
neonOrgId: realId(step.neon?.orgId),
|
|
391
435
|
neonBranchDevId: realId(step.neon?.developBranchId),
|
|
392
436
|
vercelProjectId: realId(step.vercel?.projectId),
|
|
393
437
|
githubRepo: realRepo(step.github?.repo),
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"prefixes": ["NEXT_PUBLIC_", "RATE_LIMIT_"],
|
|
3
|
+
"keys": [
|
|
4
|
+
"APP_URL",
|
|
5
|
+
"AUTH_DEBUG",
|
|
6
|
+
"AUTH_GITHUB_ID",
|
|
7
|
+
"AUTH_GOOGLE_ID",
|
|
8
|
+
"AUTH_TRUST_HOST",
|
|
9
|
+
"EMAIL_FROM",
|
|
10
|
+
"EMAIL_LOGO_URL",
|
|
11
|
+
"EMAIL_PROVIDER",
|
|
12
|
+
"EMAIL_SERVER_HOST",
|
|
13
|
+
"EMAIL_SERVER_PORT",
|
|
14
|
+
"EMAIL_SERVER_SECURE",
|
|
15
|
+
"EMAIL_SERVER_USER",
|
|
16
|
+
"MFA_ENABLED",
|
|
17
|
+
"MFA_REQUIRED_ALL",
|
|
18
|
+
"NEXTAUTH_URL",
|
|
19
|
+
"NODE_ENV",
|
|
20
|
+
"SENTRY_ORG",
|
|
21
|
+
"SENTRY_PROJECT",
|
|
22
|
+
"SUPPORT_EMAIL",
|
|
23
|
+
"VAPID_SUBJECT",
|
|
24
|
+
"WEBAUTHN_RP_ID",
|
|
25
|
+
"WEBAUTHN_RP_NAME"
|
|
26
|
+
]
|
|
27
|
+
}
|