@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.
@@ -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 { defaultEnvPath, readVercelToken, runProvisionPreflight, } from '../lib/provision-preflight.js';
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. The order is normalized regardless of `--services` input.
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` ' +
@@ -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;
@@ -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
- /** Roots a manifest path may live under (mirrors `profiles.json#track`). */
29
- const ALLOWED_ROOTS = ['.claude/', 'scripts/', '.husky/'];
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/**`). A correct
33
- * release never produces these — this guards a mispublished/tampered tarball from
34
- * writing `src/` or escaping the repo root. Separator-agnostic (Windows-safe).
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 === CLAUDE_MD_FILE || ALLOWED_ROOTS.some((root) => rel.startsWith(root));
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 (\`.claude/\`, \`scripts/\`, \`CLAUDE.md\`, \`.husky/\`). ` +
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
  }