@timekast/factory 2.4.0 → 2.6.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.
@@ -255,6 +255,7 @@ export function parseProvisionFlags(argv) {
255
255
  repo: parseRepoFlag(readValueFlag(argv, 'repo')),
256
256
  domain,
257
257
  clientMail,
258
+ mcp: argv.includes('--mcp'),
258
259
  dns: parseDnsRoute(readValueFlag(argv, 'dns')),
259
260
  verifyTimeout: parseVerifyTimeout(readValueFlag(argv, 'verify-timeout')),
260
261
  confirm: argv.includes('--confirm'),
@@ -296,6 +297,17 @@ function buildPlan(services, state, opts = {}) {
296
297
  : {}),
297
298
  }));
298
299
  }
300
+ /**
301
+ * PVMCP-002 — the `--dry-run` line of the project's `.mcp.json`: the file and the URL its entry
302
+ * would carry, never written. With a `--domain` in the same run, the URL moves to the client's
303
+ * domain only if it goes `live`.
304
+ */
305
+ function printMcpJsonPlan(plan, clientDomain) {
306
+ console.log(` + ${plan.file} (servidor MCP del proyecto, commit propio): ${plan.url}` +
307
+ (clientDomain
308
+ ? ` — pasa a https://${clientDomain}${MCP_SERVER_PATH} si el dominio queda live`
309
+ : ''));
310
+ }
299
311
  /** Print the plain-text plan (the `--dry-run` and pre-run narration). */
300
312
  /**
301
313
  * `plan` — before anything runs (`--dry-run`): what WOULD be created. `summary` — the closing
@@ -332,7 +344,7 @@ function printPlan(result, mode = 'plan') {
332
344
  if (step.service === 'railway' && step.action === 'create') {
333
345
  console.log(` · proyecto \`${result.projectName}\` · entornos main + develop · servicio \`${RAILWAY_SERVICE_NAME}\` sin fuente`);
334
346
  console.log(` · build → pre-deploy → start: ${RAILWAY_BUILD_COMMAND} · ${RAILWAY_PREDEPLOY_COMMAND} · ${RAILWAY_START_COMMAND}`);
335
- console.log(' · la bóveda escribe PORT + AUTH_TRUST_HOST y sincroniza main:/ y develop:/ a Railway; ' +
347
+ console.log(' · la bóveda escribe PORT + AUTH_TRUST_HOST + AUTH_URL y sincroniza main:/ y develop:/ a Railway; ' +
336
348
  'el repo se conecta cuando llegaron los nombres');
337
349
  console.log(` · un primer deploy por entorno + sonda anónima de ${RAILWAY_HEALTH_PATH} (healthcheck)`);
338
350
  }
@@ -537,6 +549,8 @@ export function readCloudflareApiKey(rail = defaultRail()) {
537
549
  * `context` names the step that failed so the dev knows where the run stopped.
538
550
  */
