@timekast/factory 1.28.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.
@@ -26,7 +26,7 @@ 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, NEON_MAIN_BRANCH, findNeonProjectAcrossOrgs, listNeonOrgs, resolveNeonOrg, 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';
@@ -565,7 +565,7 @@ export async function provisionNeon(rootDir, state, deps = {}) {
565
565
  // already exists — that distinction is the whole point:
566
566
  // - already recorded in the state → use it verbatim, no call, no guessing;
567
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;
568
+ // older stack) → ask Neon which org owns that project ID (`getNeonProjectOrg`);
569
569
  // - brand new → resolve the selector / the rail default / the only org.
570
570
  // Pointing a teardown or a re-mint at the default instead of the project's real
571
571
  // org is exactly the class of error this whole change is fixing.
@@ -573,8 +573,19 @@ export async function provisionNeon(rootDir, state, deps = {}) {
573
573
  if (!orgId) {
574
574
  try {
575
575
  if (state.neon?.projectId) {
576
- const found = await findNeonProjectAcrossOrgs(apiKey, state.projectName, apiDeps);
577
- orgId = found?.org.id;
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
+ }
578
589
  }
579
590
  if (!orgId) {
580
591
  const selector = deps.neonOrg ?? readGateValue('NEON_ORG', envPath) ?? undefined;
@@ -582,11 +593,13 @@ export async function provisionNeon(rootDir, state, deps = {}) {
582
593
  }
583
594
  }
584
595
  catch (error) {
596
+ if (error instanceof CLIError)
597
+ throw error;
585
598
  if (error instanceof NeonApiError) {
586
599
  throw neonErrorToCliError(error, state.projectName, 'resolver la organización de Neon');
587
600
  }
588
- // resolveNeonOrg / findNeonProjectAcrossOrgs throw plain Errors whose message
589
- // is already the es-MX text the user needs; surface it as-is.
601
+ // resolveNeonOrg throws plain Errors whose message is already the es-MX text the
602
+ // user needs; surface it as-is.
590
603
  throw new CLIError(error instanceof Error ? error.message : String(error));
591
604
  }
592
605
  }
@@ -3301,9 +3314,9 @@ async function destroyNeonBranch(target, apiKey, fetchImpl) {
3301
3314
  *
3302
3315
  * The org id is resolved BEFORE the nuke, because after it the project is gone and
3303
3316
  * 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.
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.
3307
3320
  */
3308
3321
  async function destroyNeonProject(target, apiKey, fetchImpl) {
3309
3322
  const resource = `Neon project (${target.neonProjectId})`;
@@ -3313,9 +3326,11 @@ async function destroyNeonProject(target, apiKey, fetchImpl) {
3313
3326
  const keyName = `${target.slug}-e2e`;
3314
3327
  let orgId = target.neonOrgId;
3315
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.
3316
3332
  try {
3317
- const found = await findNeonProjectAcrossOrgs(apiKey, target.slug, { fetchImpl });
3318
- orgId = found?.org.id ?? null;
3333
+ orgId = await getNeonProjectOrg(apiKey, target.neonProjectId, { fetchImpl });
3319
3334
  }
3320
3335
  catch {
3321
3336
  orgId = null;
@@ -171,36 +171,36 @@ export async function resolveNeonOrg(apiKey, selector, deps = {}) {
171
171
  '`NEON_ORG=<nombre>` en `~/.claude/.env`.');
172
172
  }
173
173
  /**
174
- * Find an EXISTING project by name across every org the key can reach, and
175
- * return it together with the org that owns it.
174
+ * The organization that owns a project, asked BY ID — `GET /projects/{id}`.
176
175
  *
177
- * This is how every operation on an already-created project (`--adopt`,
178
- * `--destroy`, `--remint`, a `--resume` over a state written before `orgId`
179
- * existed) learns its org: read it off the project, never from a flag or a
180
- * default. Using the default here would point a teardown at the wrong org.
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.
181
183
  *
182
- * A name that exists in TWO orgs THROWS instead of picking one — an ambiguous
183
- * teardown target is precisely what must not resolve silently.
184
+ * 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).
184
186
  *
185
- * @returns the match, or `null` when no org holds a project with that name.
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".
186
189
  */
187
- export async function findNeonProjectAcrossOrgs(apiKey, projectName, deps = {}) {
188
- const orgs = await listNeonOrgs(apiKey, deps);
189
- const needle = projectName.trim().toLowerCase();
190
- const hits = [];
191
- for (const org of orgs) {
192
- const projects = await listNeonProjects(apiKey, org.id, deps);
193
- for (const project of projects) {
194
- if (project.name.trim().toLowerCase() === needle)
195
- hits.push({ org, project });
196
- }
197
- }
198
- if (hits.length > 1) {
199
- throw new Error(`Hay un proyecto de Neon llamado \`${projectName}\` en más de una organización: ` +
200
- `${hits.map((h) => `${h.org.name} (${h.project.id})`).join(', ')}.\n` +
201
- ' No elijo por ti: resuélvelo a mano en la consola de Neon (renombra uno) y reintenta.');
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}');
202
201
  }
203
- return hits[0] ?? null;
202
+ const data = (await response.json());
203
+ return data.project?.org_id ?? null;
204
204
  }
205
205
  /**
206
206
  * Create a Neon project. The project is born WITH its primary branch — we name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.28.0",
3
+ "version": "1.28.1",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",