@timekast/factory 1.19.0 → 1.21.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 +5 -1
- package/dist/commands/backlog.js +706 -0
- package/dist/commands/provision.js +232 -4
- package/dist/commands/update.js +5 -1
- package/dist/index.js +42 -2
- package/dist/lib/atomic-swap.js +28 -8
- 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/constants.js +61 -0
- package/dist/lib/env-file.js +42 -0
- package/dist/lib/package-json.js +66 -1
- package/dist/lib/provision-preflight.js +8 -0
- package/dist/lib/provision-state.js +10 -0
- package/package.json +1 -1
|
@@ -27,9 +27,10 @@ import { FACTORY_ORG } from '../lib/constants.js';
|
|
|
27
27
|
import { CLIError } from '../lib/cli-error.js';
|
|
28
28
|
import { apexZoneOf, CloudflareApiError, CLOUDFLARE_API_BASE, deleteDnsRecord, ensureCnameRecord, ensureDnsRecord, findCnameRecord, resolveZoneId, resolveZoneIdForName, 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. */
|
|
@@ -222,6 +226,7 @@ const SERVICE_LABEL = {
|
|
|
222
226
|
vercel: 'Vercel (env sync por target)',
|
|
223
227
|
github: 'GitHub + Vercel (sustrato git + proyecto + env + E2E)',
|
|
224
228
|
dns: 'DNS + Resend (dominio del entorno)',
|
|
229
|
+
backlog: 'Backlog central (proyecto en backlog.timekast.mx + API key)',
|
|
225
230
|
};
|
|
226
231
|
/** Build the ordered plan: each requested service is `skip` if complete, else `create`. */
|
|
227
232
|
function buildPlan(services, state) {
|
|
@@ -579,6 +584,163 @@ export async function provisionNeon(rootDir, state, deps = {}) {
|
|
|
579
584
|
' La API key project-scoped se creó; reintenta con `--resume` cuando el repo exista.');
|
|
580
585
|
}
|
|
581
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
|
+
}
|
|
582
744
|
/**
|
|
583
745
|
* Resolve the four pooled+direct Neon connection strings, ALWAYS by re-deriving
|
|
584
746
|
* fresh from Neon — provision persists NO credentials file (Neon is the source of
|
|
@@ -2038,6 +2200,12 @@ async function runServiceStep(service, rootDir, state, flags) {
|
|
|
2038
2200
|
// (RESEND_API_KEY + the fixed EMAIL_FROM), then `state.dns = { domain, devDomain }`.
|
|
2039
2201
|
await provisionDns(rootDir, state, flags.team);
|
|
2040
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;
|
|
2041
2209
|
default:
|
|
2042
2210
|
throw new CLIError(`Paso de provision no implementado: \`${service}\`.`);
|
|
2043
2211
|
}
|
|
@@ -2361,6 +2529,10 @@ function describeDestroyPlan(target) {
|
|
|
2361
2529
|
if (target.githubRepo) {
|
|
2362
2530
|
lines.push(` - Repo GitHub ${target.githubRepo} — \`gh repo delete\` (sesión del dev)`);
|
|
2363
2531
|
}
|
|
2532
|
+
if (target.backlogProjectId) {
|
|
2533
|
+
lines.push(` - Proyecto backlog central ${target.backlogProjectId} — DELETE /projects ` +
|
|
2534
|
+
'(soft-delete, `BACKLOG_ADMIN_TOKEN` del rail)');
|
|
2535
|
+
}
|
|
2364
2536
|
if (lines.length === 0) {
|
|
2365
2537
|
lines.push(' (No hay recursos registrados que destruir — el estado no tiene ids.)');
|
|
2366
2538
|
}
|
|
@@ -2547,6 +2719,38 @@ async function destroyGithubRepo(target, runGh) {
|
|
|
2547
2719
|
return { resource, status: 'failed', detail: message || 'error en `gh repo delete`' };
|
|
2548
2720
|
}
|
|
2549
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
|
+
}
|
|
2550
2754
|
/**
|
|
2551
2755
|
* Orchestrate the per-provider teardown (issue §4 + §8). Each provider failure is
|
|
2552
2756
|
* logged and the run CONTINUES with the others; the outcomes are collected so the
|
|
@@ -2649,6 +2853,24 @@ async function teardownProviders(target, deps) {
|
|
|
2649
2853
|
if (target.githubRepo) {
|
|
2650
2854
|
outcomes.push(await destroyGithubRepo(target, deps.runGh));
|
|
2651
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
|
+
}
|
|
2652
2874
|
return outcomes;
|
|
2653
2875
|
}
|
|
2654
2876
|
/** Print the per-provider teardown report (es-MX) and flag the not-removed ones. */
|
|
@@ -2786,6 +3008,12 @@ export async function runProvision(flags) {
|
|
|
2786
3008
|
// with no `--services` (a deferred go-live over an existing provision) or
|
|
2787
3009
|
// alongside the initial run. It always runs AFTER the service steps.
|
|
2788
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);
|
|
2789
3017
|
// 1. Flag validation.
|
|
2790
3018
|
if (flags.services.length === 0 && !hasDomain) {
|
|
2791
3019
|
throw new CLIError('No seleccionaste servicios. Usa `--services=neon,vercel,github,dns` ' +
|
package/dist/commands/update.js
CHANGED
|
@@ -45,7 +45,7 @@ import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, LOCKFILE_FILE, PRETTIERIGNORE_FILE,
|
|
|
45
45
|
import { decideCommit } from '../lib/commit-decision.js';
|
|
46
46
|
import { extractManagedBlock, syncManagedBlock } from '../lib/gitattributes.js';
|
|
47
47
|
import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
|
|
48
|
-
import { detectProfile, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
|
|
48
|
+
import { detectProfile, emptyShippedE2EPort, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
|
|
49
49
|
import { runPreflight } from '../lib/preflight.js';
|
|
50
50
|
import { printProvisionPointer } from '../lib/provision-pointer.js';
|
|
51
51
|
import { hasProvisionState } from '../lib/provision-state.js';
|
|
@@ -333,6 +333,10 @@ function maintainDerivedPkg(rootDir, agentKitVersion) {
|
|
|
333
333
|
try {
|
|
334
334
|
warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
|
|
335
335
|
setAgentKitVersion(pkgPath, agentKitVersion);
|
|
336
|
+
// Retire the E2E port the kit used to ship (`ports.e2e: 3006`) so the runner that just
|
|
337
|
+
// arrived via `scripts/**` can derive one per checkout. Conditional and silent: it only
|
|
338
|
+
// ever touches that exact value, so a dev-chosen port survives every update untouched.
|
|
339
|
+
emptyShippedE2EPort(pkgPath);
|
|
336
340
|
}
|
|
337
341
|
catch {
|
|
338
342
|
console.warn('Aviso: no se pudo actualizar package.json (¿JSON inválido?); el cerebro se instaló igual. ' +
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { dirname, join } from 'node:path';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { CLIError } from './lib/cli-error.js';
|
|
13
13
|
import { parseAddFlags, runAdd } from './commands/add.js';
|
|
14
|
+
import { parseBacklogPushFlags, parseBacklogSyncFlags, runBacklogPush, runBacklogSync, } from './commands/backlog.js';
|
|
14
15
|
import { parseEnvPushFlags, parseEnvWizardFlags, runEnvPush, runEnvWizardApply, runEnvWizardGenVapid, runEnvWizardPlan, runEnvWizardReview, } from './commands/env.js';
|
|
15
16
|
import { parseInviteAdminFlags, runInviteAdmin } from './commands/invite-admin.js';
|
|
16
17
|
import { envWizardFlow } from './tui/env-wizard-flow.js';
|
|
@@ -40,7 +41,8 @@ Comandos:
|
|
|
40
41
|
(--launcher: verifica el entorno — Node ≥ 22, gh, gh auth, membresía org)
|
|
41
42
|
publish <qué> Publica el entregable (proposal|mockup|both) a proposals.timekast.mx
|
|
42
43
|
unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
|
|
43
|
-
provision Aprovisiona la infra de un derivado (Neon/Vercel/GitHub/DNS) — flag-driven, --json
|
|
44
|
+
provision Aprovisiona la infra de un derivado (Neon/Vercel/GitHub/DNS/backlog) — flag-driven, --json
|
|
45
|
+
backlog <sub> push: refleja el backlog local en backlog.timekast.mx · sync: status + self-healing
|
|
44
46
|
secrets <file> Mergea un archivo KEY=value de secretos de metodología a ~/.claude/.env (chmod 600)
|
|
45
47
|
env <sub> push: sube product vars a Vercel (day-2) · wizard: configura .env.local
|
|
46
48
|
invite-admin Crea una invitación de super_admin para un derivado (sin password en texto plano)
|
|
@@ -82,7 +84,10 @@ Sobre \`provision\`:
|
|
|
82
84
|
headless-safe; la interacción la pone la skill \`tk-provision\`. Preflight
|
|
83
85
|
fail-closed (gh + tokens del gate + \`VERCEL_TOKEN\` con acceso al team);
|
|
84
86
|
\`neonctl\` es opcional (ausente → warning). Flags:
|
|
85
|
-
--services=a,b Servicios a crear (neon,vercel,github,dns)
|
|
87
|
+
--services=a,b Servicios a crear (neon,vercel,github,dns,backlog). \`backlog\`
|
|
88
|
+
registra el proyecto en backlog.timekast.mx y escribe
|
|
89
|
+
BACKLOG_API_KEY + BACKLOG_PROJECT_ID a .env.local (requiere
|
|
90
|
+
BACKLOG_ADMIN_TOKEN en el rail; ausente en un bundle → se omite)
|
|
86
91
|
--slug=<slug> Identificador del stack (nombre del proyecto + dominios interinos)
|
|
87
92
|
--team=<id> Team de Vercel (override; default = \`VERCEL_TEAM_ID\` del rail ~/.claude/.env)
|
|
88
93
|
--org-id=<id> Org de GitHub (override; default = org del Factory)
|
|
@@ -118,6 +123,24 @@ Sobre \`env\`:
|
|
|
118
123
|
--plan [--json] Emite el plan de preguntas (lo consume el agente / la skill tk-provision)
|
|
119
124
|
--apply [--push] Lee \`{ answers, push? }\` JSON por stdin y escribe \`.env.local\`
|
|
120
125
|
|
|
126
|
+
Sobre \`backlog\`:
|
|
127
|
+
\`backlog push\` refleja el backlog local (\`project/backlog/*\`) en
|
|
128
|
+
backlog.timekast.mx en cascada (épicas → features de los Refs \`FT-XX\` → issues
|
|
129
|
+
story/task según el campo \`Board:\`). Read-only por defecto: sin \`BACKLOG_API_KEY\`
|
|
130
|
+
en \`.env.local\` termina con exit 0 y una nota (nunca bloquea \`/backlog\`). Flags:
|
|
131
|
+
--reconcile PATCH del status cuando el elemento ya existe (en vez de saltarlo)
|
|
132
|
+
--stamp Incluye issues/épicas sin UUID y estampa su \`Backlog UUID\` de vuelta
|
|
133
|
+
--backfill Campaña legacy: incluye + estampa un backlog pre-BSYNC-007 completo
|
|
134
|
+
--source-lang <l> Idioma del texto pusheado (default \`es\`)
|
|
135
|
+
|
|
136
|
+
\`backlog sync\` actualiza el status de un elemento puntual y corre un heartbeat de
|
|
137
|
+
self-healing (repara drift vs backlog.timekast.mx). Fire-and-forget: sin
|
|
138
|
+
\`BACKLOG_API_KEY\` termina con exit 0; acotado a un deadline de 5s (nunca bloquea el
|
|
139
|
+
workflow). Flags:
|
|
140
|
+
--issue <ID> --status <s> PATCH del status de un issue (s: enum remoto o bucket local)
|
|
141
|
+
--epic <ID> --status <s> Equivalente para una épica
|
|
142
|
+
--heal-only Corre solo el self-healing (sin el sync puntual)
|
|
143
|
+
|
|
121
144
|
Sobre \`invite-admin\`:
|
|
122
145
|
Materializa el primer super_admin de un derivado por invitación (reemplaza el
|
|
123
146
|
viejo seed con password en texto plano). Resuelve la connection string del
|
|
@@ -221,6 +244,23 @@ async function main(argv) {
|
|
|
221
244
|
await runProvision(parseProvisionFlags(rest));
|
|
222
245
|
return;
|
|
223
246
|
}
|
|
247
|
+
case 'backlog': {
|
|
248
|
+
const sub = rest[0];
|
|
249
|
+
// `factory backlog push` — mirror the local backlog to backlog.timekast.mx.
|
|
250
|
+
// Read-only by default (no key → exit 0).
|
|
251
|
+
if (sub === 'push') {
|
|
252
|
+
await runBacklogPush(parseBacklogPushFlags(rest.slice(1)));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// `factory backlog sync` — point status-sync + self-healing heartbeat
|
|
256
|
+
// (BSYNC-006). Fire-and-forget: no key → exit 0; bounded to a 5s deadline.
|
|
257
|
+
if (sub === 'sync') {
|
|
258
|
+
await runBacklogSync(parseBacklogSyncFlags(rest.slice(1)));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
throw new CLIError(`Subcomando de backlog desconocido: \`${sub ?? '(ninguno)'}\`. ` +
|
|
262
|
+
'Uso: `factory backlog push` | `factory backlog sync`.');
|
|
263
|
+
}
|
|
224
264
|
case 'publish': {
|
|
225
265
|
await runPublish(parsePublishArgs(rest));
|
|
226
266
|
return;
|
package/dist/lib/atomic-swap.js
CHANGED
|
@@ -23,24 +23,44 @@
|
|
|
23
23
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
import { CLIError } from './cli-error.js';
|
|
26
|
-
import { CLAUDE_MD_FILE, TIMEKAST_DIR } from './constants.js';
|
|
26
|
+
import { CLAUDE_MD_FILE, TIMEKAST_DIR, VITEST_SETUP_FILE } from './constants.js';
|
|
27
27
|
import { parseLockfile } from './lockfile.js';
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Directory roots a manifest path may live under (mirrors `profiles.json#track`).
|
|
30
|
+
*
|
|
31
|
+
* `.github/workflows/` is deliberately NARROWER than `.github/` — the tracked set is
|
|
32
|
+
* only the CI workflows, and widening the guard to the whole directory would let a
|
|
33
|
+
* tampered tarball rewrite issue templates, CODEOWNERS or funding config.
|
|
34
|
+
*/
|
|
35
|
+
const ALLOWED_ROOTS = ['.claude/', 'scripts/', '.husky/', '.github/workflows/'];
|
|
36
|
+
/**
|
|
37
|
+
* Repo-root FILES a manifest path may be (the non-directory half of `track`).
|
|
38
|
+
*
|
|
39
|
+
* Matched EXACTLY, never by prefix. Folding these into `ALLOWED_ROOTS` would be the
|
|
40
|
+
* short spelling, but `startsWith('vitest.setup.ts')` also accepts
|
|
41
|
+
* `vitest.setup.ts.evil` — the same widening `.github/workflows/` exists to avoid. A
|
|
42
|
+
* root file is a single path, so exact equality is what the track actually says.
|
|
43
|
+
*/
|
|
44
|
+
const ALLOWED_FILES = [CLAUDE_MD_FILE, VITEST_SETUP_FILE];
|
|
45
|
+
/** Human-readable rendering of the tracked scope, for the error message. */
|
|
46
|
+
const TRACKED_SCOPE_LABEL = [...ALLOWED_ROOTS, ...ALLOWED_FILES]
|
|
47
|
+
.map((entry) => `\`${entry}\``)
|
|
48
|
+
.join(', ');
|
|
30
49
|
/**
|
|
31
50
|
* Reject a manifest path that is unsafe to write: absolute, escaping via `..`, or
|
|
32
|
-
* outside the tracked scope (`.claude/**`, `scripts/**`, `CLAUDE.md`, `.husky
|
|
33
|
-
* release never produces these —
|
|
34
|
-
* writing `src/` or escaping the repo
|
|
51
|
+
* outside the tracked scope (`.claude/**`, `scripts/**`, `CLAUDE.md`, `.husky/**`,
|
|
52
|
+
* `.github/workflows/**`, `vitest.setup.ts`). A correct release never produces these —
|
|
53
|
+
* this guards a mispublished/tampered tarball from writing `src/` or escaping the repo
|
|
54
|
+
* root. Separator-agnostic (Windows-safe).
|
|
35
55
|
*/
|
|
36
56
|
export function assertSafeTrackedPath(rel) {
|
|
37
57
|
const segments = rel.split(/[\\/]/);
|
|
38
58
|
if (rel.length === 0 || path.isAbsolute(rel) || segments.includes('..')) {
|
|
39
59
|
throw new CLIError(`El manifest declara un path inseguro \`${rel}\` (absoluto o con \`..\`); no se tocó nada.`);
|
|
40
60
|
}
|
|
41
|
-
const tracked = rel
|
|
61
|
+
const tracked = ALLOWED_FILES.includes(rel) || ALLOWED_ROOTS.some((root) => rel.startsWith(root));
|
|
42
62
|
if (!tracked) {
|
|
43
|
-
throw new CLIError(`El manifest declara \`${rel}\`, fuera del scope rastreado (
|
|
63
|
+
throw new CLIError(`El manifest declara \`${rel}\`, fuera del scope rastreado (${TRACKED_SCOPE_LABEL}). ` +
|
|
44
64
|
'El release está mal construido; no se tocó nada.');
|
|
45
65
|
}
|
|
46
66
|
}
|