@timekast/factory 1.27.0 → 1.28.1
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/provision.js +375 -73
- package/dist/index.js +7 -0
- package/dist/lib/env-file.js +25 -11
- package/dist/lib/initial-migration.js +11 -5
- package/dist/lib/neon-api.js +186 -21
- package/dist/lib/preflight.js +5 -1
- package/dist/lib/provision-preflight.js +29 -9
- package/dist/lib/provision-state.js +44 -0
- package/package.json +1 -1
|
@@ -26,13 +26,13 @@ import prompts from 'prompts';
|
|
|
26
26
|
import { FACTORY_ORG } from '../lib/constants.js';
|
|
27
27
|
import { CLIError } from '../lib/cli-error.js';
|
|
28
28
|
import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, deleteDnsRecord, ensureCnameRecord, ensureDnsRecord, findCnameRecord, resolveZoneId, resolveZoneIdForName, resolveZoneIdForDomain, CNAME_VERCEL_TARGET, TIMEKAST_ZONE_NAME, provisionDomains, existingProvisionDomains, ORG_ZONE_NAMES, } from '../lib/cloudflare-api.js';
|
|
29
|
-
import { createDevelopBranch, createNeonProject, createProjectApiKey, deleteNeonApiKeysByName, deleteNeonProject, getConnectionUri, listNeonBranches, listNeonProjects, NeonApiError, NEON_API_BASE, NEON_DEVELOP_BRANCH, } from '../lib/neon-api.js';
|
|
29
|
+
import { createDevelopBranch, createNeonProject, createProjectApiKey, deleteNeonApiKeysByName, NEON_MAIN_BRANCH, getNeonProjectOrg, listNeonOrgs, resolveNeonOrg, deleteNeonProject, getConnectionUri, listNeonBranches, listNeonProjects, NeonApiError, NEON_API_BASE, NEON_DEVELOP_BRANCH, } from '../lib/neon-api.js';
|
|
30
30
|
import { createBacklogProject, deleteBacklogProject, mintBacklogKey, BacklogApiError, } from '../lib/backlog-api.js';
|
|
31
31
|
import { defaultEnvPath, readEnvKeyNames, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
|
|
32
32
|
import { addVercelDomain, buildEnvPlan, createVercelProject, generateAuthSecret, generateMfaEncryptionKey, getLinkedRepoId, getVercelDomainStatus, getVercelProjectByName, GitHubAppNotInstalledError, linkGitRepo, listProjectDomains, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, removeVercelProject, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, VERCEL_AUTO_APEX, writeVercelProjectJson, } from '../lib/vercel-env.js';
|
|
33
33
|
import { decideInitialMigration, hasGeneratedMigrations, MIGRATION_SKIP_MESSAGES, MIGRATIONS_DIR, } from '../lib/initial-migration.js';
|
|
34
34
|
import { writeBacklogLocalCreds, writeE2eLocalCreds, writeEmailLocalCreds, writeEnvLocal, } from '../lib/env-file.js';
|
|
35
|
-
import { adoptState, ADOPT_UNKNOWN, clearProvisionState, clearState, hasProvisionState, isServiceComplete, PROVISION_DIR, PROVISION_STATE_FILE, readDestroyTarget, readProvisionState, writeProvisionState, } from '../lib/provision-state.js';
|
|
35
|
+
import { adoptState, ADOPT_UNKNOWN, clearProvisionState, clearState, hasProvisionState, isServiceComplete, PROVISION_DIR, PROVISION_STATE_FILE, readDestroyTarget, readRemintState, readProvisionState, writeProvisionState, } from '../lib/provision-state.js';
|
|
36
36
|
/**
|
|
37
37
|
* The THREE GitHub Secrets the E2E runner (`pnpm test:e2e` → `e2e-runner.ts` +
|
|
38
38
|
* `setup:e2e`) reads to create an ISOLATED Neon branch per CI run. provision sets
|
|
@@ -206,6 +206,8 @@ export function parseProvisionFlags(argv) {
|
|
|
206
206
|
services: parseServices(readValueFlag(argv, 'services')),
|
|
207
207
|
team: readValueFlag(argv, 'team'),
|
|
208
208
|
orgId: readValueFlag(argv, 'org-id'),
|
|
209
|
+
neonOrg: readValueFlag(argv, 'neon-org'),
|
|
210
|
+
remint: argv.includes('--remint'),
|
|
209
211
|
slug: readValueFlag(argv, 'slug'),
|
|
210
212
|
repo: parseRepoFlag(readValueFlag(argv, 'repo')),
|
|
211
213
|
domain: readValueFlag(argv, 'domain'),
|
|
@@ -229,11 +231,18 @@ const SERVICE_LABEL = {
|
|
|
229
231
|
dns: 'DNS + Resend (dominio del entorno)',
|
|
230
232
|
backlog: 'Backlog central (proyecto en backlog.timekast.mx + API key)',
|
|
231
233
|
};
|
|
232
|
-
/**
|
|
233
|
-
|
|
234
|
+
/**
|
|
235
|
+
* Build the ordered plan: each requested service is `skip` if complete, else
|
|
236
|
+
* `create`.
|
|
237
|
+
*
|
|
238
|
+
* `remint` forces the `neon` step to run even when complete — the whole point of
|
|
239
|
+
* that flag is to re-enter a finished step, so skipping it here would make the flag
|
|
240
|
+
* a silent no-op. It changes nothing for any other service.
|
|
241
|
+
*/
|
|
242
|
+
function buildPlan(services, state, opts = {}) {
|
|
234
243
|
return services.map((service) => ({
|
|
235
244
|
service,
|
|
236
|
-
action: isServiceComplete(state, service) ? 'skip' : 'create',
|
|
245
|
+
action: isServiceComplete(state, service) && !(opts.remint && service === 'neon') ? 'skip' : 'create',
|
|
237
246
|
}));
|
|
238
247
|
}
|
|
239
248
|
/** Print the plain-text plan (the `--dry-run` and pre-run narration). */
|
|
@@ -436,6 +445,51 @@ function neonErrorToCliError(error, projectName, context) {
|
|
|
436
445
|
return new CLIError(`Falló la llamada a Neon en ${context}: ${error.message}`);
|
|
437
446
|
}
|
|
438
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* The branch name the E2E runner clones its throwaway branches FROM, for this repo.
|
|
450
|
+
*
|
|
451
|
+
* Read from the repo's own workflow (`E2E_PARENT_BRANCH:` in
|
|
452
|
+
* `.github/workflows/e2e.yml`), because that is where the value actually lives and the
|
|
453
|
+
* runner honors the same one (`resolveParentBranchName` in `scripts/tools/neon-branch.ts`).
|
|
454
|
+
* A provisioned derivative pins `develop` there; the Factory itself pins `production`,
|
|
455
|
+
* and a project is free to pin anything. Returns `null` when there is no workflow or no
|
|
456
|
+
* pin — the caller then falls back to `develop` and finally to the project's default
|
|
457
|
+
* branch.
|
|
458
|
+
*
|
|
459
|
+
* 🔴 Assuming `develop` here is what broke `--remint` on the Factory, whose Neon project
|
|
460
|
+
* has a single branch called `production`: the re-mint would have refused (or, worse,
|
|
461
|
+
* pushed a DATABASE_URL for a branch the runner never parents on).
|
|
462
|
+
*/
|
|
463
|
+
export function readE2eParentBranch(rootDir) {
|
|
464
|
+
try {
|
|
465
|
+
const workflow = readFileSync(join(rootDir, '.github', 'workflows', 'e2e.yml'), 'utf8');
|
|
466
|
+
const match = workflow.match(/^\s*E2E_PARENT_BRANCH:\s*(\S+)/m);
|
|
467
|
+
const pinned = match?.[1];
|
|
468
|
+
// The template ships the placeholder with its anchor comment; an unsubstituted
|
|
469
|
+
// placeholder is not a branch name.
|
|
470
|
+
if (!pinned || pinned.startsWith('__'))
|
|
471
|
+
return null;
|
|
472
|
+
return pinned;
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Resolve the branch whose connection string becomes the E2E `DATABASE_URL` secret, on
|
|
480
|
+
* a project that ALREADY exists. Preference: the repo's pinned parent branch → a branch
|
|
481
|
+
* literally named `develop` (what provision creates) → the project's default branch.
|
|
482
|
+
*
|
|
483
|
+
* Never creates anything: a re-mint repairs credentials, and creating a branch to
|
|
484
|
+
* satisfy a credential refresh would be a side effect nobody asked for.
|
|
485
|
+
*/
|
|
486
|
+
function pickE2eBranch(branches, pinnedName) {
|
|
487
|
+
const byName = (name) => branches.find((b) => b.name === name);
|
|
488
|
+
return ((pinnedName ? byName(pinnedName) : undefined) ??
|
|
489
|
+
byName(NEON_DEVELOP_BRANCH) ??
|
|
490
|
+
branches.find((b) => b.isDefault) ??
|
|
491
|
+
branches[0]);
|
|
492
|
+
}
|
|
439
493
|
/** The GitHub repo (`owner/name`) the project-scoped secret is pushed to. */
|
|
440
494
|
function neonSecretRepo(state) {
|
|
441
495
|
// `state.github.repo` carries the RESOLVED repo: `runProvision` seeds it before
|
|
@@ -464,13 +518,22 @@ async function setGithubSecret(key, value, repo) {
|
|
|
464
518
|
* after `develop`, so a mid-step failure leaves a resumable partial state
|
|
465
519
|
* (issue §6 + §8 Gherkin).
|
|
466
520
|
*
|
|
467
|
-
* Endpoint order (asserted by the unit test):
|
|
468
|
-
*
|
|
469
|
-
* POST /api_keys →
|
|
521
|
+
* Endpoint order (asserted by the unit test): GET /users/me/organizations (only
|
|
522
|
+
* when the org is not already known) → POST /projects → POST branches (develop) →
|
|
523
|
+
* GET connection_uri (develop) → POST /organizations/{org_id}/api_keys →
|
|
524
|
+
* `gh secret set`.
|
|
525
|
+
*
|
|
526
|
+
* The org resolution sits AFTER the `apiKeySecretDone` guard on purpose: a
|
|
527
|
+
* completed Neon step must still make zero network calls.
|
|
470
528
|
*
|
|
471
529
|
* `--resume` short-circuits ENTIRELY when `state.neon.projectId` already exists
|
|
472
530
|
* (caller skips the step via `isServiceComplete`; this is a defensive guard too).
|
|
531
|
+
* `deps.remint` is the one switch that deliberately re-enters a completed step.
|
|
473
532
|
*
|
|
533
|
+
* @returns the project-scoped key minted in THIS run, or `null` when nothing was
|
|
534
|
+
* minted (completed step, no remint). The value is a credential: it is never
|
|
535
|
+
* persisted to the state, only handed to the caller so the `.env.local` step of
|
|
536
|
+
* the same process can write it.
|
|
474
537
|
* @throws {CLIError} mapping the Neon §8 edges (401/402/409/429) and a `develop`
|
|
475
538
|
* failure (state saved partial → resumable). A failed `gh secret set` does NOT
|
|
476
539
|
* abort: it records `apiKeySecretPending` and reports (issue §8).
|
|
@@ -479,8 +542,10 @@ export async function provisionNeon(rootDir, state, deps = {}) {
|
|
|
479
542
|
// Fully complete only when the FINAL step landed (key minted + stored as the GH
|
|
480
543
|
// secret). developBranchId alone is NOT "done": re-entering must finish the
|
|
481
544
|
// remaining steps WITHOUT re-minting the key. Every sub-step below is idempotent.
|
|
482
|
-
|
|
483
|
-
|
|
545
|
+
// `--remint` is the deliberate exception: it exists to re-issue a key that was
|
|
546
|
+
// revoked out-of-band, which is the one case a "done" step must be re-entered.
|
|
547
|
+
if (state.neon?.apiKeySecretDone && !deps.remint)
|
|
548
|
+
return null;
|
|
484
549
|
const envPath = deps.envPath ?? defaultEnvPath();
|
|
485
550
|
const apiKey = readNeonApiKey(envPath);
|
|
486
551
|
if (!apiKey) {
|
|
@@ -489,16 +554,72 @@ export async function provisionNeon(rootDir, state, deps = {}) {
|
|
|
489
554
|
}
|
|
490
555
|
const setSecret = deps.setSecret ?? setGithubSecret;
|
|
491
556
|
const apiDeps = { fetchImpl: deps.fetchImpl };
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
const
|
|
557
|
+
// See `persistState`: an adopted stack's state file must not be overwritten with
|
|
558
|
+
// this step's shape. `saveState` is the ONLY writer below.
|
|
559
|
+
const persist = deps.persistState ?? true;
|
|
560
|
+
const saveState = () => {
|
|
561
|
+
if (persist)
|
|
562
|
+
writeProvisionState(rootDir, state);
|
|
563
|
+
};
|
|
564
|
+
// WHICH org. Three sources, and which one applies depends on whether the project
|
|
565
|
+
// already exists — that distinction is the whole point:
|
|
566
|
+
// - already recorded in the state → use it verbatim, no call, no guessing;
|
|
567
|
+
// - exists in Neon but the state predates `orgId` (or this is a --remint on an
|
|
568
|
+
// older stack) → ask Neon which org owns that project ID (`getNeonProjectOrg`);
|
|
569
|
+
// - brand new → resolve the selector / the rail default / the only org.
|
|
570
|
+
// Pointing a teardown or a re-mint at the default instead of the project's real
|
|
571
|
+
// org is exactly the class of error this whole change is fixing.
|
|
572
|
+
let orgId = state.neon?.orgId;
|
|
573
|
+
if (!orgId) {
|
|
574
|
+
try {
|
|
575
|
+
if (state.neon?.projectId) {
|
|
576
|
+
// 🔴 BY ID, not by name. Asking which project is called like this repo's folder
|
|
577
|
+
// fails for almost every real project — they are named by hand in the console
|
|
578
|
+
// (`Aditivo CRM`, `agent-fimubac`) — and the fallback then aims at the default
|
|
579
|
+
// org: silently right inside it, `404 project not found` outside. Both happened
|
|
580
|
+
// the same afternoon. With the id recorded, one GET answers it exactly.
|
|
581
|
+
orgId = (await getNeonProjectOrg(apiKey, state.neon.projectId, apiDeps)) ?? undefined;
|
|
582
|
+
if (!orgId) {
|
|
583
|
+
throw new CLIError(`El proyecto Neon \`${state.neon.projectId}\` que registra el estado de este ` +
|
|
584
|
+
'repo no existe o la key del rail no lo alcanza.\n' +
|
|
585
|
+
' Si el proyecto se borró, reconstruye el estado con `factory provision --adopt ' +
|
|
586
|
+
'--force`. Si existe, revisa que la `NEON_API_KEY` del rail sea la de la cuenta ' +
|
|
587
|
+
'de servicio con acceso a su organización.');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (!orgId) {
|
|
591
|
+
const selector = deps.neonOrg ?? readGateValue('NEON_ORG', envPath) ?? undefined;
|
|
592
|
+
orgId = (await resolveNeonOrg(apiKey, selector, apiDeps)).id;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
if (error instanceof CLIError)
|
|
597
|
+
throw error;
|
|
598
|
+
if (error instanceof NeonApiError) {
|
|
599
|
+
throw neonErrorToCliError(error, state.projectName, 'resolver la organización de Neon');
|
|
600
|
+
}
|
|
601
|
+
// resolveNeonOrg throws plain Errors whose message is already the es-MX text the
|
|
602
|
+
// user needs; surface it as-is.
|
|
603
|
+
throw new CLIError(error instanceof Error ? error.message : String(error));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
495
606
|
// 1. Reuse the project from a partial run; create it only if missing. Persist it
|
|
496
607
|
// immediately so a branch failure leaves projectId + mainBranchId durable.
|
|
608
|
+
//
|
|
609
|
+
// 🔴 A RECORDED `projectId` is sufficient to reuse, and this is not a style
|
|
610
|
+
// preference. This condition used to require `mainBranchId` too, so a state that
|
|
611
|
+
// carried the project but not that branch fell through to CREATE — and produced a
|
|
612
|
+
// SECOND Neon project for a derivative that already had one, with the CLI then
|
|
613
|
+
// pointing the repo's secrets and `.env.local` at the empty one. That is exactly
|
|
614
|
+
// what happened on a real client repo (2026-09-12) the first time `--remint` ran
|
|
615
|
+
// over an adopted stack, whose shape never recorded `mainBranchId`.
|
|
616
|
+
// When the primary branch is genuinely needed (only to parent a missing `develop`),
|
|
617
|
+
// it is resolved from Neon below — never by creating anything.
|
|
497
618
|
let projectId;
|
|
498
619
|
let mainBranchId;
|
|
499
|
-
if (state.neon?.projectId
|
|
620
|
+
if (state.neon?.projectId) {
|
|
500
621
|
projectId = state.neon.projectId;
|
|
501
|
-
mainBranchId = state.neon.mainBranchId;
|
|
622
|
+
mainBranchId = state.neon.mainBranchId ?? '';
|
|
502
623
|
}
|
|
503
624
|
else {
|
|
504
625
|
let project;
|
|
@@ -513,31 +634,73 @@ export async function provisionNeon(rootDir, state, deps = {}) {
|
|
|
513
634
|
}
|
|
514
635
|
projectId = project.projectId;
|
|
515
636
|
mainBranchId = project.mainBranchId;
|
|
516
|
-
state.neon = { projectId, mainBranchId, developBranchId: '' };
|
|
517
|
-
|
|
637
|
+
state.neon = { projectId, orgId, mainBranchId, developBranchId: '' };
|
|
638
|
+
saveState();
|
|
518
639
|
}
|
|
519
640
|
// 2. CLEAN `develop` branch — reuse the recorded id; else create it (idempotent
|
|
520
641
|
// on a 409: createDevelopBranch reuses an existing develop) and persist the id
|
|
521
642
|
// IMMEDIATELY (FIX 6 — it was saved only at step 3, so a connection-string
|
|
522
643
|
// failure lost it and the resume re-created → 409).
|
|
523
644
|
let developBranchId = state.neon?.developBranchId ?? '';
|
|
645
|
+
if (!developBranchId && deps.remint) {
|
|
646
|
+
// A re-mint over a project whose working branch the state never recorded (an
|
|
647
|
+
// adopted stack) resolves it from Neon, honoring whatever this repo pinned as the
|
|
648
|
+
// E2E parent. It must NOT create a branch: see `pickE2eBranch`.
|
|
649
|
+
let branches;
|
|
650
|
+
try {
|
|
651
|
+
branches = await listNeonBranches(apiKey, projectId, apiDeps);
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
if (error instanceof NeonApiError) {
|
|
655
|
+
throw neonErrorToCliError(error, state.projectName, 'listar los branches del proyecto');
|
|
656
|
+
}
|
|
657
|
+
throw error;
|
|
658
|
+
}
|
|
659
|
+
const picked = pickE2eBranch(branches, readE2eParentBranch(rootDir));
|
|
660
|
+
if (!picked) {
|
|
661
|
+
throw new CLIError(`El proyecto Neon \`${projectId}\` no reporta ningún branch, así que no puedo ` +
|
|
662
|
+
'saber de cuál sacar el `DATABASE_URL` de E2E. Revísalo en la consola de Neon.');
|
|
663
|
+
}
|
|
664
|
+
developBranchId = picked.id;
|
|
665
|
+
console.log(` · Branch de E2E para el \`DATABASE_URL\`: ${picked.name}`);
|
|
666
|
+
}
|
|
524
667
|
if (!developBranchId) {
|
|
668
|
+
// The parent of a new `develop` is the project's primary branch. An adopted state
|
|
669
|
+
// does not record it, so resolve it from Neon rather than assume — and rather than
|
|
670
|
+
// let a create run with an empty parent.
|
|
671
|
+
if (!mainBranchId) {
|
|
672
|
+
try {
|
|
673
|
+
const branches = await listNeonBranches(apiKey, projectId, apiDeps);
|
|
674
|
+
mainBranchId =
|
|
675
|
+
branches.find((b) => b.name === NEON_MAIN_BRANCH)?.id ?? branches[0]?.id ?? '';
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
if (error instanceof NeonApiError) {
|
|
679
|
+
throw neonErrorToCliError(error, state.projectName, 'leer los branches del proyecto para ubicar el branch primario');
|
|
680
|
+
}
|
|
681
|
+
throw error;
|
|
682
|
+
}
|
|
683
|
+
if (!mainBranchId) {
|
|
684
|
+
throw new CLIError(`El proyecto Neon \`${projectId}\` no reporta ningún branch, así que no puedo ` +
|
|
685
|
+
'crear `develop` sin inventar recursos. Revísalo en la consola de Neon.');
|
|
686
|
+
}
|
|
687
|
+
}
|
|
525
688
|
try {
|
|
526
689
|
developBranchId = await createDevelopBranch(apiKey, projectId, mainBranchId, apiDeps);
|
|
527
690
|
}
|
|
528
691
|
catch (error) {
|
|
529
692
|
// Save the partial state (issue §8 Gherkin: "guarda el state parcial").
|
|
530
693
|
// IDs only — never a connection string (those are re-derived from Neon on demand).
|
|
531
|
-
state.neon = { projectId, mainBranchId, developBranchId: '' };
|
|
532
|
-
|
|
694
|
+
state.neon = { projectId, orgId, mainBranchId, developBranchId: '' };
|
|
695
|
+
saveState();
|
|
533
696
|
if (error instanceof NeonApiError) {
|
|
534
697
|
throw new CLIError(`El proyecto Neon se creó pero falló la creación del branch \`develop\` (${error.message}).\n` +
|
|
535
698
|
'El estado parcial se guardó; reintenta con `--resume` para crear solo el branch develop.');
|
|
536
699
|
}
|
|
537
700
|
throw error;
|
|
538
701
|
}
|
|
539
|
-
state.neon = { projectId, mainBranchId, developBranchId };
|
|
540
|
-
|
|
702
|
+
state.neon = { projectId, orgId, mainBranchId, developBranchId };
|
|
703
|
+
saveState();
|
|
541
704
|
}
|
|
542
705
|
// 3. Read the develop branch's POOLED connection string (idempotent GET) — the
|
|
543
706
|
// ONLY string this step needs, for the E2E `DATABASE_URL` secret below. provision
|
|
@@ -554,14 +717,25 @@ export async function provisionNeon(rootDir, state, deps = {}) {
|
|
|
554
717
|
}
|
|
555
718
|
throw error;
|
|
556
719
|
}
|
|
557
|
-
// 4. Mint a PROJECT-SCOPED API key
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
720
|
+
// 4. Mint a PROJECT-SCOPED API key — blast radius = this project only, and now
|
|
721
|
+
// actually so: the key comes from `POST /organizations/{org_id}/api_keys`,
|
|
722
|
+
// which 404s on a sibling project (the old `POST /api_keys` silently returned
|
|
723
|
+
// a personal, all-orgs key). Only the `apiKeySecretDone` guard above prevents
|
|
724
|
+
// re-minting; a resume after a pending secret push DOES re-mint (the prior key
|
|
725
|
+
// value was never persisted — it is a credential) and the old key is left
|
|
726
|
+
// orphaned. Acceptable: it only happens when the GH secret push keeps failing
|
|
727
|
+
// (e.g. the repo does not exist yet).
|
|
728
|
+
const keyName = `${state.projectName}-e2e`;
|
|
562
729
|
let projectKey;
|
|
563
730
|
try {
|
|
564
|
-
|
|
731
|
+
// `--remint` revokes the live key BEFORE issuing its replacement, so the org
|
|
732
|
+
// does not accumulate a key per rotation. Best-effort by contract: a key that
|
|
733
|
+
// was already revoked out-of-band (the case that motivated --remint) is a
|
|
734
|
+
// no-op here, not an error.
|
|
735
|
+
if (deps.remint) {
|
|
736
|
+
await deleteNeonApiKeysByName(apiKey, orgId, keyName, apiDeps);
|
|
737
|
+
}
|
|
738
|
+
projectKey = await createProjectApiKey(apiKey, orgId, projectId, keyName, apiDeps);
|
|
565
739
|
}
|
|
566
740
|
catch (error) {
|
|
567
741
|
if (error instanceof NeonApiError) {
|
|
@@ -579,17 +753,19 @@ export async function provisionNeon(rootDir, state, deps = {}) {
|
|
|
579
753
|
await setSecret(NEON_KEY_SECRET, projectKey, repo);
|
|
580
754
|
await setSecret(NEON_PROJECT_ID_SECRET, projectId, repo);
|
|
581
755
|
await setSecret(E2E_DATABASE_URL_SECRET, developDatabaseUrl, repo);
|
|
582
|
-
state.neon = { projectId, mainBranchId, developBranchId, apiKeySecretDone: true };
|
|
583
|
-
|
|
756
|
+
state.neon = { projectId, orgId, mainBranchId, developBranchId, apiKeySecretDone: true };
|
|
757
|
+
saveState();
|
|
758
|
+
return projectKey;
|
|
584
759
|
}
|
|
585
760
|
catch {
|
|
586
|
-
state.neon = { projectId, mainBranchId, developBranchId, apiKeySecretPending: true };
|
|
587
|
-
|
|
761
|
+
state.neon = { projectId, orgId, mainBranchId, developBranchId, apiKeySecretPending: true };
|
|
762
|
+
saveState();
|
|
588
763
|
console.warn(`⚠ No se pudieron guardar los secrets de E2E ` +
|
|
589
764
|
`(\`${NEON_KEY_SECRET}\`/\`${NEON_PROJECT_ID_SECRET}\`/\`${E2E_DATABASE_URL_SECRET}\`) ` +
|
|
590
765
|
`en \`${neonSecretRepo(state)}\` (¿el repo no existe aún o falta permiso?).\n` +
|
|
591
766
|
' La API key project-scoped se creó; reintenta con `--resume` cuando el repo exista.');
|
|
592
767
|
}
|
|
768
|
+
return projectKey;
|
|
593
769
|
}
|
|
594
770
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
595
771
|
// BSYNC-003 — `backlog`: register the project in backlog.timekast.mx
|
|
@@ -961,13 +1137,18 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
|
|
|
961
1137
|
else if (envLocal === 'skipped') {
|
|
962
1138
|
console.log(' ⏭ .env.local ya existía (no se sobrescribe — usa --force para regenerarlo)');
|
|
963
1139
|
}
|
|
964
|
-
// Local E2E creds: write
|
|
965
|
-
//
|
|
1140
|
+
// Local E2E creds: write the PROJECT-SCOPED key minted by `provisionNeon` in this
|
|
1141
|
+
// same process + NEON_PROJECT_ID, so `pnpm test:e2e` runs locally with zero manual
|
|
966
1142
|
// `setup:e2e`. Both are PROVISION_OWNED → never pushed to Vercel. Safe on a skipped
|
|
967
|
-
// `.env.local` (setEnvLine touches only these two keys). The key value is never
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1143
|
+
// `.env.local` (setEnvLine touches only these two keys). The key value is never
|
|
1144
|
+
// printed. NEVER falls back to the rail key: that is what leaked an all-orgs
|
|
1145
|
+
// credential into every derivative's `.env.local`.
|
|
1146
|
+
const e2eKey = deps.neonProjectKey ?? null;
|
|
1147
|
+
if (state.neon?.projectId && writeE2eLocalCreds(rootDir, state.neon.projectId, e2eKey)) {
|
|
1148
|
+
console.log(e2eKey
|
|
1149
|
+
? ' + .env.local (NEON_API_KEY · NEON_PROJECT_ID — para `pnpm test:e2e` local)'
|
|
1150
|
+
: ' + .env.local (NEON_PROJECT_ID) · ⚠ sin NEON_API_KEY: la key de este run no se acuñó — ' +
|
|
1151
|
+
'córrelo con `--remint` para refrescarla');
|
|
971
1152
|
}
|
|
972
1153
|
// Email config: write the org Resend key (from the rail) + the fixed EMAIL_FROM so local
|
|
973
1154
|
// sends (`invite:admin`, magic-link, `db:seed`) DELIVER instead of the console fallback.
|
|
@@ -2555,15 +2736,28 @@ function printClientDomainPlan(domain, interim, route, confirm) {
|
|
|
2555
2736
|
* `vercel` (PROV-003) and `dns` (PROV-005) are implemented. `teamId` is required
|
|
2556
2737
|
* for the Vercel + GitHub + DNS steps (validated upstream).
|
|
2557
2738
|
*/
|
|
2558
|
-
async function runServiceStep(service, rootDir, state, flags
|
|
2739
|
+
async function runServiceStep(service, rootDir, state, flags,
|
|
2740
|
+
/**
|
|
2741
|
+
* Carries the project-scoped Neon key from the `neon` step to the `vercel` step
|
|
2742
|
+
* (which writes `.env.local`) WITHIN this process. A credential, so it is never
|
|
2743
|
+
* persisted — the state has no field for it and must not grow one.
|
|
2744
|
+
*/
|
|
2745
|
+
runCtx = {}) {
|
|
2559
2746
|
switch (service) {
|
|
2560
2747
|
case 'neon':
|
|
2561
|
-
await provisionNeon(rootDir, state
|
|
2748
|
+
runCtx.neonProjectKey = await provisionNeon(rootDir, state, {
|
|
2749
|
+
neonOrg: flags.neonOrg,
|
|
2750
|
+
remint: flags.remint,
|
|
2751
|
+
persistState: runCtx.persistState,
|
|
2752
|
+
});
|
|
2562
2753
|
break;
|
|
2563
2754
|
case 'vercel':
|
|
2564
2755
|
// PROV-003: push env (teamId on every call) + write .vercel/project.json +
|
|
2565
2756
|
// pnpm install. The Vercel PROJECT itself is created by PROV-004.
|
|
2566
|
-
await provisionVercel(rootDir, state, flags.team, {
|
|
2757
|
+
await provisionVercel(rootDir, state, flags.team, {
|
|
2758
|
+
force: flags.force,
|
|
2759
|
+
neonProjectKey: runCtx.neonProjectKey,
|
|
2760
|
+
});
|
|
2567
2761
|
break;
|
|
2568
2762
|
case 'github':
|
|
2569
2763
|
// PROV-004: git substrate (rename → remote add → push main) + create the
|
|
@@ -2700,25 +2894,48 @@ function projectsToCandidates(projects, slug, teamId) {
|
|
|
2700
2894
|
}));
|
|
2701
2895
|
}
|
|
2702
2896
|
/**
|
|
2703
|
-
* Reconstruct the Neon project id + `develop` branch id (read-only).
|
|
2704
|
-
*
|
|
2705
|
-
*
|
|
2706
|
-
*
|
|
2897
|
+
* Reconstruct the Neon project id + its org + the `develop` branch id (read-only).
|
|
2898
|
+
*
|
|
2899
|
+
* Sweeps EVERY org the rail key can reach (`GET /users/me/organizations`, then one
|
|
2900
|
+
* project listing per org) and filters by slug across all of them. The org is
|
|
2901
|
+
* therefore READ OFF the project that matches — never taken from a configured
|
|
2902
|
+
* default, which may well point somewhere else: a backfill that recorded the
|
|
2903
|
+
* default's org id would aim a later `--destroy` at the wrong organization.
|
|
2904
|
+
*
|
|
2905
|
+
* The per-org listing still passes `org_id`, which Neon REQUIRES (it answers
|
|
2906
|
+
* `400 org_id is required` without it).
|
|
2907
|
+
*
|
|
2908
|
+
* Once the project resolves, lists its branches to find the `develop` one. A branch
|
|
2909
|
+
* lookup failure leaves the branch id `unknown` without failing the whole adopt
|
|
2910
|
+
* (issue §4).
|
|
2707
2911
|
*/
|
|
2708
|
-
async function adoptNeon(slug, apiKey,
|
|
2709
|
-
const
|
|
2710
|
-
const
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2912
|
+
async function adoptNeon(slug, apiKey, deps, recordAmbiguity) {
|
|
2913
|
+
const apiDeps = { fetchImpl: deps.fetchImpl };
|
|
2914
|
+
const orgs = await listNeonOrgs(apiKey, apiDeps);
|
|
2915
|
+
const candidates = [];
|
|
2916
|
+
/** projectId → owning org id, so the chosen candidate can report its org. */
|
|
2917
|
+
const orgOfProject = new Map();
|
|
2918
|
+
let scanned = 0;
|
|
2919
|
+
for (const org of orgs) {
|
|
2920
|
+
const projects = await listNeonProjects(apiKey, org.id, apiDeps);
|
|
2921
|
+
scanned += projects.length;
|
|
2922
|
+
for (const p of matchProjectsBySlug(projects, slug)) {
|
|
2923
|
+
orgOfProject.set(p.id, org.id);
|
|
2924
|
+
candidates.push({
|
|
2925
|
+
id: p.id,
|
|
2926
|
+
name: `${p.name} · ${org.name}`,
|
|
2927
|
+
url: `https://console.neon.tech/app/projects/${p.id}`,
|
|
2928
|
+
});
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2715
2931
|
if (candidates.length === 0) {
|
|
2716
|
-
console.log(` · Ningún proyecto de Neon se parece a \`${slug}\` (revisé ${
|
|
2717
|
-
return { projectId: null, branchDevId: null };
|
|
2932
|
+
console.log(` · Ningún proyecto de Neon se parece a \`${slug}\` (revisé ${scanned} en ${orgs.length} organización(es)) — \`neonProjectId: unknown\`.`);
|
|
2933
|
+
return { projectId: null, orgId: null, branchDevId: null };
|
|
2718
2934
|
}
|
|
2719
2935
|
const projectId = await resolveCandidate('Neon', candidates, deps, recordAmbiguity);
|
|
2720
2936
|
if (!projectId)
|
|
2721
|
-
return { projectId: null, branchDevId: null };
|
|
2937
|
+
return { projectId: null, orgId: null, branchDevId: null };
|
|
2938
|
+
const orgId = orgOfProject.get(projectId) ?? null;
|
|
2722
2939
|
// Resolve the develop branch (best-effort — a failure → unknown, not a hard stop).
|
|
2723
2940
|
let branchDevId = null;
|
|
2724
2941
|
try {
|
|
@@ -2728,7 +2945,7 @@ async function adoptNeon(slug, apiKey, orgId, deps, recordAmbiguity) {
|
|
|
2728
2945
|
catch {
|
|
2729
2946
|
branchDevId = null;
|
|
2730
2947
|
}
|
|
2731
|
-
return { projectId, branchDevId };
|
|
2948
|
+
return { projectId, orgId, branchDevId };
|
|
2732
2949
|
}
|
|
2733
2950
|
/**
|
|
2734
2951
|
* Reconstruct the GitHub repo (read-only) via `gh repo view {org}/{slug} --json
|
|
@@ -2939,17 +3156,16 @@ export async function runAdopt(flags, deps = {}) {
|
|
|
2939
3156
|
// Neon (GET /projects + branches) — needs the rail key. Absent → `unknown` (the
|
|
2940
3157
|
// Gherkin "Provider Neon sin token disponible" path).
|
|
2941
3158
|
let neonProjectId = null;
|
|
3159
|
+
let neonOrgId = null;
|
|
2942
3160
|
let neonBranchDevId = null;
|
|
2943
3161
|
const neonApiKey = readNeonApiKey(envPath);
|
|
2944
|
-
// Same rail value the create path threads (`NEON_ORGANIZATION_ID`): Neon REQUIRES
|
|
2945
|
-
// it on an org-scoped key, and answers `400 org_id is required` without it.
|
|
2946
|
-
const neonOrgId = readGateValue('NEON_ORGANIZATION_ID', envPath) ?? undefined;
|
|
2947
3162
|
if (neonApiKey) {
|
|
2948
3163
|
try {
|
|
2949
|
-
const neon = await adoptNeon(slug, neonApiKey,
|
|
3164
|
+
const neon = await adoptNeon(slug, neonApiKey, adeps, (list) => {
|
|
2950
3165
|
candidates.neon = list;
|
|
2951
3166
|
});
|
|
2952
3167
|
neonProjectId = neon.projectId;
|
|
3168
|
+
neonOrgId = neon.orgId;
|
|
2953
3169
|
neonBranchDevId = neon.branchDevId;
|
|
2954
3170
|
}
|
|
2955
3171
|
catch {
|
|
@@ -2971,6 +3187,7 @@ export async function runAdopt(flags, deps = {}) {
|
|
|
2971
3187
|
slug,
|
|
2972
3188
|
vercelProjectId: vercelProjectId ?? ADOPT_UNKNOWN,
|
|
2973
3189
|
neonProjectId: neonProjectId ?? ADOPT_UNKNOWN,
|
|
3190
|
+
neonOrgId: neonOrgId ?? ADOPT_UNKNOWN,
|
|
2974
3191
|
neonBranchDevId: neonBranchDevId ?? ADOPT_UNKNOWN,
|
|
2975
3192
|
githubRepo: githubRepo ?? ADOPT_UNKNOWN,
|
|
2976
3193
|
domains,
|
|
@@ -2983,6 +3200,7 @@ export async function runAdopt(flags, deps = {}) {
|
|
|
2983
3200
|
slug,
|
|
2984
3201
|
vercelProjectId: partial.vercelProjectId,
|
|
2985
3202
|
neonProjectId: partial.neonProjectId,
|
|
3203
|
+
neonOrgId: partial.neonOrgId,
|
|
2986
3204
|
neonBranchDevId: partial.neonBranchDevId,
|
|
2987
3205
|
githubRepo: partial.githubRepo,
|
|
2988
3206
|
domains,
|
|
@@ -3093,18 +3311,44 @@ async function destroyNeonBranch(target, apiKey, fetchImpl) {
|
|
|
3093
3311
|
* project id, so it also tears down a PARTIAL stack whose develop branch never got
|
|
3094
3312
|
* created (FIX 9). A 404 → already gone (§8). The dev's key does this, never the
|
|
3095
3313
|
* shared org create-only key.
|
|
3314
|
+
*
|
|
3315
|
+
* The org id is resolved BEFORE the nuke, because after it the project is gone and
|
|
3316
|
+
* nothing can say which org owned it any more. It comes from the state when present
|
|
3317
|
+
* and is asked of Neon BY PROJECT ID otherwise (every stack provisioned before `orgId`
|
|
3318
|
+
* existed takes that path). Unresolvable → the project is still nuked and only the key
|
|
3319
|
+
* cleanup is skipped, with the key named so it can be revoked by hand.
|
|
3096
3320
|
*/
|
|
3097
3321
|
async function destroyNeonProject(target, apiKey, fetchImpl) {
|
|
3098
3322
|
const resource = `Neon project (${target.neonProjectId})`;
|
|
3099
3323
|
if (!target.neonProjectId) {
|
|
3100
3324
|
return { resource: 'Neon project', status: 'skipped', detail: 'sin id en el estado' };
|
|
3101
3325
|
}
|
|
3326
|
+
const keyName = `${target.slug}-e2e`;
|
|
3327
|
+
let orgId = target.neonOrgId;
|
|
3328
|
+
if (!orgId) {
|
|
3329
|
+
// BY ID (see `getNeonProjectOrg`): the project's name in the console rarely equals
|
|
3330
|
+
// the repo's slug, so the by-name sweep this used to do found nothing for most real
|
|
3331
|
+
// projects — and the key cleanup was then skipped for no good reason.
|
|
3332
|
+
try {
|
|
3333
|
+
orgId = await getNeonProjectOrg(apiKey, target.neonProjectId, { fetchImpl });
|
|
3334
|
+
}
|
|
3335
|
+
catch {
|
|
3336
|
+
orgId = null;
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3102
3339
|
try {
|
|
3103
3340
|
await deleteNeonProject(apiKey, target.neonProjectId, { fetchImpl });
|
|
3341
|
+
if (!orgId) {
|
|
3342
|
+
return {
|
|
3343
|
+
resource,
|
|
3344
|
+
status: 'removed',
|
|
3345
|
+
detail: `proyecto eliminado (nuke) — no pude resolver la org, revoca \`${keyName}\` a mano`,
|
|
3346
|
+
};
|
|
3347
|
+
}
|
|
3104
3348
|
// Project gone — clean the project-scoped API key(s) it minted (`{slug}-e2e`)
|
|
3105
3349
|
// so the Neon org does not accumulate dead keys across provisions/tests.
|
|
3106
3350
|
// Best-effort (never throws): the project nuke already succeeded.
|
|
3107
|
-
const keysDeleted = await deleteNeonApiKeysByName(apiKey,
|
|
3351
|
+
const keysDeleted = await deleteNeonApiKeysByName(apiKey, orgId, keyName, { fetchImpl });
|
|
3108
3352
|
return {
|
|
3109
3353
|
resource,
|
|
3110
3354
|
status: 'removed',
|
|
@@ -3601,17 +3845,51 @@ export async function runProvision(flags) {
|
|
|
3601
3845
|
if (flags.force) {
|
|
3602
3846
|
clearProvisionState(rootDir);
|
|
3603
3847
|
}
|
|
3848
|
+
// `--remint` re-enters an EXISTING stack by definition: it re-issues the key of a
|
|
3849
|
+
// project that is already provisioned. Requiring `--resume` alongside it would be a
|
|
3850
|
+
// trap with no upside — there is nothing to start fresh — so it reads the state the
|
|
3851
|
+
// same way a resume does, and the existing-state guard below lets it through.
|
|
3852
|
+
const reentersState = flags.resume || flags.remint || standaloneDomain;
|
|
3604
3853
|
let state = null;
|
|
3605
|
-
|
|
3606
|
-
|
|
3854
|
+
// A `--remint` reads the state through the SHAPE-TOLERANT reader, because the whole
|
|
3855
|
+
// existing fleet carries the adopted shape (`slug`/`neonProjectId`, no `neon` block)
|
|
3856
|
+
// and `readProvisionState` rejects it. Without this, the one command written to
|
|
3857
|
+
// repair those projects failed on exactly them. When that shape is what we got, the
|
|
3858
|
+
// step must not persist (see `persistState`): writing this step's shape over an
|
|
3859
|
+
// adopted file would drop `vercelProjectId`, `githubRepo` and the domains.
|
|
3860
|
+
let remintOverAdopted = false;
|
|
3861
|
+
if (reentersState && !flags.force) {
|
|
3862
|
+
try {
|
|
3863
|
+
state = readProvisionState(rootDir); // throws CLIError on corrupt → hints --force
|
|
3864
|
+
}
|
|
3865
|
+
catch (error) {
|
|
3866
|
+
// `readProvisionState` THROWS on a shape it does not recognize, and the adopted
|
|
3867
|
+
// shape is one of those — so for a re-mint the throw is not an error, it is the
|
|
3868
|
+
// signal to read the other shape. Any other flag keeps the original behavior:
|
|
3869
|
+
// a genuinely corrupt file must still abort loudly.
|
|
3870
|
+
if (!flags.remint)
|
|
3871
|
+
throw error;
|
|
3872
|
+
state = null;
|
|
3873
|
+
}
|
|
3874
|
+
if (!state && flags.remint) {
|
|
3875
|
+
state = readRemintState(rootDir);
|
|
3876
|
+
remintOverAdopted = state !== null;
|
|
3877
|
+
}
|
|
3607
3878
|
if (!state) {
|
|
3608
3879
|
throw new CLIError(standaloneDomain
|
|
3609
3880
|
? '`--domain` en modo patch requiere un provision existente (`.timekast/provision.json`), ' +
|
|
3610
3881
|
'pero no hay estado. Corre el provision inicial (`--services=...`) primero.'
|
|
3611
|
-
:
|
|
3882
|
+
: flags.remint
|
|
3883
|
+
? '`--remint` reacuña la key de un proyecto YA provisionado, pero no hay estado ' +
|
|
3884
|
+
'(`.timekast/provision.json`) en este directorio. Corre el provision inicial ' +
|
|
3885
|
+
'(`--services=neon`) primero.'
|
|
3886
|
+
: 'No hay estado de provision para retomar (`--resume`). Corre sin `--resume` para empezar.');
|
|
3612
3887
|
}
|
|
3613
3888
|
}
|
|
3614
|
-
else if (!flags.force &&
|
|
3889
|
+
else if (!flags.force &&
|
|
3890
|
+
!flags.remint &&
|
|
3891
|
+
flags.services.length > 0 &&
|
|
3892
|
+
hasProvisionState(rootDir)) {
|
|
3615
3893
|
// A fresh (non-resume, non-force) service run must not clobber a prior state.
|
|
3616
3894
|
throw new CLIError('Ya existe un estado de provision (`.timekast/provision.json`). ' +
|
|
3617
3895
|
'Usa `--resume` para retomarlo o `--force` para descartarlo y empezar de cero.');
|
|
@@ -3629,7 +3907,7 @@ export async function runProvision(flags) {
|
|
|
3629
3907
|
const repoSlug = flags.services.includes('github') || flags.services.includes('neon')
|
|
3630
3908
|
? await resolveRepoSlug(rootDir, projectName, state?.github?.repo, flags.repo, gitInDir)
|
|
3631
3909
|
: undefined;
|
|
3632
|
-
const steps = buildPlan(flags.services, state);
|
|
3910
|
+
const steps = buildPlan(flags.services, state, { remint: flags.remint });
|
|
3633
3911
|
const result = {
|
|
3634
3912
|
projectName,
|
|
3635
3913
|
lifecycle: state?.lifecycle ?? lifecycle,
|
|
@@ -3692,20 +3970,35 @@ export async function runProvision(flags) {
|
|
|
3692
3970
|
}
|
|
3693
3971
|
// Ensure the state dir exists + a baseline file before the first step, so a
|
|
3694
3972
|
// step failure on the very first service still leaves a resumable state.
|
|
3695
|
-
|
|
3973
|
+
//
|
|
3974
|
+
// 🔴 Never for a re-mint over an ADOPTED stack. This baseline write is the one that
|
|
3975
|
+
// actually clobbered a client repo's adopted state file on 2026-09-12: the step
|
|
3976
|
+
// itself was already guarded, and this line — three screens away from it — was not.
|
|
3977
|
+
// An adopted file holds `vercelProjectId`, `githubRepo` and the domains, none of
|
|
3978
|
+
// which this shape carries, so writing it here loses them for good.
|
|
3979
|
+
if (!remintOverAdopted)
|
|
3980
|
+
writeProvisionState(rootDir, runState);
|
|
3696
3981
|
// 5.0. Initial migration (GAP-001) — BEFORE the steps, so the `.sql` is committed
|
|
3697
3982
|
// before the `github` step pushes `main` and Vercel builds the remote tree
|
|
3698
3983
|
// (`vercel-build` = `pnpm build && pnpm db:migrate`). A failed generate/install
|
|
3699
3984
|
// aborts here, with nothing pushed and nothing deployed.
|
|
3700
3985
|
await ensureInitialMigration(rootDir, greenfield);
|
|
3986
|
+
// In-memory carrier for the project-scoped Neon key (see `runServiceStep`), plus
|
|
3987
|
+
// whether this run may persist the step-by-step state at all.
|
|
3988
|
+
const runCtx = {
|
|
3989
|
+
persistState: !remintOverAdopted,
|
|
3990
|
+
};
|
|
3701
3991
|
for (const step of steps) {
|
|
3702
3992
|
if (step.action === 'skip')
|
|
3703
3993
|
continue;
|
|
3704
|
-
await runServiceStep(step.service, rootDir, runState, flags);
|
|
3994
|
+
await runServiceStep(step.service, rootDir, runState, flags, runCtx);
|
|
3705
3995
|
// Persist after each create. PROV-002 (neon) already writes its state
|
|
3706
3996
|
// atomically inside the step; the remaining stubs record nothing yet, and
|
|
3707
|
-
// this write keeps the baseline durable for them too.
|
|
3708
|
-
|
|
3997
|
+
// this write keeps the baseline durable for them too. Skipped entirely for a
|
|
3998
|
+
// re-mint over an adopted stack, whose state file is a different shape we must
|
|
3999
|
+
// not overwrite.
|
|
4000
|
+
if (!remintOverAdopted)
|
|
4001
|
+
writeProvisionState(rootDir, runState);
|
|
3709
4002
|
}
|
|
3710
4003
|
// 5a. Retry the E2E GitHub Secrets if Neon recorded them pending. Neon runs BEFORE
|
|
3711
4004
|
// the git substrate, so on a fresh bootstrap the repo may not exist yet when it
|
|
@@ -3716,16 +4009,25 @@ export async function runProvision(flags) {
|
|
|
3716
4009
|
// Without this, CI E2E stays credential-less until a MANUAL `--resume`.
|
|
3717
4010
|
if (!flags.dryRun && runState.neon?.apiKeySecretPending && runState.github?.repo) {
|
|
3718
4011
|
console.log('\n↻ Reintentando los secrets de E2E ahora que el repo existe...');
|
|
3719
|
-
await provisionNeon(rootDir, runState
|
|
3720
|
-
|
|
4012
|
+
runCtx.neonProjectKey = await provisionNeon(rootDir, runState, {
|
|
4013
|
+
neonOrg: flags.neonOrg,
|
|
4014
|
+
remint: flags.remint,
|
|
4015
|
+
persistState: !remintOverAdopted,
|
|
4016
|
+
});
|
|
4017
|
+
if (!remintOverAdopted)
|
|
4018
|
+
writeProvisionState(rootDir, runState);
|
|
3721
4019
|
}
|
|
3722
4020
|
// 5a.2. Ensure the local E2E creds are in `.env.local` even on a resume/adopt where
|
|
3723
4021
|
// the Vercel substrate was already `done` (writeE2eLocalCreds only runs inside
|
|
3724
4022
|
// that step's first pass). Idempotent — `setEnvLine` replaces/appends the two
|
|
3725
4023
|
// keys — so `pnpm test:e2e` works locally without a manual edit after a re-run.
|
|
3726
4024
|
if (!flags.dryRun && runState.neon?.projectId) {
|
|
3727
|
-
|
|
3728
|
-
|
|
4025
|
+
const e2eKey = runCtx.neonProjectKey ?? null;
|
|
4026
|
+
if (writeE2eLocalCreds(rootDir, runState.neon.projectId, e2eKey)) {
|
|
4027
|
+
console.log(e2eKey
|
|
4028
|
+
? ' + .env.local (NEON_API_KEY · NEON_PROJECT_ID — para `pnpm test:e2e` local)'
|
|
4029
|
+
: ' + .env.local (NEON_PROJECT_ID) · ⚠ sin NEON_API_KEY: no se acuñó una key en este run — ' +
|
|
4030
|
+
'corre `factory provision --services=neon --remint` para refrescarla');
|
|
3729
4031
|
}
|
|
3730
4032
|
}
|
|
3731
4033
|
// Same for email config on resume/adopt (writeEmailLocalCreds only runs in the first
|
package/dist/index.js
CHANGED
|
@@ -94,6 +94,13 @@ Sobre \`provision\`:
|
|
|
94
94
|
--slug=<slug> Identificador del stack (nombre del proyecto + dominios interinos)
|
|
95
95
|
--team=<id> Team de Vercel (override; default = \`VERCEL_TEAM_ID\` del rail ~/.claude/.env)
|
|
96
96
|
--org-id=<id> Org de GitHub (override; default = org del Factory)
|
|
97
|
+
--neon-org=<x> Organización de Neon donde CREAR el proyecto: nombre o id.
|
|
98
|
+
Default = \`NEON_ORG\` del rail; si la key ve una sola org, esa.
|
|
99
|
+
Varias orgs y sin selector → aborta (nunca adivina). Un proyecto
|
|
100
|
+
que YA existe ignora este flag: su org se lee del proyecto
|
|
101
|
+
--remint Reacuña la API key project-scoped de Neon aunque el paso esté
|
|
102
|
+
completo (revoca la vigente, sube los 3 secrets de E2E y
|
|
103
|
+
reescribe .env.local). Para una key revocada por fuera
|
|
97
104
|
--domain=<dom> Dominio definitivo del cliente (diferible; standalone o inicial)
|
|
98
105
|
--dns=<ruta> Ruta DNS para --domain: org (default) | cliente-cf | registrar
|
|
99
106
|
--verify-timeout=<min> Timeout del sondeo de verificación de Vercel (default 10)
|
package/dist/lib/env-file.js
CHANGED
|
@@ -36,8 +36,9 @@ export const PROVISION_OWNED = new Set([
|
|
|
36
36
|
'MFA_ENCRYPTION_KEY',
|
|
37
37
|
'WEBAUTHN_RP_ID',
|
|
38
38
|
// LOCAL-ONLY E2E credentials (like DATABASE_URL_MAIN): the E2E runner reads them
|
|
39
|
-
// from `.env.local` to spin an isolated Neon branch per run.
|
|
40
|
-
//
|
|
39
|
+
// from `.env.local` to spin an isolated Neon branch per run. Both local and CI hold
|
|
40
|
+
// the SAME project-scoped key (see `writeE2eLocalCreds`); they must NEVER reach
|
|
41
|
+
// Vercel (a day-2 push would leak it).
|
|
41
42
|
'NEON_API_KEY',
|
|
42
43
|
'NEON_PROJECT_ID',
|
|
43
44
|
// LOCAL-ONLY backlog-central credentials (BSYNC-003): the derivative's `backlog
|
|
@@ -98,24 +99,37 @@ export function writeEnvLocal(rootDir, neon, opts = {}) {
|
|
|
98
99
|
/**
|
|
99
100
|
* Write the LOCAL E2E Neon credentials into `.env.local` so `pnpm test:e2e` runs on
|
|
100
101
|
* the dev's machine without a manual `setup:e2e`. The E2E runner reads NEON_API_KEY +
|
|
101
|
-
* NEON_PROJECT_ID from `.env.local` to spin an isolated branch per run.
|
|
102
|
-
*
|
|
103
|
-
* the
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
102
|
+
* NEON_PROJECT_ID from `.env.local` to spin an isolated branch per run.
|
|
103
|
+
*
|
|
104
|
+
* 🔴 `apiKey` is the PROJECT-SCOPED key provision just minted — the same value CI
|
|
105
|
+
* gets as a GitHub Secret — and never the rail key. This used to write the rail key
|
|
106
|
+
* ("locally we reuse the dev's ORG key, already on their host"), which is how a
|
|
107
|
+
* credential reaching every organization ended up in the `.env.local` of every
|
|
108
|
+
* derivative. A scoped key 404s on any other project, so it is the only value that
|
|
109
|
+
* belongs in a per-project file.
|
|
110
|
+
*
|
|
111
|
+
* `apiKey` of `null` means "no key was minted in this run" (a resume over an already
|
|
112
|
+
* complete Neon step, where the value is unrecoverable because it was never
|
|
113
|
+
* persisted). Then only `NEON_PROJECT_ID` is written — never a fallback to the rail —
|
|
114
|
+
* and the caller tells the dev the local key was not refreshed.
|
|
115
|
+
*
|
|
116
|
+
* Both keys stay LOCAL-ONLY (PROVISION_OWNED → never pushed to Vercel). Returns
|
|
117
|
+
* false (no-op) when the project id is missing or `.env.local` does not exist yet
|
|
118
|
+
* (`writeEnvLocal` creates it first). Safe on an existing file: `setEnvLine` only
|
|
119
|
+
* touches these two keys, preserving the rest.
|
|
107
120
|
*/
|
|
108
121
|
export function writeE2eLocalCreds(rootDir, projectId, apiKey) {
|
|
109
|
-
if (!
|
|
122
|
+
if (!projectId)
|
|
110
123
|
return false;
|
|
111
124
|
const localPath = join(rootDir, '.env.local');
|
|
112
125
|
if (!existsSync(localPath))
|
|
113
126
|
return false;
|
|
114
127
|
let body = readFileSync(localPath, 'utf8');
|
|
115
128
|
body = setEnvLine(body, 'NEON_PROJECT_ID', projectId);
|
|
116
|
-
|
|
129
|
+
if (apiKey)
|
|
130
|
+
body = setEnvLine(body, 'NEON_API_KEY', apiKey);
|
|
117
131
|
writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
|
|
118
|
-
chmodSync(localPath, 0o600); //
|
|
132
|
+
chmodSync(localPath, 0o600); // may hold NEON_API_KEY → owner-only.
|
|
119
133
|
return true;
|
|
120
134
|
}
|
|
121
135
|
/**
|
|
@@ -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
|
+
* The organization that owns a project, asked BY ID — `GET /projects/{id}`.
|
|
175
|
+
*
|
|
176
|
+
* 🔴 This is the right question whenever the project id is known, and the reason is a
|
|
177
|
+
* bug that shipped: the org used to be resolved by sweeping every org for a project
|
|
178
|
+
* whose NAME equals the repo's slug. Projects are named by humans in the console
|
|
179
|
+
* (`Aditivo CRM`, `agent-fimubac`), so that match usually fails — and when it fails the
|
|
180
|
+
* caller falls back to a configured default. In the org that happens to be the default
|
|
181
|
+
* it silently "works"; outside it, the mint 404s with `project not found`. Observed on
|
|
182
|
+
* both, the same afternoon. With an id in hand, asking by name is a detour.
|
|
183
|
+
*
|
|
184
|
+
* Returns `null` when the project does not exist or the key cannot see it — the caller
|
|
185
|
+
* then decides (a re-mint refuses; nothing is ever created on a guess).
|
|
186
|
+
*
|
|
187
|
+
* @throws {NeonApiError} on a non-2xx that is NOT 404 (auth, quota, network): those are
|
|
188
|
+
* real failures and must not be read as "no such project".
|
|
189
|
+
*/
|
|
190
|
+
export async function getNeonProjectOrg(apiKey, projectId, deps = {}) {
|
|
191
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
192
|
+
const response = await fetchImpl(`${NEON_API_BASE}/projects/${encodeURIComponent(projectId)}`, {
|
|
193
|
+
method: 'GET',
|
|
194
|
+
headers: neonHeaders(apiKey),
|
|
195
|
+
signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
|
|
196
|
+
});
|
|
197
|
+
if (response.status === 404)
|
|
198
|
+
return null;
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
throw new NeonApiError(response.status, await safeBody(response), 'GET /projects/{id}');
|
|
201
|
+
}
|
|
202
|
+
const data = (await response.json());
|
|
203
|
+
return data.project?.org_id ?? 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/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),
|