@timekast/factory 1.18.0 → 1.19.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/add.js +1 -0
- package/dist/commands/env.js +9 -0
- package/dist/commands/provision.js +457 -38
- package/dist/commands/update.js +40 -0
- package/dist/index.js +1 -0
- package/dist/lib/cloudflare-api.js +112 -2
- package/dist/lib/env-file.js +9 -6
- package/dist/lib/env-wizard.js +30 -8
- package/dist/lib/vercel-env.js +42 -8
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
package/dist/commands/env.js
CHANGED
|
@@ -21,6 +21,12 @@ import { pushEnvPlan, vercelProjectPath, VercelApiError, } from '../lib/vercel-e
|
|
|
21
21
|
// Re-exported so existing importers (and `cli/tests/unit/env-push.test.ts`) keep
|
|
22
22
|
// resolving PROVISION_OWNED from this module after it moved to `lib/env-file.js`.
|
|
23
23
|
export { PROVISION_OWNED };
|
|
24
|
+
/**
|
|
25
|
+
* Post-push notice: freshly uploaded env vars stay INERT until the next Vercel
|
|
26
|
+
* deployment (stale-config window — see BOOT-010). Printed only after a
|
|
27
|
+
* successful push with at least one real change (created or updated).
|
|
28
|
+
*/
|
|
29
|
+
export const REDEPLOY_NOTICE = 'ℹ️ Las variables aplican en el próximo deployment — redeploya el proyecto en Vercel para que tomen efecto.';
|
|
24
30
|
export function parseEnvPushFlags(argv) {
|
|
25
31
|
return { dryRun: argv.includes('--dry-run') };
|
|
26
32
|
}
|
|
@@ -114,6 +120,9 @@ export async function runEnvPush(flags, rootDir = process.cwd()) {
|
|
|
114
120
|
const result = await pushEnvPlan(projectId, token, teamId, plan);
|
|
115
121
|
console.log(`✔ env push: ${result.created.length} creadas, ${result.updated.length} actualizadas ` +
|
|
116
122
|
'(production, preview).');
|
|
123
|
+
if (result.created.length > 0 || result.updated.length > 0) {
|
|
124
|
+
console.log(REDEPLOY_NOTICE);
|
|
125
|
+
}
|
|
117
126
|
}
|
|
118
127
|
catch (error) {
|
|
119
128
|
if (error instanceof VercelApiError) {
|
|
@@ -25,7 +25,7 @@ import { execa } from 'execa';
|
|
|
25
25
|
import prompts from 'prompts';
|
|
26
26
|
import { FACTORY_ORG } from '../lib/constants.js';
|
|
27
27
|
import { CLIError } from '../lib/cli-error.js';
|
|
28
|
-
import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, ensureCnameRecord, findCnameRecord, resolveZoneId, resolveZoneIdForName, CNAME_VERCEL_TARGET, TIMEKAST_ZONE_NAME, provisionDomains, } from '../lib/cloudflare-api.js';
|
|
28
|
+
import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, deleteDnsRecord, ensureCnameRecord, ensureDnsRecord, findCnameRecord, resolveZoneId, resolveZoneIdForName, CNAME_VERCEL_TARGET, TIMEKAST_ZONE_NAME, provisionDomains, } from '../lib/cloudflare-api.js';
|
|
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 { defaultEnvPath, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
|
|
31
31
|
import { addVercelDomain, buildEnvPlan, createVercelProject, generateAuthSecret, generateMfaEncryptionKey, getLinkedRepoId, getVercelDomainStatus, GitHubAppNotInstalledError, linkGitRepo, listVercelProjects, patchProjectSettings, pushEnvPlan, removeVercelDomain, triggerDeployment, upsertEnvVar, listEnvVars, VercelApiError, writeVercelProjectJson, } from '../lib/vercel-env.js';
|
|
@@ -180,6 +180,21 @@ export function parseVerifyTimeout(raw) {
|
|
|
180
180
|
}
|
|
181
181
|
return minutes;
|
|
182
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Parse + validate `--repo=<org/name>` (BOOT-001). Absent → `undefined` (the repo
|
|
185
|
+
* resolution then falls back to the `origin` remote / `FACTORY_ORG/projectName`).
|
|
186
|
+
* A malformed value (no `/`, more than one `/`, or an empty side) throws a clear
|
|
187
|
+
* `CLIError` — a corrupt slug must never silently reach the GitHub/Vercel calls.
|
|
188
|
+
*/
|
|
189
|
+
export function parseRepoFlag(raw) {
|
|
190
|
+
if (raw === undefined)
|
|
191
|
+
return undefined;
|
|
192
|
+
const parts = raw.split('/');
|
|
193
|
+
if (parts.length !== 2 || parts.some((part) => part.trim() === '')) {
|
|
194
|
+
throw new CLIError(`\`--repo\` debe tener el formato \`org/nombre\` (un solo \`/\`; recibí \`${raw}\`).`);
|
|
195
|
+
}
|
|
196
|
+
return raw;
|
|
197
|
+
}
|
|
183
198
|
/** Parse `provision`'s argv slice into typed flags. */
|
|
184
199
|
export function parseProvisionFlags(argv) {
|
|
185
200
|
return {
|
|
@@ -187,6 +202,7 @@ export function parseProvisionFlags(argv) {
|
|
|
187
202
|
team: readValueFlag(argv, 'team'),
|
|
188
203
|
orgId: readValueFlag(argv, 'org-id'),
|
|
189
204
|
slug: readValueFlag(argv, 'slug'),
|
|
205
|
+
repo: parseRepoFlag(readValueFlag(argv, 'repo')),
|
|
190
206
|
domain: readValueFlag(argv, 'domain'),
|
|
191
207
|
dns: parseDnsRoute(readValueFlag(argv, 'dns')),
|
|
192
208
|
verifyTimeout: parseVerifyTimeout(readValueFlag(argv, 'verify-timeout')),
|
|
@@ -217,6 +233,11 @@ function buildPlan(services, state) {
|
|
|
217
233
|
/** Print the plain-text plan (the `--dry-run` and pre-run narration). */
|
|
218
234
|
function printPlan(result) {
|
|
219
235
|
console.log(`Plan de provision para \`${result.projectName}\` (lifecycle: ${result.lifecycle}):`);
|
|
236
|
+
// BOOT-001: print the RESOLVED repo (--repo → origin → FACTORY_ORG/name), so a
|
|
237
|
+
// `--dry-run` shows the exact repo the github step / E2E secrets will target.
|
|
238
|
+
if (result.repo) {
|
|
239
|
+
console.log(` Repo de GitHub: ${result.repo}`);
|
|
240
|
+
}
|
|
220
241
|
for (const step of result.steps) {
|
|
221
242
|
const mark = step.action === 'skip' ? '⏭ (ya completado)' : '+ crear';
|
|
222
243
|
console.log(` ${mark} ${SERVICE_LABEL[step.service]}`);
|
|
@@ -405,9 +426,11 @@ function neonErrorToCliError(error, projectName, context) {
|
|
|
405
426
|
}
|
|
406
427
|
/** The GitHub repo (`owner/name`) the project-scoped secret is pushed to. */
|
|
407
428
|
function neonSecretRepo(state) {
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
429
|
+
// `state.github.repo` carries the RESOLVED repo: `runProvision` seeds it before
|
|
430
|
+
// any step runs (BOOT-001: --repo → origin → FACTORY_ORG/name) and PROV-004
|
|
431
|
+
// re-records it. The fallback only covers a direct `provisionNeon` call with no
|
|
432
|
+
// seeded state, so the secret push is still attempted (a non-existent repo →
|
|
433
|
+
// §8 pending path, never an abort).
|
|
411
434
|
return state.github?.repo ?? `${FACTORY_ORG}/${state.projectName}`;
|
|
412
435
|
}
|
|
413
436
|
/**
|
|
@@ -696,10 +719,10 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
|
|
|
696
719
|
// Mint an INDEPENDENT AUTH_SECRET per environment (issue §4 — never shared).
|
|
697
720
|
// POOLED strings → the Vercel runtime DATABASE_URL (serverless/concurrent).
|
|
698
721
|
// WebAuthn rpID = the EXACT host per env (provision's interim domains). A passkey
|
|
699
|
-
// dies if the rpID changes, so
|
|
700
|
-
//
|
|
701
|
-
// is minted independently per env (base64url) so a derivative
|
|
702
|
-
// never boots broken (P-1).
|
|
722
|
+
// dies if the rpID changes, so the custom-domain swap (--domain / PROV-006)
|
|
723
|
+
// re-points the PRODUCTION WEBAUTHN_RP_ID too (`updateDomainEnv`, BOOT-004).
|
|
724
|
+
// MFA_ENCRYPTION_KEY is minted independently per env (base64url) so a derivative
|
|
725
|
+
// with MFA enabled never boots broken (P-1).
|
|
703
726
|
const rpDomains = provisionDomains(state.projectName);
|
|
704
727
|
const plan = buildEnvPlan({
|
|
705
728
|
mainDatabaseUrl: neonSecrets.mainPooled,
|
|
@@ -811,6 +834,19 @@ export { writeEnvLocal };
|
|
|
811
834
|
function repoHttpsUrl(repoSlug) {
|
|
812
835
|
return `https://github.com/${repoSlug}.git`;
|
|
813
836
|
}
|
|
837
|
+
/**
|
|
838
|
+
* Parse the `owner/name` slug out of a GitHub `origin` URL, HTTPS or SSH
|
|
839
|
+
* (`https://github.com/Owner/repo[.git]`, `git@github.com:Owner/repo[.git]`,
|
|
840
|
+
* `ssh://git@github.com/Owner/repo[.git]`). Returns `null` for anything else —
|
|
841
|
+
* including non-GitHub hosts, deliberately: deriving a slug from a GitLab/other
|
|
842
|
+
* origin and re-pointing it at `github.com` (see {@link repoHttpsUrl}) would
|
|
843
|
+
* silently retarget the project, so an unparsable origin falls back to the
|
|
844
|
+
* historical `FACTORY_ORG/projectName` default instead (BOOT-001).
|
|
845
|
+
*/
|
|
846
|
+
export function parseOriginRepoSlug(originUrl) {
|
|
847
|
+
const match = /^(?:https:\/\/|ssh:\/\/git@|git@)github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(originUrl.trim());
|
|
848
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
849
|
+
}
|
|
814
850
|
/** Default git runner — `execa` in the derived dir, no shell. */
|
|
815
851
|
async function gitInDir(args, cwd) {
|
|
816
852
|
const { stdout } = await execa('git', args, { cwd });
|
|
@@ -831,6 +867,47 @@ async function readOriginUrl(runGit, cwd) {
|
|
|
831
867
|
return null;
|
|
832
868
|
}
|
|
833
869
|
}
|
|
870
|
+
/**
|
|
871
|
+
* Resolve the GitHub repo slug (`owner/name`) the `github` step targets (BOOT-001
|
|
872
|
+
* — decouples the repo from the project slug). Priority:
|
|
873
|
+
* 1. The repo PERSISTED in the state (a prior run's authority — a `--resume`
|
|
874
|
+
* must never silently re-point a repo already in progress; a CONFLICTING
|
|
875
|
+
* `--repo` throws instead, mirroring the `--force` discipline of the other
|
|
876
|
+
* steps — discard the state with `--force` to start over).
|
|
877
|
+
* 2. An explicit `--repo=<org/name>` (the dev's stated intent — wins over an
|
|
878
|
+
* `origin` pointing elsewhere; the remote is updated via `buildGitSubstrate`'s
|
|
879
|
+
* existing same-repo/`--force` flow).
|
|
880
|
+
* 3. The existing `origin` remote, when it parses as a GitHub repo (a derived
|
|
881
|
+
* project whose repo name ≠ slug, or living outside the Factory org).
|
|
882
|
+
* 4. The historical default `${FACTORY_ORG}/${projectName}` (unchanged behavior
|
|
883
|
+
* when there is no flag and no `origin`).
|
|
884
|
+
*/
|
|
885
|
+
export async function resolveRepoSlug(rootDir, projectName, persistedRepo, explicitRepo, runGit) {
|
|
886
|
+
if (persistedRepo) {
|
|
887
|
+
// GitHub slugs are case-insensitive — only a REAL divergence is a conflict.
|
|
888
|
+
if (explicitRepo && explicitRepo.toLowerCase() !== persistedRepo.toLowerCase()) {
|
|
889
|
+
throw new CLIError(`El estado de provision ya registra el repo \`${persistedRepo}\` (de un run anterior), ` +
|
|
890
|
+
`distinto de \`--repo=${explicitRepo}\`.\n` +
|
|
891
|
+
'En `--resume` el estado es la autoridad; usa `--force` para descartarlo y empezar de cero con el repo nuevo.');
|
|
892
|
+
}
|
|
893
|
+
return persistedRepo;
|
|
894
|
+
}
|
|
895
|
+
if (explicitRepo)
|
|
896
|
+
return explicitRepo;
|
|
897
|
+
const fallback = `${FACTORY_ORG}/${projectName}`;
|
|
898
|
+
const origin = await readOriginUrl(runGit, rootDir);
|
|
899
|
+
if (origin) {
|
|
900
|
+
const derived = parseOriginRepoSlug(origin);
|
|
901
|
+
if (derived) {
|
|
902
|
+
// An origin at the SAME repo as the canonical default (case drift only, e.g.
|
|
903
|
+
// `TimeKast/Acme` vs `TimeKast/acme`) keeps the CANONICAL slug, so
|
|
904
|
+
// `buildGitSubstrate` re-cases the remote exactly as it always did (GitHub
|
|
905
|
+
// slugs are case-insensitive — reuse that normalization, don't fork it).
|
|
906
|
+
return derived.toLowerCase() === fallback.toLowerCase() ? fallback : derived;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return fallback;
|
|
910
|
+
}
|
|
834
911
|
/**
|
|
835
912
|
* Build the git substrate in the documented, asserted ORDER (issue §6):
|
|
836
913
|
* 1. rename `master` → `main` (only when the current branch is `master`)
|
|
@@ -870,11 +947,14 @@ async function buildGitSubstrate(rootDir, repoSlug, runGit, force) {
|
|
|
870
947
|
if (existingOrigin && existingOrigin !== url) {
|
|
871
948
|
// GitHub repo URLs are case-insensitive: an `origin` differing ONLY by case
|
|
872
949
|
// (e.g. `TimeKast/Edmond.git` vs the canonical lowercase `TimeKast/edmond.git`)
|
|
873
|
-
// points at the SAME repo.
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
|
|
950
|
+
// points at the SAME repo. So does an SSH origin (`git@github.com:Org/name.git`)
|
|
951
|
+
// whose parsed `owner/name` matches the resolved slug (BOOT-001 — the slug may
|
|
952
|
+
// have been DERIVED from that very origin). Either way, re-point it to the
|
|
953
|
+
// canonical HTTPS URL silently — no `--force` needed. A genuinely different
|
|
954
|
+
// repo → throw unless `--force` resets it.
|
|
955
|
+
const sameRepo = existingOrigin.toLowerCase() === url.toLowerCase() ||
|
|
956
|
+
parseOriginRepoSlug(existingOrigin)?.toLowerCase() === repoSlug.toLowerCase();
|
|
957
|
+
if (!sameRepo && !force) {
|
|
878
958
|
throw new CLIError(`El repo ya tiene un remote \`origin\` apuntando a otro URL (\`${existingOrigin}\` ≠ \`${url}\`).\n` +
|
|
879
959
|
'Usa `--force` para sobrescribirlo, o limpia el remote si reutilizas este directorio.');
|
|
880
960
|
}
|
|
@@ -988,7 +1068,10 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
|
|
|
988
1068
|
}
|
|
989
1069
|
const runGit = deps.runGit ?? gitInDir;
|
|
990
1070
|
const apiDeps = { fetchImpl: deps.fetchImpl };
|
|
991
|
-
|
|
1071
|
+
// BOOT-001: the repo is RESOLVED (--repo → existing origin → FACTORY_ORG/name),
|
|
1072
|
+
// no longer hardcoded to `${FACTORY_ORG}/${projectName}`. On resume the persisted
|
|
1073
|
+
// `state.github.repo` is the authority (a conflicting `--repo` throws).
|
|
1074
|
+
const repoSlug = await resolveRepoSlug(rootDir, state.projectName, state.github?.repo, deps.repo, runGit);
|
|
992
1075
|
// Record the repo on the state up-front so the Neon secret push (PROV-002) and a
|
|
993
1076
|
// `--resume` target it; this is durable even if a later REST step fails.
|
|
994
1077
|
state.github = { repo: repoSlug };
|
|
@@ -1287,44 +1370,262 @@ async function promptConfirm(message) {
|
|
|
1287
1370
|
const { ok } = await prompts({ type: 'confirm', name: 'ok', message, initial: false });
|
|
1288
1371
|
return ok === true;
|
|
1289
1372
|
}
|
|
1373
|
+
/** The `status` Resend reports once its DNS records validated (the poll's goal). */
|
|
1374
|
+
const RESEND_DOMAIN_VERIFIED_STATUS = 'verified';
|
|
1375
|
+
/** Shared headers for every Resend REST call (Bearer auth + JSON) — no-echo. */
|
|
1376
|
+
function resendHeaders(resendKey) {
|
|
1377
|
+
return {
|
|
1378
|
+
Authorization: `Bearer ${resendKey}`,
|
|
1379
|
+
'Content-Type': 'application/json',
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Build the FQDN for a record name Resend returns (BOOT-003 §4). Resend hands
|
|
1384
|
+
* record names RELATIVE to the registered domain (`send` → `send.updates.miapp.com`,
|
|
1385
|
+
* `resend._domainkey` → `resend._domainkey.updates.miapp.com`); Cloudflare's
|
|
1386
|
+
* `dns_records` API wants the fully-qualified name. Defensive on shape drift: a
|
|
1387
|
+
* name that already carries the domain (or the apex marker `@`) passes through.
|
|
1388
|
+
*/
|
|
1389
|
+
export function resendRecordFqdn(name, domain) {
|
|
1390
|
+
if (name === '' || name === '@' || name === domain)
|
|
1391
|
+
return domain;
|
|
1392
|
+
if (name.endsWith(`.${domain}`))
|
|
1393
|
+
return name;
|
|
1394
|
+
return `${name}.${domain}`;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Look up an existing Resend domain by NAME — `GET /domains` (list) then
|
|
1398
|
+
* `GET /domains/{id}` for its records. This is the `--resume` path (issue §8):
|
|
1399
|
+
* a re-run's `POST /domains` 403s when the domain already exists in THIS account,
|
|
1400
|
+
* so recovering the domain object lets the step re-ensure the DNS records and
|
|
1401
|
+
* re-poll verification instead of dead-ending on a warning. Returns `null` when
|
|
1402
|
+
* the domain is NOT in this account (then it really is "another account" and the
|
|
1403
|
+
* caller keeps the preserved warning behavior — issue §4). Best-effort: any
|
|
1404
|
+
* network/API failure degrades to `null`, never a throw.
|
|
1405
|
+
*/
|
|
1406
|
+
async function findResendDomain(domain, resendKey, fetchImpl) {
|
|
1407
|
+
try {
|
|
1408
|
+
const listResponse = await fetchImpl(`${RESEND_API_BASE}/domains`, {
|
|
1409
|
+
method: 'GET',
|
|
1410
|
+
headers: resendHeaders(resendKey),
|
|
1411
|
+
signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
|
|
1412
|
+
});
|
|
1413
|
+
if (!listResponse.ok)
|
|
1414
|
+
return null;
|
|
1415
|
+
const list = (await listResponse.json());
|
|
1416
|
+
const match = (list.data ?? []).find((d) => d.name === domain);
|
|
1417
|
+
if (!match?.id)
|
|
1418
|
+
return null;
|
|
1419
|
+
const getResponse = await fetchImpl(`${RESEND_API_BASE}/domains/${match.id}`, {
|
|
1420
|
+
method: 'GET',
|
|
1421
|
+
headers: resendHeaders(resendKey),
|
|
1422
|
+
signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
|
|
1423
|
+
});
|
|
1424
|
+
// Detail fetch failed → still return the id (poll works; records unknown → none created).
|
|
1425
|
+
if (!getResponse.ok)
|
|
1426
|
+
return { id: match.id, name: domain };
|
|
1427
|
+
return (await getResponse.json());
|
|
1428
|
+
}
|
|
1429
|
+
catch {
|
|
1430
|
+
return null;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Trigger Resend's DNS verification for a domain — `POST /domains/{id}/verify`
|
|
1435
|
+
* (BOOT-003 §4). Without this call Resend never starts checking the records and
|
|
1436
|
+
* the domain sits `not_started` forever (the exact bug this issue fixes). Best
|
|
1437
|
+
* effort: a failure here is non-fatal (the poll surfaces the real outcome, and
|
|
1438
|
+
* the step is re-runnable with `--resume`).
|
|
1439
|
+
*/
|
|
1440
|
+
async function triggerResendVerification(domainId, resendKey, fetchImpl) {
|
|
1441
|
+
try {
|
|
1442
|
+
await fetchImpl(`${RESEND_API_BASE}/domains/${domainId}/verify`, {
|
|
1443
|
+
method: 'POST',
|
|
1444
|
+
headers: resendHeaders(resendKey),
|
|
1445
|
+
signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
// Non-fatal — the verification poll below reports the actual state.
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Poll `GET /domains/{id}` until Resend reports `status: verified` or the timeout
|
|
1454
|
+
* budget runs out (BOOT-003 §4) — the Resend sibling of {@link pollDomainVerification},
|
|
1455
|
+
* same injectable `sleep`/`now`/timeout seams so the unit test never waits for real.
|
|
1456
|
+
* Returns `true` once verified; `false` on timeout OR on any network/API failure
|
|
1457
|
+
* (non-fatal either way — the DNS records are already created, Resend keeps
|
|
1458
|
+
* checking them in background, and the step is re-runnable with `--resume`).
|
|
1459
|
+
*/
|
|
1460
|
+
async function pollResendDomainVerification(domainId, resendKey, deps, fetchImpl) {
|
|
1461
|
+
const sleep = deps.sleep ?? realSleep;
|
|
1462
|
+
const now = deps.now ?? Date.now;
|
|
1463
|
+
const timeoutMs = (deps.verifyTimeoutMin ?? DEFAULT_VERIFY_TIMEOUT_MIN) * 60_000;
|
|
1464
|
+
const intervalMs = VERIFY_POLL_INTERVAL_SEC * 1_000;
|
|
1465
|
+
const deadline = now() + timeoutMs;
|
|
1466
|
+
// First check immediately (the domain may already be verified — idempotent re-run).
|
|
1467
|
+
for (;;) {
|
|
1468
|
+
let status;
|
|
1469
|
+
try {
|
|
1470
|
+
const response = await fetchImpl(`${RESEND_API_BASE}/domains/${domainId}`, {
|
|
1471
|
+
method: 'GET',
|
|
1472
|
+
headers: resendHeaders(resendKey),
|
|
1473
|
+
signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
|
|
1474
|
+
});
|
|
1475
|
+
if (!response.ok)
|
|
1476
|
+
return false;
|
|
1477
|
+
status = (await response.json()).status;
|
|
1478
|
+
}
|
|
1479
|
+
catch {
|
|
1480
|
+
// A failure while POLLING is non-fatal: pending + resumable, never a throw.
|
|
1481
|
+
return false;
|
|
1482
|
+
}
|
|
1483
|
+
if (status === RESEND_DOMAIN_VERIFIED_STATUS)
|
|
1484
|
+
return true;
|
|
1485
|
+
if (now() >= deadline)
|
|
1486
|
+
return false;
|
|
1487
|
+
console.log(` … esperando verificación del dominio en Resend (reintento en ${VERIFY_POLL_INTERVAL_SEC}s)`);
|
|
1488
|
+
await sleep(intervalMs);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1290
1491
|
/**
|
|
1291
|
-
* Register the client domain in Resend
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1492
|
+
* Register the client mail domain in Resend AND make it verifiable end-to-end
|
|
1493
|
+
* (BOOT-003): `POST /domains { name }` with the rail's `RESEND_API_KEY`, then
|
|
1494
|
+
* CREATE the DNS records Resend returns (DKIM/SPF/MX — exactly the ones it
|
|
1495
|
+
* returns, never an assumed fixed set, §8) in the client's Cloudflare zone via
|
|
1496
|
+
* {@link ensureDnsRecord} (create-or-PATCH, idempotent on re-run, never delete),
|
|
1497
|
+
* trigger verification, and poll until `verified` or timeout.
|
|
1498
|
+
*
|
|
1499
|
+
* `dns` carries the zone + token already resolved for the client-domain CNAME
|
|
1500
|
+
* (same org/cliente-cf route); `null` = the `registrar` route (no DNS API — the
|
|
1501
|
+
* records must be created by hand in the Resend dashboard; extending
|
|
1502
|
+
* `printManualRecords` to them is an explicit follow-up, issue §9).
|
|
1503
|
+
*
|
|
1504
|
+
* Non-fatal by design — the domain switch already happened on Vercel:
|
|
1505
|
+
* - absent `RESEND_API_KEY` → warning (mail stays unconfigured).
|
|
1506
|
+
* - `POST /domains` rejected → recover the domain from THIS account when it
|
|
1507
|
+
* exists (`--resume`, §8) or keep the preserved warning ("another account", §4).
|
|
1508
|
+
* - a record create/PATCH failure → warning; the poll is skipped (it cannot
|
|
1509
|
+
* succeed with missing records) and the step stays re-runnable.
|
|
1510
|
+
* - poll timeout → warning + `pending-verification` note, NEVER a hard failure
|
|
1511
|
+
* (§8 — the records are in place; Resend keeps verifying in background).
|
|
1512
|
+
*
|
|
1513
|
+
* No-echo: the Resend key and Cloudflare token never reach stdout.
|
|
1296
1514
|
*/
|
|
1297
|
-
async function registerResendDomain(domain, envPath, fetchImpl) {
|
|
1515
|
+
async function registerResendDomain(domain, envPath, deps, fetchImpl, dns) {
|
|
1298
1516
|
const resendKey = readGateValue('RESEND_API_KEY', envPath);
|
|
1299
1517
|
if (!resendKey) {
|
|
1300
1518
|
console.warn('⚠ No hay `RESEND_API_KEY` en `~/.claude/.env` — omito el alta del dominio en Resend ' +
|
|
1301
1519
|
'(bájala con `factory secrets <archivo>` y reintenta con `--resume`).');
|
|
1302
1520
|
return;
|
|
1303
1521
|
}
|
|
1522
|
+
// 1. Register (or recover, on re-run) the domain in Resend.
|
|
1523
|
+
let resendDomain = null;
|
|
1304
1524
|
try {
|
|
1305
1525
|
const response = await fetchImpl(`${RESEND_API_BASE}/domains`, {
|
|
1306
1526
|
method: 'POST',
|
|
1307
|
-
headers:
|
|
1308
|
-
Authorization: `Bearer ${resendKey}`,
|
|
1309
|
-
'Content-Type': 'application/json',
|
|
1310
|
-
},
|
|
1527
|
+
headers: resendHeaders(resendKey),
|
|
1311
1528
|
body: JSON.stringify({ name: domain }),
|
|
1312
1529
|
signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
|
|
1313
1530
|
});
|
|
1314
|
-
if (
|
|
1315
|
-
|
|
1316
|
-
console.
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1531
|
+
if (response.ok) {
|
|
1532
|
+
resendDomain = (await response.json());
|
|
1533
|
+
console.log(` + Resend: dominio ${domain} registrado`);
|
|
1534
|
+
}
|
|
1535
|
+
else {
|
|
1536
|
+
// Rejected (e.g. 403 already-exists). If the domain lives in THIS account
|
|
1537
|
+
// (a `--resume` re-run), recover it and continue; otherwise keep the
|
|
1538
|
+
// preserved warning behavior (issue §4) — warn + continue, never abort.
|
|
1539
|
+
const status = response.status;
|
|
1540
|
+
resendDomain = await findResendDomain(domain, resendKey, fetchImpl);
|
|
1541
|
+
if (resendDomain) {
|
|
1542
|
+
console.log(` ↻ Resend: dominio ${domain} ya registrado en esta cuenta (reuso sus records)`);
|
|
1543
|
+
}
|
|
1544
|
+
else {
|
|
1545
|
+
console.warn(`⚠ Resend rechazó el alta de \`${domain}\` (status ${status}). ` +
|
|
1546
|
+
'Quizá ya está verificado en otra cuenta — `EMAIL_FROM` se actualiza igual; ' +
|
|
1547
|
+
'verifica el dominio a mano en Resend si hace falta.');
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1320
1550
|
}
|
|
1321
|
-
console.log(` + Resend: dominio ${domain} registrado (verifica los records DNS en Resend)`);
|
|
1322
1551
|
}
|
|
1323
1552
|
catch (error) {
|
|
1324
1553
|
// A network/timeout failure on Resend is non-fatal — the domain switch already
|
|
1325
1554
|
// happened on Vercel; Resend verification is re-runnable with `--resume`.
|
|
1326
1555
|
console.warn(`⚠ No se pudo dar de alta \`${domain}\` en Resend (${error?.message ?? 'error'}). ` +
|
|
1327
1556
|
'Reintenta con `--resume`; `EMAIL_FROM` ya se actualizó.');
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
if (resendDomain.status === RESEND_DOMAIN_VERIFIED_STATUS) {
|
|
1560
|
+
console.log(` ✔ Resend: dominio ${domain} ya verificado`);
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
// 2. Create the DNS records Resend returned — the ones it RETURNS, no fixed set (§8).
|
|
1564
|
+
if (!dns) {
|
|
1565
|
+
// `registrar` route: no DNS API — the records live in the Resend dashboard and
|
|
1566
|
+
// must be created by hand (printing them here is a follow-up — issue §9).
|
|
1567
|
+
console.warn(`⚠ (registrar) Los records DNS de Resend para \`${domain}\` no se crean automáticamente. ` +
|
|
1568
|
+
'Créalos a mano desde el dashboard de Resend y verifica el dominio ahí.');
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const records = resendDomain.records ?? [];
|
|
1572
|
+
const cloudflareDeps = { fetchImpl };
|
|
1573
|
+
let allEnsured = true;
|
|
1574
|
+
for (const record of records) {
|
|
1575
|
+
const type = record.type?.toUpperCase();
|
|
1576
|
+
if ((type !== 'TXT' && type !== 'MX') || !record.name || !record.value) {
|
|
1577
|
+
// Only the types Resend requires are supported (issue §9) — anything else
|
|
1578
|
+
// (or a malformed record) is surfaced for manual creation, never guessed.
|
|
1579
|
+
console.warn(` ⚠ Record de Resend no soportado o incompleto (${record.record ?? record.type ?? '?'}) — créalo a mano en Cloudflare.`);
|
|
1580
|
+
allEnsured = false;
|
|
1581
|
+
continue;
|
|
1582
|
+
}
|
|
1583
|
+
const spec = {
|
|
1584
|
+
type,
|
|
1585
|
+
name: resendRecordFqdn(record.name, domain),
|
|
1586
|
+
content: record.value,
|
|
1587
|
+
...(record.priority !== undefined ? { priority: record.priority } : {}),
|
|
1588
|
+
};
|
|
1589
|
+
try {
|
|
1590
|
+
const outcome = await ensureDnsRecord(dns.zoneId, spec, dns.token, cloudflareDeps);
|
|
1591
|
+
const mark = outcome === 'created'
|
|
1592
|
+
? '+'
|
|
1593
|
+
: outcome === 'updated'
|
|
1594
|
+
? '↻ (contenido actualizado)'
|
|
1595
|
+
: '⏭ (ya estaba)';
|
|
1596
|
+
console.log(` ${mark} ${spec.type} ${spec.name} (record ${record.record ?? 'DNS'} de Resend)`);
|
|
1597
|
+
}
|
|
1598
|
+
catch (error) {
|
|
1599
|
+
// Non-fatal (the mail path is additive): warn with the actionable mapping
|
|
1600
|
+
// and keep going — the step is re-runnable with `--resume`.
|
|
1601
|
+
allEnsured = false;
|
|
1602
|
+
const detail = error instanceof CloudflareApiError
|
|
1603
|
+
? cloudflareErrorToCliError(error, `crear el record ${spec.type} \`${spec.name}\``)
|
|
1604
|
+
.message
|
|
1605
|
+
: (error?.message ?? 'error');
|
|
1606
|
+
console.warn(` ⚠ No se pudo asegurar el record ${spec.type} \`${spec.name}\`: ${detail}`);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
if (!allEnsured) {
|
|
1610
|
+
console.warn(`⚠ El dominio \`${domain}\` queda pending en Resend (faltaron records DNS). ` +
|
|
1611
|
+
'Corrige lo anterior y reintenta con `--resume`.');
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
// 3. Trigger verification + poll until `verified` or timeout (never a hard fail).
|
|
1615
|
+
if (!resendDomain.id) {
|
|
1616
|
+
console.warn(`⚠ Resend no devolvió un id para \`${domain}\` — no puedo sondear la verificación. ` +
|
|
1617
|
+
'Revisa el dominio en el dashboard de Resend.');
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
await triggerResendVerification(resendDomain.id, resendKey, fetchImpl);
|
|
1621
|
+
const verified = await pollResendDomainVerification(resendDomain.id, resendKey, deps, fetchImpl);
|
|
1622
|
+
if (verified) {
|
|
1623
|
+
console.log(` ✔ Resend: dominio ${domain} verificado`);
|
|
1624
|
+
}
|
|
1625
|
+
else {
|
|
1626
|
+
// §8: the records ARE created — pending-verification, resumable, never fatal.
|
|
1627
|
+
console.warn(`⚠ El dominio \`${domain}\` quedó pending-verification en Resend (los records DNS ya están; ` +
|
|
1628
|
+
'Resend sigue verificando en background). Reintenta con `--resume` o revisa el dashboard.');
|
|
1328
1629
|
}
|
|
1329
1630
|
}
|
|
1330
1631
|
/** Print the exact DNS records the owner must create for the `--dns=registrar` path. */
|
|
@@ -1421,6 +1722,9 @@ async function resolveDnsToken(route, envPath, headless) {
|
|
|
1421
1722
|
* - domain already attached to Vercel (409) → only polls, never re-adds.
|
|
1422
1723
|
* - interim retire fails → WARNING, the step is NOT marked failed (interim stays).
|
|
1423
1724
|
* - Resend already verified elsewhere → warning + continue.
|
|
1725
|
+
* - Resend DNS records (BOOT-003) → `ensureDnsRecord` create-or-PATCH (a rotated
|
|
1726
|
+
* DKIM is PATCHed in place, never deleted); a Resend poll timeout is a WARNING
|
|
1727
|
+
* (pending-verification, resumable), never a step failure.
|
|
1424
1728
|
* - `AUTH_URL`/`NEXTAUTH_URL` already correct → the upsert PATCH is omitted.
|
|
1425
1729
|
*
|
|
1426
1730
|
* On timeout the state is left `clientDomainStatus: pending-verification` (resumable);
|
|
@@ -1456,6 +1760,9 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
1456
1760
|
throw error;
|
|
1457
1761
|
}
|
|
1458
1762
|
// 2. DNS record: org/cliente-cf CREATE the CNAME; registrar only PRINTS it (§4).
|
|
1763
|
+
// The resolved zone + token are kept for step 6: the Resend DKIM/SPF/MX records
|
|
1764
|
+
// live in the SAME client zone (`updates.<domain>` shares the apex — BOOT-003).
|
|
1765
|
+
let resendDns = null;
|
|
1459
1766
|
if (route === 'registrar') {
|
|
1460
1767
|
// Print the canonical record now; the poll loop re-prints Vercel's verification.
|
|
1461
1768
|
printManualRecords(domain, []);
|
|
@@ -1474,6 +1781,7 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
1474
1781
|
}
|
|
1475
1782
|
throw error; // resolveZoneIdForName's own CLIError (zone not found) propagates.
|
|
1476
1783
|
}
|
|
1784
|
+
resendDns = { zoneId, token: cfToken };
|
|
1477
1785
|
try {
|
|
1478
1786
|
const cnameOutcome = await ensureCnameRecord(zoneId, domain, cfToken, cloudflareDeps);
|
|
1479
1787
|
const mark = cnameOutcome === 'created'
|
|
@@ -1514,7 +1822,9 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
1514
1822
|
// 6. Register the client's MAIL SUBDOMAIN (`updates.<domain>`) in Resend — NOT the
|
|
1515
1823
|
// apex, which holds the client's corporate MX/SPF (Google Workspace, M365, …).
|
|
1516
1824
|
// Verifying Resend on a dedicated subdomain keeps the client's own mail intact.
|
|
1517
|
-
|
|
1825
|
+
// BOOT-003: also CREATES the DKIM/SPF/MX records Resend returns (in the same
|
|
1826
|
+
// client zone as the CNAME) and polls until Resend reports `verified`.
|
|
1827
|
+
await registerResendDomain(mailDomainFor(domain), envPath, deps, fetchImpl, resendDns);
|
|
1518
1828
|
// 7. Record the client domain as LIVE and persist.
|
|
1519
1829
|
state.dns = {
|
|
1520
1830
|
...(state.dns ?? {
|
|
@@ -1532,6 +1842,12 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
1532
1842
|
* pre-approves; otherwise a TTY prompt asks (headless without `--confirm` → NOT
|
|
1533
1843
|
* retired). The DEV domain (`{slug}-dev.timekast.mx`) is ALWAYS conserved. A
|
|
1534
1844
|
* removal failure is a WARNING, never a step failure (issue §8 — interim stays up).
|
|
1845
|
+
*
|
|
1846
|
+
* BOOT-007: once Vercel detaches the interim, its CNAME in the org `timekast.mx`
|
|
1847
|
+
* zone is deleted too — otherwise it stays orphaned, resolving to Vercel's generic
|
|
1848
|
+
* target and serving a 404 forever. The delete lives INSIDE the confirmed block
|
|
1849
|
+
* (a declined prompt never touches Cloudflare) and is SKIPPED when the Vercel
|
|
1850
|
+
* detach failed (the interim is still live — its CNAME must keep resolving).
|
|
1535
1851
|
*/
|
|
1536
1852
|
async function retireInterimDomain(projectId, token, teamId, state, deps, fetchImpl) {
|
|
1537
1853
|
const interim = provisionDomains(state.projectName).prod;
|
|
@@ -1558,15 +1874,78 @@ async function retireInterimDomain(projectId, token, teamId, state, deps, fetchI
|
|
|
1558
1874
|
// the step is NOT marked failed, the dev retires it manually later.
|
|
1559
1875
|
console.warn(`⚠ No se pudo retirar el dominio interino \`${interim}\` (${error?.message ?? 'error'}).\n` +
|
|
1560
1876
|
' Queda activo; retíralo a mano en Vercel o reintenta con `--resume`. El paso NO falla por esto.');
|
|
1877
|
+
// BOOT-007: the interim is still attached to Vercel — its CNAME stays too.
|
|
1878
|
+
return;
|
|
1561
1879
|
}
|
|
1562
1880
|
// The DEV domain is intentionally never touched (issue §4 — conserved).
|
|
1881
|
+
// BOOT-007: the interim is off Vercel — delete its now-orphaned CNAME as well.
|
|
1882
|
+
await deleteInterimCname(interim, deps.envPath ?? defaultEnvPath(), fetchImpl);
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Delete the interim CNAME (`{slug}.timekast.mx`) from the org Cloudflare zone
|
|
1886
|
+
* after Vercel detached the domain (BOOT-007). Without this, the record stays
|
|
1887
|
+
* orphaned: it keeps resolving to `cname.vercel-dns.com` while no Vercel project
|
|
1888
|
+
* answers for the host → a 404 forever, dead weight in the `timekast.mx` zone.
|
|
1889
|
+
*
|
|
1890
|
+
* Runs ONLY inside the already-confirmed retire block (a declined prompt never
|
|
1891
|
+
* reaches here). Best-effort by design (issue §4 + §8): EVERY failure — missing
|
|
1892
|
+
* rail token, zone not visible to the token (the `resolveZoneId` CLIError that is
|
|
1893
|
+
* hard in the CREATION path), an out-of-scope token, a 5xx on the DELETE — is a
|
|
1894
|
+
* WARNING, never a step failure; the record stays and the cleanup is retryable.
|
|
1895
|
+
* Idempotent: an already-absent record (`findCnameRecord` → null, or a 404 on the
|
|
1896
|
+
* DELETE) is a narrated no-op. The record content is NOT validated before deleting
|
|
1897
|
+
* (this is a retirement, not a repair — issue §8). The DEV domain's CNAME is never
|
|
1898
|
+
* touched: only the prod interim name is looked up.
|
|
1899
|
+
*/
|
|
1900
|
+
async function deleteInterimCname(interim, envPath, fetchImpl) {
|
|
1901
|
+
const warnRetryable = (reason) => {
|
|
1902
|
+
console.warn(`⚠ No se pudo borrar el CNAME interino \`${interim}\` en Cloudflare (${reason}).\n` +
|
|
1903
|
+
` El registro queda en la zona \`${TIMEKAST_ZONE_NAME}\`; bórralo a mano o reintenta. El paso NO falla por esto.`);
|
|
1904
|
+
};
|
|
1905
|
+
const cloudflareToken = readCloudflareApiKey(envPath);
|
|
1906
|
+
if (!cloudflareToken) {
|
|
1907
|
+
warnRetryable('sin `CLOUDFLARE_API_TOKEN` en el rail');
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
const cloudflareDeps = { fetchImpl };
|
|
1911
|
+
try {
|
|
1912
|
+
const zoneId = await resolveZoneId(cloudflareToken, cloudflareDeps);
|
|
1913
|
+
const record = await findCnameRecord(zoneId, interim, cloudflareToken, cloudflareDeps);
|
|
1914
|
+
// Same outcome narration as the Vercel retire above (no-echo — only the
|
|
1915
|
+
// domain NAME prints; the token never crosses this boundary).
|
|
1916
|
+
const outcome = record
|
|
1917
|
+
? await deleteDnsRecord(zoneId, record.id, cloudflareToken, cloudflareDeps)
|
|
1918
|
+
: 'absent';
|
|
1919
|
+
console.log(outcome === 'absent'
|
|
1920
|
+
? ` ⏭ (ya no estaba) CNAME interino ${interim} en Cloudflare`
|
|
1921
|
+
: ` - CNAME interino ${interim} borrado de Cloudflare`);
|
|
1922
|
+
}
|
|
1923
|
+
catch (error) {
|
|
1924
|
+
// §8: zone not visible (CLIError) / out-of-scope token / 5xx → warning,
|
|
1925
|
+
// retryable — never the hard CLIError the creation path uses.
|
|
1926
|
+
warnRetryable(error?.message ?? 'error');
|
|
1927
|
+
}
|
|
1563
1928
|
}
|
|
1564
1929
|
/**
|
|
1565
|
-
* Update `EMAIL_FROM` + `AUTH_URL`/`NEXTAUTH_URL` + `NEXT_PUBLIC_APP_URL`
|
|
1566
|
-
* Vercel env, per-target and
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1930
|
+
* Update `EMAIL_FROM` + `AUTH_URL`/`NEXTAUTH_URL` + `NEXT_PUBLIC_APP_URL` +
|
|
1931
|
+
* `WEBAUTHN_RP_ID` to the client domain in the Vercel env, per-target and
|
|
1932
|
+
* idempotently (PROV-006 §4 + BOOT-004). Lists the existing vars once and upserts
|
|
1933
|
+
* each entry via `upsertEnvVar`, which reconciles a key that the substrate left
|
|
1934
|
+
* SPLIT per target (e.g. `NEXT_PUBLIC_APP_URL` as `['production']` + `['preview']`
|
|
1935
|
+
* from `buildEnvPlan`) into ONE combined-target record (BOOT-002 — a single-record
|
|
1936
|
+
* PATCH would 400 against the sibling). A second run over the reconciled state
|
|
1937
|
+
* re-PATCHes in place: no duplicate records, no 400 (issue §8).
|
|
1938
|
+
*
|
|
1939
|
+
* `WEBAUTHN_RP_ID` is re-pointed for PRODUCTION ONLY: the interim dev domain stays
|
|
1940
|
+
* the preview rpID (the dev domain is never retired — see `retireInterimDomain`),
|
|
1941
|
+
* so `buildEnvPlan`'s intentional split pair is preserved — the single-target plan
|
|
1942
|
+
* only PATCHes the overlapping production record and never touches the preview
|
|
1943
|
+
* sibling (no BOOT-002 400 reintroduced). Changing the rpID invalidates every
|
|
1944
|
+
* passkey registered under the interim rpID and the CLI cannot count them (that
|
|
1945
|
+
* table lives in the derivative's DB), so a warning prints UNCONDITIONALLY.
|
|
1946
|
+
*
|
|
1947
|
+
* No-echo: only KEY NAMES + targets print (the values here are public, but the
|
|
1948
|
+
* discipline holds).
|
|
1570
1949
|
*/
|
|
1571
1950
|
async function updateDomainEnv(projectId, token, teamId, domain, deps) {
|
|
1572
1951
|
const authUrl = `https://${domain}`;
|
|
@@ -1583,12 +1962,22 @@ async function updateDomainEnv(projectId, token, teamId, domain, deps) {
|
|
|
1583
1962
|
// Keep the public app origin in lockstep with AUTH_URL once a client domain lands
|
|
1584
1963
|
// (the substrate set it to the interim domain in buildEnvPlan; this re-points it).
|
|
1585
1964
|
{ key: 'NEXT_PUBLIC_APP_URL', value: authUrl, target: ['production', 'preview'] },
|
|
1965
|
+
// WebAuthn rpID is the registrable HOST, never a URL — the bare domain (BOOT-004).
|
|
1966
|
+
// PRODUCTION only: the interim dev domain remains the preview rpID (the dev domain
|
|
1967
|
+
// is never retired), so the intentional split pair from buildEnvPlan is preserved.
|
|
1968
|
+
{ key: 'WEBAUTHN_RP_ID', value: domain, target: ['production'] },
|
|
1586
1969
|
];
|
|
1587
1970
|
const existing = await listEnvVars(projectId, token, teamId, deps);
|
|
1588
1971
|
for (const entry of plan) {
|
|
1589
1972
|
const outcome = await upsertEnvVar(projectId, token, teamId, entry, existing, deps);
|
|
1590
1973
|
console.log(` ${outcome === 'created' ? '+' : '↻'} ${entry.key} [${entry.target.join(', ')}]`);
|
|
1591
1974
|
}
|
|
1975
|
+
// BOOT-004: changing the rpID orphans every passkey registered under the interim
|
|
1976
|
+
// rpID. The CLI cannot query how many exist (that table lives in the derivative's
|
|
1977
|
+
// DB, unreachable from provisioning), so the warning is UNCONDITIONAL.
|
|
1978
|
+
console.warn(`⚠ WEBAUTHN_RP_ID (production) ahora es \`${domain}\`: cualquier passkey registrada ` +
|
|
1979
|
+
'bajo el rpID interino queda invalidada — esos usuarios deben registrar una nueva ' +
|
|
1980
|
+
'passkey en el dominio definitivo.');
|
|
1592
1981
|
}
|
|
1593
1982
|
/**
|
|
1594
1983
|
* Print the resolved client-domain operation tree for `--domain --dry-run`
|
|
@@ -1609,7 +1998,14 @@ function printClientDomainPlan(domain, interim, route, confirm) {
|
|
|
1609
1998
|
console.log(` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim} ` +
|
|
1610
1999
|
`(el dev ${domain}-dev se conserva)`);
|
|
1611
2000
|
console.log(` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · AUTH_URL=https://${domain} · NEXTAUTH_URL=https://${domain} · NEXT_PUBLIC_APP_URL=https://${domain}`);
|
|
2001
|
+
console.log(` ↻ WEBAUTHN_RP_ID=${domain} (solo production — las passkeys del rpID interino quedan invalidadas; el preview conserva el rpID dev)`);
|
|
1612
2002
|
console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
|
|
2003
|
+
if (route === 'registrar') {
|
|
2004
|
+
console.log(' · (registrar) los records DNS de Resend (DKIM/SPF/MX) se crean a mano en el dashboard de Resend');
|
|
2005
|
+
}
|
|
2006
|
+
else {
|
|
2007
|
+
console.log(` + records DNS de Resend (DKIM/SPF/MX, los que devuelva) en la zona ${apexZoneOf(domain)} + sondeo hasta verified`);
|
|
2008
|
+
}
|
|
1613
2009
|
console.log('\n(`--dry-run`) No se ejecutó ninguna llamada a API ni se escribió estado.');
|
|
1614
2010
|
}
|
|
1615
2011
|
/**
|
|
@@ -1631,7 +2027,10 @@ async function runServiceStep(service, rootDir, state, flags) {
|
|
|
1631
2027
|
// PROV-004: git substrate (rename → remote add → push main) + create the
|
|
1632
2028
|
// Vercel project + connect git (GitHub App) + PATCH production-branch +
|
|
1633
2029
|
// .vercel/project.json + activate E2E. Migrations run in vercel-build, not a workflow.
|
|
1634
|
-
await provisionGithubVercel(rootDir, state, flags.team, {
|
|
2030
|
+
await provisionGithubVercel(rootDir, state, flags.team, {
|
|
2031
|
+
force: flags.force,
|
|
2032
|
+
repo: flags.repo,
|
|
2033
|
+
});
|
|
1635
2034
|
break;
|
|
1636
2035
|
case 'dns':
|
|
1637
2036
|
// PROV-005: attach the interim domains to Vercel (dev → develop, prod →
|
|
@@ -2431,6 +2830,16 @@ export async function runProvision(flags) {
|
|
|
2431
2830
|
// In patch mode the projectName comes from the loaded state (the interim slug);
|
|
2432
2831
|
// a fresh run derives it from flags (the scaffold default).
|
|
2433
2832
|
const projectName = state?.projectName ?? deriveProjectName(flags, rootDir);
|
|
2833
|
+
// BOOT-001: resolve the target GitHub repo ONCE (--repo → existing origin →
|
|
2834
|
+
// FACTORY_ORG/projectName) for every consumer of the slug: the plan/report
|
|
2835
|
+
// (`--dry-run` included), the Neon E2E-secret push (which runs BEFORE the github
|
|
2836
|
+
// step) and the github step itself. Reading the local `origin` is a read-only
|
|
2837
|
+
// git call — safe under `--dry-run` (no REST, no state write). On resume the
|
|
2838
|
+
// persisted repo is the authority (a conflicting `--repo` throws, never a
|
|
2839
|
+
// silent re-point).
|
|
2840
|
+
const repoSlug = flags.services.includes('github') || flags.services.includes('neon')
|
|
2841
|
+
? await resolveRepoSlug(rootDir, projectName, state?.github?.repo, flags.repo, gitInDir)
|
|
2842
|
+
: undefined;
|
|
2434
2843
|
const steps = buildPlan(flags.services, state);
|
|
2435
2844
|
const result = {
|
|
2436
2845
|
projectName,
|
|
@@ -2439,6 +2848,9 @@ export async function runProvision(flags) {
|
|
|
2439
2848
|
resumed: (flags.resume || standaloneDomain) && !flags.force,
|
|
2440
2849
|
steps,
|
|
2441
2850
|
};
|
|
2851
|
+
if (repoSlug) {
|
|
2852
|
+
result.repo = repoSlug;
|
|
2853
|
+
}
|
|
2442
2854
|
if (hasDomain) {
|
|
2443
2855
|
result.clientDomain = { domain: flags.domain, route: flags.dns };
|
|
2444
2856
|
}
|
|
@@ -2472,6 +2884,13 @@ export async function runProvision(flags) {
|
|
|
2472
2884
|
scope: flags.team ?? flags.orgId ?? '',
|
|
2473
2885
|
createdAt: new Date().toISOString(),
|
|
2474
2886
|
};
|
|
2887
|
+
// BOOT-001: record the resolved repo BEFORE the steps run, so the Neon E2E-secret
|
|
2888
|
+
// push (`neonSecretRepo`, which runs before the github step) targets the resolved
|
|
2889
|
+
// repo — not the historical `${FACTORY_ORG}/${projectName}` fallback. Never
|
|
2890
|
+
// clobbers a resumed state's repo (that one is the authority).
|
|
2891
|
+
if (repoSlug && !runState.github?.repo) {
|
|
2892
|
+
runState.github = { repo: repoSlug };
|
|
2893
|
+
}
|
|
2475
2894
|
// Ensure the state dir exists + a baseline file before the first step, so a
|
|
2476
2895
|
// step failure on the very first service still leaves a resumable state.
|
|
2477
2896
|
writeProvisionState(rootDir, runState);
|
package/dist/commands/update.js
CHANGED
|
@@ -58,6 +58,7 @@ export function parseUpdateFlags(argv) {
|
|
|
58
58
|
mineAll: argv.includes('--mine-all'),
|
|
59
59
|
resume: argv.includes('--resume'),
|
|
60
60
|
verify: argv.includes('--verify'),
|
|
61
|
+
dryRun: argv.includes('--dry-run'),
|
|
61
62
|
core: argv.includes('--core'),
|
|
62
63
|
full: argv.includes('--full'),
|
|
63
64
|
commit: argv.includes('--commit'),
|
|
@@ -395,6 +396,26 @@ function syncDerivedPrettierignore(rootDir, stagedDir) {
|
|
|
395
396
|
console.warn('Aviso: no se pudo sincronizar `.prettierignore`; el cerebro se instaló igual.');
|
|
396
397
|
}
|
|
397
398
|
}
|
|
399
|
+
/** Exit code of `--dry-run` when the preview found conflicts (0 = none). Lets a
|
|
400
|
+
* non-interactive caller (the GUI) branch on the result instead of clobbering. */
|
|
401
|
+
const DRY_RUN_CONFLICTS_EXIT = 2;
|
|
402
|
+
/**
|
|
403
|
+
* `--dry-run`: preview the plan (buckets + the explicit conflict list) and apply
|
|
404
|
+
* NOTHING. Prints the conflicting paths so a human or agent sees exactly what
|
|
405
|
+
* collides before deciding. The GUI runs this first: no conflicts → it is safe to
|
|
406
|
+
* apply `--theirs-all` (only adds/overwrites of clean files); conflicts → hand off
|
|
407
|
+
* to the CLI for per-file resolution instead of silently taking theirs.
|
|
408
|
+
*/
|
|
409
|
+
function reportDryRun(diff, manifest) {
|
|
410
|
+
console.log(`\n🔍 Vista previa de update a v${manifest.version} — no se modificó nada:`);
|
|
411
|
+
reportSummary(diff, manifest);
|
|
412
|
+
if (diff.conflicts.length > 0) {
|
|
413
|
+
console.log('\nArchivos en conflicto (los modificaste tú y el Factory también los cambió):');
|
|
414
|
+
for (const c of diff.conflicts)
|
|
415
|
+
console.log(` • ${c.path}`);
|
|
416
|
+
console.log('\nPara resolverlos, corre `factory update` (sin `--dry-run`): pregunta archivo por archivo.');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
398
419
|
/** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
|
|
399
420
|
function reportSummary(diff, manifest) {
|
|
400
421
|
if (diff.deleteSilent.length > 0) {
|
|
@@ -469,6 +490,9 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
469
490
|
if (flags.beta && flags.stable) {
|
|
470
491
|
throw new CLIError('No puedes combinar `--beta` con `--stable`.');
|
|
471
492
|
}
|
|
493
|
+
if (flags.dryRun && (flags.verify || flags.resume)) {
|
|
494
|
+
throw new CLIError('No puedes combinar `--dry-run` con `--verify` ni `--resume`.');
|
|
495
|
+
}
|
|
472
496
|
// --verify and --resume short-circuit (no download).
|
|
473
497
|
if (flags.verify) {
|
|
474
498
|
runVerify(rootDir);
|
|
@@ -529,6 +553,14 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
529
553
|
'El release está mal publicado; no se modificó nada.');
|
|
530
554
|
}
|
|
531
555
|
if (legacy) {
|
|
556
|
+
// --dry-run on a no-lockfile repo: nothing to conflict with (first sync has no
|
|
557
|
+
// prior version), so report clean and STOP — never auto-register under dry-run.
|
|
558
|
+
if (flags.dryRun) {
|
|
559
|
+
console.log('\n🔍 Vista previa: este repo aún no tiene lockfile. El primer `factory update` ' +
|
|
560
|
+
'registra el cerebro; en el primer sync no hay conflictos posibles.');
|
|
561
|
+
process.exitCode = 0;
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
532
564
|
await applyLegacy(rootDir, stagedDir, manifest);
|
|
533
565
|
// Legacy auto-register succeeded → end-pointer (PVSKILL-002).
|
|
534
566
|
printProvisionPointer({ assumeYes: flags.yes });
|
|
@@ -542,6 +574,14 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
542
574
|
]);
|
|
543
575
|
const disk = hashDiskPaths(rootDir, trackedPaths);
|
|
544
576
|
const diff = diffLockfiles(oldLock, manifest, disk);
|
|
577
|
+
// --dry-run: preview + STOP. No prompts, no apply, no lockfile, no commit.
|
|
578
|
+
// Exit 2 when conflicts exist so the GUI branches on it (0 otherwise). `return`
|
|
579
|
+
// runs the finally → tmpRoot is cleaned (no update-state was written).
|
|
580
|
+
if (flags.dryRun) {
|
|
581
|
+
reportDryRun(diff, manifest);
|
|
582
|
+
process.exitCode = diff.conflicts.length > 0 ? DRY_RUN_CONFLICTS_EXIT : 0;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
545
585
|
// Resolve conflicts.
|
|
546
586
|
const theirsConflicts = [];
|
|
547
587
|
for (const conflict of diff.conflicts) {
|
package/dist/index.js
CHANGED
|
@@ -69,6 +69,7 @@ Sobre \`update\`:
|
|
|
69
69
|
--mine-all Conserva tu versión en todos los conflictos
|
|
70
70
|
--resume Retoma un update interrumpido sin re-descargar
|
|
71
71
|
--verify Reporta diferencias disco-vs-lockfile sin modificar nada
|
|
72
|
+
--dry-run Previsualiza el plan (conflictos incluidos) SIN aplicar; sale 2 si hay conflictos
|
|
72
73
|
--beta Entra al canal beta (instala el último pre-release \`-beta.N\`)
|
|
73
74
|
--stable Sale del canal beta y baja al estable (limpia \`beta\` del lockfile)
|
|
74
75
|
--yes / -y Auto-confirma el auto-registro de un repo legacy (sin lockfile) —
|
|
@@ -16,8 +16,12 @@
|
|
|
16
16
|
* actionable `CLIError` (issue §8 Gherkin) — it NEVER deletes a record to recover.
|
|
17
17
|
*
|
|
18
18
|
* What this module does NOT do:
|
|
19
|
-
* - Delete a DNS record
|
|
20
|
-
* record is never removed
|
|
19
|
+
* - Delete a DNS record to RECOVER from an error. The idempotent provisioning
|
|
20
|
+
* path is create-or-PATCH; a record is never removed to "fix" a failed call
|
|
21
|
+
* (issue §8). The ONE deletion shipped here ({@link deleteDnsRecord},
|
|
22
|
+
* BOOT-007) is DELIBERATE cleanup inside a flow a human already confirmed
|
|
23
|
+
* (retiring the interim domain after the client domain went live) — it is
|
|
24
|
+
* never an automatic recovery move.
|
|
21
25
|
* - Read or log a token value. The token arrives as an argument and is handed
|
|
22
26
|
* straight to the `Authorization` header; it never reaches stdout.
|
|
23
27
|
*
|
|
@@ -241,3 +245,109 @@ export async function ensureCnameRecord(zoneId, name, token, deps = {}) {
|
|
|
241
245
|
}
|
|
242
246
|
return 'unchanged';
|
|
243
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Delete a DNS record by id — `DELETE /zones/{id}/dns_records/{recordId}`
|
|
250
|
+
* (BOOT-007). The ONE deletion in this module, and it is DELIBERATE: it runs only
|
|
251
|
+
* inside a flow the human already confirmed (retiring the interim domain after the
|
|
252
|
+
* client domain verified — the orphaned interim CNAME would otherwise resolve to
|
|
253
|
+
* {@link CNAME_VERCEL_TARGET} and serve a 404 forever). It is NEVER used to
|
|
254
|
+
* recover from a failed call — the module-header contract stands.
|
|
255
|
+
*
|
|
256
|
+
* Mirrors `removeVercelDomain`'s 404 tolerance: a record already gone (deleted by
|
|
257
|
+
* hand or by a previous run) is `absent`, not an error — the cleanup is idempotent.
|
|
258
|
+
*
|
|
259
|
+
* @throws {CloudflareApiError} on any non-2xx other than 404 / `success: false`.
|
|
260
|
+
*/
|
|
261
|
+
export async function deleteDnsRecord(zoneId, recordId, token, deps = {}) {
|
|
262
|
+
try {
|
|
263
|
+
await cloudflareApi('DELETE', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, token, deps);
|
|
264
|
+
return 'removed';
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (error instanceof CloudflareApiError && error.status === 404)
|
|
268
|
+
return 'absent';
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Normalize a record content for comparison (issue §8 idempotency). Cloudflare
|
|
274
|
+
* returns TXT content wrapped in double quotes (`"v=spf1 …"`) while Resend hands
|
|
275
|
+
* it bare — a byte-compare would false-positive a "different content" and PATCH
|
|
276
|
+
* on every re-run. Stripping ONE layer of surrounding quotes + trimming makes the
|
|
277
|
+
* re-run comparison stable without touching the value actually stored.
|
|
278
|
+
*/
|
|
279
|
+
function normalizeRecordContent(value) {
|
|
280
|
+
const trimmed = value.trim();
|
|
281
|
+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
282
|
+
return trimmed.slice(1, -1);
|
|
283
|
+
}
|
|
284
|
+
return trimmed;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Find an existing record of `spec.type` for `name` in the zone (issue §8
|
|
288
|
+
* idempotency) — same lookup shape as {@link findCnameRecord}, parameterized on
|
|
289
|
+
* the type. Returns the record (so the caller can compare content and PATCH a
|
|
290
|
+
* rotated value) or `null` when absent.
|
|
291
|
+
*
|
|
292
|
+
* @throws {CloudflareApiError} on any non-2xx / `success: false`.
|
|
293
|
+
*/
|
|
294
|
+
export async function findDnsRecord(zoneId, type, name, token, deps = {}) {
|
|
295
|
+
const data = (await cloudflareApi('GET', `/zones/${encodeURIComponent(zoneId)}/dns_records`, token, deps, undefined, { type, name }));
|
|
296
|
+
return (data.result ?? []).find((r) => r.name === name && r.type === type) ?? null;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Create a TXT/MX record — `POST /zones/{id}/dns_records` (BOOT-003 §4), same
|
|
300
|
+
* call shape as {@link createCnameRecord} (`ttl: 1` = auto). TXT/MX cannot be
|
|
301
|
+
* proxied, so no `proxied` flag is sent. `priority` rides along for MX only.
|
|
302
|
+
*
|
|
303
|
+
* @throws {CloudflareApiError} on any non-2xx / `success: false`.
|
|
304
|
+
*/
|
|
305
|
+
export async function createDnsRecord(zoneId, spec, token, deps = {}) {
|
|
306
|
+
await cloudflareApi('POST', `/zones/${encodeURIComponent(zoneId)}/dns_records`, token, deps, {
|
|
307
|
+
type: spec.type,
|
|
308
|
+
name: spec.name,
|
|
309
|
+
content: spec.content,
|
|
310
|
+
ttl: 1,
|
|
311
|
+
...(spec.priority !== undefined ? { priority: spec.priority } : {}),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* PATCH an existing record to the content/priority the spec demands — `PATCH
|
|
316
|
+
* /zones/{id}/dns_records/{recordId}` (issue §8: a DKIM rotation leaves a record
|
|
317
|
+
* with STALE content; we repoint it in place). Same principle as
|
|
318
|
+
* {@link unproxyCnameRecord}: we NEVER delete a record to "fix" it.
|
|
319
|
+
*
|
|
320
|
+
* @throws {CloudflareApiError} on any non-2xx / `success: false`.
|
|
321
|
+
*/
|
|
322
|
+
export async function patchDnsRecord(zoneId, recordId, spec, token, deps = {}) {
|
|
323
|
+
await cloudflareApi('PATCH', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, token, deps, {
|
|
324
|
+
type: spec.type,
|
|
325
|
+
name: spec.name,
|
|
326
|
+
content: spec.content,
|
|
327
|
+
...(spec.priority !== undefined ? { priority: spec.priority } : {}),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Idempotently ensure a TXT/MX record matching `spec` exists in the zone
|
|
332
|
+
* (BOOT-003 §4 + §8) — the TXT/MX sibling of {@link ensureCnameRecord}. Returns
|
|
333
|
+
* the outcome so the caller can narrate it:
|
|
334
|
+
* - `created` — no record existed → POSTed a fresh one
|
|
335
|
+
* - `updated` — a record existed with DIFFERENT content/priority (e.g. a DKIM
|
|
336
|
+
* rotation) → PATCHed it in place (never deleted — §8)
|
|
337
|
+
* - `unchanged` — a record with the same content already existed → nothing to do
|
|
338
|
+
*
|
|
339
|
+
* @throws {CloudflareApiError} on any non-2xx / `success: false` (caller maps it).
|
|
340
|
+
*/
|
|
341
|
+
export async function ensureDnsRecord(zoneId, spec, token, deps = {}) {
|
|
342
|
+
const existing = await findDnsRecord(zoneId, spec.type, spec.name, token, deps);
|
|
343
|
+
if (!existing) {
|
|
344
|
+
await createDnsRecord(zoneId, spec, token, deps);
|
|
345
|
+
return 'created';
|
|
346
|
+
}
|
|
347
|
+
const sameContent = normalizeRecordContent(existing.content) === normalizeRecordContent(spec.content);
|
|
348
|
+
const samePriority = spec.priority === undefined || existing.priority === spec.priority;
|
|
349
|
+
if (sameContent && samePriority)
|
|
350
|
+
return 'unchanged';
|
|
351
|
+
await patchDnsRecord(zoneId, existing.id, spec, token, deps);
|
|
352
|
+
return 'updated';
|
|
353
|
+
}
|
package/dist/lib/env-file.js
CHANGED
|
@@ -78,10 +78,13 @@ export function writeEnvLocal(rootDir, neon, opts = {}) {
|
|
|
78
78
|
body = setEnvLine(body, 'DATABASE_URL', neon.developDirect);
|
|
79
79
|
body = setEnvLine(body, 'DATABASE_URL_MAIN', neon.mainDirect);
|
|
80
80
|
body = setEnvLine(body, 'AUTH_SECRET', generateAuthSecret());
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
81
|
+
// `.env.local` holds DB strings + AUTH_SECRET → owner-only (0600) from birth.
|
|
82
|
+
// The `mode: 0o600` makes the file created 0600 with no world-readable window
|
|
83
|
+
// between create and chmod — critical on the headless multi-tenant Agent Server
|
|
84
|
+
// where the default umask would otherwise leave one tenant's secrets briefly
|
|
85
|
+
// readable by others. `mode` only applies when the file is CREATED, so the
|
|
86
|
+
// chmod below still covers the `--force` overwrite path (file already exists).
|
|
87
|
+
writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
|
|
85
88
|
chmodSync(localPath, 0o600);
|
|
86
89
|
return 'written';
|
|
87
90
|
}
|
|
@@ -104,7 +107,7 @@ export function writeE2eLocalCreds(rootDir, projectId, apiKey) {
|
|
|
104
107
|
let body = readFileSync(localPath, 'utf8');
|
|
105
108
|
body = setEnvLine(body, 'NEON_PROJECT_ID', projectId);
|
|
106
109
|
body = setEnvLine(body, 'NEON_API_KEY', apiKey);
|
|
107
|
-
writeFileSync(localPath, body, 'utf8');
|
|
110
|
+
writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
|
|
108
111
|
chmodSync(localPath, 0o600); // holds NEON_API_KEY → owner-only.
|
|
109
112
|
return true;
|
|
110
113
|
}
|
|
@@ -133,7 +136,7 @@ export function writeEmailLocalCreds(rootDir, resendKey, emailFrom) {
|
|
|
133
136
|
body = setEnvLine(body, 'EMAIL_PROVIDER', 'resend');
|
|
134
137
|
body = setEnvLine(body, 'RESEND_API_KEY', resendKey);
|
|
135
138
|
body = setEnvLine(body, 'EMAIL_FROM', emailFrom);
|
|
136
|
-
writeFileSync(localPath, body, 'utf8');
|
|
139
|
+
writeFileSync(localPath, body, { encoding: 'utf8', mode: 0o600 });
|
|
137
140
|
chmodSync(localPath, 0o600); // holds RESEND_API_KEY → owner-only.
|
|
138
141
|
return true;
|
|
139
142
|
}
|
package/dist/lib/env-wizard.js
CHANGED
|
@@ -41,6 +41,12 @@ const DIVIDER = /^#\s*-{3,}\s*$/;
|
|
|
41
41
|
const ACTIVE = /^([A-Z][A-Z0-9_]*)=(.*)$/;
|
|
42
42
|
const COMMENTED = /^#\s*([A-Z][A-Z0-9_]*)=(.*)$/;
|
|
43
43
|
const SECRET_KEY = /(_SECRET|_KEY|_TOKEN|_PASSWORD)$/;
|
|
44
|
+
/**
|
|
45
|
+
* `NEXT_PUBLIC_*` vars are public by Next.js convention — they're inlined into
|
|
46
|
+
* the client bundle, so they can never be secrets regardless of their suffix
|
|
47
|
+
* (e.g. NEXT_PUBLIC_AUTH_PASSWORD is a boolean feature flag, not a credential).
|
|
48
|
+
*/
|
|
49
|
+
const PUBLIC_PREFIX = /^NEXT_PUBLIC_/;
|
|
44
50
|
function unquote(raw) {
|
|
45
51
|
const v = raw.trim();
|
|
46
52
|
if (v.length >= 2 &&
|
|
@@ -104,10 +110,16 @@ export function parseEnvExample(text) {
|
|
|
104
110
|
}
|
|
105
111
|
return entries;
|
|
106
112
|
}
|
|
107
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Classify a var into the widget the wizard renders. Secret detection delegates
|
|
115
|
+
* to `isSecretKey` so classification, masking and review all share one rule
|
|
116
|
+
* (secret suffix AND not `NEXT_PUBLIC_*` — public-by-convention vars are never
|
|
117
|
+
* secrets, e.g. NEXT_PUBLIC_AUTH_PASSWORD="true" is a boolean flag).
|
|
118
|
+
*/
|
|
119
|
+
export function questionKind(key, defaultValue) {
|
|
108
120
|
if (SELECTS[key])
|
|
109
121
|
return 'select';
|
|
110
|
-
if (
|
|
122
|
+
if (isSecretKey(key))
|
|
111
123
|
return 'secret';
|
|
112
124
|
if (defaultValue === 'true' || defaultValue === 'false')
|
|
113
125
|
return 'boolean';
|
|
@@ -189,9 +201,13 @@ export function applyWizardAnswers(rootDir, answers) {
|
|
|
189
201
|
// secret values, (c) auto-generate what a dev can't type, and (d) catch broken
|
|
190
202
|
// setups (a provider flag on with empty creds). All pure over their inputs.
|
|
191
203
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
192
|
-
/**
|
|
204
|
+
/**
|
|
205
|
+
* True when `key`'s value is a secret (never printed in review/summary output).
|
|
206
|
+
* `NEXT_PUBLIC_*` keys are excluded: Next.js inlines them into the client bundle,
|
|
207
|
+
* so they're public by convention even when their suffix looks secret-ish.
|
|
208
|
+
*/
|
|
193
209
|
export function isSecretKey(key) {
|
|
194
|
-
return SECRET_KEY.test(key);
|
|
210
|
+
return !PUBLIC_PREFIX.test(key) && SECRET_KEY.test(key);
|
|
195
211
|
}
|
|
196
212
|
/** Parse an env-file body into `{ KEY: value }` (unquoted), skipping comments/blanks. */
|
|
197
213
|
export function parseEnvBody(body) {
|
|
@@ -247,8 +263,12 @@ const OAUTH_PROVIDERS = [
|
|
|
247
263
|
];
|
|
248
264
|
/**
|
|
249
265
|
* Warn (do NOT block) about half-configured setups that would ship a broken app:
|
|
250
|
-
* an OAuth
|
|
251
|
-
*
|
|
266
|
+
* an OAuth flag on with missing creds → `next build` FAILS (BOOT-005: auth.ts
|
|
267
|
+
* resolves the provider credentials at module load, i.e. during the build — a
|
|
268
|
+
* deploy that never ships on derivatives whose frozen src/ predates the auth.ts
|
|
269
|
+
* guard); notifications enabled with no VAPID keys → dead push. Pure over the
|
|
270
|
+
* resolved env map. Warn-never-block by contract (headless-safe): the wizard
|
|
271
|
+
* stays pure + non-interactive, so escalation is message-only.
|
|
252
272
|
*/
|
|
253
273
|
export function checkEnvCompleteness(env) {
|
|
254
274
|
const warnings = [];
|
|
@@ -257,8 +277,10 @@ export function checkEnvCompleteness(env) {
|
|
|
257
277
|
continue;
|
|
258
278
|
const missing = [p.id, p.secret].filter((k) => !env[k]);
|
|
259
279
|
if (missing.length) {
|
|
260
|
-
warnings.push(`Login con ${p.label} activado pero falta ${missing.join(' + ')} →
|
|
261
|
-
`
|
|
280
|
+
warnings.push(`Login con ${p.label} activado pero falta ${missing.join(' + ')} → EL BUILD FALLARÁ ` +
|
|
281
|
+
`mientras ${p.flag} quede en "true" sin credenciales (next build resuelve las ` +
|
|
282
|
+
`credenciales del provider al compilar — el deploy nunca sale). ` +
|
|
283
|
+
`Complétalas o pon ${p.flag}="false".`);
|
|
262
284
|
}
|
|
263
285
|
}
|
|
264
286
|
if (env['NEXT_PUBLIC_NOTIFICATIONS_ENABLED'] === 'true' &&
|
package/dist/lib/vercel-env.js
CHANGED
|
@@ -334,19 +334,53 @@ export async function listEnvVars(projectId, token, teamId, deps = {}) {
|
|
|
334
334
|
return data.envs ?? [];
|
|
335
335
|
}
|
|
336
336
|
/**
|
|
337
|
-
* Idempotently set ONE env var for its targets (issue §4 + §8). If
|
|
338
|
-
*
|
|
339
|
-
* (`/v10/projects/{id}/env/{envId}
|
|
340
|
-
* (`/v10/projects/{id}/env`). Either way
|
|
337
|
+
* Idempotently set ONE env var for its targets (issue §4 + §8). If existing vars
|
|
338
|
+
* already cover any of the requested targets, they are RECONCILED into a single
|
|
339
|
+
* record (PATCH `/v10/projects/{id}/env/{envId}`, DELETEing any surplus record —
|
|
340
|
+
* see below); otherwise the var is POSTed (`/v10/projects/{id}/env`). Either way
|
|
341
|
+
* every call threads `?teamId=`.
|
|
341
342
|
*
|
|
342
343
|
* Matching is by `key` + target overlap — a var pushed for `production` does not
|
|
343
344
|
* clobber the same key's `preview` entry (they are distinct Vercel env records,
|
|
344
|
-
* which is exactly how `AUTH_SECRET` can differ per environment).
|
|
345
|
+
* which is exactly how `AUTH_SECRET` can differ per environment). Same-key
|
|
346
|
+
* records whose target does NOT overlap the plan are never touched, so the
|
|
347
|
+
* intentional split pairs `buildEnvPlan` creates (distinct values per env) are
|
|
348
|
+
* never collapsed by a per-target push.
|
|
349
|
+
*
|
|
350
|
+
* Split-target reconciliation (BOOT-002): `buildEnvPlan` intentionally creates
|
|
351
|
+
* SPLIT records per target for some keys (e.g. `NEXT_PUBLIC_APP_URL` as
|
|
352
|
+
* `['production']` + `['preview']`). When a later plan re-points that key with a
|
|
353
|
+
* COMBINED target (`['production','preview']` — e.g. the `--domain` go-live),
|
|
354
|
+
* ALL overlapping records are gathered, the surplus ones are DELETEd FIRST
|
|
355
|
+
* (Vercel rejects a PATCH whose expanded target collides with a sibling record),
|
|
356
|
+
* and the first record is then PATCHed with the plan's value + target — leaving
|
|
357
|
+
* ONE consistent record for the key. A re-run over the already-reconciled state
|
|
358
|
+
* finds a single record and simply re-PATCHes it in place: no duplicate, no 400.
|
|
359
|
+
*
|
|
360
|
+
* @throws {CLIError} when a surplus-record DELETE fails mid-reconciliation — the
|
|
361
|
+
* message names the key and the step stays re-runnable with `--resume` (the next
|
|
362
|
+
* run re-lists the records and retries the cleanup).
|
|
363
|
+
* @throws {VercelApiError} on any other non-2xx (the caller maps it).
|
|
345
364
|
*/
|
|
346
365
|
export async function upsertEnvVar(projectId, token, teamId, plan, existing, deps = {}) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
366
|
+
// EVERY record of the key whose target overlaps the plan — not just the first
|
|
367
|
+
// (a combined-target plan over split records must reconcile the whole set).
|
|
368
|
+
const matches = existing.filter((e) => e.key === plan.key && (e.target ?? []).some((t) => plan.target.includes(t)));
|
|
369
|
+
if (matches.length > 0) {
|
|
370
|
+
const [survivor, ...surplus] = matches;
|
|
371
|
+
// DELETE the surplus records BEFORE the PATCH: expanding the survivor's
|
|
372
|
+
// target while a sibling still owns one of those targets 400s on Vercel.
|
|
373
|
+
for (const record of surplus) {
|
|
374
|
+
try {
|
|
375
|
+
await vercelApi('DELETE', `/v10/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(record.id)}`, token, teamId, deps);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
throw new CLIError(`No se pudo eliminar el record duplicado de \`${plan.key}\` en Vercel al reconciliar ` +
|
|
379
|
+
`sus targets (${error?.message ?? 'error desconocido'}).\n` +
|
|
380
|
+
'La variable quedó a medio reconciliar; reintenta con `--resume` para completar la limpieza.');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
await vercelApi('PATCH', `/v10/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(survivor.id)}`, token, teamId, deps, {
|
|
350
384
|
value: plan.value,
|
|
351
385
|
target: plan.target,
|
|
352
386
|
type: 'encrypted',
|