@timekast/factory 1.23.1 → 1.24.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.
@@ -29,7 +29,7 @@ import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, deleteDnsRecord, e
29
29
  import { createDevelopBranch, createNeonProject, createProjectApiKey, deleteNeonApiKeysByName, 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
- import { addVercelDomain, buildEnvPlan, createVercelProject, generateAuthSecret, generateMfaEncryptionKey, getLinkedRepoId, getVercelDomainStatus, GitHubAppNotInstalledError, linkGitRepo, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, writeVercelProjectJson, } from '../lib/vercel-env.js';
32
+ import { addVercelDomain, buildEnvPlan, createVercelProject, generateAuthSecret, generateMfaEncryptionKey, getLinkedRepoId, getVercelDomainStatus, getVercelProjectByName, GitHubAppNotInstalledError, linkGitRepo, listProjectDomains, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, VERCEL_AUTO_APEX, writeVercelProjectJson, } from '../lib/vercel-env.js';
33
33
  import { writeBacklogLocalCreds, writeE2eLocalCreds, writeEmailLocalCreds, writeEnvLocal, } from '../lib/env-file.js';
34
34
  import { adoptState, ADOPT_UNKNOWN, clearProvisionState, clearState, hasProvisionState, isServiceComplete, PROVISION_DIR, PROVISION_STATE_FILE, readDestroyTarget, readProvisionState, writeProvisionState, } from '../lib/provision-state.js';
35
35
  /**
@@ -2279,19 +2279,58 @@ async function resolveCandidate(provider, candidates, deps, recordAmbiguity) {
2279
2279
  const chosen = await select(provider, candidates);
2280
2280
  return chosen?.id ?? null;
2281
2281
  }
2282
- /** Reconstruct the Vercel project id (read-only) — filter by name/slug. */
2282
+ /**
2283
+ * Normalize a provider-side project name for slug comparison. Provider consoles
2284
+ * hold DISPLAY names — a Neon project created as `Aditivo CRM` is the same stack
2285
+ * as the `aditivo-crm` directory — so a raw string compare answers "was this
2286
+ * created by the CLI", not "is this my project". Lowercase, non-alphanumerics
2287
+ * collapsed to a single `-`, edges trimmed: `Aditivo CRM` → `aditivo-crm`.
2288
+ */
2289
+ function normalizeProjectName(name) {
2290
+ return name
2291
+ .toLowerCase()
2292
+ .replace(/[^a-z0-9]+/g, '-')
2293
+ .replace(/^-+|-+$/g, '');
2294
+ }
2295
+ /**
2296
+ * Pick the candidate pool for a slug out of a provider's project list: an EXACT
2297
+ * (normalized) name match wins alone; otherwise every normalized name that
2298
+ * contains the slug (or is contained by it) is surfaced for disambiguation.
2299
+ */
2300
+ function matchProjectsBySlug(projects, slug) {
2301
+ const target = normalizeProjectName(slug);
2302
+ const matches = projects.filter((p) => {
2303
+ const name = normalizeProjectName(p.name);
2304
+ return name === target || name.includes(target) || target.includes(name);
2305
+ });
2306
+ const exact = matches.filter((p) => normalizeProjectName(p.name) === target);
2307
+ return exact.length === 1 ? exact : matches;
2308
+ }
2309
+ /**
2310
+ * Reconstruct the Vercel project id (read-only).
2311
+ *
2312
+ * The exact lookup goes FIRST — `GET /v9/projects/{name}` answers "is there a
2313
+ * project called `{slug}`" in one request. The listing is the fallback for a
2314
+ * project whose name merely resembles the directory, and it is the reason this
2315
+ * order matters: the listing is paginated, and a team with a real fleet used to
2316
+ * bury the project past the first page, where "not in this page" was recorded
2317
+ * as `unknown` — indistinguishable from "does not exist".
2318
+ */
2283
2319
  async function adoptVercel(slug, teamId, token, deps, recordAmbiguity) {
2320
+ const exact = await getVercelProjectByName(slug, token, teamId, { fetchImpl: deps.fetchImpl });
2321
+ if (exact)
2322
+ return exact.id;
2284
2323
  const projects = await listVercelProjects(token, teamId, { fetchImpl: deps.fetchImpl });
2285
2324
  const candidates = projectsToCandidates(projects, slug, teamId);
2325
+ if (candidates.length === 0) {
2326
+ console.log(` · Ningún proyecto de Vercel se parece a \`${slug}\` (revisé ${projects.length} del team \`${teamId}\`) — \`vercelProjectId: unknown\`.`);
2327
+ return null;
2328
+ }
2286
2329
  return resolveCandidate('Vercel', candidates, deps, recordAmbiguity);
2287
2330
  }
2288
2331
  /** Map Vercel project summaries to candidates, keeping only slug-name matches. */
2289
2332
  function projectsToCandidates(projects, slug, teamId) {
2290
- const matches = projects.filter((p) => p.name === slug || p.name.includes(slug) || slug.includes(p.name));
2291
- // Prefer an EXACT name match alone (unambiguous); otherwise surface all matches.
2292
- const exact = matches.filter((p) => p.name === slug);
2293
- const pool = exact.length === 1 ? exact : matches;
2294
- return pool.map((p) => ({
2333
+ return matchProjectsBySlug(projects, slug).map((p) => ({
2295
2334
  id: p.id,
2296
2335
  name: p.name,
2297
2336
  url: `https://vercel.com/${teamId}/${p.name}`,
@@ -2303,16 +2342,17 @@ function projectsToCandidates(projects, slug, teamId) {
2303
2342
  * the `develop` one. A branch lookup failure leaves the branch id `unknown`
2304
2343
  * without failing the whole adopt (issue §4).
2305
2344
  */
2306
- async function adoptNeon(slug, apiKey, deps, recordAmbiguity) {
2307
- const projects = await listNeonProjects(apiKey, { fetchImpl: deps.fetchImpl });
2308
- const matches = projects.filter((p) => p.name === slug || p.name.includes(slug) || slug.includes(p.name));
2309
- const exact = matches.filter((p) => p.name === slug);
2310
- const pool = exact.length === 1 ? exact : matches;
2311
- const candidates = pool.map((p) => ({
2345
+ async function adoptNeon(slug, apiKey, orgId, deps, recordAmbiguity) {
2346
+ const projects = await listNeonProjects(apiKey, orgId, { fetchImpl: deps.fetchImpl });
2347
+ const candidates = matchProjectsBySlug(projects, slug).map((p) => ({
2312
2348
  id: p.id,
2313
2349
  name: p.name,
2314
2350
  url: `https://console.neon.tech/app/projects/${p.id}`,
2315
2351
  }));
2352
+ if (candidates.length === 0) {
2353
+ console.log(` · Ningún proyecto de Neon se parece a \`${slug}\` (revisé ${projects.length} de la org) — \`neonProjectId: unknown\`.`);
2354
+ return { projectId: null, branchDevId: null };
2355
+ }
2316
2356
  const projectId = await resolveCandidate('Neon', candidates, deps, recordAmbiguity);
2317
2357
  if (!projectId)
2318
2358
  return { projectId: null, branchDevId: null };
@@ -2359,17 +2399,23 @@ async function adoptGithub(slug, deps) {
2359
2399
  * source, through the very door DOM-001 closed from the other side.
2360
2400
  *
2361
2401
  * So it probes the org zones in order and keeps the one whose CNAMEs actually
2362
- * exist. Deriving from the slug survives only as the LAST resort — no token, no
2363
- * zone reachable, or a project whose CNAMEs are genuinely absent — where naming a
2364
- * domain by convention is all anyone can do, and the narration says so.
2402
+ * exist. When none does, it asks VERCEL where the app answers — a project living
2403
+ * on the client's own domain (`crm.cliente.mx`) has no CNAME in any org zone, and
2404
+ * for it the org-zone question has no true answer: the conventional name would be
2405
+ * a host that resolves nowhere, written into the field destroy and the WebAuthn
2406
+ * rpID both read.
2407
+ *
2408
+ * Deriving from the slug survives only as the LAST resort — no token, no zone
2409
+ * reachable, and no Vercel project to ask — where naming a domain by convention
2410
+ * is all anyone can do, and the narration says so.
2365
2411
  */
2366
- async function adoptDns(slug, deps) {
2412
+ async function adoptDns(slug, deps, vercel) {
2367
2413
  const domains = provisionDomains(slug);
2368
2414
  const cfToken = readCloudflareApiKey(deps.envPath ?? defaultEnvPath());
2369
2415
  if (!cfToken) {
2370
- console.warn('⚠ No hay `CLOUDFLARE_API_TOKEN` en el rail — registro los dominios por nombre sin ' +
2371
- 'confirmar sus CNAMEs (es read-only; corre `factory secrets <archivo>` si quieres la verificación).');
2372
- return domains;
2416
+ console.warn('⚠ No hay `CLOUDFLARE_API_TOKEN` en el rail — no puedo confirmar CNAMEs ' +
2417
+ '(es read-only; corre `factory secrets <archivo>` si quieres la verificación).');
2418
+ return (await adoptDomainsFromVercel(vercel, deps)) ?? narrateDerivedDomains(domains);
2373
2419
  }
2374
2420
  const cfDeps = { fetchImpl: deps.fetchImpl };
2375
2421
  for (const zone of ORG_ZONE_NAMES) {
@@ -2407,9 +2453,60 @@ async function adoptDns(slug, deps) {
2407
2453
  }
2408
2454
  }
2409
2455
  console.log(` · Sin CNAMEs en ninguna zona del org (${ORG_ZONE_NAMES.join(', ')}) — ` +
2410
- 'registro los dominios por nombre en la zona actual.');
2456
+ 'le pregunto a Vercel dónde responde el proyecto.');
2457
+ return (await adoptDomainsFromVercel(vercel, deps)) ?? narrateDerivedDomains(domains);
2458
+ }
2459
+ /** Say out loud that these hosts are a convention, not a confirmed fact. */
2460
+ function narrateDerivedDomains(domains) {
2461
+ console.warn(`⚠ No pude confirmar dominios en ningún proveedor — registro los de convención ` +
2462
+ `(\`${domains.prod}\` / \`${domains.dev}\`). Si el proyecto usa otro dominio, corrígelos ` +
2463
+ 'a mano en `.timekast/provision.json` antes de cualquier `--destroy`.');
2411
2464
  return domains;
2412
2465
  }
2466
+ /**
2467
+ * Read the project's REAL hosts off Vercel (read-only) and split them into the
2468
+ * dev/prod pair the state holds. A domain pinned to the `develop` branch is dev;
2469
+ * an unpinned one is prod. Custom domains win over the `*.vercel.app` Vercel
2470
+ * mints on its own — but the auto host is kept when a role has nothing else, so
2471
+ * the rpID of a dev environment that never got a custom domain is still true.
2472
+ *
2473
+ * Returns `null` when there is no project to ask, the call fails, or the project
2474
+ * has no domains at all — the caller falls back to the conventional names.
2475
+ */
2476
+ async function adoptDomainsFromVercel(vercel, deps) {
2477
+ if (!vercel)
2478
+ return null;
2479
+ let domains;
2480
+ try {
2481
+ domains = await listProjectDomains(vercel.projectId, vercel.token, vercel.teamId, {
2482
+ fetchImpl: deps.fetchImpl,
2483
+ });
2484
+ }
2485
+ catch {
2486
+ console.warn('⚠ No se pudo leer los dominios del proyecto en Vercel (read-only) — continúo.');
2487
+ return null;
2488
+ }
2489
+ if (domains.length === 0)
2490
+ return null;
2491
+ const isAuto = (d) => d.apexName === VERCEL_AUTO_APEX;
2492
+ const isDev = (d) => d.gitBranch === NEON_DEVELOP_BRANCH;
2493
+ const pick = (wantDev) => domains.find((d) => isDev(d) === wantDev && !isAuto(d)) ??
2494
+ domains.find((d) => isDev(d) === wantDev);
2495
+ const prod = pick(false);
2496
+ const dev = pick(true);
2497
+ if (!prod && !dev)
2498
+ return null;
2499
+ const resolved = { dev: dev?.name ?? '', prod: prod?.name ?? '' };
2500
+ if (!resolved.prod || !resolved.dev) {
2501
+ // One role has no host at all: report it rather than inventing its twin.
2502
+ console.log(` ℹ Vercel solo tiene dominio de ${resolved.prod ? 'producción' : 'develop'} para este proyecto.`);
2503
+ }
2504
+ console.log(` ✔ Dominios tomados de Vercel — prod: ${resolved.prod || '(ninguno)'} · dev: ${resolved.dev || '(ninguno)'}`);
2505
+ return {
2506
+ dev: resolved.dev || ADOPT_UNKNOWN,
2507
+ prod: resolved.prod || ADOPT_UNKNOWN,
2508
+ };
2509
+ }
2413
2510
  /** Print the reconstructed adopt state in plain es-MX (the non-`--json` narration). */
2414
2511
  function printAdoptState(result) {
2415
2512
  const s = result.state;
@@ -2481,9 +2578,12 @@ export async function runAdopt(flags, deps = {}) {
2481
2578
  let neonProjectId = null;
2482
2579
  let neonBranchDevId = null;
2483
2580
  const neonApiKey = readNeonApiKey(envPath);
2581
+ // Same rail value the create path threads (`NEON_ORGANIZATION_ID`): Neon REQUIRES
2582
+ // it on an org-scoped key, and answers `400 org_id is required` without it.
2583
+ const neonOrgId = readGateValue('NEON_ORGANIZATION_ID', envPath) ?? undefined;
2484
2584
  if (neonApiKey) {
2485
2585
  try {
2486
- const neon = await adoptNeon(slug, neonApiKey, adeps, (list) => {
2586
+ const neon = await adoptNeon(slug, neonApiKey, neonOrgId, adeps, (list) => {
2487
2587
  candidates.neon = list;
2488
2588
  });
2489
2589
  neonProjectId = neon.projectId;
@@ -2499,8 +2599,11 @@ export async function runAdopt(flags, deps = {}) {
2499
2599
  }
2500
2600
  // GitHub (gh repo view) — uses the dev's gh auth (no rail token needed).
2501
2601
  const githubRepo = await adoptGithub(slug, adeps);
2502
- // DNS (GET dns_records) — domain names always derivable; CNAME check best-effort.
2503
- const domains = await adoptDns(slug, adeps);
2602
+ // DNS (GET dns_records) — the org zones first, then the project's own Vercel
2603
+ // domains, and only then the conventional names (see `adoptDns`).
2604
+ const domains = await adoptDns(slug, adeps, vercelProjectId && vercelToken && flags.team
2605
+ ? { projectId: vercelProjectId, teamId: flags.team, token: vercelToken }
2606
+ : null);
2504
2607
  const partial = {
2505
2608
  slug,
2506
2609
  vercelProjectId: vercelProjectId ?? ADOPT_UNKNOWN,
@@ -2847,6 +2950,18 @@ async function teardownProviders(target, deps) {
2847
2950
  // was never found and stayed behind. Keyed by domain→zone-id, resolved on demand.
2848
2951
  const zoneIdByDomain = new Map();
2849
2952
  for (const domain of target.domains) {
2953
+ // A `*.vercel.app` host reaches the state only via `--adopt`, which records the
2954
+ // auto host when a role has no custom domain. Vercel mints and owns it: there is
2955
+ // no CNAME of ours to delete and no zone to resolve, so both branches below would
2956
+ // report a `failed` for a resource that was never ours.
2957
+ if (domain.endsWith(`.${VERCEL_AUTO_APEX}`)) {
2958
+ outcomes.push({
2959
+ resource: `Dominio ${domain}`,
2960
+ status: 'skipped',
2961
+ detail: 'host automático de Vercel — se va con el proyecto',
2962
+ });
2963
+ continue;
2964
+ }
2850
2965
  // The domain is registered in the state, so there IS a resource to remove. If
2851
2966
  // a prerequisite is missing (FIX 3: e.g. `scope` null on an adopted stack, or
2852
2967
  // no token), emit `failed` — NOT a silent skip — so `hadFailures` keeps the
@@ -225,29 +225,60 @@ export async function getConnectionUri(apiKey, projectId, branchId, pooled, deps
225
225
  }
226
226
  return data.uri;
227
227
  }
228
+ /** Page size for the project listing — Neon's documented maximum (`limit` ≤ 400). */
229
+ const NEON_PROJECTS_PAGE_SIZE = 400;
230
+ /** Hard stop on the pagination loop — 20 full pages is 8000 projects, far past any org. */
231
+ const NEON_PROJECTS_MAX_PAGES = 20;
228
232
  /**
229
233
  * List the org's Neon projects — `GET /projects` (PROV-007 `--adopt`). STRICTLY
230
234
  * read-only: it never POSTs/PATCHes/DELETEs. The caller filters by slug/name to
231
235
  * reconstruct the adopted state and disambiguates when more than one matches
232
- * (issue §4). Neon paginates with `?limit=`; one page (default 100) covers the
233
- * org's project list for the adopt use case.
236
+ * (issue §4).
237
+ *
238
+ * Two things this endpoint does NOT forgive, both learned the hard way from an
239
+ * adopt that recorded `neonProjectId: unknown` on a project that plainly exists:
240
+ *
241
+ * - `org_id` is REQUIRED for an org-scoped API key — the same requirement
242
+ * {@link createNeonProject} already honors. Without it Neon answers `400
243
+ * org_id is required`, so the adopt path had never once resolved a project.
244
+ * - The default page size is 10. An org with a real fleet needs the FULL list,
245
+ * or the answer to "does this project exist" is decided by how recently it
246
+ * was touched. Pages are walked to exhaustion via `pagination.cursor`.
234
247
  *
235
248
  * @throws {NeonApiError} on any non-2xx (caller records the field as `unknown`).
236
249
  */
237
- export async function listNeonProjects(apiKey, deps = {}) {
250
+ export async function listNeonProjects(apiKey, orgId, deps = {}) {
238
251
  const fetchImpl = deps.fetchImpl ?? fetch;
239
- const response = await fetchImpl(`${NEON_API_BASE}/projects`, {
240
- method: 'GET',
241
- headers: neonHeaders(apiKey),
242
- signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
243
- });
244
- if (!response.ok) {
245
- throw new NeonApiError(response.status, await safeBody(response), 'GET /projects');
252
+ const collected = [];
253
+ let cursor;
254
+ for (let page = 0; page < NEON_PROJECTS_MAX_PAGES; page += 1) {
255
+ const query = new URLSearchParams({ limit: String(NEON_PROJECTS_PAGE_SIZE) });
256
+ if (orgId)
257
+ query.set('org_id', orgId);
258
+ if (cursor)
259
+ query.set('cursor', cursor);
260
+ const response = await fetchImpl(`${NEON_API_BASE}/projects?${query.toString()}`, {
261
+ method: 'GET',
262
+ headers: neonHeaders(apiKey),
263
+ signal: AbortSignal.timeout(NEON_API_TIMEOUT_MS),
264
+ });
265
+ if (!response.ok) {
266
+ throw new NeonApiError(response.status, await safeBody(response), 'GET /projects');
267
+ }
268
+ const data = (await response.json());
269
+ const projects = data.projects ?? [];
270
+ for (const p of projects) {
271
+ if (p.id && p.name)
272
+ collected.push({ id: p.id, name: p.name });
273
+ }
274
+ // Neon returns a cursor even on the LAST page, so a short page — not a missing
275
+ // cursor — is what ends the walk; the cursor guard only catches a truncated body.
276
+ const next = data.pagination?.cursor;
277
+ if (projects.length < NEON_PROJECTS_PAGE_SIZE || !next || next === cursor)
278
+ break;
279
+ cursor = next;
246
280
  }
247
- const data = (await response.json());
248
- return (data.projects ?? [])
249
- .filter((p) => Boolean(p.id) && Boolean(p.name))
250
- .map((p) => ({ id: p.id, name: p.name }));
281
+ return collected;
251
282
  }
252
283
  /**
253
284
  * List a Neon project's branches — `GET /projects/{id}/branches` (PROV-007
@@ -303,25 +303,83 @@ export async function removeVercelDomain(projectId, name, token, teamId, deps =
303
303
  throw error;
304
304
  }
305
305
  }
306
+ /** Page size for the project listing — Vercel's documented maximum (`limit` ≤ 100). */
307
+ const VERCEL_PROJECTS_PAGE_SIZE = 100;
308
+ /** Hard stop on the pagination loop — 20 full pages is 2000 projects, far past any team. */
309
+ const VERCEL_PROJECTS_MAX_PAGES = 20;
306
310
  /**
307
- * List the team's Vercel projects — `GET /v9/projects?teamId=&limit=20` (PROV-007
311
+ * Look up ONE Vercel project by name — `GET /v9/projects/{idOrName}` (PROV-007
312
+ * `--adopt`). Vercel accepts the project NAME in that path slot, which is the
313
+ * cheap exact answer to "is there a project called `{slug}`": one request, no
314
+ * pagination, no substring heuristic. Returns `null` on a 404 (no such project)
315
+ * so the caller can fall back to the fuzzy listing below.
316
+ *
317
+ * @throws {VercelApiError} on any non-2xx OTHER than 404 (401/403 must not be
318
+ * read as "absent" — a bad token is a different fact from a missing project).
319
+ */
320
+ export async function getVercelProjectByName(name, token, teamId, deps = {}) {
321
+ try {
322
+ const data = (await vercelApi('GET', `/v9/projects/${encodeURIComponent(name)}`, token, teamId, deps));
323
+ return data.id && data.name ? { id: data.id, name: data.name } : null;
324
+ }
325
+ catch (error) {
326
+ if (error instanceof VercelApiError && error.status === 404)
327
+ return null;
328
+ throw error;
329
+ }
330
+ }
331
+ /**
332
+ * List the team's Vercel projects — `GET /v9/projects?teamId=` (PROV-007
308
333
  * `--adopt`). STRICTLY read-only: it never POSTs/PATCHes/DELETEs anything. The
309
334
  * caller filters the returned summaries by slug/name to reconstruct the adopted
310
335
  * state; an exact-name match is preferred, but every project name that CONTAINS
311
336
  * the slug is returned as a candidate so the caller can disambiguate (issue §4).
312
337
  *
313
- * `limit` is threaded as a query param (default 20 — issue §6); `teamId` flows
314
- * through {@link vercelUrl} like every other call. Used by `--adopt` only.
338
+ * Pages are walked to exhaustion (`pagination.next` → `until`). It used to take
339
+ * the first 20 and stop, which on a real team meant the listing answered "are you
340
+ * among the 20 most recently touched projects", not "do you exist" — a project
341
+ * further down was recorded `unknown` with no way to tell that from absent.
315
342
  *
316
343
  * @throws {VercelApiError} on any non-2xx (caller maps it / records `unknown`).
317
344
  */
318
- export async function listVercelProjects(token, teamId, deps = {}, limit = 20) {
319
- const data = (await vercelApi('GET', '/v9/projects', token, teamId, deps, undefined, {
320
- limit: String(limit),
321
- }));
322
- return (data.projects ?? [])
323
- .filter((p) => Boolean(p.id) && Boolean(p.name))
324
- .map((p) => ({ id: p.id, name: p.name }));
345
+ export async function listVercelProjects(token, teamId, deps = {}) {
346
+ const collected = [];
347
+ let until;
348
+ for (let page = 0; page < VERCEL_PROJECTS_MAX_PAGES; page += 1) {
349
+ const query = { limit: String(VERCEL_PROJECTS_PAGE_SIZE) };
350
+ if (until !== undefined)
351
+ query.until = String(until);
352
+ const data = (await vercelApi('GET', '/v9/projects', token, teamId, deps, undefined, query));
353
+ for (const p of data.projects ?? []) {
354
+ if (p.id && p.name)
355
+ collected.push({ id: p.id, name: p.name });
356
+ }
357
+ const next = data.pagination?.next;
358
+ if (next === undefined || next === null || next === until)
359
+ break;
360
+ until = next;
361
+ }
362
+ return collected;
363
+ }
364
+ /**
365
+ * Apex of the hosts Vercel mints on its own (`{project}.vercel.app`). Nobody
366
+ * created these and nobody can delete them, so they are the LAST choice when
367
+ * reconstructing a project's domains and are skipped by the teardown.
368
+ */
369
+ export const VERCEL_AUTO_APEX = 'vercel.app';
370
+ /**
371
+ * List the domains attached to a Vercel project — `GET /v9/projects/{id}/domains`
372
+ * (PROV-007 `--adopt`). STRICTLY read-only. This is the authority on where a
373
+ * derived app actually answers: a project on a client's own domain has no CNAME
374
+ * in any org zone, so DNS alone cannot reconstruct its hosts.
375
+ *
376
+ * @throws {VercelApiError} on any non-2xx (caller degrades, never fails the adopt).
377
+ */
378
+ export async function listProjectDomains(projectId, token, teamId, deps = {}) {
379
+ const data = (await vercelApi('GET', `/v9/projects/${encodeURIComponent(projectId)}/domains`, token, teamId, deps));
380
+ return (data.domains ?? [])
381
+ .filter((d) => Boolean(d.name))
382
+ .map((d) => ({ name: d.name, apexName: d.apexName ?? '', gitBranch: d.gitBranch ?? null }));
325
383
  }
326
384
  /**
327
385
  * List the project's existing env vars (issue §8 idempotency: we PATCH a var that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.23.1",
3
+ "version": "1.24.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",