@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.
- package/dist/commands/provision.js +139 -24
- package/dist/lib/neon-api.js +45 -14
- package/dist/lib/vercel-env.js +68 -10
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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.
|
|
2363
|
-
*
|
|
2364
|
-
*
|
|
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 —
|
|
2371
|
-
'
|
|
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
|
-
'
|
|
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) —
|
|
2503
|
-
|
|
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
|
package/dist/lib/neon-api.js
CHANGED
|
@@ -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).
|
|
233
|
-
*
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
package/dist/lib/vercel-env.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
314
|
-
*
|
|
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 = {}
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|