@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
|
@@ -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, findNeonProjectAcrossOrgs, 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,59 @@ 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) → read it OFF THE PROJECT by sweeping the orgs the key sees;
|
|
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
|
+
const found = await findNeonProjectAcrossOrgs(apiKey, state.projectName, apiDeps);
|
|
577
|
+
orgId = found?.org.id;
|
|
578
|
+
}
|
|
579
|
+
if (!orgId) {
|
|
580
|
+
const selector = deps.neonOrg ?? readGateValue('NEON_ORG', envPath) ?? undefined;
|
|
581
|
+
orgId = (await resolveNeonOrg(apiKey, selector, apiDeps)).id;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
if (error instanceof NeonApiError) {
|
|
586
|
+
throw neonErrorToCliError(error, state.projectName, 'resolver la organización de Neon');
|
|
587
|
+
}
|
|
588
|
+
// resolveNeonOrg / findNeonProjectAcrossOrgs throw plain Errors whose message
|
|
589
|
+
// is already the es-MX text the user needs; surface it as-is.
|
|
590
|
+
throw new CLIError(error instanceof Error ? error.message : String(error));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
495
593
|
// 1. Reuse the project from a partial run; create it only if missing. Persist it
|
|
496
594
|
// immediately so a branch failure leaves projectId + mainBranchId durable.
|
|
595
|
+
//
|
|
596
|
+
// 🔴 A RECORDED `projectId` is sufficient to reuse, and this is not a style
|
|
597
|
+
// preference. This condition used to require `mainBranchId` too, so a state that
|
|
598
|
+
// carried the project but not that branch fell through to CREATE — and produced a
|
|
599
|
+
// SECOND Neon project for a derivative that already had one, with the CLI then
|
|
600
|
+
// pointing the repo's secrets and `.env.local` at the empty one. That is exactly
|
|
601
|
+
// what happened on a real client repo (2026-09-12) the first time `--remint` ran
|
|
602
|
+
// over an adopted stack, whose shape never recorded `mainBranchId`.
|
|
603
|
+
// When the primary branch is genuinely needed (only to parent a missing `develop`),
|
|
604
|
+
// it is resolved from Neon below — never by creating anything.
|
|
497
605
|
let projectId;
|
|
498
606
|
let mainBranchId;
|
|
499
|
-
if (state.neon?.projectId
|
|
607
|
+
if (state.neon?.projectId) {
|
|
500
608
|
projectId = state.neon.projectId;
|
|
501
|
-
mainBranchId = state.neon.mainBranchId;
|
|
609
|
+
mainBranchId = state.neon.mainBranchId ?? '';
|
|
502
610
|
}
|
|
503
611
|
else {
|
|
504
612
|
let project;
|
|
@@ -513,31 +621,73 @@ export async function provisionNeon(rootDir, state, deps = {}) {
|
|
|
513
621
|
}
|
|
514
622
|
projectId = project.projectId;
|
|
515
623
|
mainBranchId = project.mainBranchId;
|
|
516
|
-
state.neon = { projectId, mainBranchId, developBranchId: '' };
|
|
517
|
-
|
|
624
|
+
state.neon = { projectId, orgId, mainBranchId, developBranchId: '' };
|
|
625
|
+
saveState();
|
|
518
626
|
}
|
|
519
627
|
// 2. CLEAN `develop` branch — reuse the recorded id; else create it (idempotent
|
|
520
628
|
// on a 409: createDevelopBranch reuses an existing develop) and persist the id
|
|
521
629
|
// IMMEDIATELY (FIX 6 — it was saved only at step 3, so a connection-string
|
|
522
630
|
// failure lost it and the resume re-created → 409).
|
|
523
631
|
let developBranchId = state.neon?.developBranchId ?? '';
|
|
632
|
+
if (!developBranchId && deps.remint) {
|
|
633
|
+
// A re-mint over a project whose working branch the state never recorded (an
|
|
634
|
+
// adopted stack) resolves it from Neon, honoring whatever this repo pinned as the
|
|
635
|
+
// E2E parent. It must NOT create a branch: see `pickE2eBranch`.
|
|
636
|
+
let branches;
|
|
637
|
+
try {
|
|
638
|
+
branches = await listNeonBranches(apiKey, projectId, apiDeps);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
if (error instanceof NeonApiError) {
|
|
642
|
+
throw neonErrorToCliError(error, state.projectName, 'listar los branches del proyecto');
|
|
643
|
+
}
|
|
644
|
+
throw error;
|
|
645
|
+
}
|
|
646
|
+
const picked = pickE2eBranch(branches, readE2eParentBranch(rootDir));
|
|
647
|
+
if (!picked) {
|
|
648
|
+
throw new CLIError(`El proyecto Neon \`${projectId}\` no reporta ningún branch, así que no puedo ` +
|
|
649
|
+
'saber de cuál sacar el `DATABASE_URL` de E2E. Revísalo en la consola de Neon.');
|
|
650
|
+
}
|
|
651
|
+
developBranchId = picked.id;
|
|
652
|
+
console.log(` · Branch de E2E para el \`DATABASE_URL\`: ${picked.name}`);
|
|
653
|
+
}
|
|
524
654
|
if (!developBranchId) {
|
|
655
|
+
// The parent of a new `develop` is the project's primary branch. An adopted state
|
|
656
|
+
// does not record it, so resolve it from Neon rather than assume — and rather than
|
|
657
|
+
// let a create run with an empty parent.
|
|
658
|
+
if (!mainBranchId) {
|
|
659
|
+
try {
|
|
660
|
+
const branches = await listNeonBranches(apiKey, projectId, apiDeps);
|
|
661
|
+
mainBranchId =
|
|
662
|
+
branches.find((b) => b.name === NEON_MAIN_BRANCH)?.id ?? branches[0]?.id ?? '';
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
if (error instanceof NeonApiError) {
|
|
666
|
+
throw neonErrorToCliError(error, state.projectName, 'leer los branches del proyecto para ubicar el branch primario');
|
|
667
|
+
}
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
670
|
+
if (!mainBranchId) {
|
|
671
|
+
throw new CLIError(`El proyecto Neon \`${projectId}\` no reporta ningún branch, así que no puedo ` +
|
|
672
|
+
'crear `develop` sin inventar recursos. Revísalo en la consola de Neon.');
|
|
673
|
+
}
|
|
674
|
+
}
|
|
525
675
|
try {
|
|
526
676
|
developBranchId = await createDevelopBranch(apiKey, projectId, mainBranchId, apiDeps);
|
|
527
677
|
}
|
|
528
678
|
catch (error) {
|
|
529
679
|
// Save the partial state (issue §8 Gherkin: "guarda el state parcial").
|
|
530
680
|
// IDs only — never a connection string (those are re-derived from Neon on demand).
|
|
531
|
-
state.neon = { projectId, mainBranchId, developBranchId: '' };
|
|
532
|
-
|
|
681
|
+
state.neon = { projectId, orgId, mainBranchId, developBranchId: '' };
|
|
682
|
+
saveState();
|
|
533
683
|
if (error instanceof NeonApiError) {
|
|
534
684
|
throw new CLIError(`El proyecto Neon se creó pero falló la creación del branch \`develop\` (${error.message}).\n` +
|
|
535
685
|
'El estado parcial se guardó; reintenta con `--resume` para crear solo el branch develop.');
|
|
536
686
|
}
|
|
537
687
|
throw error;
|
|
538
688
|
}
|
|
539
|
-
state.neon = { projectId, mainBranchId, developBranchId };
|
|
540
|
-
|
|
689
|
+
state.neon = { projectId, orgId, mainBranchId, developBranchId };
|
|
690
|
+
saveState();
|
|
541
691
|
}
|
|
542
692
|
// 3. Read the develop branch's POOLED connection string (idempotent GET) — the
|
|
543
693
|
// ONLY string this step needs, for the E2E `DATABASE_URL` secret below. provision
|
|
@@ -554,14 +704,25 @@ export async function provisionNeon(rootDir, state, deps = {}) {
|
|
|
554
704
|
}
|
|
555
705
|
throw error;
|
|
556
706
|
}
|
|
557
|
-
// 4. Mint a PROJECT-SCOPED API key
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
707
|
+
// 4. Mint a PROJECT-SCOPED API key — blast radius = this project only, and now
|
|
708
|
+
// actually so: the key comes from `POST /organizations/{org_id}/api_keys`,
|
|
709
|
+
// which 404s on a sibling project (the old `POST /api_keys` silently returned
|
|
710
|
+
// a personal, all-orgs key). Only the `apiKeySecretDone` guard above prevents
|
|
711
|
+
// re-minting; a resume after a pending secret push DOES re-mint (the prior key
|
|
712
|
+
// value was never persisted — it is a credential) and the old key is left
|
|
713
|
+
// orphaned. Acceptable: it only happens when the GH secret push keeps failing
|
|
714
|
+
// (e.g. the repo does not exist yet).
|
|
715
|
+
const keyName = `${state.projectName}-e2e`;
|
|
562
716
|
let projectKey;
|
|
563
717
|
try {
|
|
564
|
-
|
|
718
|
+
// `--remint` revokes the live key BEFORE issuing its replacement, so the org
|
|
719
|
+
// does not accumulate a key per rotation. Best-effort by contract: a key that
|
|
720
|
+
// was already revoked out-of-band (the case that motivated --remint) is a
|
|
721
|
+
// no-op here, not an error.
|
|
722
|
+
if (deps.remint) {
|
|
723
|
+
await deleteNeonApiKeysByName(apiKey, orgId, keyName, apiDeps);
|
|
724
|
+
}
|
|
725
|
+
projectKey = await createProjectApiKey(apiKey, orgId, projectId, keyName, apiDeps);
|
|
565
726
|
}
|
|
566
727
|
catch (error) {
|
|
567
728
|
if (error instanceof NeonApiError) {
|
|
@@ -579,17 +740,19 @@ export async function provisionNeon(rootDir, state, deps = {}) {
|
|
|
579
740
|
await setSecret(NEON_KEY_SECRET, projectKey, repo);
|
|
580
741
|
await setSecret(NEON_PROJECT_ID_SECRET, projectId, repo);
|
|
581
742
|
await setSecret(E2E_DATABASE_URL_SECRET, developDatabaseUrl, repo);
|
|
582
|
-
state.neon = { projectId, mainBranchId, developBranchId, apiKeySecretDone: true };
|
|
583
|
-
|
|
743
|
+
state.neon = { projectId, orgId, mainBranchId, developBranchId, apiKeySecretDone: true };
|
|
744
|
+
saveState();
|
|
745
|
+
return projectKey;
|
|
584
746
|
}
|
|
585
747
|
catch {
|
|
586
|
-
state.neon = { projectId, mainBranchId, developBranchId, apiKeySecretPending: true };
|
|
587
|
-
|
|
748
|
+
state.neon = { projectId, orgId, mainBranchId, developBranchId, apiKeySecretPending: true };
|
|
749
|
+
saveState();
|
|
588
750
|
console.warn(`⚠ No se pudieron guardar los secrets de E2E ` +
|
|
589
751
|
`(\`${NEON_KEY_SECRET}\`/\`${NEON_PROJECT_ID_SECRET}\`/\`${E2E_DATABASE_URL_SECRET}\`) ` +
|
|
590
752
|
`en \`${neonSecretRepo(state)}\` (¿el repo no existe aún o falta permiso?).\n` +
|
|
591
753
|
' La API key project-scoped se creó; reintenta con `--resume` cuando el repo exista.');
|
|
592
754
|
}
|
|
755
|
+
return projectKey;
|
|
593
756
|
}
|
|
594
757
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
595
758
|
// BSYNC-003 — `backlog`: register the project in backlog.timekast.mx
|
|
@@ -961,13 +1124,18 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
|
|
|
961
1124
|
else if (envLocal === 'skipped') {
|
|
962
1125
|
console.log(' ⏭ .env.local ya existía (no se sobrescribe — usa --force para regenerarlo)');
|
|
963
1126
|
}
|
|
964
|
-
// Local E2E creds: write
|
|
965
|
-
//
|
|
1127
|
+
// Local E2E creds: write the PROJECT-SCOPED key minted by `provisionNeon` in this
|
|
1128
|
+
// same process + NEON_PROJECT_ID, so `pnpm test:e2e` runs locally with zero manual
|
|
966
1129
|
// `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
|
-
|
|
1130
|
+
// `.env.local` (setEnvLine touches only these two keys). The key value is never
|
|
1131
|
+
// printed. NEVER falls back to the rail key: that is what leaked an all-orgs
|
|
1132
|
+
// credential into every derivative's `.env.local`.
|
|
1133
|
+
const e2eKey = deps.neonProjectKey ?? null;
|
|
1134
|
+
if (state.neon?.projectId && writeE2eLocalCreds(rootDir, state.neon.projectId, e2eKey)) {
|
|
1135
|
+
console.log(e2eKey
|
|
1136
|
+
? ' + .env.local (NEON_API_KEY · NEON_PROJECT_ID — para `pnpm test:e2e` local)'
|
|
1137
|
+
: ' + .env.local (NEON_PROJECT_ID) · ⚠ sin NEON_API_KEY: la key de este run no se acuñó — ' +
|
|
1138
|
+
'córrelo con `--remint` para refrescarla');
|
|
971
1139
|
}
|
|
972
1140
|
// Email config: write the org Resend key (from the rail) + the fixed EMAIL_FROM so local
|
|
973
1141
|
// sends (`invite:admin`, magic-link, `db:seed`) DELIVER instead of the console fallback.
|
|
@@ -1245,7 +1413,7 @@ export function writeE2eWorkflow(rootDir) {
|
|
|
1245
1413
|
* PROV-004 — the `github` step: build the git substrate, create the Vercel PROJECT,
|
|
1246
1414
|
* connect the GitHub repo (GitHub App gate), confirm `productionBranch: main`,
|
|
1247
1415
|
* write `.vercel/project.json`, and activate the E2E workflow. Migrations run in the
|
|
1248
|
-
* Vercel build (`vercel-build`: `pnpm
|
|
1416
|
+
* Vercel build (`vercel-build`: `pnpm build && pnpm db:migrate`), NOT a GitHub Actions
|
|
1249
1417
|
* workflow. HIGH-risk: every failure is recoverable via `--resume`.
|
|
1250
1418
|
*
|
|
1251
1419
|
* Order (asserted by the unit test): git substrate (rename → remote add → push
|
|
@@ -1334,7 +1502,7 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
|
|
|
1334
1502
|
// the template is absent. It lands in the working tree and is committed below.
|
|
1335
1503
|
//
|
|
1336
1504
|
// Migrations are NOT a GitHub Actions workflow: they run in the Vercel build
|
|
1337
|
-
// (`vercel-build`: `pnpm
|
|
1505
|
+
// (`vercel-build`: `pnpm build && pnpm db:migrate`, shipped in package.json),
|
|
1338
1506
|
// so every deployment migrates ITS target's DB (production←main, preview←develop)
|
|
1339
1507
|
// before building — no migrate.yml, no Deploy Hook, no manual step.
|
|
1340
1508
|
const e2e = writeE2eWorkflow(rootDir);
|
|
@@ -1367,7 +1535,7 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
|
|
|
1367
1535
|
* before provision activated this workflow, so it lives only in the working tree
|
|
1368
1536
|
* until committed here. (Migrations are not part of THIS commit, but they do have one:
|
|
1369
1537
|
* `ensureInitialMigration` commits the `0000` earlier in the run, with its own pathspec.
|
|
1370
|
-
* The Vercel build only APPLIES it — `vercel-build` runs `pnpm
|
|
1538
|
+
* The Vercel build only APPLIES it — `vercel-build` runs `pnpm build && pnpm db:migrate`,
|
|
1371
1539
|
* there is no migration workflow.) Idempotent — when a `--resume`
|
|
1372
1540
|
* finds it already committed, `git add` stages nothing and the commit/push is skipped.
|
|
1373
1541
|
*/
|
|
@@ -1418,7 +1586,7 @@ const INITIAL_MIGRATION_COMMIT = 'chore(db): add the initial database migration'
|
|
|
1418
1586
|
const INITIAL_MIGRATION_LABEL = 'Migración inicial de la base (0000 de Drizzle)';
|
|
1419
1587
|
/**
|
|
1420
1588
|
* The branch the step commits on and pushes: the one `vercel-build` runs
|
|
1421
|
-
* `pnpm
|
|
1589
|
+
* `pnpm build && pnpm db:migrate` from for PRODUCTION. Named, never inlined
|
|
1422
1590
|
* (CODING.md §5) — it appears in the checkout guard, in both pushes and in the
|
|
1423
1591
|
* remote confirmation, and those four have to mean the same branch.
|
|
1424
1592
|
*/
|
|
@@ -1567,7 +1735,7 @@ async function confirmMigrationOnRemote(rootDir, runGit) {
|
|
|
1567
1735
|
/**
|
|
1568
1736
|
* Generate the initial Drizzle migration, commit it with its OWN pathspec and get it to
|
|
1569
1737
|
* the remote BEFORE Vercel builds the tree (PROV-004 pushes `main`; `vercel-build` runs
|
|
1570
|
-
* `pnpm
|
|
1738
|
+
* `pnpm build && pnpm db:migrate`). Without this a fresh derivative deploys against an
|
|
1571
1739
|
* empty database.
|
|
1572
1740
|
*
|
|
1573
1741
|
* Order and its reasons:
|
|
@@ -1706,7 +1874,7 @@ export async function ensureInitialMigration(rootDir, evidence, deps = {}) {
|
|
|
1706
1874
|
return;
|
|
1707
1875
|
}
|
|
1708
1876
|
// Un `try` por push, y no uno compartido: `main` es el que le importa a producción
|
|
1709
|
-
// (`vercel-build` = `pnpm
|
|
1877
|
+
// (`vercel-build` = `pnpm build && pnpm db:migrate`) y `develop` puede fallar solo
|
|
1710
1878
|
// (non-fast-forward con un remoto divergido). Con un `try` compartido, esa falla de
|
|
1711
1879
|
// `develop` imprimía "no pude pushear la migración" y se saltaba la confirmación —
|
|
1712
1880
|
// un diagnóstico falso justo cuando `main` SÍ había llegado.
|
|
@@ -2233,7 +2401,7 @@ async function resolveDnsToken(route, envPath, headless) {
|
|
|
2233
2401
|
* PROV-006 — attach the client's DEFINITIVE domain to the Vercel project, create
|
|
2234
2402
|
* (or print, for `registrar`) its DNS record, poll Vercel until verified, and on
|
|
2235
2403
|
* success: retire the interim prod domain (with explicit confirmation), update
|
|
2236
|
-
* `EMAIL_FROM` + `
|
|
2404
|
+
* `EMAIL_FROM` + `NEXT_PUBLIC_APP_URL`, and register the domain in Resend.
|
|
2237
2405
|
*
|
|
2238
2406
|
* Standalone-safe: runnable as a PATCH over an existing provision (the `dns` step
|
|
2239
2407
|
* of PROV-005 already attached the interim domains) or inside the initial run. It
|
|
@@ -2246,7 +2414,7 @@ async function resolveDnsToken(route, envPath, headless) {
|
|
|
2246
2414
|
* - Resend DNS records (BOOT-003) → `ensureDnsRecord` create-or-PATCH (a rotated
|
|
2247
2415
|
* DKIM is PATCHed in place, never deleted); a Resend poll timeout is a WARNING
|
|
2248
2416
|
* (pending-verification, resumable), never a step failure.
|
|
2249
|
-
* - `
|
|
2417
|
+
* - `EMAIL_FROM`/`NEXT_PUBLIC_APP_URL` already correct → the upsert PATCH is omitted.
|
|
2250
2418
|
*
|
|
2251
2419
|
* On timeout the state is left `clientDomainStatus: pending-verification` (resumable);
|
|
2252
2420
|
* on success it is `live`.
|
|
@@ -2462,7 +2630,7 @@ async function deleteInterimCname(interim, envPath, fetchImpl) {
|
|
|
2462
2630
|
}
|
|
2463
2631
|
}
|
|
2464
2632
|
/**
|
|
2465
|
-
* Update `EMAIL_FROM` + `
|
|
2633
|
+
* Update `EMAIL_FROM` + `NEXT_PUBLIC_APP_URL` +
|
|
2466
2634
|
* `WEBAUTHN_RP_ID` to the client domain in the Vercel env, per-target and
|
|
2467
2635
|
* idempotently (PROV-006 §4 + BOOT-004). Lists the existing vars once and upserts
|
|
2468
2636
|
* each entry via `upsertEnvVar`, which reconciles a key that the substrate left
|
|
@@ -2483,20 +2651,27 @@ async function deleteInterimCname(interim, envPath, fetchImpl) {
|
|
|
2483
2651
|
* discipline holds).
|
|
2484
2652
|
*/
|
|
2485
2653
|
async function updateDomainEnv(projectId, token, teamId, domain, deps) {
|
|
2486
|
-
const
|
|
2654
|
+
const appOrigin = `https://${domain}`;
|
|
2487
2655
|
const plan = [
|
|
2488
2656
|
// EMAIL_FROM uses the dedicated mail subdomain (`updates.<domain>`), NOT the apex
|
|
2489
|
-
// — the apex carries the client's corporate mail.
|
|
2657
|
+
// — the apex carries the client's corporate mail.
|
|
2490
2658
|
{
|
|
2491
2659
|
key: 'EMAIL_FROM',
|
|
2492
2660
|
value: `noreply@${mailDomainFor(domain)}`,
|
|
2493
2661
|
target: ['production', 'preview'],
|
|
2494
2662
|
},
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
//
|
|
2663
|
+
// 🔴 AUTH_URL / NEXTAUTH_URL are DELIBERATELY ABSENT — do not re-add them.
|
|
2664
|
+
// `next-auth/lib/env.js` rewrites the origin of every auth request to their value,
|
|
2665
|
+
// so scoping either to `preview` pins all branch deployments' callbacks to this one
|
|
2666
|
+
// host and kills their sign-in. `trustHost` (auth.config.ts) already infers the
|
|
2667
|
+
// origin per request, which is correct on production AND on each preview.
|
|
2668
|
+
// Auth needs nothing here; the domain reaches the app through NEXT_PUBLIC_APP_URL
|
|
2669
|
+
// below, which is what `getAppUrl()` reads for email links (src/lib/env.ts).
|
|
2670
|
+
// A project that enables OAuth and must pin the callback to a fixed host sets it
|
|
2671
|
+
// manually, `production` only, knowing it forfeits preview sign-in.
|
|
2672
|
+
// Keep the public app origin pointing at the client domain once it lands
|
|
2498
2673
|
// (the substrate set it to the interim domain in buildEnvPlan; this re-points it).
|
|
2499
|
-
{ key: 'NEXT_PUBLIC_APP_URL', value:
|
|
2674
|
+
{ key: 'NEXT_PUBLIC_APP_URL', value: appOrigin, target: ['production', 'preview'] },
|
|
2500
2675
|
// WebAuthn rpID is the registrable HOST, never a URL — the bare domain (BOOT-004).
|
|
2501
2676
|
// PRODUCTION only: the interim dev domain remains the preview rpID (the dev domain
|
|
2502
2677
|
// is never retired), so the intentional split pair from buildEnvPlan is preserved.
|
|
@@ -2532,7 +2707,7 @@ function printClientDomainPlan(domain, interim, route, confirm) {
|
|
|
2532
2707
|
console.log(` … sondea GET /v9/projects/.../domains/${domain} hasta verified:true (o timeout)`);
|
|
2533
2708
|
console.log(` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim} ` +
|
|
2534
2709
|
`(el dev ${domain}-dev se conserva)`);
|
|
2535
|
-
console.log(` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} ·
|
|
2710
|
+
console.log(` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · NEXT_PUBLIC_APP_URL=https://${domain}`);
|
|
2536
2711
|
console.log(` ↻ WEBAUTHN_RP_ID=${domain} (solo production — las passkeys del rpID interino quedan invalidadas; el preview conserva el rpID dev)`);
|
|
2537
2712
|
console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
|
|
2538
2713
|
if (route === 'registrar') {
|
|
@@ -2548,15 +2723,28 @@ function printClientDomainPlan(domain, interim, route, confirm) {
|
|
|
2548
2723
|
* `vercel` (PROV-003) and `dns` (PROV-005) are implemented. `teamId` is required
|
|
2549
2724
|
* for the Vercel + GitHub + DNS steps (validated upstream).
|
|
2550
2725
|
*/
|
|
2551
|
-
async function runServiceStep(service, rootDir, state, flags
|
|
2726
|
+
async function runServiceStep(service, rootDir, state, flags,
|
|
2727
|
+
/**
|
|
2728
|
+
* Carries the project-scoped Neon key from the `neon` step to the `vercel` step
|
|
2729
|
+
* (which writes `.env.local`) WITHIN this process. A credential, so it is never
|
|
2730
|
+
* persisted — the state has no field for it and must not grow one.
|
|
2731
|
+
*/
|
|
2732
|
+
runCtx = {}) {
|
|
2552
2733
|
switch (service) {
|
|
2553
2734
|
case 'neon':
|
|
2554
|
-
await provisionNeon(rootDir, state
|
|
2735
|
+
runCtx.neonProjectKey = await provisionNeon(rootDir, state, {
|
|
2736
|
+
neonOrg: flags.neonOrg,
|
|
2737
|
+
remint: flags.remint,
|
|
2738
|
+
persistState: runCtx.persistState,
|
|
2739
|
+
});
|
|
2555
2740
|
break;
|
|
2556
2741
|
case 'vercel':
|
|
2557
2742
|
// PROV-003: push env (teamId on every call) + write .vercel/project.json +
|
|
2558
2743
|
// pnpm install. The Vercel PROJECT itself is created by PROV-004.
|
|
2559
|
-
await provisionVercel(rootDir, state, flags.team, {
|
|
2744
|
+
await provisionVercel(rootDir, state, flags.team, {
|
|
2745
|
+
force: flags.force,
|
|
2746
|
+
neonProjectKey: runCtx.neonProjectKey,
|
|
2747
|
+
});
|
|
2560
2748
|
break;
|
|
2561
2749
|
case 'github':
|
|
2562
2750
|
// PROV-004: git substrate (rename → remote add → push main) + create the
|
|
@@ -2693,25 +2881,48 @@ function projectsToCandidates(projects, slug, teamId) {
|
|
|
2693
2881
|
}));
|
|
2694
2882
|
}
|
|
2695
2883
|
/**
|
|
2696
|
-
* Reconstruct the Neon project id + `develop` branch id (read-only).
|
|
2697
|
-
*
|
|
2698
|
-
*
|
|
2699
|
-
*
|
|
2884
|
+
* Reconstruct the Neon project id + its org + the `develop` branch id (read-only).
|
|
2885
|
+
*
|
|
2886
|
+
* Sweeps EVERY org the rail key can reach (`GET /users/me/organizations`, then one
|
|
2887
|
+
* project listing per org) and filters by slug across all of them. The org is
|
|
2888
|
+
* therefore READ OFF the project that matches — never taken from a configured
|
|
2889
|
+
* default, which may well point somewhere else: a backfill that recorded the
|
|
2890
|
+
* default's org id would aim a later `--destroy` at the wrong organization.
|
|
2891
|
+
*
|
|
2892
|
+
* The per-org listing still passes `org_id`, which Neon REQUIRES (it answers
|
|
2893
|
+
* `400 org_id is required` without it).
|
|
2894
|
+
*
|
|
2895
|
+
* Once the project resolves, lists its branches to find the `develop` one. A branch
|
|
2896
|
+
* lookup failure leaves the branch id `unknown` without failing the whole adopt
|
|
2897
|
+
* (issue §4).
|
|
2700
2898
|
*/
|
|
2701
|
-
async function adoptNeon(slug, apiKey,
|
|
2702
|
-
const
|
|
2703
|
-
const
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2899
|
+
async function adoptNeon(slug, apiKey, deps, recordAmbiguity) {
|
|
2900
|
+
const apiDeps = { fetchImpl: deps.fetchImpl };
|
|
2901
|
+
const orgs = await listNeonOrgs(apiKey, apiDeps);
|
|
2902
|
+
const candidates = [];
|
|
2903
|
+
/** projectId → owning org id, so the chosen candidate can report its org. */
|
|
2904
|
+
const orgOfProject = new Map();
|
|
2905
|
+
let scanned = 0;
|
|
2906
|
+
for (const org of orgs) {
|
|
2907
|
+
const projects = await listNeonProjects(apiKey, org.id, apiDeps);
|
|
2908
|
+
scanned += projects.length;
|
|
2909
|
+
for (const p of matchProjectsBySlug(projects, slug)) {
|
|
2910
|
+
orgOfProject.set(p.id, org.id);
|
|
2911
|
+
candidates.push({
|
|
2912
|
+
id: p.id,
|
|
2913
|
+
name: `${p.name} · ${org.name}`,
|
|
2914
|
+
url: `https://console.neon.tech/app/projects/${p.id}`,
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2708
2918
|
if (candidates.length === 0) {
|
|
2709
|
-
console.log(` · Ningún proyecto de Neon se parece a \`${slug}\` (revisé ${
|
|
2710
|
-
return { projectId: null, branchDevId: null };
|
|
2919
|
+
console.log(` · Ningún proyecto de Neon se parece a \`${slug}\` (revisé ${scanned} en ${orgs.length} organización(es)) — \`neonProjectId: unknown\`.`);
|
|
2920
|
+
return { projectId: null, orgId: null, branchDevId: null };
|
|
2711
2921
|
}
|
|
2712
2922
|
const projectId = await resolveCandidate('Neon', candidates, deps, recordAmbiguity);
|
|
2713
2923
|
if (!projectId)
|
|
2714
|
-
return { projectId: null, branchDevId: null };
|
|
2924
|
+
return { projectId: null, orgId: null, branchDevId: null };
|
|
2925
|
+
const orgId = orgOfProject.get(projectId) ?? null;
|
|
2715
2926
|
// Resolve the develop branch (best-effort — a failure → unknown, not a hard stop).
|
|
2716
2927
|
let branchDevId = null;
|
|
2717
2928
|
try {
|
|
@@ -2721,7 +2932,7 @@ async function adoptNeon(slug, apiKey, orgId, deps, recordAmbiguity) {
|
|
|
2721
2932
|
catch {
|
|
2722
2933
|
branchDevId = null;
|
|
2723
2934
|
}
|
|
2724
|
-
return { projectId, branchDevId };
|
|
2935
|
+
return { projectId, orgId, branchDevId };
|
|
2725
2936
|
}
|
|
2726
2937
|
/**
|
|
2727
2938
|
* Reconstruct the GitHub repo (read-only) via `gh repo view {org}/{slug} --json
|
|
@@ -2932,17 +3143,16 @@ export async function runAdopt(flags, deps = {}) {
|
|
|
2932
3143
|
// Neon (GET /projects + branches) — needs the rail key. Absent → `unknown` (the
|
|
2933
3144
|
// Gherkin "Provider Neon sin token disponible" path).
|
|
2934
3145
|
let neonProjectId = null;
|
|
3146
|
+
let neonOrgId = null;
|
|
2935
3147
|
let neonBranchDevId = null;
|
|
2936
3148
|
const neonApiKey = readNeonApiKey(envPath);
|
|
2937
|
-
// Same rail value the create path threads (`NEON_ORGANIZATION_ID`): Neon REQUIRES
|
|
2938
|
-
// it on an org-scoped key, and answers `400 org_id is required` without it.
|
|
2939
|
-
const neonOrgId = readGateValue('NEON_ORGANIZATION_ID', envPath) ?? undefined;
|
|
2940
3149
|
if (neonApiKey) {
|
|
2941
3150
|
try {
|
|
2942
|
-
const neon = await adoptNeon(slug, neonApiKey,
|
|
3151
|
+
const neon = await adoptNeon(slug, neonApiKey, adeps, (list) => {
|
|
2943
3152
|
candidates.neon = list;
|
|
2944
3153
|
});
|
|
2945
3154
|
neonProjectId = neon.projectId;
|
|
3155
|
+
neonOrgId = neon.orgId;
|
|
2946
3156
|
neonBranchDevId = neon.branchDevId;
|
|
2947
3157
|
}
|
|
2948
3158
|
catch {
|
|
@@ -2964,6 +3174,7 @@ export async function runAdopt(flags, deps = {}) {
|
|
|
2964
3174
|
slug,
|
|
2965
3175
|
vercelProjectId: vercelProjectId ?? ADOPT_UNKNOWN,
|
|
2966
3176
|
neonProjectId: neonProjectId ?? ADOPT_UNKNOWN,
|
|
3177
|
+
neonOrgId: neonOrgId ?? ADOPT_UNKNOWN,
|
|
2967
3178
|
neonBranchDevId: neonBranchDevId ?? ADOPT_UNKNOWN,
|
|
2968
3179
|
githubRepo: githubRepo ?? ADOPT_UNKNOWN,
|
|
2969
3180
|
domains,
|
|
@@ -2976,6 +3187,7 @@ export async function runAdopt(flags, deps = {}) {
|
|
|
2976
3187
|
slug,
|
|
2977
3188
|
vercelProjectId: partial.vercelProjectId,
|
|
2978
3189
|
neonProjectId: partial.neonProjectId,
|
|
3190
|
+
neonOrgId: partial.neonOrgId,
|
|
2979
3191
|
neonBranchDevId: partial.neonBranchDevId,
|
|
2980
3192
|
githubRepo: partial.githubRepo,
|
|
2981
3193
|
domains,
|
|
@@ -3086,18 +3298,42 @@ async function destroyNeonBranch(target, apiKey, fetchImpl) {
|
|
|
3086
3298
|
* project id, so it also tears down a PARTIAL stack whose develop branch never got
|
|
3087
3299
|
* created (FIX 9). A 404 → already gone (§8). The dev's key does this, never the
|
|
3088
3300
|
* shared org create-only key.
|
|
3301
|
+
*
|
|
3302
|
+
* The org id is resolved BEFORE the nuke, because after it the project is gone and
|
|
3303
|
+
* nothing can say which org owned it any more. It comes from the state when present
|
|
3304
|
+
* and is read off the project otherwise (every stack provisioned before `orgId`
|
|
3305
|
+
* existed takes that path). Unresolvable → the project is still nuked and only the
|
|
3306
|
+
* key cleanup is skipped, with the key named so it can be revoked by hand.
|
|
3089
3307
|
*/
|
|
3090
3308
|
async function destroyNeonProject(target, apiKey, fetchImpl) {
|
|
3091
3309
|
const resource = `Neon project (${target.neonProjectId})`;
|
|
3092
3310
|
if (!target.neonProjectId) {
|
|
3093
3311
|
return { resource: 'Neon project', status: 'skipped', detail: 'sin id en el estado' };
|
|
3094
3312
|
}
|
|
3313
|
+
const keyName = `${target.slug}-e2e`;
|
|
3314
|
+
let orgId = target.neonOrgId;
|
|
3315
|
+
if (!orgId) {
|
|
3316
|
+
try {
|
|
3317
|
+
const found = await findNeonProjectAcrossOrgs(apiKey, target.slug, { fetchImpl });
|
|
3318
|
+
orgId = found?.org.id ?? null;
|
|
3319
|
+
}
|
|
3320
|
+
catch {
|
|
3321
|
+
orgId = null;
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3095
3324
|
try {
|
|
3096
3325
|
await deleteNeonProject(apiKey, target.neonProjectId, { fetchImpl });
|
|
3326
|
+
if (!orgId) {
|
|
3327
|
+
return {
|
|
3328
|
+
resource,
|
|
3329
|
+
status: 'removed',
|
|
3330
|
+
detail: `proyecto eliminado (nuke) — no pude resolver la org, revoca \`${keyName}\` a mano`,
|
|
3331
|
+
};
|
|
3332
|
+
}
|
|
3097
3333
|
// Project gone — clean the project-scoped API key(s) it minted (`{slug}-e2e`)
|
|
3098
3334
|
// so the Neon org does not accumulate dead keys across provisions/tests.
|
|
3099
3335
|
// Best-effort (never throws): the project nuke already succeeded.
|
|
3100
|
-
const keysDeleted = await deleteNeonApiKeysByName(apiKey,
|
|
3336
|
+
const keysDeleted = await deleteNeonApiKeysByName(apiKey, orgId, keyName, { fetchImpl });
|
|
3101
3337
|
return {
|
|
3102
3338
|
resource,
|
|
3103
3339
|
status: 'removed',
|
|
@@ -3594,17 +3830,51 @@ export async function runProvision(flags) {
|
|
|
3594
3830
|
if (flags.force) {
|
|
3595
3831
|
clearProvisionState(rootDir);
|
|
3596
3832
|
}
|
|
3833
|
+
// `--remint` re-enters an EXISTING stack by definition: it re-issues the key of a
|
|
3834
|
+
// project that is already provisioned. Requiring `--resume` alongside it would be a
|
|
3835
|
+
// trap with no upside — there is nothing to start fresh — so it reads the state the
|
|
3836
|
+
// same way a resume does, and the existing-state guard below lets it through.
|
|
3837
|
+
const reentersState = flags.resume || flags.remint || standaloneDomain;
|
|
3597
3838
|
let state = null;
|
|
3598
|
-
|
|
3599
|
-
|
|
3839
|
+
// A `--remint` reads the state through the SHAPE-TOLERANT reader, because the whole
|
|
3840
|
+
// existing fleet carries the adopted shape (`slug`/`neonProjectId`, no `neon` block)
|
|
3841
|
+
// and `readProvisionState` rejects it. Without this, the one command written to
|
|
3842
|
+
// repair those projects failed on exactly them. When that shape is what we got, the
|
|
3843
|
+
// step must not persist (see `persistState`): writing this step's shape over an
|
|
3844
|
+
// adopted file would drop `vercelProjectId`, `githubRepo` and the domains.
|
|
3845
|
+
let remintOverAdopted = false;
|
|
3846
|
+
if (reentersState && !flags.force) {
|
|
3847
|
+
try {
|
|
3848
|
+
state = readProvisionState(rootDir); // throws CLIError on corrupt → hints --force
|
|
3849
|
+
}
|
|
3850
|
+
catch (error) {
|
|
3851
|
+
// `readProvisionState` THROWS on a shape it does not recognize, and the adopted
|
|
3852
|
+
// shape is one of those — so for a re-mint the throw is not an error, it is the
|
|
3853
|
+
// signal to read the other shape. Any other flag keeps the original behavior:
|
|
3854
|
+
// a genuinely corrupt file must still abort loudly.
|
|
3855
|
+
if (!flags.remint)
|
|
3856
|
+
throw error;
|
|
3857
|
+
state = null;
|
|
3858
|
+
}
|
|
3859
|
+
if (!state && flags.remint) {
|
|
3860
|
+
state = readRemintState(rootDir);
|
|
3861
|
+
remintOverAdopted = state !== null;
|
|
3862
|
+
}
|
|
3600
3863
|
if (!state) {
|
|
3601
3864
|
throw new CLIError(standaloneDomain
|
|
3602
3865
|
? '`--domain` en modo patch requiere un provision existente (`.timekast/provision.json`), ' +
|
|
3603
3866
|
'pero no hay estado. Corre el provision inicial (`--services=...`) primero.'
|
|
3604
|
-
:
|
|
3867
|
+
: flags.remint
|
|
3868
|
+
? '`--remint` reacuña la key de un proyecto YA provisionado, pero no hay estado ' +
|
|
3869
|
+
'(`.timekast/provision.json`) en este directorio. Corre el provision inicial ' +
|
|
3870
|
+
'(`--services=neon`) primero.'
|
|
3871
|
+
: 'No hay estado de provision para retomar (`--resume`). Corre sin `--resume` para empezar.');
|
|
3605
3872
|
}
|
|
3606
3873
|
}
|
|
3607
|
-
else if (!flags.force &&
|
|
3874
|
+
else if (!flags.force &&
|
|
3875
|
+
!flags.remint &&
|
|
3876
|
+
flags.services.length > 0 &&
|
|
3877
|
+
hasProvisionState(rootDir)) {
|
|
3608
3878
|
// A fresh (non-resume, non-force) service run must not clobber a prior state.
|
|
3609
3879
|
throw new CLIError('Ya existe un estado de provision (`.timekast/provision.json`). ' +
|
|
3610
3880
|
'Usa `--resume` para retomarlo o `--force` para descartarlo y empezar de cero.');
|
|
@@ -3622,7 +3892,7 @@ export async function runProvision(flags) {
|
|
|
3622
3892
|
const repoSlug = flags.services.includes('github') || flags.services.includes('neon')
|
|
3623
3893
|
? await resolveRepoSlug(rootDir, projectName, state?.github?.repo, flags.repo, gitInDir)
|
|
3624
3894
|
: undefined;
|
|
3625
|
-
const steps = buildPlan(flags.services, state);
|
|
3895
|
+
const steps = buildPlan(flags.services, state, { remint: flags.remint });
|
|
3626
3896
|
const result = {
|
|
3627
3897
|
projectName,
|
|
3628
3898
|
lifecycle: state?.lifecycle ?? lifecycle,
|
|
@@ -3685,20 +3955,35 @@ export async function runProvision(flags) {
|
|
|
3685
3955
|
}
|
|
3686
3956
|
// Ensure the state dir exists + a baseline file before the first step, so a
|
|
3687
3957
|
// step failure on the very first service still leaves a resumable state.
|
|
3688
|
-
|
|
3958
|
+
//
|
|
3959
|
+
// 🔴 Never for a re-mint over an ADOPTED stack. This baseline write is the one that
|
|
3960
|
+
// actually clobbered a client repo's adopted state file on 2026-09-12: the step
|
|
3961
|
+
// itself was already guarded, and this line — three screens away from it — was not.
|
|
3962
|
+
// An adopted file holds `vercelProjectId`, `githubRepo` and the domains, none of
|
|
3963
|
+
// which this shape carries, so writing it here loses them for good.
|
|
3964
|
+
if (!remintOverAdopted)
|
|
3965
|
+
writeProvisionState(rootDir, runState);
|
|
3689
3966
|
// 5.0. Initial migration (GAP-001) — BEFORE the steps, so the `.sql` is committed
|
|
3690
3967
|
// before the `github` step pushes `main` and Vercel builds the remote tree
|
|
3691
|
-
// (`vercel-build` = `pnpm
|
|
3968
|
+
// (`vercel-build` = `pnpm build && pnpm db:migrate`). A failed generate/install
|
|
3692
3969
|
// aborts here, with nothing pushed and nothing deployed.
|
|
3693
3970
|
await ensureInitialMigration(rootDir, greenfield);
|
|
3971
|
+
// In-memory carrier for the project-scoped Neon key (see `runServiceStep`), plus
|
|
3972
|
+
// whether this run may persist the step-by-step state at all.
|
|
3973
|
+
const runCtx = {
|
|
3974
|
+
persistState: !remintOverAdopted,
|
|
3975
|
+
};
|
|
3694
3976
|
for (const step of steps) {
|
|
3695
3977
|
if (step.action === 'skip')
|
|
3696
3978
|
continue;
|
|
3697
|
-
await runServiceStep(step.service, rootDir, runState, flags);
|
|
3979
|
+
await runServiceStep(step.service, rootDir, runState, flags, runCtx);
|
|
3698
3980
|
// Persist after each create. PROV-002 (neon) already writes its state
|
|
3699
3981
|
// atomically inside the step; the remaining stubs record nothing yet, and
|
|
3700
|
-
// this write keeps the baseline durable for them too.
|
|
3701
|
-
|
|
3982
|
+
// this write keeps the baseline durable for them too. Skipped entirely for a
|
|
3983
|
+
// re-mint over an adopted stack, whose state file is a different shape we must
|
|
3984
|
+
// not overwrite.
|
|
3985
|
+
if (!remintOverAdopted)
|
|
3986
|
+
writeProvisionState(rootDir, runState);
|
|
3702
3987
|
}
|
|
3703
3988
|
// 5a. Retry the E2E GitHub Secrets if Neon recorded them pending. Neon runs BEFORE
|
|
3704
3989
|
// the git substrate, so on a fresh bootstrap the repo may not exist yet when it
|
|
@@ -3709,16 +3994,25 @@ export async function runProvision(flags) {
|
|
|
3709
3994
|
// Without this, CI E2E stays credential-less until a MANUAL `--resume`.
|
|
3710
3995
|
if (!flags.dryRun && runState.neon?.apiKeySecretPending && runState.github?.repo) {
|
|
3711
3996
|
console.log('\n↻ Reintentando los secrets de E2E ahora que el repo existe...');
|
|
3712
|
-
await provisionNeon(rootDir, runState
|
|
3713
|
-
|
|
3997
|
+
runCtx.neonProjectKey = await provisionNeon(rootDir, runState, {
|
|
3998
|
+
neonOrg: flags.neonOrg,
|
|
3999
|
+
remint: flags.remint,
|
|
4000
|
+
persistState: !remintOverAdopted,
|
|
4001
|
+
});
|
|
4002
|
+
if (!remintOverAdopted)
|
|
4003
|
+
writeProvisionState(rootDir, runState);
|
|
3714
4004
|
}
|
|
3715
4005
|
// 5a.2. Ensure the local E2E creds are in `.env.local` even on a resume/adopt where
|
|
3716
4006
|
// the Vercel substrate was already `done` (writeE2eLocalCreds only runs inside
|
|
3717
4007
|
// that step's first pass). Idempotent — `setEnvLine` replaces/appends the two
|
|
3718
4008
|
// keys — so `pnpm test:e2e` works locally without a manual edit after a re-run.
|
|
3719
4009
|
if (!flags.dryRun && runState.neon?.projectId) {
|
|
3720
|
-
|
|
3721
|
-
|
|
4010
|
+
const e2eKey = runCtx.neonProjectKey ?? null;
|
|
4011
|
+
if (writeE2eLocalCreds(rootDir, runState.neon.projectId, e2eKey)) {
|
|
4012
|
+
console.log(e2eKey
|
|
4013
|
+
? ' + .env.local (NEON_API_KEY · NEON_PROJECT_ID — para `pnpm test:e2e` local)'
|
|
4014
|
+
: ' + .env.local (NEON_PROJECT_ID) · ⚠ sin NEON_API_KEY: no se acuñó una key en este run — ' +
|
|
4015
|
+
'corre `factory provision --services=neon --remint` para refrescarla');
|
|
3722
4016
|
}
|
|
3723
4017
|
}
|
|
3724
4018
|
// Same for email config on resume/adopt (writeEmailLocalCreds only runs in the first
|