@timekast/factory 1.18.0 → 1.20.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 +6 -1
- package/dist/commands/backlog.js +706 -0
- package/dist/commands/env.js +9 -0
- package/dist/commands/provision.js +689 -42
- package/dist/commands/update.js +45 -1
- package/dist/index.js +43 -2
- package/dist/lib/atomic-swap.js +13 -6
- package/dist/lib/backlog-api.js +302 -0
- package/dist/lib/backlog-mapping.js +175 -0
- package/dist/lib/backlog-parse.js +208 -0
- package/dist/lib/cloudflare-api.js +112 -2
- package/dist/lib/constants.js +23 -0
- package/dist/lib/env-file.js +51 -6
- package/dist/lib/env-wizard.js +30 -8
- package/dist/lib/package-json.js +59 -1
- package/dist/lib/provision-preflight.js +8 -0
- package/dist/lib/provision-state.js +10 -0
- package/dist/lib/vercel-env.js +42 -8
- package/package.json +1 -1
|
@@ -25,11 +25,12 @@ 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
|
-
import {
|
|
30
|
+
import { createBacklogProject, deleteBacklogProject, mintBacklogKey, BacklogApiError, } from '../lib/backlog-api.js';
|
|
31
|
+
import { defaultEnvPath, readEnvKeyNames, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
|
|
31
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 { writeE2eLocalCreds, writeEmailLocalCreds, writeEnvLocal } from '../lib/env-file.js';
|
|
33
|
+
import { writeBacklogLocalCreds, writeE2eLocalCreds, writeEmailLocalCreds, writeEnvLocal, } from '../lib/env-file.js';
|
|
33
34
|
import { adoptState, ADOPT_UNKNOWN, clearProvisionState, clearState, hasProvisionState, isServiceComplete, PROVISION_DIR, PROVISION_STATE_FILE, readDestroyTarget, readProvisionState, writeProvisionState, } from '../lib/provision-state.js';
|
|
34
35
|
/**
|
|
35
36
|
* The THREE GitHub Secrets the E2E runner (`pnpm test:e2e` → `e2e-runner.ts` +
|
|
@@ -103,9 +104,12 @@ export function isValidSubdomainSlug(slug) {
|
|
|
103
104
|
* The services `--services` understands, in canonical provisioning order. `github`
|
|
104
105
|
* precedes `vercel`: PROV-004 (the `github` step) builds the git substrate AND
|
|
105
106
|
* creates the Vercel PROJECT, whose `projectId` PROV-003 (the `vercel` env-sync
|
|
106
|
-
* step) then consumes.
|
|
107
|
+
* step) then consumes. `backlog` (BSYNC-003) runs LAST — after `vercel`/`dns` have
|
|
108
|
+
* created `.env.local` — so it APPENDS its two keys instead of racing the Neon-string
|
|
109
|
+
* write (`writeEnvLocal` only creates `.env.local` when absent). The order is
|
|
110
|
+
* normalized regardless of `--services` input.
|
|
107
111
|
*/
|
|
108
|
-
const KNOWN_SERVICES = ['neon', 'github', 'vercel', 'dns'];
|
|
112
|
+
const KNOWN_SERVICES = ['neon', 'github', 'vercel', 'dns', 'backlog'];
|
|
109
113
|
/** The known `--dns` route tokens, for validation. */
|
|
110
114
|
const KNOWN_DNS_ROUTES = ['org', 'cliente-cf', 'registrar'];
|
|
111
115
|
/** Default verification-poll timeout (minutes) and interval (seconds) — issue §4. */
|
|
@@ -180,6 +184,21 @@ export function parseVerifyTimeout(raw) {
|
|
|
180
184
|
}
|
|
181
185
|
return minutes;
|
|
182
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Parse + validate `--repo=<org/name>` (BOOT-001). Absent → `undefined` (the repo
|
|
189
|
+
* resolution then falls back to the `origin` remote / `FACTORY_ORG/projectName`).
|
|
190
|
+
* A malformed value (no `/`, more than one `/`, or an empty side) throws a clear
|
|
191
|
+
* `CLIError` — a corrupt slug must never silently reach the GitHub/Vercel calls.
|
|
192
|
+
*/
|
|
193
|
+
export function parseRepoFlag(raw) {
|
|
194
|
+
if (raw === undefined)
|
|
195
|
+
return undefined;
|
|
196
|
+
const parts = raw.split('/');
|
|
197
|
+
if (parts.length !== 2 || parts.some((part) => part.trim() === '')) {
|
|
198
|
+
throw new CLIError(`\`--repo\` debe tener el formato \`org/nombre\` (un solo \`/\`; recibí \`${raw}\`).`);
|
|
199
|
+
}
|
|
200
|
+
return raw;
|
|
201
|
+
}
|
|
183
202
|
/** Parse `provision`'s argv slice into typed flags. */
|
|
184
203
|
export function parseProvisionFlags(argv) {
|
|
185
204
|
return {
|
|
@@ -187,6 +206,7 @@ export function parseProvisionFlags(argv) {
|
|
|
187
206
|
team: readValueFlag(argv, 'team'),
|
|
188
207
|
orgId: readValueFlag(argv, 'org-id'),
|
|
189
208
|
slug: readValueFlag(argv, 'slug'),
|
|
209
|
+
repo: parseRepoFlag(readValueFlag(argv, 'repo')),
|
|
190
210
|
domain: readValueFlag(argv, 'domain'),
|
|
191
211
|
dns: parseDnsRoute(readValueFlag(argv, 'dns')),
|
|
192
212
|
verifyTimeout: parseVerifyTimeout(readValueFlag(argv, 'verify-timeout')),
|
|
@@ -206,6 +226,7 @@ const SERVICE_LABEL = {
|
|
|
206
226
|
vercel: 'Vercel (env sync por target)',
|
|
207
227
|
github: 'GitHub + Vercel (sustrato git + proyecto + env + E2E)',
|
|
208
228
|
dns: 'DNS + Resend (dominio del entorno)',
|
|
229
|
+
backlog: 'Backlog central (proyecto en backlog.timekast.mx + API key)',
|
|
209
230
|
};
|
|
210
231
|
/** Build the ordered plan: each requested service is `skip` if complete, else `create`. */
|
|
211
232
|
function buildPlan(services, state) {
|
|
@@ -217,6 +238,11 @@ function buildPlan(services, state) {
|
|
|
217
238
|
/** Print the plain-text plan (the `--dry-run` and pre-run narration). */
|
|
218
239
|
function printPlan(result) {
|
|
219
240
|
console.log(`Plan de provision para \`${result.projectName}\` (lifecycle: ${result.lifecycle}):`);
|
|
241
|
+
// BOOT-001: print the RESOLVED repo (--repo → origin → FACTORY_ORG/name), so a
|
|
242
|
+
// `--dry-run` shows the exact repo the github step / E2E secrets will target.
|
|
243
|
+
if (result.repo) {
|
|
244
|
+
console.log(` Repo de GitHub: ${result.repo}`);
|
|
245
|
+
}
|
|
220
246
|
for (const step of result.steps) {
|
|
221
247
|
const mark = step.action === 'skip' ? '⏭ (ya completado)' : '+ crear';
|
|
222
248
|
console.log(` ${mark} ${SERVICE_LABEL[step.service]}`);
|
|
@@ -405,9 +431,11 @@ function neonErrorToCliError(error, projectName, context) {
|
|
|
405
431
|
}
|
|
406
432
|
/** The GitHub repo (`owner/name`) the project-scoped secret is pushed to. */
|
|
407
433
|
function neonSecretRepo(state) {
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
434
|
+
// `state.github.repo` carries the RESOLVED repo: `runProvision` seeds it before
|
|
435
|
+
// any step runs (BOOT-001: --repo → origin → FACTORY_ORG/name) and PROV-004
|
|
436
|
+
// re-records it. The fallback only covers a direct `provisionNeon` call with no
|
|
437
|
+
// seeded state, so the secret push is still attempted (a non-existent repo →
|
|
438
|
+
// §8 pending path, never an abort).
|
|
411
439
|
return state.github?.repo ?? `${FACTORY_ORG}/${state.projectName}`;
|
|
412
440
|
}
|
|
413
441
|
/**
|
|
@@ -556,6 +584,163 @@ export async function provisionNeon(rootDir, state, deps = {}) {
|
|
|
556
584
|
' La API key project-scoped se creó; reintenta con `--resume` cuando el repo exista.');
|
|
557
585
|
}
|
|
558
586
|
}
|
|
587
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
588
|
+
// BSYNC-003 — `backlog`: register the project in backlog.timekast.mx
|
|
589
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
590
|
+
/** The rail key holding the org-level backlog `admin` token (create/mint/delete). */
|
|
591
|
+
export const BACKLOG_ADMIN_TOKEN_ENV = 'BACKLOG_ADMIN_TOKEN';
|
|
592
|
+
/**
|
|
593
|
+
* The backlog-central `internalKey` rule (`^[a-z0-9-]{3,40}$`) — DELIBERATELY DISTINCT
|
|
594
|
+
* from the DNS subdomain rule of {@link isValidSubdomainSlug} (no leading/trailing
|
|
595
|
+
* hyphen, ≤59 chars). A backlog internalKey is NOT a DNS label, so it has its own
|
|
596
|
+
* shape: 3–40 chars of lowercase, digits and hyphens (server-side rule, live-verified
|
|
597
|
+
* in `project/abraham/smoke-findings.md` Hallazgo 1 — uppercase 422s).
|
|
598
|
+
*/
|
|
599
|
+
export const BACKLOG_SLUG_RE = /^[a-z0-9-]{3,40}$/;
|
|
600
|
+
/**
|
|
601
|
+
* Normalize + validate a slug as a backlog-central `internalKey` BEFORE any POST
|
|
602
|
+
* (issue §4/§8). Lowercases first (the server 422s on uppercase), then asserts the
|
|
603
|
+
* {@link BACKLOG_SLUG_RE} shape. Returns the normalized value so the POST always uses
|
|
604
|
+
* the canonical form. Distinct from `assertValidSubdomainSlug` (the DNS-label check).
|
|
605
|
+
*
|
|
606
|
+
* @throws {CLIError} when the slug cannot be a valid backlog internalKey.
|
|
607
|
+
*/
|
|
608
|
+
export function normalizeBacklogSlug(raw) {
|
|
609
|
+
const slug = raw.toLowerCase();
|
|
610
|
+
if (!BACKLOG_SLUG_RE.test(slug)) {
|
|
611
|
+
throw new CLIError(`El slug \`${raw}\` no es un internalKey válido para backlog central.\n` +
|
|
612
|
+
'Debe ser 3–40 caracteres de minúsculas, dígitos y guiones (`^[a-z0-9-]{3,40}$`).');
|
|
613
|
+
}
|
|
614
|
+
return slug;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Map a backlog-central REST failure to an actionable, es-MX `CLIError` (issue §4/§8).
|
|
618
|
+
* The `409 INTERNAL_KEY_TAKEN` hints at `--adopt` WITHOUT auto-adopting (plan decision
|
|
619
|
+
* H2: silently adopting a colliding project risks mixing two projects by accident). A
|
|
620
|
+
* 401/403 points at re-importing the rail token; a 422 at the slug shape.
|
|
621
|
+
*/
|
|
622
|
+
function backlogErrorToCliError(error, internalKey, context) {
|
|
623
|
+
if (error instanceof BacklogApiError) {
|
|
624
|
+
switch (error.status) {
|
|
625
|
+
case 401:
|
|
626
|
+
case 403:
|
|
627
|
+
return new CLIError(`El \`BACKLOG_ADMIN_TOKEN\` es inválido o no autoriza ${context} (backlog respondió ${error.status}).\n` +
|
|
628
|
+
'Renuévalo con `factory secrets <archivo>` y reintenta.');
|
|
629
|
+
case 409:
|
|
630
|
+
return new CLIError(`Ya existe un proyecto de backlog central con el internalKey \`${internalKey}\` ` +
|
|
631
|
+
'(backlog respondió 409 `INTERNAL_KEY_TAKEN`).\n' +
|
|
632
|
+
'NO se auto-adopta (evita mezclar dos proyectos por accidente). Si es tu proyecto, ' +
|
|
633
|
+
'reconstruye el estado con `--adopt`; si no, elige otro con `--slug`.');
|
|
634
|
+
case 422:
|
|
635
|
+
return new CLIError(`Backlog central rechazó el internalKey \`${internalKey}\` como inválido (respondió 422).\n` +
|
|
636
|
+
'Usa un slug de 3–40 caracteres de minúsculas, dígitos y guiones.');
|
|
637
|
+
default:
|
|
638
|
+
return new CLIError(`Falló la llamada a backlog central en ${context}: ${error.message}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return new CLIError(`Falló la llamada a backlog central en ${context}: ${error?.message ?? 'error'}`);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* BSYNC-003 — provision the backlog-central project: normalize the slug, create the
|
|
645
|
+
* project (`POST /projects`, org `admin` token), mint a per-project `read_write` key
|
|
646
|
+
* (`POST /projects/{pid}/keys`), and write BOTH the key + the project id to
|
|
647
|
+
* `.env.local` (`BACKLOG_API_KEY` + `BACKLOG_PROJECT_ID`, PROVISION_OWNED → never
|
|
648
|
+
* pushed to Vercel). State records IDs ONLY (`{ projectId, keyMinted }`) — the
|
|
649
|
+
* SHOW-ONCE key value is NEVER persisted anywhere but the gitignored `.env.local`.
|
|
650
|
+
*
|
|
651
|
+
* Auth split (smoke Hallazgo 1): the org `admin` token drives ONLY the two org
|
|
652
|
+
* endpoints here (create project + mint key); the minted `read_write` key is what the
|
|
653
|
+
* derivative's `backlog push`/`sync` later consume. The project id is persisted
|
|
654
|
+
* IMMEDIATELY after create, so a mint failure leaves a resumable partial state.
|
|
655
|
+
*
|
|
656
|
+
* No-echo: the minted key value never reaches stdout/log — only key NAMES are printed.
|
|
657
|
+
*
|
|
658
|
+
* @throws {CLIError} on a missing rail token, an invalid slug (before any POST), a
|
|
659
|
+
* `409 INTERNAL_KEY_TAKEN` (hint: `--adopt`, no auto-adopt), or any backlog failure.
|
|
660
|
+
*/
|
|
661
|
+
export async function provisionBacklog(rootDir, state, deps = {}) {
|
|
662
|
+
// Fully complete only when the key was minted + written. Re-entering a partial run
|
|
663
|
+
// (project created, key not yet minted) finishes the mint WITHOUT re-creating.
|
|
664
|
+
if (state.backlog?.keyMinted)
|
|
665
|
+
return;
|
|
666
|
+
const envPath = deps.envPath ?? defaultEnvPath();
|
|
667
|
+
const adminToken = readGateValue(BACKLOG_ADMIN_TOKEN_ENV, envPath);
|
|
668
|
+
if (!adminToken) {
|
|
669
|
+
throw new CLIError('No se pudo leer `BACKLOG_ADMIN_TOKEN` de `~/.claude/.env`.\n' +
|
|
670
|
+
'Ejecuta `factory secrets <archivo>` para bajar el token del archivo de secretos y reintenta.');
|
|
671
|
+
}
|
|
672
|
+
// Normalize + validate the slug against the backlog rule BEFORE any POST (§4/§8).
|
|
673
|
+
const internalKey = normalizeBacklogSlug(state.projectName);
|
|
674
|
+
const apiDeps = { fetchImpl: deps.fetchImpl, sleepImpl: deps.sleepImpl };
|
|
675
|
+
// 1. Create the project (reuse a partial run's projectId; else POST). Persist the id
|
|
676
|
+
// IMMEDIATELY so a mint failure leaves a resumable partial state (IDs only).
|
|
677
|
+
let projectId;
|
|
678
|
+
if (state.backlog?.projectId) {
|
|
679
|
+
projectId = state.backlog.projectId;
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
let project;
|
|
683
|
+
try {
|
|
684
|
+
project = await createBacklogProject(adminToken, { name: state.projectName, internalKey }, apiDeps);
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
throw backlogErrorToCliError(error, internalKey, 'crear el proyecto');
|
|
688
|
+
}
|
|
689
|
+
projectId = project.id;
|
|
690
|
+
state.backlog = { projectId };
|
|
691
|
+
writeProvisionState(rootDir, state);
|
|
692
|
+
}
|
|
693
|
+
// 2. Mint the per-project `read_write` key (SHOW-ONCE). Its VALUE goes straight to
|
|
694
|
+
// `.env.local`; it is NEVER persisted in the state nor printed (no-echo).
|
|
695
|
+
let apiKey;
|
|
696
|
+
try {
|
|
697
|
+
apiKey = await mintBacklogKey(adminToken, projectId, `${state.projectName}-provision`, apiDeps);
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
throw backlogErrorToCliError(error, internalKey, 'mintar la API key del proyecto');
|
|
701
|
+
}
|
|
702
|
+
// 3. Write BACKLOG_API_KEY + BACKLOG_PROJECT_ID to `.env.local` (0600, PROVISION_OWNED).
|
|
703
|
+
// writeBacklogLocalCreds CREATES `.env.local` when absent (a standalone `backlog`
|
|
704
|
+
// run has no Neon/Vercel step to seed it). No-echo: print only KEY NAMES.
|
|
705
|
+
if (writeBacklogLocalCreds(rootDir, projectId, apiKey)) {
|
|
706
|
+
console.log(' + .env.local (BACKLOG_API_KEY · BACKLOG_PROJECT_ID — registro en backlog.timekast.mx)');
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
console.warn('⚠ No se pudieron escribir las credenciales de backlog en `.env.local`. ' +
|
|
710
|
+
'El proyecto se creó; agrégalas a mano (la key es show-once, revísala en la app).');
|
|
711
|
+
}
|
|
712
|
+
// 4. Mark done — key minted + written. State holds IDs only (never the key value).
|
|
713
|
+
state.backlog = { projectId, keyMinted: true };
|
|
714
|
+
writeProvisionState(rootDir, state);
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Resolve whether the `backlog` service can run given the rail token. UNLIKE the
|
|
718
|
+
* fail-closed rail tokens (Neon/Vercel abort when missing), a missing
|
|
719
|
+
* `BACKLOG_ADMIN_TOKEN` degrades GRACEFULLY (issue AC): a WARN + skip when `backlog`
|
|
720
|
+
* is ONE of several requested services (the rest continue), but a hard error when it
|
|
721
|
+
* is the SOLE service requested (there'd be nothing to do). Returns the services to
|
|
722
|
+
* actually run — `backlog` stripped when skipped. Pure + FS-light: `envPath` is
|
|
723
|
+
* explicit so the unit test drives every branch against a temp rail.
|
|
724
|
+
*
|
|
725
|
+
* Runs BEFORE the preflight, so a stripped `backlog` never reaches
|
|
726
|
+
* `assertGateTokensPresent` (which would otherwise fail-close on the missing token).
|
|
727
|
+
*
|
|
728
|
+
* @throws {CLIError} when `backlog` is the sole requested service and the token is absent.
|
|
729
|
+
*/
|
|
730
|
+
export function resolveBacklogService(services, envPath = defaultEnvPath()) {
|
|
731
|
+
if (!services.includes('backlog'))
|
|
732
|
+
return services;
|
|
733
|
+
if (readEnvKeyNames(envPath).has(BACKLOG_ADMIN_TOKEN_ENV))
|
|
734
|
+
return services;
|
|
735
|
+
if (services.length === 1) {
|
|
736
|
+
throw new CLIError('Falta `BACKLOG_ADMIN_TOKEN` en `~/.claude/.env` y `backlog` es el único servicio pedido.\n' +
|
|
737
|
+
'Impórtalo con `factory secrets <archivo>` (el master del org) y reintenta.');
|
|
738
|
+
}
|
|
739
|
+
console.warn('⚠ Falta `BACKLOG_ADMIN_TOKEN` en `~/.claude/.env` — omito el servicio `backlog` ' +
|
|
740
|
+
'(los demás continúan).\n' +
|
|
741
|
+
' Impórtalo con `factory secrets <archivo>` para registrar el proyecto en backlog.timekast.mx.');
|
|
742
|
+
return services.filter((s) => s !== 'backlog');
|
|
743
|
+
}
|
|
559
744
|
/**
|
|
560
745
|
* Resolve the four pooled+direct Neon connection strings, ALWAYS by re-deriving
|
|
561
746
|
* fresh from Neon — provision persists NO credentials file (Neon is the source of
|
|
@@ -696,10 +881,10 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
|
|
|
696
881
|
// Mint an INDEPENDENT AUTH_SECRET per environment (issue §4 — never shared).
|
|
697
882
|
// POOLED strings → the Vercel runtime DATABASE_URL (serverless/concurrent).
|
|
698
883
|
// 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).
|
|
884
|
+
// dies if the rpID changes, so the custom-domain swap (--domain / PROV-006)
|
|
885
|
+
// re-points the PRODUCTION WEBAUTHN_RP_ID too (`updateDomainEnv`, BOOT-004).
|
|
886
|
+
// MFA_ENCRYPTION_KEY is minted independently per env (base64url) so a derivative
|
|
887
|
+
// with MFA enabled never boots broken (P-1).
|
|
703
888
|
const rpDomains = provisionDomains(state.projectName);
|
|
704
889
|
const plan = buildEnvPlan({
|
|
705
890
|
mainDatabaseUrl: neonSecrets.mainPooled,
|
|
@@ -811,6 +996,19 @@ export { writeEnvLocal };
|
|
|
811
996
|
function repoHttpsUrl(repoSlug) {
|
|
812
997
|
return `https://github.com/${repoSlug}.git`;
|
|
813
998
|
}
|
|
999
|
+
/**
|
|
1000
|
+
* Parse the `owner/name` slug out of a GitHub `origin` URL, HTTPS or SSH
|
|
1001
|
+
* (`https://github.com/Owner/repo[.git]`, `git@github.com:Owner/repo[.git]`,
|
|
1002
|
+
* `ssh://git@github.com/Owner/repo[.git]`). Returns `null` for anything else —
|
|
1003
|
+
* including non-GitHub hosts, deliberately: deriving a slug from a GitLab/other
|
|
1004
|
+
* origin and re-pointing it at `github.com` (see {@link repoHttpsUrl}) would
|
|
1005
|
+
* silently retarget the project, so an unparsable origin falls back to the
|
|
1006
|
+
* historical `FACTORY_ORG/projectName` default instead (BOOT-001).
|
|
1007
|
+
*/
|
|
1008
|
+
export function parseOriginRepoSlug(originUrl) {
|
|
1009
|
+
const match = /^(?:https:\/\/|ssh:\/\/git@|git@)github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(originUrl.trim());
|
|
1010
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
1011
|
+
}
|
|
814
1012
|
/** Default git runner — `execa` in the derived dir, no shell. */
|
|
815
1013
|
async function gitInDir(args, cwd) {
|
|
816
1014
|
const { stdout } = await execa('git', args, { cwd });
|
|
@@ -831,6 +1029,47 @@ async function readOriginUrl(runGit, cwd) {
|
|
|
831
1029
|
return null;
|
|
832
1030
|
}
|
|
833
1031
|
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Resolve the GitHub repo slug (`owner/name`) the `github` step targets (BOOT-001
|
|
1034
|
+
* — decouples the repo from the project slug). Priority:
|
|
1035
|
+
* 1. The repo PERSISTED in the state (a prior run's authority — a `--resume`
|
|
1036
|
+
* must never silently re-point a repo already in progress; a CONFLICTING
|
|
1037
|
+
* `--repo` throws instead, mirroring the `--force` discipline of the other
|
|
1038
|
+
* steps — discard the state with `--force` to start over).
|
|
1039
|
+
* 2. An explicit `--repo=<org/name>` (the dev's stated intent — wins over an
|
|
1040
|
+
* `origin` pointing elsewhere; the remote is updated via `buildGitSubstrate`'s
|
|
1041
|
+
* existing same-repo/`--force` flow).
|
|
1042
|
+
* 3. The existing `origin` remote, when it parses as a GitHub repo (a derived
|
|
1043
|
+
* project whose repo name ≠ slug, or living outside the Factory org).
|
|
1044
|
+
* 4. The historical default `${FACTORY_ORG}/${projectName}` (unchanged behavior
|
|
1045
|
+
* when there is no flag and no `origin`).
|
|
1046
|
+
*/
|
|
1047
|
+
export async function resolveRepoSlug(rootDir, projectName, persistedRepo, explicitRepo, runGit) {
|
|
1048
|
+
if (persistedRepo) {
|
|
1049
|
+
// GitHub slugs are case-insensitive — only a REAL divergence is a conflict.
|
|
1050
|
+
if (explicitRepo && explicitRepo.toLowerCase() !== persistedRepo.toLowerCase()) {
|
|
1051
|
+
throw new CLIError(`El estado de provision ya registra el repo \`${persistedRepo}\` (de un run anterior), ` +
|
|
1052
|
+
`distinto de \`--repo=${explicitRepo}\`.\n` +
|
|
1053
|
+
'En `--resume` el estado es la autoridad; usa `--force` para descartarlo y empezar de cero con el repo nuevo.');
|
|
1054
|
+
}
|
|
1055
|
+
return persistedRepo;
|
|
1056
|
+
}
|
|
1057
|
+
if (explicitRepo)
|
|
1058
|
+
return explicitRepo;
|
|
1059
|
+
const fallback = `${FACTORY_ORG}/${projectName}`;
|
|
1060
|
+
const origin = await readOriginUrl(runGit, rootDir);
|
|
1061
|
+
if (origin) {
|
|
1062
|
+
const derived = parseOriginRepoSlug(origin);
|
|
1063
|
+
if (derived) {
|
|
1064
|
+
// An origin at the SAME repo as the canonical default (case drift only, e.g.
|
|
1065
|
+
// `TimeKast/Acme` vs `TimeKast/acme`) keeps the CANONICAL slug, so
|
|
1066
|
+
// `buildGitSubstrate` re-cases the remote exactly as it always did (GitHub
|
|
1067
|
+
// slugs are case-insensitive — reuse that normalization, don't fork it).
|
|
1068
|
+
return derived.toLowerCase() === fallback.toLowerCase() ? fallback : derived;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return fallback;
|
|
1072
|
+
}
|
|
834
1073
|
/**
|
|
835
1074
|
* Build the git substrate in the documented, asserted ORDER (issue §6):
|
|
836
1075
|
* 1. rename `master` → `main` (only when the current branch is `master`)
|
|
@@ -870,11 +1109,14 @@ async function buildGitSubstrate(rootDir, repoSlug, runGit, force) {
|
|
|
870
1109
|
if (existingOrigin && existingOrigin !== url) {
|
|
871
1110
|
// GitHub repo URLs are case-insensitive: an `origin` differing ONLY by case
|
|
872
1111
|
// (e.g. `TimeKast/Edmond.git` vs the canonical lowercase `TimeKast/edmond.git`)
|
|
873
|
-
// points at the SAME repo.
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
|
|
1112
|
+
// points at the SAME repo. So does an SSH origin (`git@github.com:Org/name.git`)
|
|
1113
|
+
// whose parsed `owner/name` matches the resolved slug (BOOT-001 — the slug may
|
|
1114
|
+
// have been DERIVED from that very origin). Either way, re-point it to the
|
|
1115
|
+
// canonical HTTPS URL silently — no `--force` needed. A genuinely different
|
|
1116
|
+
// repo → throw unless `--force` resets it.
|
|
1117
|
+
const sameRepo = existingOrigin.toLowerCase() === url.toLowerCase() ||
|
|
1118
|
+
parseOriginRepoSlug(existingOrigin)?.toLowerCase() === repoSlug.toLowerCase();
|
|
1119
|
+
if (!sameRepo && !force) {
|
|
878
1120
|
throw new CLIError(`El repo ya tiene un remote \`origin\` apuntando a otro URL (\`${existingOrigin}\` ≠ \`${url}\`).\n` +
|
|
879
1121
|
'Usa `--force` para sobrescribirlo, o limpia el remote si reutilizas este directorio.');
|
|
880
1122
|
}
|
|
@@ -988,7 +1230,10 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
|
|
|
988
1230
|
}
|
|
989
1231
|
const runGit = deps.runGit ?? gitInDir;
|
|
990
1232
|
const apiDeps = { fetchImpl: deps.fetchImpl };
|
|
991
|
-
|
|
1233
|
+
// BOOT-001: the repo is RESOLVED (--repo → existing origin → FACTORY_ORG/name),
|
|
1234
|
+
// no longer hardcoded to `${FACTORY_ORG}/${projectName}`. On resume the persisted
|
|
1235
|
+
// `state.github.repo` is the authority (a conflicting `--repo` throws).
|
|
1236
|
+
const repoSlug = await resolveRepoSlug(rootDir, state.projectName, state.github?.repo, deps.repo, runGit);
|
|
992
1237
|
// Record the repo on the state up-front so the Neon secret push (PROV-002) and a
|
|
993
1238
|
// `--resume` target it; this is durable even if a later REST step fails.
|
|
994
1239
|
state.github = { repo: repoSlug };
|
|
@@ -1287,44 +1532,262 @@ async function promptConfirm(message) {
|
|
|
1287
1532
|
const { ok } = await prompts({ type: 'confirm', name: 'ok', message, initial: false });
|
|
1288
1533
|
return ok === true;
|
|
1289
1534
|
}
|
|
1535
|
+
/** The `status` Resend reports once its DNS records validated (the poll's goal). */
|
|
1536
|
+
const RESEND_DOMAIN_VERIFIED_STATUS = 'verified';
|
|
1537
|
+
/** Shared headers for every Resend REST call (Bearer auth + JSON) — no-echo. */
|
|
1538
|
+
function resendHeaders(resendKey) {
|
|
1539
|
+
return {
|
|
1540
|
+
Authorization: `Bearer ${resendKey}`,
|
|
1541
|
+
'Content-Type': 'application/json',
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Build the FQDN for a record name Resend returns (BOOT-003 §4). Resend hands
|
|
1546
|
+
* record names RELATIVE to the registered domain (`send` → `send.updates.miapp.com`,
|
|
1547
|
+
* `resend._domainkey` → `resend._domainkey.updates.miapp.com`); Cloudflare's
|
|
1548
|
+
* `dns_records` API wants the fully-qualified name. Defensive on shape drift: a
|
|
1549
|
+
* name that already carries the domain (or the apex marker `@`) passes through.
|
|
1550
|
+
*/
|
|
1551
|
+
export function resendRecordFqdn(name, domain) {
|
|
1552
|
+
if (name === '' || name === '@' || name === domain)
|
|
1553
|
+
return domain;
|
|
1554
|
+
if (name.endsWith(`.${domain}`))
|
|
1555
|
+
return name;
|
|
1556
|
+
return `${name}.${domain}`;
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Look up an existing Resend domain by NAME — `GET /domains` (list) then
|
|
1560
|
+
* `GET /domains/{id}` for its records. This is the `--resume` path (issue §8):
|
|
1561
|
+
* a re-run's `POST /domains` 403s when the domain already exists in THIS account,
|
|
1562
|
+
* so recovering the domain object lets the step re-ensure the DNS records and
|
|
1563
|
+
* re-poll verification instead of dead-ending on a warning. Returns `null` when
|
|
1564
|
+
* the domain is NOT in this account (then it really is "another account" and the
|
|
1565
|
+
* caller keeps the preserved warning behavior — issue §4). Best-effort: any
|
|
1566
|
+
* network/API failure degrades to `null`, never a throw.
|
|
1567
|
+
*/
|
|
1568
|
+
async function findResendDomain(domain, resendKey, fetchImpl) {
|
|
1569
|
+
try {
|
|
1570
|
+
const listResponse = await fetchImpl(`${RESEND_API_BASE}/domains`, {
|
|
1571
|
+
method: 'GET',
|
|
1572
|
+
headers: resendHeaders(resendKey),
|
|
1573
|
+
signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
|
|
1574
|
+
});
|
|
1575
|
+
if (!listResponse.ok)
|
|
1576
|
+
return null;
|
|
1577
|
+
const list = (await listResponse.json());
|
|
1578
|
+
const match = (list.data ?? []).find((d) => d.name === domain);
|
|
1579
|
+
if (!match?.id)
|
|
1580
|
+
return null;
|
|
1581
|
+
const getResponse = await fetchImpl(`${RESEND_API_BASE}/domains/${match.id}`, {
|
|
1582
|
+
method: 'GET',
|
|
1583
|
+
headers: resendHeaders(resendKey),
|
|
1584
|
+
signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
|
|
1585
|
+
});
|
|
1586
|
+
// Detail fetch failed → still return the id (poll works; records unknown → none created).
|
|
1587
|
+
if (!getResponse.ok)
|
|
1588
|
+
return { id: match.id, name: domain };
|
|
1589
|
+
return (await getResponse.json());
|
|
1590
|
+
}
|
|
1591
|
+
catch {
|
|
1592
|
+
return null;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Trigger Resend's DNS verification for a domain — `POST /domains/{id}/verify`
|
|
1597
|
+
* (BOOT-003 §4). Without this call Resend never starts checking the records and
|
|
1598
|
+
* the domain sits `not_started` forever (the exact bug this issue fixes). Best
|
|
1599
|
+
* effort: a failure here is non-fatal (the poll surfaces the real outcome, and
|
|
1600
|
+
* the step is re-runnable with `--resume`).
|
|
1601
|
+
*/
|
|
1602
|
+
async function triggerResendVerification(domainId, resendKey, fetchImpl) {
|
|
1603
|
+
try {
|
|
1604
|
+
await fetchImpl(`${RESEND_API_BASE}/domains/${domainId}/verify`, {
|
|
1605
|
+
method: 'POST',
|
|
1606
|
+
headers: resendHeaders(resendKey),
|
|
1607
|
+
signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
catch {
|
|
1611
|
+
// Non-fatal — the verification poll below reports the actual state.
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Poll `GET /domains/{id}` until Resend reports `status: verified` or the timeout
|
|
1616
|
+
* budget runs out (BOOT-003 §4) — the Resend sibling of {@link pollDomainVerification},
|
|
1617
|
+
* same injectable `sleep`/`now`/timeout seams so the unit test never waits for real.
|
|
1618
|
+
* Returns `true` once verified; `false` on timeout OR on any network/API failure
|
|
1619
|
+
* (non-fatal either way — the DNS records are already created, Resend keeps
|
|
1620
|
+
* checking them in background, and the step is re-runnable with `--resume`).
|
|
1621
|
+
*/
|
|
1622
|
+
async function pollResendDomainVerification(domainId, resendKey, deps, fetchImpl) {
|
|
1623
|
+
const sleep = deps.sleep ?? realSleep;
|
|
1624
|
+
const now = deps.now ?? Date.now;
|
|
1625
|
+
const timeoutMs = (deps.verifyTimeoutMin ?? DEFAULT_VERIFY_TIMEOUT_MIN) * 60_000;
|
|
1626
|
+
const intervalMs = VERIFY_POLL_INTERVAL_SEC * 1_000;
|
|
1627
|
+
const deadline = now() + timeoutMs;
|
|
1628
|
+
// First check immediately (the domain may already be verified — idempotent re-run).
|
|
1629
|
+
for (;;) {
|
|
1630
|
+
let status;
|
|
1631
|
+
try {
|
|
1632
|
+
const response = await fetchImpl(`${RESEND_API_BASE}/domains/${domainId}`, {
|
|
1633
|
+
method: 'GET',
|
|
1634
|
+
headers: resendHeaders(resendKey),
|
|
1635
|
+
signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
|
|
1636
|
+
});
|
|
1637
|
+
if (!response.ok)
|
|
1638
|
+
return false;
|
|
1639
|
+
status = (await response.json()).status;
|
|
1640
|
+
}
|
|
1641
|
+
catch {
|
|
1642
|
+
// A failure while POLLING is non-fatal: pending + resumable, never a throw.
|
|
1643
|
+
return false;
|
|
1644
|
+
}
|
|
1645
|
+
if (status === RESEND_DOMAIN_VERIFIED_STATUS)
|
|
1646
|
+
return true;
|
|
1647
|
+
if (now() >= deadline)
|
|
1648
|
+
return false;
|
|
1649
|
+
console.log(` … esperando verificación del dominio en Resend (reintento en ${VERIFY_POLL_INTERVAL_SEC}s)`);
|
|
1650
|
+
await sleep(intervalMs);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1290
1653
|
/**
|
|
1291
|
-
* Register the client domain in Resend
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1654
|
+
* Register the client mail domain in Resend AND make it verifiable end-to-end
|
|
1655
|
+
* (BOOT-003): `POST /domains { name }` with the rail's `RESEND_API_KEY`, then
|
|
1656
|
+
* CREATE the DNS records Resend returns (DKIM/SPF/MX — exactly the ones it
|
|
1657
|
+
* returns, never an assumed fixed set, §8) in the client's Cloudflare zone via
|
|
1658
|
+
* {@link ensureDnsRecord} (create-or-PATCH, idempotent on re-run, never delete),
|
|
1659
|
+
* trigger verification, and poll until `verified` or timeout.
|
|
1660
|
+
*
|
|
1661
|
+
* `dns` carries the zone + token already resolved for the client-domain CNAME
|
|
1662
|
+
* (same org/cliente-cf route); `null` = the `registrar` route (no DNS API — the
|
|
1663
|
+
* records must be created by hand in the Resend dashboard; extending
|
|
1664
|
+
* `printManualRecords` to them is an explicit follow-up, issue §9).
|
|
1665
|
+
*
|
|
1666
|
+
* Non-fatal by design — the domain switch already happened on Vercel:
|
|
1667
|
+
* - absent `RESEND_API_KEY` → warning (mail stays unconfigured).
|
|
1668
|
+
* - `POST /domains` rejected → recover the domain from THIS account when it
|
|
1669
|
+
* exists (`--resume`, §8) or keep the preserved warning ("another account", §4).
|
|
1670
|
+
* - a record create/PATCH failure → warning; the poll is skipped (it cannot
|
|
1671
|
+
* succeed with missing records) and the step stays re-runnable.
|
|
1672
|
+
* - poll timeout → warning + `pending-verification` note, NEVER a hard failure
|
|
1673
|
+
* (§8 — the records are in place; Resend keeps verifying in background).
|
|
1674
|
+
*
|
|
1675
|
+
* No-echo: the Resend key and Cloudflare token never reach stdout.
|
|
1296
1676
|
*/
|
|
1297
|
-
async function registerResendDomain(domain, envPath, fetchImpl) {
|
|
1677
|
+
async function registerResendDomain(domain, envPath, deps, fetchImpl, dns) {
|
|
1298
1678
|
const resendKey = readGateValue('RESEND_API_KEY', envPath);
|
|
1299
1679
|
if (!resendKey) {
|
|
1300
1680
|
console.warn('⚠ No hay `RESEND_API_KEY` en `~/.claude/.env` — omito el alta del dominio en Resend ' +
|
|
1301
1681
|
'(bájala con `factory secrets <archivo>` y reintenta con `--resume`).');
|
|
1302
1682
|
return;
|
|
1303
1683
|
}
|
|
1684
|
+
// 1. Register (or recover, on re-run) the domain in Resend.
|
|
1685
|
+
let resendDomain = null;
|
|
1304
1686
|
try {
|
|
1305
1687
|
const response = await fetchImpl(`${RESEND_API_BASE}/domains`, {
|
|
1306
1688
|
method: 'POST',
|
|
1307
|
-
headers:
|
|
1308
|
-
Authorization: `Bearer ${resendKey}`,
|
|
1309
|
-
'Content-Type': 'application/json',
|
|
1310
|
-
},
|
|
1689
|
+
headers: resendHeaders(resendKey),
|
|
1311
1690
|
body: JSON.stringify({ name: domain }),
|
|
1312
1691
|
signal: AbortSignal.timeout(RESEND_API_TIMEOUT_MS),
|
|
1313
1692
|
});
|
|
1314
|
-
if (
|
|
1315
|
-
|
|
1316
|
-
console.
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1693
|
+
if (response.ok) {
|
|
1694
|
+
resendDomain = (await response.json());
|
|
1695
|
+
console.log(` + Resend: dominio ${domain} registrado`);
|
|
1696
|
+
}
|
|
1697
|
+
else {
|
|
1698
|
+
// Rejected (e.g. 403 already-exists). If the domain lives in THIS account
|
|
1699
|
+
// (a `--resume` re-run), recover it and continue; otherwise keep the
|
|
1700
|
+
// preserved warning behavior (issue §4) — warn + continue, never abort.
|
|
1701
|
+
const status = response.status;
|
|
1702
|
+
resendDomain = await findResendDomain(domain, resendKey, fetchImpl);
|
|
1703
|
+
if (resendDomain) {
|
|
1704
|
+
console.log(` ↻ Resend: dominio ${domain} ya registrado en esta cuenta (reuso sus records)`);
|
|
1705
|
+
}
|
|
1706
|
+
else {
|
|
1707
|
+
console.warn(`⚠ Resend rechazó el alta de \`${domain}\` (status ${status}). ` +
|
|
1708
|
+
'Quizá ya está verificado en otra cuenta — `EMAIL_FROM` se actualiza igual; ' +
|
|
1709
|
+
'verifica el dominio a mano en Resend si hace falta.');
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1320
1712
|
}
|
|
1321
|
-
console.log(` + Resend: dominio ${domain} registrado (verifica los records DNS en Resend)`);
|
|
1322
1713
|
}
|
|
1323
1714
|
catch (error) {
|
|
1324
1715
|
// A network/timeout failure on Resend is non-fatal — the domain switch already
|
|
1325
1716
|
// happened on Vercel; Resend verification is re-runnable with `--resume`.
|
|
1326
1717
|
console.warn(`⚠ No se pudo dar de alta \`${domain}\` en Resend (${error?.message ?? 'error'}). ` +
|
|
1327
1718
|
'Reintenta con `--resume`; `EMAIL_FROM` ya se actualizó.');
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
if (resendDomain.status === RESEND_DOMAIN_VERIFIED_STATUS) {
|
|
1722
|
+
console.log(` ✔ Resend: dominio ${domain} ya verificado`);
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
// 2. Create the DNS records Resend returned — the ones it RETURNS, no fixed set (§8).
|
|
1726
|
+
if (!dns) {
|
|
1727
|
+
// `registrar` route: no DNS API — the records live in the Resend dashboard and
|
|
1728
|
+
// must be created by hand (printing them here is a follow-up — issue §9).
|
|
1729
|
+
console.warn(`⚠ (registrar) Los records DNS de Resend para \`${domain}\` no se crean automáticamente. ` +
|
|
1730
|
+
'Créalos a mano desde el dashboard de Resend y verifica el dominio ahí.');
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
const records = resendDomain.records ?? [];
|
|
1734
|
+
const cloudflareDeps = { fetchImpl };
|
|
1735
|
+
let allEnsured = true;
|
|
1736
|
+
for (const record of records) {
|
|
1737
|
+
const type = record.type?.toUpperCase();
|
|
1738
|
+
if ((type !== 'TXT' && type !== 'MX') || !record.name || !record.value) {
|
|
1739
|
+
// Only the types Resend requires are supported (issue §9) — anything else
|
|
1740
|
+
// (or a malformed record) is surfaced for manual creation, never guessed.
|
|
1741
|
+
console.warn(` ⚠ Record de Resend no soportado o incompleto (${record.record ?? record.type ?? '?'}) — créalo a mano en Cloudflare.`);
|
|
1742
|
+
allEnsured = false;
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
const spec = {
|
|
1746
|
+
type,
|
|
1747
|
+
name: resendRecordFqdn(record.name, domain),
|
|
1748
|
+
content: record.value,
|
|
1749
|
+
...(record.priority !== undefined ? { priority: record.priority } : {}),
|
|
1750
|
+
};
|
|
1751
|
+
try {
|
|
1752
|
+
const outcome = await ensureDnsRecord(dns.zoneId, spec, dns.token, cloudflareDeps);
|
|
1753
|
+
const mark = outcome === 'created'
|
|
1754
|
+
? '+'
|
|
1755
|
+
: outcome === 'updated'
|
|
1756
|
+
? '↻ (contenido actualizado)'
|
|
1757
|
+
: '⏭ (ya estaba)';
|
|
1758
|
+
console.log(` ${mark} ${spec.type} ${spec.name} (record ${record.record ?? 'DNS'} de Resend)`);
|
|
1759
|
+
}
|
|
1760
|
+
catch (error) {
|
|
1761
|
+
// Non-fatal (the mail path is additive): warn with the actionable mapping
|
|
1762
|
+
// and keep going — the step is re-runnable with `--resume`.
|
|
1763
|
+
allEnsured = false;
|
|
1764
|
+
const detail = error instanceof CloudflareApiError
|
|
1765
|
+
? cloudflareErrorToCliError(error, `crear el record ${spec.type} \`${spec.name}\``)
|
|
1766
|
+
.message
|
|
1767
|
+
: (error?.message ?? 'error');
|
|
1768
|
+
console.warn(` ⚠ No se pudo asegurar el record ${spec.type} \`${spec.name}\`: ${detail}`);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
if (!allEnsured) {
|
|
1772
|
+
console.warn(`⚠ El dominio \`${domain}\` queda pending en Resend (faltaron records DNS). ` +
|
|
1773
|
+
'Corrige lo anterior y reintenta con `--resume`.');
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
// 3. Trigger verification + poll until `verified` or timeout (never a hard fail).
|
|
1777
|
+
if (!resendDomain.id) {
|
|
1778
|
+
console.warn(`⚠ Resend no devolvió un id para \`${domain}\` — no puedo sondear la verificación. ` +
|
|
1779
|
+
'Revisa el dominio en el dashboard de Resend.');
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
await triggerResendVerification(resendDomain.id, resendKey, fetchImpl);
|
|
1783
|
+
const verified = await pollResendDomainVerification(resendDomain.id, resendKey, deps, fetchImpl);
|
|
1784
|
+
if (verified) {
|
|
1785
|
+
console.log(` ✔ Resend: dominio ${domain} verificado`);
|
|
1786
|
+
}
|
|
1787
|
+
else {
|
|
1788
|
+
// §8: the records ARE created — pending-verification, resumable, never fatal.
|
|
1789
|
+
console.warn(`⚠ El dominio \`${domain}\` quedó pending-verification en Resend (los records DNS ya están; ` +
|
|
1790
|
+
'Resend sigue verificando en background). Reintenta con `--resume` o revisa el dashboard.');
|
|
1328
1791
|
}
|
|
1329
1792
|
}
|
|
1330
1793
|
/** Print the exact DNS records the owner must create for the `--dns=registrar` path. */
|
|
@@ -1421,6 +1884,9 @@ async function resolveDnsToken(route, envPath, headless) {
|
|
|
1421
1884
|
* - domain already attached to Vercel (409) → only polls, never re-adds.
|
|
1422
1885
|
* - interim retire fails → WARNING, the step is NOT marked failed (interim stays).
|
|
1423
1886
|
* - Resend already verified elsewhere → warning + continue.
|
|
1887
|
+
* - Resend DNS records (BOOT-003) → `ensureDnsRecord` create-or-PATCH (a rotated
|
|
1888
|
+
* DKIM is PATCHed in place, never deleted); a Resend poll timeout is a WARNING
|
|
1889
|
+
* (pending-verification, resumable), never a step failure.
|
|
1424
1890
|
* - `AUTH_URL`/`NEXTAUTH_URL` already correct → the upsert PATCH is omitted.
|
|
1425
1891
|
*
|
|
1426
1892
|
* On timeout the state is left `clientDomainStatus: pending-verification` (resumable);
|
|
@@ -1456,6 +1922,9 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
1456
1922
|
throw error;
|
|
1457
1923
|
}
|
|
1458
1924
|
// 2. DNS record: org/cliente-cf CREATE the CNAME; registrar only PRINTS it (§4).
|
|
1925
|
+
// The resolved zone + token are kept for step 6: the Resend DKIM/SPF/MX records
|
|
1926
|
+
// live in the SAME client zone (`updates.<domain>` shares the apex — BOOT-003).
|
|
1927
|
+
let resendDns = null;
|
|
1459
1928
|
if (route === 'registrar') {
|
|
1460
1929
|
// Print the canonical record now; the poll loop re-prints Vercel's verification.
|
|
1461
1930
|
printManualRecords(domain, []);
|
|
@@ -1474,6 +1943,7 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
1474
1943
|
}
|
|
1475
1944
|
throw error; // resolveZoneIdForName's own CLIError (zone not found) propagates.
|
|
1476
1945
|
}
|
|
1946
|
+
resendDns = { zoneId, token: cfToken };
|
|
1477
1947
|
try {
|
|
1478
1948
|
const cnameOutcome = await ensureCnameRecord(zoneId, domain, cfToken, cloudflareDeps);
|
|
1479
1949
|
const mark = cnameOutcome === 'created'
|
|
@@ -1514,7 +1984,9 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
1514
1984
|
// 6. Register the client's MAIL SUBDOMAIN (`updates.<domain>`) in Resend — NOT the
|
|
1515
1985
|
// apex, which holds the client's corporate MX/SPF (Google Workspace, M365, …).
|
|
1516
1986
|
// Verifying Resend on a dedicated subdomain keeps the client's own mail intact.
|
|
1517
|
-
|
|
1987
|
+
// BOOT-003: also CREATES the DKIM/SPF/MX records Resend returns (in the same
|
|
1988
|
+
// client zone as the CNAME) and polls until Resend reports `verified`.
|
|
1989
|
+
await registerResendDomain(mailDomainFor(domain), envPath, deps, fetchImpl, resendDns);
|
|
1518
1990
|
// 7. Record the client domain as LIVE and persist.
|
|
1519
1991
|
state.dns = {
|
|
1520
1992
|
...(state.dns ?? {
|
|
@@ -1532,6 +2004,12 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
|
|
|
1532
2004
|
* pre-approves; otherwise a TTY prompt asks (headless without `--confirm` → NOT
|
|
1533
2005
|
* retired). The DEV domain (`{slug}-dev.timekast.mx`) is ALWAYS conserved. A
|
|
1534
2006
|
* removal failure is a WARNING, never a step failure (issue §8 — interim stays up).
|
|
2007
|
+
*
|
|
2008
|
+
* BOOT-007: once Vercel detaches the interim, its CNAME in the org `timekast.mx`
|
|
2009
|
+
* zone is deleted too — otherwise it stays orphaned, resolving to Vercel's generic
|
|
2010
|
+
* target and serving a 404 forever. The delete lives INSIDE the confirmed block
|
|
2011
|
+
* (a declined prompt never touches Cloudflare) and is SKIPPED when the Vercel
|
|
2012
|
+
* detach failed (the interim is still live — its CNAME must keep resolving).
|
|
1535
2013
|
*/
|
|
1536
2014
|
async function retireInterimDomain(projectId, token, teamId, state, deps, fetchImpl) {
|
|
1537
2015
|
const interim = provisionDomains(state.projectName).prod;
|
|
@@ -1558,15 +2036,78 @@ async function retireInterimDomain(projectId, token, teamId, state, deps, fetchI
|
|
|
1558
2036
|
// the step is NOT marked failed, the dev retires it manually later.
|
|
1559
2037
|
console.warn(`⚠ No se pudo retirar el dominio interino \`${interim}\` (${error?.message ?? 'error'}).\n` +
|
|
1560
2038
|
' Queda activo; retíralo a mano en Vercel o reintenta con `--resume`. El paso NO falla por esto.');
|
|
2039
|
+
// BOOT-007: the interim is still attached to Vercel — its CNAME stays too.
|
|
2040
|
+
return;
|
|
1561
2041
|
}
|
|
1562
2042
|
// The DEV domain is intentionally never touched (issue §4 — conserved).
|
|
2043
|
+
// BOOT-007: the interim is off Vercel — delete its now-orphaned CNAME as well.
|
|
2044
|
+
await deleteInterimCname(interim, deps.envPath ?? defaultEnvPath(), fetchImpl);
|
|
1563
2045
|
}
|
|
1564
2046
|
/**
|
|
1565
|
-
*
|
|
1566
|
-
* Vercel
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
2047
|
+
* Delete the interim CNAME (`{slug}.timekast.mx`) from the org Cloudflare zone
|
|
2048
|
+
* after Vercel detached the domain (BOOT-007). Without this, the record stays
|
|
2049
|
+
* orphaned: it keeps resolving to `cname.vercel-dns.com` while no Vercel project
|
|
2050
|
+
* answers for the host → a 404 forever, dead weight in the `timekast.mx` zone.
|
|
2051
|
+
*
|
|
2052
|
+
* Runs ONLY inside the already-confirmed retire block (a declined prompt never
|
|
2053
|
+
* reaches here). Best-effort by design (issue §4 + §8): EVERY failure — missing
|
|
2054
|
+
* rail token, zone not visible to the token (the `resolveZoneId` CLIError that is
|
|
2055
|
+
* hard in the CREATION path), an out-of-scope token, a 5xx on the DELETE — is a
|
|
2056
|
+
* WARNING, never a step failure; the record stays and the cleanup is retryable.
|
|
2057
|
+
* Idempotent: an already-absent record (`findCnameRecord` → null, or a 404 on the
|
|
2058
|
+
* DELETE) is a narrated no-op. The record content is NOT validated before deleting
|
|
2059
|
+
* (this is a retirement, not a repair — issue §8). The DEV domain's CNAME is never
|
|
2060
|
+
* touched: only the prod interim name is looked up.
|
|
2061
|
+
*/
|
|
2062
|
+
async function deleteInterimCname(interim, envPath, fetchImpl) {
|
|
2063
|
+
const warnRetryable = (reason) => {
|
|
2064
|
+
console.warn(`⚠ No se pudo borrar el CNAME interino \`${interim}\` en Cloudflare (${reason}).\n` +
|
|
2065
|
+
` El registro queda en la zona \`${TIMEKAST_ZONE_NAME}\`; bórralo a mano o reintenta. El paso NO falla por esto.`);
|
|
2066
|
+
};
|
|
2067
|
+
const cloudflareToken = readCloudflareApiKey(envPath);
|
|
2068
|
+
if (!cloudflareToken) {
|
|
2069
|
+
warnRetryable('sin `CLOUDFLARE_API_TOKEN` en el rail');
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
const cloudflareDeps = { fetchImpl };
|
|
2073
|
+
try {
|
|
2074
|
+
const zoneId = await resolveZoneId(cloudflareToken, cloudflareDeps);
|
|
2075
|
+
const record = await findCnameRecord(zoneId, interim, cloudflareToken, cloudflareDeps);
|
|
2076
|
+
// Same outcome narration as the Vercel retire above (no-echo — only the
|
|
2077
|
+
// domain NAME prints; the token never crosses this boundary).
|
|
2078
|
+
const outcome = record
|
|
2079
|
+
? await deleteDnsRecord(zoneId, record.id, cloudflareToken, cloudflareDeps)
|
|
2080
|
+
: 'absent';
|
|
2081
|
+
console.log(outcome === 'absent'
|
|
2082
|
+
? ` ⏭ (ya no estaba) CNAME interino ${interim} en Cloudflare`
|
|
2083
|
+
: ` - CNAME interino ${interim} borrado de Cloudflare`);
|
|
2084
|
+
}
|
|
2085
|
+
catch (error) {
|
|
2086
|
+
// §8: zone not visible (CLIError) / out-of-scope token / 5xx → warning,
|
|
2087
|
+
// retryable — never the hard CLIError the creation path uses.
|
|
2088
|
+
warnRetryable(error?.message ?? 'error');
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Update `EMAIL_FROM` + `AUTH_URL`/`NEXTAUTH_URL` + `NEXT_PUBLIC_APP_URL` +
|
|
2093
|
+
* `WEBAUTHN_RP_ID` to the client domain in the Vercel env, per-target and
|
|
2094
|
+
* idempotently (PROV-006 §4 + BOOT-004). Lists the existing vars once and upserts
|
|
2095
|
+
* each entry via `upsertEnvVar`, which reconciles a key that the substrate left
|
|
2096
|
+
* SPLIT per target (e.g. `NEXT_PUBLIC_APP_URL` as `['production']` + `['preview']`
|
|
2097
|
+
* from `buildEnvPlan`) into ONE combined-target record (BOOT-002 — a single-record
|
|
2098
|
+
* PATCH would 400 against the sibling). A second run over the reconciled state
|
|
2099
|
+
* re-PATCHes in place: no duplicate records, no 400 (issue §8).
|
|
2100
|
+
*
|
|
2101
|
+
* `WEBAUTHN_RP_ID` is re-pointed for PRODUCTION ONLY: the interim dev domain stays
|
|
2102
|
+
* the preview rpID (the dev domain is never retired — see `retireInterimDomain`),
|
|
2103
|
+
* so `buildEnvPlan`'s intentional split pair is preserved — the single-target plan
|
|
2104
|
+
* only PATCHes the overlapping production record and never touches the preview
|
|
2105
|
+
* sibling (no BOOT-002 400 reintroduced). Changing the rpID invalidates every
|
|
2106
|
+
* passkey registered under the interim rpID and the CLI cannot count them (that
|
|
2107
|
+
* table lives in the derivative's DB), so a warning prints UNCONDITIONALLY.
|
|
2108
|
+
*
|
|
2109
|
+
* No-echo: only KEY NAMES + targets print (the values here are public, but the
|
|
2110
|
+
* discipline holds).
|
|
1570
2111
|
*/
|
|
1571
2112
|
async function updateDomainEnv(projectId, token, teamId, domain, deps) {
|
|
1572
2113
|
const authUrl = `https://${domain}`;
|
|
@@ -1583,12 +2124,22 @@ async function updateDomainEnv(projectId, token, teamId, domain, deps) {
|
|
|
1583
2124
|
// Keep the public app origin in lockstep with AUTH_URL once a client domain lands
|
|
1584
2125
|
// (the substrate set it to the interim domain in buildEnvPlan; this re-points it).
|
|
1585
2126
|
{ key: 'NEXT_PUBLIC_APP_URL', value: authUrl, target: ['production', 'preview'] },
|
|
2127
|
+
// WebAuthn rpID is the registrable HOST, never a URL — the bare domain (BOOT-004).
|
|
2128
|
+
// PRODUCTION only: the interim dev domain remains the preview rpID (the dev domain
|
|
2129
|
+
// is never retired), so the intentional split pair from buildEnvPlan is preserved.
|
|
2130
|
+
{ key: 'WEBAUTHN_RP_ID', value: domain, target: ['production'] },
|
|
1586
2131
|
];
|
|
1587
2132
|
const existing = await listEnvVars(projectId, token, teamId, deps);
|
|
1588
2133
|
for (const entry of plan) {
|
|
1589
2134
|
const outcome = await upsertEnvVar(projectId, token, teamId, entry, existing, deps);
|
|
1590
2135
|
console.log(` ${outcome === 'created' ? '+' : '↻'} ${entry.key} [${entry.target.join(', ')}]`);
|
|
1591
2136
|
}
|
|
2137
|
+
// BOOT-004: changing the rpID orphans every passkey registered under the interim
|
|
2138
|
+
// rpID. The CLI cannot query how many exist (that table lives in the derivative's
|
|
2139
|
+
// DB, unreachable from provisioning), so the warning is UNCONDITIONAL.
|
|
2140
|
+
console.warn(`⚠ WEBAUTHN_RP_ID (production) ahora es \`${domain}\`: cualquier passkey registrada ` +
|
|
2141
|
+
'bajo el rpID interino queda invalidada — esos usuarios deben registrar una nueva ' +
|
|
2142
|
+
'passkey en el dominio definitivo.');
|
|
1592
2143
|
}
|
|
1593
2144
|
/**
|
|
1594
2145
|
* Print the resolved client-domain operation tree for `--domain --dry-run`
|
|
@@ -1609,7 +2160,14 @@ function printClientDomainPlan(domain, interim, route, confirm) {
|
|
|
1609
2160
|
console.log(` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim} ` +
|
|
1610
2161
|
`(el dev ${domain}-dev se conserva)`);
|
|
1611
2162
|
console.log(` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · AUTH_URL=https://${domain} · NEXTAUTH_URL=https://${domain} · NEXT_PUBLIC_APP_URL=https://${domain}`);
|
|
2163
|
+
console.log(` ↻ WEBAUTHN_RP_ID=${domain} (solo production — las passkeys del rpID interino quedan invalidadas; el preview conserva el rpID dev)`);
|
|
1612
2164
|
console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
|
|
2165
|
+
if (route === 'registrar') {
|
|
2166
|
+
console.log(' · (registrar) los records DNS de Resend (DKIM/SPF/MX) se crean a mano en el dashboard de Resend');
|
|
2167
|
+
}
|
|
2168
|
+
else {
|
|
2169
|
+
console.log(` + records DNS de Resend (DKIM/SPF/MX, los que devuelva) en la zona ${apexZoneOf(domain)} + sondeo hasta verified`);
|
|
2170
|
+
}
|
|
1613
2171
|
console.log('\n(`--dry-run`) No se ejecutó ninguna llamada a API ni se escribió estado.');
|
|
1614
2172
|
}
|
|
1615
2173
|
/**
|
|
@@ -1631,7 +2189,10 @@ async function runServiceStep(service, rootDir, state, flags) {
|
|
|
1631
2189
|
// PROV-004: git substrate (rename → remote add → push main) + create the
|
|
1632
2190
|
// Vercel project + connect git (GitHub App) + PATCH production-branch +
|
|
1633
2191
|
// .vercel/project.json + activate E2E. Migrations run in vercel-build, not a workflow.
|
|
1634
|
-
await provisionGithubVercel(rootDir, state, flags.team, {
|
|
2192
|
+
await provisionGithubVercel(rootDir, state, flags.team, {
|
|
2193
|
+
force: flags.force,
|
|
2194
|
+
repo: flags.repo,
|
|
2195
|
+
});
|
|
1635
2196
|
break;
|
|
1636
2197
|
case 'dns':
|
|
1637
2198
|
// PROV-005: attach the interim domains to Vercel (dev → develop, prod →
|
|
@@ -1639,6 +2200,12 @@ async function runServiceStep(service, rootDir, state, flags) {
|
|
|
1639
2200
|
// (RESEND_API_KEY + the fixed EMAIL_FROM), then `state.dns = { domain, devDomain }`.
|
|
1640
2201
|
await provisionDns(rootDir, state, flags.team);
|
|
1641
2202
|
break;
|
|
2203
|
+
case 'backlog':
|
|
2204
|
+
// BSYNC-003: register the project in backlog.timekast.mx (POST /projects with the
|
|
2205
|
+
// org admin token) + mint a per-project read_write key + write BACKLOG_API_KEY /
|
|
2206
|
+
// BACKLOG_PROJECT_ID to `.env.local` (PROVISION_OWNED — never to Vercel).
|
|
2207
|
+
await provisionBacklog(rootDir, state, {});
|
|
2208
|
+
break;
|
|
1642
2209
|
default:
|
|
1643
2210
|
throw new CLIError(`Paso de provision no implementado: \`${service}\`.`);
|
|
1644
2211
|
}
|
|
@@ -1962,6 +2529,10 @@ function describeDestroyPlan(target) {
|
|
|
1962
2529
|
if (target.githubRepo) {
|
|
1963
2530
|
lines.push(` - Repo GitHub ${target.githubRepo} — \`gh repo delete\` (sesión del dev)`);
|
|
1964
2531
|
}
|
|
2532
|
+
if (target.backlogProjectId) {
|
|
2533
|
+
lines.push(` - Proyecto backlog central ${target.backlogProjectId} — DELETE /projects ` +
|
|
2534
|
+
'(soft-delete, `BACKLOG_ADMIN_TOKEN` del rail)');
|
|
2535
|
+
}
|
|
1965
2536
|
if (lines.length === 0) {
|
|
1966
2537
|
lines.push(' (No hay recursos registrados que destruir — el estado no tiene ids.)');
|
|
1967
2538
|
}
|
|
@@ -2148,6 +2719,38 @@ async function destroyGithubRepo(target, runGh) {
|
|
|
2148
2719
|
return { resource, status: 'failed', detail: message || 'error en `gh repo delete`' };
|
|
2149
2720
|
}
|
|
2150
2721
|
}
|
|
2722
|
+
/**
|
|
2723
|
+
* Soft-delete the backlog-central project via `DELETE /projects/{pid}` (BSYNC-003).
|
|
2724
|
+
* INTENTIONALLY DIFFERENT from the other providers' teardown: those hard-delete with
|
|
2725
|
+
* the DEV's own session (`gh`/`vercel`) — backlog has no such per-dev session, so it
|
|
2726
|
+
* reuses the rail's `BACKLOG_ADMIN_TOKEN` (the smoke confirmed the admin token DELETEs
|
|
2727
|
+
* a project). The delete is a SOFT delete (deactivates, no cascade) and IDEMPOTENT: a
|
|
2728
|
+
* repeat returns 200, and a 404 (already gone) is treated as "already clean" — same
|
|
2729
|
+
* 404-tolerance principle as {@link destroyNeonProject} (issue §8).
|
|
2730
|
+
*/
|
|
2731
|
+
async function destroyBacklogProject(target, adminToken, deps) {
|
|
2732
|
+
const resource = `Proyecto backlog central (${target.backlogProjectId})`;
|
|
2733
|
+
if (!target.backlogProjectId) {
|
|
2734
|
+
return {
|
|
2735
|
+
resource: 'Proyecto backlog central',
|
|
2736
|
+
status: 'skipped',
|
|
2737
|
+
detail: 'sin id en el estado',
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
try {
|
|
2741
|
+
await deleteBacklogProject(adminToken, target.backlogProjectId, deps);
|
|
2742
|
+
return { resource, status: 'removed', detail: 'desactivado (soft-delete idempotente)' };
|
|
2743
|
+
}
|
|
2744
|
+
catch (error) {
|
|
2745
|
+
if (error instanceof BacklogApiError && error.status === 404) {
|
|
2746
|
+
return { resource, status: 'removed', detail: 'ya estaba desactivado (404)' };
|
|
2747
|
+
}
|
|
2748
|
+
const detail = error instanceof BacklogApiError
|
|
2749
|
+
? `backlog respondió ${error.status}`
|
|
2750
|
+
: (error?.message ?? 'error de red');
|
|
2751
|
+
return { resource, status: 'failed', detail };
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2151
2754
|
/**
|
|
2152
2755
|
* Orchestrate the per-provider teardown (issue §4 + §8). Each provider failure is
|
|
2153
2756
|
* logged and the run CONTINUES with the others; the outcomes are collected so the
|
|
@@ -2250,6 +2853,24 @@ async function teardownProviders(target, deps) {
|
|
|
2250
2853
|
if (target.githubRepo) {
|
|
2251
2854
|
outcomes.push(await destroyGithubRepo(target, deps.runGh));
|
|
2252
2855
|
}
|
|
2856
|
+
// 5. Backlog central — soft-delete the project via `DELETE /projects` with the rail's
|
|
2857
|
+
// `BACKLOG_ADMIN_TOKEN`. UNLIKE the providers above (deleted with the dev's own
|
|
2858
|
+
// session), backlog only HAS the org admin token — create+delete-capable per the
|
|
2859
|
+
// smoke — so the teardown reuses it. A missing token is a `failed` (keeps the
|
|
2860
|
+
// state + flags the orphan), never a silent skip. Soft + idempotent (404 → gone).
|
|
2861
|
+
if (target.backlogProjectId) {
|
|
2862
|
+
const backlogToken = readGateValue(BACKLOG_ADMIN_TOKEN_ENV, deps.envPath);
|
|
2863
|
+
if (!backlogToken) {
|
|
2864
|
+
outcomes.push({
|
|
2865
|
+
resource: `Proyecto backlog central (${target.backlogProjectId})`,
|
|
2866
|
+
status: 'failed',
|
|
2867
|
+
detail: 'sin `BACKLOG_ADMIN_TOKEN` en el rail — desactívalo a mano en la app',
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
else {
|
|
2871
|
+
outcomes.push(await destroyBacklogProject(target, backlogToken, { fetchImpl: deps.fetchImpl }));
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2253
2874
|
return outcomes;
|
|
2254
2875
|
}
|
|
2255
2876
|
/** Print the per-provider teardown report (es-MX) and flag the not-removed ones. */
|
|
@@ -2387,6 +3008,12 @@ export async function runProvision(flags) {
|
|
|
2387
3008
|
// with no `--services` (a deferred go-live over an existing provision) or
|
|
2388
3009
|
// alongside the initial run. It always runs AFTER the service steps.
|
|
2389
3010
|
const hasDomain = Boolean(flags.domain);
|
|
3011
|
+
// Backlog token gate (BSYNC-003): degrade gracefully vs the fail-closed rail tokens.
|
|
3012
|
+
// A missing `BACKLOG_ADMIN_TOKEN` strips `backlog` (WARN) when it is one of several
|
|
3013
|
+
// services, or throws when it is the only one — BEFORE the preflight so the stripped
|
|
3014
|
+
// service never reaches the fail-closed `assertGateTokensPresent`. Reads the rail for
|
|
3015
|
+
// presence only (no mutation) → safe to run ahead of the `--dry-run` return too.
|
|
3016
|
+
flags.services = resolveBacklogService(flags.services);
|
|
2390
3017
|
// 1. Flag validation.
|
|
2391
3018
|
if (flags.services.length === 0 && !hasDomain) {
|
|
2392
3019
|
throw new CLIError('No seleccionaste servicios. Usa `--services=neon,vercel,github,dns` ' +
|
|
@@ -2431,6 +3058,16 @@ export async function runProvision(flags) {
|
|
|
2431
3058
|
// In patch mode the projectName comes from the loaded state (the interim slug);
|
|
2432
3059
|
// a fresh run derives it from flags (the scaffold default).
|
|
2433
3060
|
const projectName = state?.projectName ?? deriveProjectName(flags, rootDir);
|
|
3061
|
+
// BOOT-001: resolve the target GitHub repo ONCE (--repo → existing origin →
|
|
3062
|
+
// FACTORY_ORG/projectName) for every consumer of the slug: the plan/report
|
|
3063
|
+
// (`--dry-run` included), the Neon E2E-secret push (which runs BEFORE the github
|
|
3064
|
+
// step) and the github step itself. Reading the local `origin` is a read-only
|
|
3065
|
+
// git call — safe under `--dry-run` (no REST, no state write). On resume the
|
|
3066
|
+
// persisted repo is the authority (a conflicting `--repo` throws, never a
|
|
3067
|
+
// silent re-point).
|
|
3068
|
+
const repoSlug = flags.services.includes('github') || flags.services.includes('neon')
|
|
3069
|
+
? await resolveRepoSlug(rootDir, projectName, state?.github?.repo, flags.repo, gitInDir)
|
|
3070
|
+
: undefined;
|
|
2434
3071
|
const steps = buildPlan(flags.services, state);
|
|
2435
3072
|
const result = {
|
|
2436
3073
|
projectName,
|
|
@@ -2439,6 +3076,9 @@ export async function runProvision(flags) {
|
|
|
2439
3076
|
resumed: (flags.resume || standaloneDomain) && !flags.force,
|
|
2440
3077
|
steps,
|
|
2441
3078
|
};
|
|
3079
|
+
if (repoSlug) {
|
|
3080
|
+
result.repo = repoSlug;
|
|
3081
|
+
}
|
|
2442
3082
|
if (hasDomain) {
|
|
2443
3083
|
result.clientDomain = { domain: flags.domain, route: flags.dns };
|
|
2444
3084
|
}
|
|
@@ -2472,6 +3112,13 @@ export async function runProvision(flags) {
|
|
|
2472
3112
|
scope: flags.team ?? flags.orgId ?? '',
|
|
2473
3113
|
createdAt: new Date().toISOString(),
|
|
2474
3114
|
};
|
|
3115
|
+
// BOOT-001: record the resolved repo BEFORE the steps run, so the Neon E2E-secret
|
|
3116
|
+
// push (`neonSecretRepo`, which runs before the github step) targets the resolved
|
|
3117
|
+
// repo — not the historical `${FACTORY_ORG}/${projectName}` fallback. Never
|
|
3118
|
+
// clobbers a resumed state's repo (that one is the authority).
|
|
3119
|
+
if (repoSlug && !runState.github?.repo) {
|
|
3120
|
+
runState.github = { repo: repoSlug };
|
|
3121
|
+
}
|
|
2475
3122
|
// Ensure the state dir exists + a baseline file before the first step, so a
|
|
2476
3123
|
// step failure on the very first service still leaves a resumable state.
|
|
2477
3124
|
writeProvisionState(rootDir, runState);
|