539
551
  function neonErrorToCliError(error, projectName, context) {
552
+ // A full org is the common quota hit, and another org the key reaches is the way out.
553
+ const otherOrgHint = 'Si la org llegó a su tope de proyectos, créalo en otra que alcance la key: `--neon-org "<nombre>"`.';
540
554
  switch (error.status) {
541
555
  case 401:
542
556
  return new CLIError(`El \`NEON_API_KEY\` del rail es inválido o expiró (Neon respondió 401 en ${context}).\n` +
@@ -544,12 +558,14 @@ function neonErrorToCliError(error, projectName, context) {
544
558
  case 402:
545
559
  case 429:
546
560
  return new CLIError(`Neon rechazó la operación por cuota/facturación (respondió ${error.status} en ${context}).\n` +
547
- 'Revisa el plan/cuota de proyectos Neon de la org y reintenta con `--resume`.');
561
+ 'Revisa el plan/cuota de proyectos Neon de la org y reintenta con `--resume`.\n' +
562
+ otherOrgHint);
548
563
  case 409:
549
564
  return new CLIError(`Ya existe un proyecto Neon con un nombre que choca con \`${projectName}\` (Neon respondió 409 en ${context}).\n` +
550
565
  'Usa `--force` para descartar el estado y empezar de cero con otro nombre, o elimina el proyecto previo.');
551
566
  default:
552
- return new CLIError(`Falló la llamada a Neon en ${context}: ${error.message}`);
567
+ return new CLIError(`Falló la llamada a Neon en ${context}: ${error.message}` +
568
+ (/project.*limit|limit.*project/i.test(error.message) ? `\n${otherOrgHint}` : ''));
553
569
  }
554
570
  }
555
571
  /**
@@ -1358,12 +1374,22 @@ const VAULT_LOCAL_SHARED_REFS = [
1358
1374
  * preview each got an independent `AUTH_SECRET`; the vault keeps it.
1359
1375
  */
1360
1376
  const VAULT_PER_ENV_MINTED = ['AUTH_SECRET'];
1377
+ /**
1378
+ * The per-environment feature flags (PVMCP-001): `main:/` and `develop:/` each hold their OWN
1379
+ * value — never a reference, which would make them shared — because `/deploy` turns the questions
1380
+ * section off in production only. `local` references develop's, like {@link VAULT_PER_ENV_MINTED}.
1381
+ * `QUESTIONS_ENABLED` is born on; `MCP_ENABLED` exists only with MCP (absent = off, and an absent
1382
+ * key is what lets a later `--mcp --resume` turn it on under `create-if-missing`).
1383
+ */
1384
+ export function perEnvFlagsFor(opts = {}) {
1385
+ return { QUESTIONS_ENABLED: 'true', ...(opts.mcp ? { MCP_ENABLED: 'true' } : {}) };
1386
+ }
1361
1387
  /**
1362
1388
  * The vault plan for a run (names only — `--dry-run`, `--json`, and the pre-run narration).
1363
1389
  * Which keys each destination gets depends on the services the run includes: `neon` writes the
1364
1390
  * connection strings and the CI keys, `vercel` the runtime values, `backlog` its local creds.
1365
1391
  */
1366
- export function buildVaultPlan(projectName, services, target) {
1392
+ export function buildVaultPlan(projectName, services, target, opts = {}) {
1367
1393
  const keys = {};
1368
1394
  const references = {};
1369
1395
  const add = (loc, names, asRef = false) => {
@@ -1381,24 +1407,29 @@ export function buildVaultPlan(projectName, services, target) {
1381
1407
  }
1382
1408
  // RWAY-004: the `railway` step writes the same runtime values the `vercel` step does.
1383
1409
  if (services.includes('vercel') || services.includes('railway')) {
1410
+ const flags = Object.keys(perEnvFlagsFor(opts));
1384
1411
  add(VAULT_MAIN_ROOT, [
1385
1412
  ...VAULT_PER_ENV_MINTED,
1386
1413
  ...VAULT_SHARED_KEYS,
1387
1414
  'NEXT_PUBLIC_APP_URL',
1388
1415
  'NEXT_PUBLIC_APP_ENV',
1416
+ ...flags,
1389
1417
  ]);
1390
1418
  add(VAULT_DEVELOP_ROOT, [
1391
1419
  ...VAULT_PER_ENV_MINTED,
1392
1420
  'NEXT_PUBLIC_APP_URL',
1393
1421
  'NEXT_PUBLIC_APP_ENV',
1422
+ ...flags,
1394
1423
  ]);
1395
1424
  add(VAULT_DEVELOP_ROOT, [...VAULT_SHARED_KEYS], true);
1396
1425
  add(VAULT_LOCAL_ROOT, ['NEXT_PUBLIC_APP_URL', 'WEBAUTHN_RP_ID']);
1397
- add(VAULT_LOCAL_ROOT, [...VAULT_PER_ENV_MINTED, ...VAULT_LOCAL_SHARED_REFS], true);
1426
+ add(VAULT_LOCAL_ROOT, [...VAULT_PER_ENV_MINTED, ...VAULT_LOCAL_SHARED_REFS, ...flags], true);
1398
1427
  }
1399
1428
  if (services.includes('railway')) {
1400
1429
  add(VAULT_MAIN_ROOT, Object.keys(RAILWAY_VAULT_VALUES));
1401
1430
  add(VAULT_DEVELOP_ROOT, Object.keys(RAILWAY_VAULT_VALUES), true);
1431
+ add(VAULT_MAIN_ROOT, [RAILWAY_AUTH_URL_KEY], true);
1432
+ add(VAULT_DEVELOP_ROOT, [RAILWAY_AUTH_URL_KEY], true);
1402
1433
  }
1403
1434
  if (services.includes('backlog')) {
1404
1435
  add(VAULT_LOCAL_ROOT, ['BACKLOG_API_KEY', 'BACKLOG_PROJECT_ID']);
@@ -1457,6 +1488,18 @@ async function vaultWrite(ctx, location, entries, mode) {
1457
1488
  return;
1458
1489
  reportVaultWrite(location, await ctx.client.writeSecrets(ctx.projectId, location, entries, mode));
1459
1490
  }
1491
+ /**
1492
+ * Write the per-env feature flags (PVMCP-001): each deploy environment its own value,
1493
+ * `create-if-missing` — a re-run or `--resume` never reverts what `/deploy` or `env set` left
1494
+ * (e.g. production's `QUESTIONS_ENABLED=false`) — and `local` references develop's.
1495
+ * Self-contained on purpose: it is also the write a `--resume` over a finished alta needs.
1496
+ */
1497
+ export async function writePerEnvFlagsToVault(ctx, opts = {}) {
1498
+ const flags = perEnvFlagsFor(opts);
1499
+ await vaultWrite(ctx, VAULT_MAIN_ROOT, flags, 'create-if-missing');
1500
+ await vaultWrite(ctx, VAULT_DEVELOP_ROOT, flags, 'create-if-missing');
1501
+ await vaultReference(ctx, VAULT_LOCAL_ROOT, Object.keys(flags), VAULT_DEVELOP_ROOT);
1502
+ }
1460
1503
  /** Reference each `keys` from `location` to the same key in `target`, and narrate. */
1461
1504
  async function vaultReference(ctx, location, keys, target) {
1462
1505
  for (const key of keys) {
@@ -2068,6 +2111,9 @@ async function ensureProjectResendKey(rootDir, state, ctx, rail, fetchImpl, pers
2068
2111
  * `EMAIL_PROVIDER=resend` (without it the runtime defaults to `'none'`: mail OFF in the
2069
2112
  * deploys) — plus its own `NEXT_PUBLIC_APP_URL`/`_ENV`;
2070
2113
  * - `develop:/` holds its own `NEXT_PUBLIC_APP_URL`/`_ENV` and REFERENCES the shared ones;
2114
+ * - `main:/` and `develop:/` each hold their own per-env feature flags (`QUESTIONS_ENABLED`, and
2115
+ * `MCP_ENABLED` only with `opts.mcp`), which `local` references from develop
2116
+ * ({@link writePerEnvFlagsToVault});
2071
2117
  * - `local` holds only the machine's own values (`NEXT_PUBLIC_APP_URL` and `WEBAUTHN_RP_ID` of
2072
2118
  * localhost) and references the rest.
2073
2119
  *
@@ -2076,7 +2122,7 @@ async function ensureProjectResendKey(rootDir, state, ctx, rail, fetchImpl, pers
2076
2122
  * MFA key would leave every stored TOTP secret unreadable — and never moves a live rpID. The
2077
2123
  * values are READ BACK from `main:/`, so the Vercel push below carries the vault's values.
2078
2124
  */
2079
- async function writeRuntimeToVault(rootDir, state, ctx, hosts, rail, fetchImpl) {
2125
+ async function writeRuntimeToVault(rootDir, state, ctx, hosts, rail, fetchImpl, opts = {}) {
2080
2126
  await vaultWrite(ctx, VAULT_MAIN_ROOT, {
2081
2127
  AUTH_SECRET: generateAuthSecret(),
2082
2128
  MFA_ENCRYPTION_KEY: generateMfaEncryptionKey(),
@@ -2097,6 +2143,7 @@ async function writeRuntimeToVault(rootDir, state, ctx, hosts, rail, fetchImpl)
2097
2143
  NEXT_PUBLIC_APP_ENV: 'preview',
2098
2144
  }, 'create-if-missing');
2099
2145
  await vaultReference(ctx, VAULT_DEVELOP_ROOT, sharedKeysFor(VAULT_SHARED_KEYS, Boolean(resendKey)), VAULT_MAIN_ROOT);
2146
+ await writePerEnvFlagsToVault(ctx, opts);
2100
2147
  const local = {
2101
2148
  NEXT_PUBLIC_APP_URL: exampleDefault(rootDir, 'NEXT_PUBLIC_APP_URL', LOCAL_APP_URL_FALLBACK),
2102
2149
  WEBAUTHN_RP_ID: exampleDefault(rootDir, 'WEBAUTHN_RP_ID', LOCAL_RP_ID_FALLBACK),
@@ -3006,7 +3053,9 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
3006
3053
  const fetchImpl = deps.fetchImpl ?? fetch;
3007
3054
  const resendCarrier = deps.resendCarrier ?? {};
3008
3055
  const vaultValues = deps.vault
3009
- ? await writeRuntimeToVault(rootDir, state, deps.vault, rpDomains, rail, fetchImpl)
3056
+ ? await writeRuntimeToVault(rootDir, state, deps.vault, rpDomains, rail, fetchImpl, {
3057
+ mcp: state.mcp === true,
3058
+ })
3010
3059
  : null;
3011
3060
  // The project's own Resend key — from the vault, or (no vault) minted for this run. The rail's
3012
3061
  // TEAM key only mints: it never reaches a deploy nor `.env.local` (fx-secrets-vault §2).
@@ -3027,6 +3076,7 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
3027
3076
  prodHost: rpDomains.prod,
3028
3077
  previewHost: rpDomains.dev,
3029
3078
  shared: resendKey ? { RESEND_API_KEY: resendKey, EMAIL_PROVIDER: RESEND_EMAIL_PROVIDER } : {},
3079
+ mcp: state.mcp === true,
3030
3080
  });
3031
3081
  let result;
3032
3082
  try {
@@ -3524,23 +3574,9 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
3524
3574
  // AFTER building (in Vercel `build` goes first; Railway's pre-deploy runs once the
3525
3575
  // build finished), so a failed build never touches the DB — no migrate.yml, no
3526
3576
  // Deploy Hook, no manual step.
3527
- const e2e = writeE2eWorkflow(rootDir);
3528
- if (e2e === 'activated') {
3529
- console.log(` + ${E2E_WORKFLOW_PATH} (E2E activado — dispara en main: push + PRs)`);
3530
- }
3531
- else if (e2e === 'no-playwright') {
3532
- console.log(' ⏭ E2E no activado (el proyecto no usa @playwright/test)');
3533
- }
3534
- else {
3535
- console.log(' ⏭ E2E no activado (no se encontró e2e.yml.example en el repo)');
3536
- }
3537
- // 7. Commit + push the activated E2E workflow. `factory new` pushed the initial
3538
- // scaffold commit BEFORE this workflow existed, so it lives only in the working
3539
- // tree until committed here. Idempotent: a `--resume` with it already committed
3540
- // stages nothing → skips.
3541
- if (e2e === 'activated') {
3542
- await commitAndPushWorkflows(rootDir, runGit);
3543
- }
3577
+ // It is NOT pushed here: the push triggers the workflow, and CI's secrets reach GitHub
3578
+ // later in the run — `pushE2eWorkflowWhenReady` pushes it once they did.
3579
+ logE2eActivation(writeE2eWorkflow(rootDir));
3544
3580
  // 8. Mark the github step DONE — the substrate, Vercel project, git connect,
3545
3581
  // workflows and secrets are all in place. The early returns above (e.g. the
3546
3582
  // GitHub-App-not-installed HITL pause) never reach here, so a step that did
@@ -3561,10 +3597,15 @@ export async function provisionGithubSubstrate(rootDir, state, deps = {}) {
3561
3597
  state.github = { repo: repoSlug };
3562
3598
  writeProvisionState(rootDir, state);
3563
3599
  await buildGitSubstrate(rootDir, repoSlug, runGit, deps.force ?? false);
3564
- const e2e = writeE2eWorkflow(rootDir);
3600
+ // Written, not pushed: `pushE2eWorkflowWhenReady` pushes it once CI's secrets are in GitHub.
3601
+ logE2eActivation(writeE2eWorkflow(rootDir));
3602
+ state.github = { repo: repoSlug, done: true };
3603
+ writeProvisionState(rootDir, state);
3604
+ }
3605
+ function logE2eActivation(e2e) {
3565
3606
  if (e2e === 'activated') {
3566
- console.log(` + ${E2E_WORKFLOW_PATH} (E2E activado — dispara en main: push + PRs)`);
3567
- await commitAndPushWorkflows(rootDir, runGit);
3607
+ console.log(` + ${E2E_WORKFLOW_PATH} (E2E activado — dispara en main: push + PRs; se pushea al cierre, ` +
3608
+ 'cuando CI ya tenga sus secrets)');
3568
3609
  }
3569
3610
  else if (e2e === 'no-playwright') {
3570
3611
  console.log(' ⏭ E2E no activado (el proyecto no usa @playwright/test)');
@@ -3572,8 +3613,37 @@ export async function provisionGithubSubstrate(rootDir, state, deps = {}) {
3572
3613
  else {
3573
3614
  console.log(' ⏭ E2E no activado (no se encontró e2e.yml.example en el repo)');
3574
3615
  }
3575
- state.github = { repo: repoSlug, done: true };
3576
- writeProvisionState(rootDir, state);
3616
+ }
3617
+ /**
3618
+ * Push the activated `e2e.yml` only once CI can run it. Pushing it triggers the workflow on
3619
+ * `main`, and its preflight aborts without `DATABASE_URL` / `NEON_API_KEY` / `NEON_PROJECT_ID`:
3620
+ * pushed before they reach GitHub, the project's first day opens with a red "E2E failed" that
3621
+ * is not a defect of the app. With a vault, the proof is the `develop:/ci` sync's delivery
3622
+ * ({@link waitForGithubDelivery}, bounded); without one, the `neon` step having set the secrets.
3623
+ * No proof → the file stays in the working tree, unpushed, and a `--resume` pushes it.
3624
+ * Never pushes blind. Idempotent: an already committed workflow is left alone.
3625
+ */
3626
+ export async function pushE2eWorkflowWhenReady(rootDir, options) {
3627
+ const runGit = options.runGit ?? gitInDir;
3628
+ if (!existsSync(join(rootDir, E2E_WORKFLOW_PATH)))
3629
+ return 'nothing';
3630
+ const pending = (await runGit(['status', '--porcelain', '--', E2E_WORKFLOW_PATH], rootDir)).stdout.trim();
3631
+ if (!pending)
3632
+ return 'nothing';
3633
+ const ready = options.vault
3634
+ ? await waitForGithubDelivery(options.vault, {}, options.repo, options.sync)
3635
+ : !options.secretsPending;
3636
+ if (!ready) {
3637
+ console.warn(`⚠ ${E2E_WORKFLOW_PATH} queda sin pushear: los secrets de E2E todavía no llegan a GitHub ` +
3638
+ (options.vault
3639
+ ? `(el sync ${syncLabels(['github-ci'])} no ha entregado). `
3640
+ : '(el paso `neon` no pudo escribirlos). ') +
3641
+ 'Pushearlo ahora haría fallar la primera corrida de E2E. Repite con `--resume` y se pushea ' +
3642
+ 'en cuanto lleguen.');
3643
+ return 'held';
3644
+ }
3645
+ await commitAndPushWorkflows(rootDir, runGit);
3646
+ return 'pushed';
3577
3647
  }
3578
3648
  /**
3579
3649
  * Commit the activated E2E workflow (`e2e.yml`) and push it to BOTH `main` and
@@ -3607,22 +3677,161 @@ async function commitAndPushWorkflows(rootDir, runGit) {
3607
3677
  * the full state (all service IDs) is on disk. Best-effort + idempotent: an unchanged
3608
3678
  * state stages nothing → skips.
3609
3679
  */
3610
- async function commitProvisionState(rootDir) {
3680
+ async function commitProvisionState(rootDir, runGit = gitInDir) {
3611
3681
  const statePath = join(PROVISION_DIR, PROVISION_STATE_FILE);
3612
3682
  try {
3613
- await gitInDir(['add', statePath], rootDir);
3614
- const staged = (await gitInDir(['diff', '--cached', '--name-only'], rootDir)).stdout.trim();
3683
+ await runGit(['add', statePath], rootDir);
3684
+ const staged = (await runGit(['diff', '--cached', '--name-only'], rootDir)).stdout.trim();
3615
3685
  if (!staged)
3616
3686
  return;
3617
- await gitInDir(['commit', '-m', 'chore: persist provision state'], rootDir);
3618
- await gitInDir(['push', 'origin', 'main'], rootDir);
3619
- await gitInDir(['push', 'origin', 'main:develop'], rootDir);
3687
+ await runGit(['commit', '-m', 'chore: persist provision state'], rootDir);
3688
+ await runGit(['push', 'origin', 'main'], rootDir);
3689
+ await runGit(['push', 'origin', 'main:develop'], rootDir);
3620
3690
  console.log(' ↑ .timekast/provision.json commiteado (main + develop)');
3621
3691
  }
3622
3692
  catch {
3623
3693
  // Non-fatal: the local state is intact; the dev can commit it later.
3624
3694
  }
3625
3695
  }
3696
+ // ─────────────────────────────────────────────────────────────────────────────
3697
+ // PVMCP-002 — `--mcp`: the project's `.mcp.json` (one entry, production only)
3698
+ // ─────────────────────────────────────────────────────────────────────────────
3699
+ /** Claude Code's project-scope MCP config — what `claude mcp add --scope project` writes. */
3700
+ export const MCP_JSON_FILE = '.mcp.json';
3701
+ /** The path the boilerplate serves its MCP server on. */
3702
+ export const MCP_SERVER_PATH = '/api/mcp';
3703
+ /** The subject of the `.mcp.json` commit (English — GIT.md §3.1). */
3704
+ export const MCP_JSON_COMMIT = 'chore: declare mcp server';
3705
+ /** Does this run treat the project as carrying MCP? The flag, or the decision saved by the alta. */
3706
+ export function resolveMcpDecision(flags, state) {
3707
+ return flags.mcp === true || state?.mcp === true;
3708
+ }
3709
+ /**
3710
+ * The URL of the project's MCP server — ALWAYS production (one entry, decision CP1): the client's
3711
+ * domain once it is `live`, else the interim production host the state records (or derives,
3712
+ * when the alta has no `dns` block yet). Never the develop host; a `pending-verification` client
3713
+ * domain does not count.
3714
+ */
3715
+ export function mcpServerUrl(projectName, dns) {
3716
+ const host = dns?.clientDomainStatus === 'live' && dns.clientDomain
3717
+ ? dns.clientDomain
3718
+ : existingProvisionDomains(projectName, dns).prod;
3719
+ return `https://${host}${MCP_SERVER_PATH}`;
3720
+ }
3721
+ /**
3722
+ * Write (or merge) the project's entry in `.mcp.json`: `mcpServers.<slug> = { type: 'http', url }`.
3723
+ * Every other entry — and any other field of the project's own entry — is kept. A file that is not
3724
+ * a JSON object (or whose `mcpServers` is not one) is NOT touched: one warning naming the manual
3725
+ * action, and the run goes on. An entry that already says the same thing writes nothing.
3726
+ */
3727
+ export function writeMcpJson(rootDir, projectName, dns) {
3728
+ const file = join(rootDir, MCP_JSON_FILE);
3729
+ const url = mcpServerUrl(projectName, dns);
3730
+ let doc = {};
3731
+ const existed = existsSync(file);
3732
+ if (existed) {
3733
+ let parsed;
3734
+ try {
3735
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
3736
+ }
3737
+ catch {
3738
+ parsed = null;
3739
+ }
3740
+ const servers = parsed?.mcpServers;
3741
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
3742
+ if (!isObject(parsed) || (servers !== undefined && !isObject(servers))) {
3743
+ console.warn(`⚠ \`${MCP_JSON_FILE}\` existe pero no es un JSON válido con \`mcpServers\` como objeto: no lo toqué. ` +
3744
+ `Agrega a mano, dentro de \`mcpServers\`, la entrada \`${projectName}\` → ` +
3745
+ `{ "type": "http", "url": "${url}" }.`);
3746
+ return 'invalid';
3747
+ }
3748
+ doc = parsed;
3749
+ }
3750
+ const servers = doc.mcpServers ?? {};
3751
+ const current = servers[projectName];
3752
+ if (current && current.type === 'http' && current.url === url)
3753
+ return 'unchanged';
3754
+ const next = {
3755
+ ...doc,
3756
+ mcpServers: { ...servers, [projectName]: { ...(current ?? {}), type: 'http', url } },
3757
+ };
3758
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
3759
+ if (current) {
3760
+ console.log(` ↻ ${MCP_JSON_FILE}: el servidor \`${projectName}\` ahora apunta a ${url}`);
3761
+ return 'updated';
3762
+ }
3763
+ console.log(` + ${MCP_JSON_FILE}: servidor \`${projectName}\` → ${url}` +
3764
+ (existed ? ' (las demás entradas se conservan)' : ''));
3765
+ return existed ? 'updated' : 'created';
3766
+ }
3767
+ /**
3768
+ * Commit `.mcp.json` in its OWN commit (after the state's) and push it to `main` and `develop`, the
3769
+ * way {@link commitProvisionState} does. By pathspec (GIT.md §3.6.1): `git add` first only when the
3770
+ * file is new (a pathspec cannot reach an untracked file), and nothing else of the index can slip
3771
+ * in. Never `--no-verify`. A file git already holds unchanged → no commit. Best-effort: a git
3772
+ * failure never fails the provisioning — it is narrated and the file stays written.
3773
+ */
3774
+ export async function commitMcpJson(rootDir, runGit = gitInDir) {
3775
+ if (!existsSync(join(rootDir, MCP_JSON_FILE)))
3776
+ return 'absent';
3777
+ try {
3778
+ const tracked = (await runGit(['ls-files', '--', MCP_JSON_FILE], rootDir)).stdout.trim();
3779
+ if (tracked) {
3780
+ const pending = (await runGit(['status', '--porcelain', '--', MCP_JSON_FILE], rootDir)).stdout.trim();
3781
+ if (!pending)
3782
+ return 'unchanged';
3783
+ }
3784
+ else {
3785
+ await runGit(['add', MCP_JSON_FILE], rootDir);
3786
+ }
3787
+ await runGit(['commit', '-m', MCP_JSON_COMMIT, '--', MCP_JSON_FILE], rootDir);
3788
+ await runGit(['push', 'origin', 'main'], rootDir);
3789
+ await runGit(['push', 'origin', 'main:develop'], rootDir);
3790
+ console.log(` ↑ ${MCP_JSON_FILE} commiteado (main + develop)`);
3791
+ return 'committed';
3792
+ }
3793
+ catch (error) {
3794
+ console.warn(`⚠ No pude commitear o pushear \`${MCP_JSON_FILE}\` (${error?.message ?? 'error de git'}): ` +
3795
+ 'el archivo quedó escrito en el repo. Commitéalo a mano o repite con `--resume`.');
3796
+ return 'failed';
3797
+ }
3798
+ }
3799
+ /**
3800
+ * `--mcp` over an alta that is ALREADY complete (its `vercel`/`railway` step is skipped, so the step
3801
+ * that writes the flags never runs): turn `MCP_ENABLED` on here. Never reverts a value someone set:
3802
+ * · with a vault → {@link writePerEnvFlagsToVault} (`create-if-missing` in `main:/` and
3803
+ * `develop:/`; `local` references develop's) — its syncs carry it to the deploys;
3804
+ * · without one, destination Vercel → one record per target (production, preview), created only
3805
+ * where the key is missing;
3806
+ * · Railway without a vault does not exist (Railway requires the vault).
3807
+ */
3808
+ export async function ensureMcpFlagsOnFinishedAlta(state, deps) {
3809
+ if (deps.vault) {
3810
+ await writePerEnvFlagsToVault(deps.vault, { mcp: true });
3811
+ return;
3812
+ }
3813
+ // A repo in the vault whose run has no vault client: the deploys belong to the syncs.
3814
+ if (state.vault !== undefined || state.target === 'railway')
3815
+ return;
3816
+ const projectId = state.vercel?.projectId;
3817
+ if (!projectId || !deps.teamId) {
3818
+ console.warn('⚠ No prendí `MCP_ENABLED` en Vercel: falta el proyecto de Vercel o el team en esta corrida. ' +
3819
+ 'Repite con `--resume --mcp` cuando el paso `vercel` esté hecho.');
3820
+ return;
3821
+ }
3822
+ const token = await readVercelToken(deps.rail ?? defaultRail());
3823
+ const vercelDeps = provisionVercelApiDeps(deps.fetchImpl, { vault: null, state });
3824
+ const existing = await listEnvVarNames(projectId, token, deps.teamId, vercelDeps);
3825
+ for (const target of ['production', 'preview']) {
3826
+ const has = existing.some((env) => env.key === 'MCP_ENABLED' && env.target.includes(target));
3827
+ if (has) {
3828
+ console.log(` = Vercel MCP_ENABLED [${target}] (ya estaba — no lo toco)`);
3829
+ continue;
3830
+ }
3831
+ await upsertEnvVar(projectId, token, deps.teamId, { key: 'MCP_ENABLED', value: 'true', target: [target] }, [], vercelDeps);
3832
+ console.log(` + Vercel MCP_ENABLED [${target}]`);
3833
+ }
3834
+ }
3626
3835
  /** Default `pnpm db:generate` runner — `execa` in the derived dir, no shell. */
3627
3836
  async function drizzleGenerate(cwd) {
3628
3837
  await execa('pnpm', ['db:generate'], { cwd });
@@ -4852,6 +5061,9 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
4852
5061
  clientDomainStatus: 'live',
4853
5062
  }, clientMail);
4854
5063
  writeProvisionState(rootDir, state);
5064
+ // PVMCP-002 — the MCP entry follows production to the client's domain (committed by the run).
5065
+ if (state.mcp === true)
5066
+ writeMcpJson(rootDir, state.projectName, state.dns);
4855
5067
  }
4856
5068
  /** RWAY-005 — the two records the owner creates by hand for `--dns=registrar` on Railway. */
4857
5069
  function printRailwayManualRecords(dns) {
@@ -5012,6 +5224,9 @@ async function provisionRailwayClientDomain(rootDir, state, domain, deps) {
5012
5224
  // 7. Live.
5013
5225
  state.dns = withMailChoice({ ...dnsBlock(), clientDomainStatus: 'live' }, clientMail);
5014
5226
  writeProvisionState(rootDir, state);
5227
+ // PVMCP-002 — the MCP entry follows production to the client's domain (committed by the run).
5228
+ if (state.mcp === true)
5229
+ writeMcpJson(rootDir, state.projectName, state.dns);
5015
5230
  }
5016
5231
  /**
5017
5232
  * Retire the interim prod domain (`{slug}.timekast.mx`) AFTER the client domain
@@ -5149,7 +5364,8 @@ export async function updateDomainEnv(projectId, token, teamId, domain, deps, ho
5149
5364
  ...(clientMail
5150
5365
  ? [{ key: 'EMAIL_FROM', value: emailFrom, target: ['production', 'preview'] }]
5151
5366
  : []),
5152
- // 🔴 AUTH_URL / NEXTAUTH_URL are DELIBERATELY ABSENT — do not re-add them.
5367
+ // 🔴 AUTH_URL / NEXTAUTH_URL are DELIBERATELY ABSENT on Vercel — do not re-add them here.
5368
+ // (Railway is the opposite case and lives apart: see RAILWAY_AUTH_URL_KEY.)
5153
5369
  // `next-auth/lib/env.js` rewrites the origin of every auth request to their value,
5154
5370
  // so scoping either to `preview` pins all branch deployments' callbacks to this one
5155
5371
  // host and kills their sign-in. `trustHost` (auth.config.ts) already infers the
@@ -7610,7 +7826,16 @@ export const RAILWAY_LEGACY_HEALTH_PATH = '/api/health';
7610
7826
  */
7611
7827
  export const RAILWAY_BUILD_COMMAND = 'TK_VAULT=off pnpm build';
7612
7828
  export const RAILWAY_PREDEPLOY_COMMAND = 'TK_VAULT=off pnpm db:migrate';
7613
- export const RAILWAY_START_COMMAND = 'TK_VAULT=off pnpm start';
7829
+ /**
7830
+ * Next as the container's main process — no shell, no `pnpm`, no wrapper. A start with a prepended
7831
+ * assignment runs in a shell and `pnpm` adds a `sh -c`; Railway's SIGTERM never reaches Next through
7832
+ * that chain, so a graceful shutdown (closing streams, releasing leases) never runs (Factory#66). The
7833
+ * wrapper adds nothing here: it already passes through on Railway. `railway.json` is not a way out —
7834
+ * Railway deprecated config-as-code and ignores it; the setting lives on the service instance.
7835
+ */
7836
+ export const RAILWAY_START_COMMAND = 'node node_modules/next/dist/bin/next start';
7837
+ /** Time Railway gives the old deployment between SIGTERM and SIGKILL, so it can shut down. */
7838
+ export const RAILWAY_DRAINING_SECONDS = 30;
7614
7839
  /**
7615
7840
  * What the VAULT writes for Railway (Decision 10) — one copy in `main:/`, referenced from
7616
7841
  * `develop:/`; the vault stays the only writer of the deploys. Never written with destination
@@ -7620,6 +7845,15 @@ export const RAILWAY_VAULT_VALUES = {
7620
7845
  PORT: String(RAILWAY_APP_PORT),
7621
7846
  AUTH_TRUST_HOST: 'true',
7622
7847
  };
7848
+ /**
7849
+ * Behind Railway's proxy `next start` sees the container's bind host, so even with `trustHost`
7850
+ * Auth.js would build its URLs on `localhost:3000` (Factory#60). `AUTH_URL` pins them to the public
7851
+ * origin: written in `main:/` and `develop:/` as a reference to `NEXT_PUBLIC_APP_URL` of the SAME
7852
+ * folder, so the go-live moving that origin moves this one too. Never with destination Vercel,
7853
+ * where it would pin every branch preview to one host (`sk-security § trustHost`).
7854
+ */
7855
+ export const RAILWAY_AUTH_URL_KEY = 'AUTH_URL';
7856
+ const RAILWAY_AUTH_URL_SOURCE_KEY = 'NEXT_PUBLIC_APP_URL';
7623
7857
  /** Budget and poll interval of the wait for NAMES before the repo is connected. */
7624
7858
  export const RAILWAY_NAMES_TIMEOUT_MS = FIRST_SYNC_TIMEOUT_MS;
7625
7859
  export const RAILWAY_NAMES_POLL_MS = FIRST_SYNC_POLL_MS;
@@ -8136,16 +8370,21 @@ export async function provisionRailway(rootDir, state, deps) {
8136
8370
  buildCommand: RAILWAY_BUILD_COMMAND,
8137
8371
  preDeployCommand: RAILWAY_PREDEPLOY_COMMAND,
8138
8372
  startCommand: RAILWAY_START_COMMAND,
8373
+ drainingSeconds: RAILWAY_DRAINING_SECONDS,
8139
8374
  ...(region ? { region } : {}),
8140
8375
  },
8141
8376
  });
8142
8377
  }
8143
- console.log(` + Railway: build → pre-deploy (\`db:migrate\`) → start con TK_VAULT=off` +
8378
+ console.log(` + Railway: build → pre-deploy (\`db:migrate\`) → start (Next directo, ${RAILWAY_DRAINING_SECONDS} s para apagarse)` +
8144
8379
  (region ? ` · región ${region}` : ''));
8145
- // 8. The vault is the only writer: runtime values + PORT/AUTH_TRUST_HOST, before any sync.
8146
- await writeRuntimeToVault(rootDir, state, vault, existingProvisionDomains(projectName, state.dns), rail, fetchImpl);
8380
+ // 8. The vault is the only writer: runtime values + PORT/AUTH_TRUST_HOST/AUTH_URL, before any sync.
8381
+ await writeRuntimeToVault(rootDir, state, vault, existingProvisionDomains(projectName, state.dns), rail, fetchImpl, { mcp: state.mcp === true });
8147
8382
  await vaultWrite(vault, VAULT_MAIN_ROOT, { ...RAILWAY_VAULT_VALUES }, 'create-if-missing');
8148
8383
  await vaultReference(vault, VAULT_DEVELOP_ROOT, Object.keys(RAILWAY_VAULT_VALUES), VAULT_MAIN_ROOT);
8384
+ // `create-if-missing`: an AUTH_URL a team already set by hand is never replaced.
8385
+ for (const location of [VAULT_MAIN_ROOT, VAULT_DEVELOP_ROOT]) {
8386
+ reportVaultWrite(location, await vault.client.writeReference(vault.projectId, location, RAILWAY_AUTH_URL_KEY, { ...location, key: RAILWAY_AUTH_URL_SOURCE_KEY }, 'create-if-missing'));
8387
+ }
8149
8388
  // 9. The two vault → Railway syncs (ADOPT-002's primitive), ids recorded.
8150
8389
  const ids = { ...rw.syncs?.ids };
8151
8390
  const pending = [];
@@ -8892,6 +9131,11 @@ export async function runProvision(flags, deps = {}) {
8892
9131
  ? await resolveRepoSlug(rootDir, projectName, state?.github?.repo, flags.repo, gitInDir)
8893
9132
  : undefined;
8894
9133
  const steps = buildPlan(flags.services, state, { remint: flags.remint });
9134
+ // PVMCP-002 — MCP: the flag, or the decision the alta saved (a `--resume` need not repeat it).
9135
+ const mcp = resolveMcpDecision(flags, state);
9136
+ // Whether the step that writes the per-env flags had ALREADY finished before this run: then it
9137
+ // does not run again (skip, or Railway's early return on `done`), and `--mcp` needs its own write.
9138
+ const flagsStepDoneBefore = target === 'railway' ? state?.railway?.done === true : isServiceComplete(state, 'vercel');
8895
9139
  const result = {
8896
9140
  projectName,
8897
9141
  lifecycle: state?.lifecycle ?? lifecycle,
@@ -8920,7 +9164,10 @@ export async function runProvision(flags, deps = {}) {
8920
9164
  };
8921
9165
  }
8922
9166
  if (flags.services.includes('vault')) {
8923
- result.vault = buildVaultPlan(projectName, flags.services, target);
9167
+ result.vault = buildVaultPlan(projectName, flags.services, target, { mcp });
9168
+ }
9169
+ if (mcp) {
9170
+ result.mcpJson = { file: MCP_JSON_FILE, url: mcpServerUrl(projectName, state?.dns) };
8924
9171
  }
8925
9172
  if (vaultDecision.skipped) {
8926
9173
  result.vaultSkipped = vaultDecision.skipped;
@@ -8960,6 +9207,8 @@ export async function runProvision(flags, deps = {}) {
8960
9207
  const dev = onRailway && !hasRailwayKitDomain(state, 'develop') ? undefined : hosts.dev;
8961
9208
  printClientDomainPlan(flags.domain, { interim, dev }, flags.dns, flags.confirm, target, flags.clientMail === true);
8962
9209
  }
9210
+ if (result.mcpJson)
9211
+ printMcpJsonPlan(result.mcpJson, hasDomain ? flags.domain : undefined);
8963
9212
  }
8964
9213
  return result;
8965
9214
  }
@@ -8993,6 +9242,9 @@ export async function runProvision(flags, deps = {}) {
8993
9242
  // RWAY-003 — the destination goes with the state (the `--force` restart keeps the saved one).
8994
9243
  if (target)
8995
9244
  runState.target = target;
9245
+ // PVMCP-002 — the MCP decision goes with the state too, before any step reads it.
9246
+ if (mcp)
9247
+ runState.mcp = true;
8996
9248
  // BOOT-001: record the resolved repo BEFORE the steps run, so the Neon E2E-secret
8997
9249
  // push (`neonSecretRepo`, which runs before the github step) targets the resolved
8998
9250
  // repo — not the historical `${FACTORY_ORG}/${projectName}` fallback. Never
@@ -9177,10 +9429,43 @@ export async function runProvision(flags, deps = {}) {
9177
9429
  railway: runCtx.railway,
9178
9430
  });
9179
9431
  }
9432
+ // 5b.1. The E2E workflow, pushed only now that CI's secrets are in GitHub (#96): the
9433
+ // `github` step wrote it, and pushing it there triggered a run that failed for lack of
9434
+ // them. Held (never pushed blind) when there is no proof yet; `--resume` retries.
9435
+ if (!flags.dryRun && runState.github?.done) {
9436
+ await pushE2eWorkflowWhenReady(rootDir, {
9437
+ ...(runCtx.railway?.runGit ? { runGit: runCtx.railway.runGit } : {}),
9438
+ vault: runCtx.vault ?? null,
9439
+ repo: runState.github.repo,
9440
+ secretsPending: runState.neon?.apiKeySecretPending === true,
9441
+ sync: deps.sync,
9442
+ });
9443
+ }
9444
+ // 5b.2. PVMCP-002 — `--mcp` over an alta whose flags step had already finished: that step does not
9445
+ // run again, so `MCP_ENABLED` is turned on here (create-if-missing — never reverts a value).
9446
+ if (mcp && flagsStepDoneBefore) {
9447
+ await ensureMcpFlagsOnFinishedAlta(runState, {
9448
+ vault: runCtx.vault ?? null,
9449
+ teamId: flags.team,
9450
+ rail,
9451
+ fetchImpl: deps.sync?.fetchImpl,
9452
+ });
9453
+ }
9454
+ // 5b.3. PVMCP-002 — the project's `.mcp.json`, after the go-live: a domain that went `live` in
9455
+ // this same run already gives the client's URL, and one commit covers both.
9456
+ const mcpJson = mcp && !remintOverAdopted ? writeMcpJson(rootDir, projectName, runState.dns) : null;
9180
9457
  // 5c. Persist the provision state to the repo (resource IDs) so a `--resume` from
9181
9458
  // a fresh clone has them. Only when the git substrate exists; best-effort.
9459
+ // The `.mcp.json` follows in its OWN commit, under the same condition.
9460
+ const runGit = deps.runGit ?? gitInDir;
9182
9461
  if (runState.github?.done) {
9183
- await commitProvisionState(rootDir);
9462
+ await commitProvisionState(rootDir, runGit);
9463
+ if (mcpJson && mcpJson !== 'invalid')
9464
+ await commitMcpJson(rootDir, runGit);
9465
+ }
9466
+ else if (mcpJson && mcpJson !== 'invalid') {
9467
+ console.log(` · ${MCP_JSON_FILE} quedó escrito sin commit: el repo todavía no tiene su sustrato de git ` +
9468
+ '(paso `github`). Se commitea en la corrida que lo cree.');
9184
9469
  }
9185
9470
  // 6. Report.
9186
9471
  if (flags.json) {