@timekast/factory 2.5.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.
@@ -10,8 +10,8 @@
10
10
  * are local modifications not yet reconciled via `update`.
11
11
  * - A1 WARNING: a literal security notice (design §1 + §11, decision A1) that
12
12
  * `src/` + boilerplate deps are the derived project's responsibility
13
- * post-bootstrap, and that the patch channel for `src/` is EPIC 2 (agentic
14
- * update, not yet available).
13
+ * post-bootstrap, and that kit fixes for `src/` arrive as retrofit guides
14
+ * under `.claude/docs/retrofits/` (shipped by `update`, applied by the agent).
15
15
  * - MISSING INITIAL MIGRATION: a derivative that ships a Drizzle schema and no
16
16
  * generated `.sql`, so the migration step of its deploy — `vercel-build`
17
17
  * (`pnpm build && pnpm db:migrate`) on Vercel, the pre-deploy (`pnpm db:migrate`)
@@ -78,9 +78,9 @@ export const A1_WARNING = 'Aviso de seguridad (A1): el boilerplate `src/` y sus
78
78
  'responsabilidad del proyecto derivado a partir de ahí. `factory:update` NO ' +
79
79
  'actualiza `src/` ni las dependencias: solo refresca el cerebro (`.claude/`). ' +
80
80
  'Un parche de seguridad para `src/` (un CVE en auth, middleware, headers o una ' +
81
- 'dependencia) NO llega por este canal. El canal de parches de `src/` es EPIC 2 ' +
82
- '(update agentico con merge inteligente), aún no disponible. Mantén `src/` y tus ' +
83
- 'dependencias al día por tu cuenta mientras tanto.';
81
+ 'dependencia) NO llega por este canal. Las correcciones del kit para `src/` llegan ' +
82
+ 'como guías en `.claude/docs/retrofits/`: viajan con `update` y tu agente las aplica. ' +
83
+ 'Mantén `src/` y tus dependencias al día por tu cuenta.';
84
84
  /**
85
85
  * The literal notice for a derivative whose schema has no generated migration.
86
86
  * Named constant (never an inline string — CODING.md §5) and built from the SAME
@@ -16,11 +16,11 @@ import path from 'node:path';
16
16
  import { CLIError } from '../lib/cli-error.js';
17
17
  import { senderAddressOf } from '../lib/email-from.js';
18
18
  import { PER_ENV_MARKER, PER_ENV_REGISTRY_FILE, PROVISION_OWNED, addToPerEnvRegistry, findInlinePerEnvMarkers, findPerEnvMarkedKeys, listEnvKeys, readPerEnvRegistry, setEnvLine, } from '../lib/env-file.js';
19
- import { applyDefaultOverrides, applyWizardAnswers, applyWizardAnswersToVault, buildEnvReview, buildEnvReviewFromEnv, buildQuestionPlan, checkEnvCompleteness, generateVapidKeys, isQuestionActive, parseEnvBody, parseEnvExample, provisionEmailDefaults, readVaultLocalEnv, readVaultMain, writeSharedToVault, } from '../lib/env-wizard.js';
19
+ import { applyDefaultOverrides, applyWizardAnswers, applyWizardAnswersToVault, buildEnvReview, buildEnvReviewFromEnv, buildQuestionPlan, checkEnvCompleteness, generateVapidKeys, isPublicEnvKey, isQuestionActive, parseEnvBody, parseEnvExample, provisionEmailDefaults, readVaultLocalEnv, readVaultMain, writeSharedToVault, } from '../lib/env-wizard.js';
20
20
  import { defaultRail, railTokenRejectedHint, readVercelToken } from '../lib/provision-preflight.js';
21
- import { readProvisionState, readVaultLink } from '../lib/provision-state.js';
22
- import { createVaultClient } from '../lib/vault-api.js';
23
- import { pushEnvPlan, vercelProjectPath, VercelApiError, } from '../lib/vercel-env.js';
21
+ import { readProvisionState, readProvisionTarget, readVaultLink, } from '../lib/provision-state.js';
22
+ import { VAULT_ENV_DEVELOP, VAULT_ENV_MAIN, VAULT_ROOT_PATH, createVaultClient, } from '../lib/vault-api.js';
23
+ import { listEnvVars, pushEnvPlan, readEnvVarsWithValues, upsertEnvVar, vercelProjectPath, VercelApiError, } from '../lib/vercel-env.js';
24
24
  // Re-exported so existing importers (and `cli/tests/unit/env-push.test.ts`) keep
25
25
  // resolving PROVISION_OWNED from this module after it moved to `lib/env-file.js`.
26
26
  export { PROVISION_OWNED };
@@ -519,3 +519,323 @@ async function genVapidIntoVault(vault) {
519
519
  console.log(' ℹ️ Revisa VAPID_SUBJECT — puse un placeholder (correo de contacto para push).');
520
520
  }
521
521
  }
522
+ // ─────────────────────────────────────────────────────────────────────────────
523
+ // `factory env get|set` (PVMCP-003) — read or change ONE public flag of ONE deploy environment.
524
+ //
525
+ // What `/deploy` uses to see whether the questions section is still on in production and, if the
526
+ // developer decides so, turn it off before releasing. Two lists bound it, and both are checked
527
+ // BEFORE any call to the vault, to Vercel or to the rail:
528
+ // · `get` reads only keys `isPublicEnvKey` accepts — it can never be a secret reader;
529
+ // · `set` writes only {@link SETTABLE_ENV_KEYS}, and only `true`/`false`. A public key is not
530
+ // enough for a write: `WEBAUTHN_RP_ID` or `NEXT_PUBLIC_APP_URL` are public and moving them
531
+ // breaks passkeys or the login, and a free value could carry a `${…}` reference that would
532
+ // make the vault resolve another secret into a public variable.
533
+ //
534
+ // With the vault it touches ONE location per environment (`main:/` or `develop:/`) through the
535
+ // client's own read/write — never the wizard's shared-value helpers, which write `main:/` and
536
+ // REFERENCE it from `develop:/` (a `set --env develop` must never touch production, and the other
537
+ // way round). Without the vault it goes to Vercel (`main` → production, `develop` → preview);
538
+ // Railway without the vault is refused: its values only arrive through the vault's sync.
539
+ // ─────────────────────────────────────────────────────────────────────────────
540
+ /** The only keys `factory env set` writes (SK.md §7.2: the second allowed writer of these two). */
541
+ export const SETTABLE_ENV_KEYS = ['QUESTIONS_ENABLED', 'MCP_ENABLED'];
542
+ /** The only values `factory env set` writes — literal, case-sensitive. */
543
+ export const SETTABLE_ENV_VALUES = ['true', 'false'];
544
+ /**
545
+ * Exit codes of `env get|set` — what `/deploy` branches on, never the text. `1` stays the generic
546
+ * "could not read / could not do it", which is also what an older CLI answers ("Subcomando de env
547
+ * desconocido"). The others avoid every code the CLI already uses (2 `update --dry-run`, 3 sealed
548
+ * key of `vault adopt`, 10-12 `provision --resolve-target`, 130 cancelled).
549
+ */
550
+ export const ENV_GET_SET_EXIT = {
551
+ ok: 0,
552
+ error: 1,
553
+ /** The key does not exist in that environment (the location was read fine). */
554
+ notFound: 20,
555
+ /** The key (or, for `set`, the value) is outside what the command accepts. */
556
+ rejected: 21,
557
+ /** `get`: in that environment the key is a REFERENCE to another one — not a value of its own. */
558
+ reference: 22,
559
+ };
560
+ /** A recognized subcommand error with its own exit code. */
561
+ function envGetSetError(message, code) {
562
+ return new CLIError(message, code);
563
+ }
564
+ export const ENV_GET_SET_USAGE = 'Uso: `factory env get <KEY> --env main|develop` · `factory env set <KEY> <true|false> --env main|develop`.';
565
+ /**
566
+ * Parse `get <KEY>` / `set <KEY> <VALUE>` + `--env main|develop` (also `--env=…`). A `--help`/`-h`
567
+ * anywhere short-circuits: nothing else is validated and nothing runs. `--env` has no default —
568
+ * nobody touches production by omission.
569
+ *
570
+ * @throws {CLIError} (exit 1) on a missing/extra argument, an unknown flag or a bad `--env`.
571
+ */
572
+ export function parseEnvGetSetArgs(argv, sub) {
573
+ if (argv.includes('--help') || argv.includes('-h')) {
574
+ return { help: true, key: '', value: '', env: 'main' };
575
+ }
576
+ const positional = [];
577
+ let env;
578
+ for (let i = 0; i < argv.length; i++) {
579
+ const arg = argv[i];
580
+ if (arg === '--env') {
581
+ env = argv[i + 1];
582
+ i++;
583
+ continue;
584
+ }
585
+ if (arg.startsWith('--env=')) {
586
+ env = arg.slice('--env='.length);
587
+ continue;
588
+ }
589
+ if (arg.startsWith('-')) {
590
+ throw envGetSetError(`Flag desconocido: \`${arg}\`. ${ENV_GET_SET_USAGE}`, ENV_GET_SET_EXIT.error);
591
+ }
592
+ positional.push(arg);
593
+ }
594
+ const expected = sub === 'get' ? 1 : 2;
595
+ if (positional.length !== expected) {
596
+ throw envGetSetError(`\`env ${sub}\` espera ${expected === 1 ? 'una clave' : 'una clave y un valor'}. ${ENV_GET_SET_USAGE}`, ENV_GET_SET_EXIT.error);
597
+ }
598
+ if (env === undefined) {
599
+ throw envGetSetError(`Falta \`--env\` (main o develop): no hay entorno por omisión. ${ENV_GET_SET_USAGE}`, ENV_GET_SET_EXIT.error);
600
+ }
601
+ if (env !== 'main' && env !== 'develop') {
602
+ throw envGetSetError(`\`--env\` acepta \`main\` o \`develop\`, no \`${env}\`.`, ENV_GET_SET_EXIT.error);
603
+ }
604
+ return {
605
+ help: false,
606
+ key: positional[0],
607
+ value: sub === 'set' ? positional[1] : '',
608
+ env,
609
+ };
610
+ }
611
+ /** `get`'s gate: public keys only. Runs before any call. The rejection names the key, never a value. */
612
+ function assertReadableKey(key) {
613
+ if (isPublicEnvKey(key))
614
+ return;
615
+ throw envGetSetError(`No leo \`${key}\`: \`factory env get\` sólo opera claves públicas (las de public-env-keys.json). ` +
616
+ 'Un secreto se consulta en la bóveda o en el dashboard del despliegue, nunca por este comando.', ENV_GET_SET_EXIT.rejected);
617
+ }
618
+ /** `set`'s gate: its own write list + `true`/`false`. Runs before any call. Never echoes the value. */
619
+ function assertWritable(key, value) {
620
+ if (!SETTABLE_ENV_KEYS.includes(key)) {
621
+ throw envGetSetError(`No escribo \`${key}\`: \`factory env set\` sólo cambia ${SETTABLE_ENV_KEYS.map((k) => `\`${k}\``).join(' y ')}. ` +
622
+ 'Otra clave se cambia donde vive (la bóveda, o el dashboard del despliegue sin bóveda).', ENV_GET_SET_EXIT.rejected);
623
+ }
624
+ if (!SETTABLE_ENV_VALUES.includes(value)) {
625
+ throw envGetSetError(`No escribo ese valor en \`${key}\`: \`factory env set\` sólo acepta \`true\` o \`false\` (literal).`, ENV_GET_SET_EXIT.rejected);
626
+ }
627
+ }
628
+ /** A raw (unexpanded) vault value that is — or contains — a `${…}` reference. */
629
+ const VAULT_REFERENCE_RE = /\$\{[^}]*\}/;
630
+ /** Where a `${env.KEY}` / `${env.folder.KEY}` reference points, for the message; `null` for `${KEY}`. */
631
+ function referencedEnv(raw) {
632
+ const match = /\$\{([^}]*)\}/.exec(raw);
633
+ const parts = match?.[1]?.split('.') ?? [];
634
+ return parts.length >= 2 ? parts[0] : null;
635
+ }
636
+ const VERCEL_TARGET_OF = {
637
+ main: 'production',
638
+ develop: 'preview',
639
+ };
640
+ /**
641
+ * Vault or Vercel — decided the way `env push` and `env wizard` decide it (`readVaultLink`,
642
+ * fail-closed on a malformed or unreadable state file). Railway without the vault is refused
643
+ * here, before any call.
644
+ */
645
+ function routeOf(rootDir) {
646
+ const link = readVaultLink(rootDir);
647
+ const target = readProvisionTarget(rootDir);
648
+ if (link)
649
+ return { kind: 'vault', link, railway: target === 'railway' };
650
+ if (target === 'railway') {
651
+ throw envGetSetError('Este repo despliega en Railway y no tiene bóveda: Railway exige bóveda (sus valores sólo llegan ' +
652
+ 'por el sync de la bóveda), así que no hay a dónde leer ni escribir. Pásalo a la bóveda con ' +
653
+ '`factory vault adopt` (sin `--apply` sólo enseña el plan). No consulté nada.', ENV_GET_SET_EXIT.error);
654
+ }
655
+ return { kind: 'vercel' };
656
+ }
657
+ function vaultLocationOf(env) {
658
+ return { env: env === 'main' ? VAULT_ENV_MAIN : VAULT_ENV_DEVELOP, path: VAULT_ROOT_PATH };
659
+ }
660
+ /** What a vault read asks for: the raw value of ONE location — never expanded, never imported. */
661
+ const RAW_READ = { expandReferences: false, includeImports: false };
662
+ /** Map a Vercel failure to a value-free CLIError (the response body may quote the request). */
663
+ function vercelFailure(error, operation) {
664
+ if (error instanceof VercelApiError) {
665
+ if (error.status === 401) {
666
+ throw new CLIError(`El \`VERCEL_TOKEN\` del rail es inválido o expiró (Vercel respondió 401 al ${operation}).\n` +
667
+ railTokenRejectedHint('VERCEL_TOKEN'));
668
+ }
669
+ throw new CLIError(`Vercel no respondió bien al ${operation} (status ${error.status}).`);
670
+ }
671
+ throw error;
672
+ }
673
+ /** The record of `key` for the whole `target` (not pinned to a git branch), if any. */
674
+ function recordFor(records, key, target) {
675
+ return records.find((r) => r.key === key && !r.gitBranch && (r.target ?? []).includes(target));
676
+ }
677
+ /** Read the project's values from Vercel; a response without the list is an error, never "absent". */
678
+ async function readVercelValues(rootDir, deps) {
679
+ const { projectId, teamId } = readProjectLink(rootDir);
680
+ const token = await readVercelToken(deps.rail ?? defaultRail());
681
+ const values = await readEnvVarsWithValues(projectId, token, teamId, deps.vercel ?? {});
682
+ if (values === null) {
683
+ throw new CLIError('Vercel respondió sin la lista de valores del proyecto: no puedo saber si la clave existe. ' +
684
+ 'Vuelve a intentar; no escribí nada.');
685
+ }
686
+ return { projectId, teamId, token, values };
687
+ }
688
+ /**
689
+ * `factory env get <KEY> --env main|develop` — prints ONLY the value on stdout (for
690
+ * `$(factory env get …)`). Exit codes: {@link ENV_GET_SET_EXIT}.
691
+ */
692
+ export async function runEnvGet(argv, rootDir = process.cwd(), deps = {}) {
693
+ const args = parseEnvGetSetArgs(argv, 'get');
694
+ if (args.help) {
695
+ console.log(ENV_GET_SET_USAGE);
696
+ return;
697
+ }
698
+ assertReadableKey(args.key);
699
+ const route = routeOf(rootDir);
700
+ const writeOut = deps.writeOut ?? ((text) => process.stdout.write(text));
701
+ if (route.kind === 'vault') {
702
+ const client = deps.vaultClient ?? createVaultClient();
703
+ const location = vaultLocationOf(args.env);
704
+ const raw = (await client.readSecrets(route.link.projectId, location, RAW_READ)).get(args.key);
705
+ if (raw === undefined) {
706
+ throw envGetSetError(`\`${args.key}\` no existe en \`${location.env}:/\` de la bóveda.`, ENV_GET_SET_EXIT.notFound);
707
+ }
708
+ if (VAULT_REFERENCE_RE.test(raw)) {
709
+ // Not a value of its own: print the reference as such and say where the value lives.
710
+ writeOut(`${raw}\n`);
711
+ const where = referencedEnv(raw);
712
+ console.error(`ℹ️ En \`${location.env}:/\` \`${args.key}\` es una referencia, no un valor propio` +
713
+ (where
714
+ ? `: el valor vive en \`${where}\` (\`factory env get ${args.key} --env ${where}\`).`
715
+ : '.'));
716
+ process.exitCode = ENV_GET_SET_EXIT.reference;
717
+ return;
718
+ }
719
+ writeOut(`${raw}\n`);
720
+ return;
721
+ }
722
+ const target = VERCEL_TARGET_OF[args.env];
723
+ let values;
724
+ try {
725
+ ({ values } = await readVercelValues(rootDir, deps));
726
+ }
727
+ catch (error) {
728
+ vercelFailure(error, 'leer los valores del proyecto');
729
+ }
730
+ const record = recordFor(values, args.key, target);
731
+ if (!record) {
732
+ throw envGetSetError(`\`${args.key}\` no existe en Vercel ${target}.`, ENV_GET_SET_EXIT.notFound);
733
+ }
734
+ if (record.value === null) {
735
+ throw new CLIError(`Vercel no devuelve el valor de \`${args.key}\` en ${target} (tipo ${record.type ?? 'desconocido'}).`);
736
+ }
737
+ writeOut(`${record.value}\n`);
738
+ }
739
+ /**
740
+ * `factory env set <KEY> <true|false> --env main|develop` — writes ONE environment, never the
741
+ * other. With the vault: one `writeSecrets` on `main:/` or `develop:/` (never a reference, never
742
+ * the wizard's shared-value path). Without it: Vercel, the requested target only.
743
+ */
744
+ export async function runEnvSet(argv, rootDir = process.cwd(), deps = {}) {
745
+ const args = parseEnvGetSetArgs(argv, 'set');
746
+ if (args.help) {
747
+ console.log(ENV_GET_SET_USAGE);
748
+ return;
749
+ }
750
+ assertWritable(args.key, args.value);
751
+ const route = routeOf(rootDir);
752
+ if (route.kind === 'vault') {
753
+ const client = deps.vaultClient ?? createVaultClient();
754
+ const location = vaultLocationOf(args.env);
755
+ const current = (await client.readSecrets(route.link.projectId, location, RAW_READ)).get(args.key);
756
+ if (current !== undefined && VAULT_REFERENCE_RE.test(current)) {
757
+ const where = referencedEnv(current);
758
+ throw new CLIError(`No cambio \`${args.key}\` en \`${location.env}:/\`: ahí es una referencia` +
759
+ (where ? ` y el valor vive en \`${where}:/\`` : '') +
760
+ ', y pisarla en silencio separaría los entornos sin que nadie lo decidiera. ' +
761
+ (where
762
+ ? `Para cambiar el valor compartido: \`factory env set ${args.key} <true|false> --env ${where}\` ` +
763
+ '(afecta a los dos entornos). '
764
+ : '') +
765
+ 'Para un valor propio de este entorno, reemplaza la referencia en el dashboard de la bóveda.');
766
+ }
767
+ // One location, one write — the `develop:/` copy is never a reference to `main:/` here.
768
+ const result = await client.writeSecrets(route.link.projectId, location, { [args.key]: args.value }, 'upsert');
769
+ const outcome = result.created.includes(args.key)
770
+ ? 'creada'
771
+ : result.updated.includes(args.key)
772
+ ? 'actualizada'
773
+ : 'sin cambios';
774
+ const destination = route.railway
775
+ ? `Railway \`${args.env}\``
776
+ : `Vercel ${VERCEL_TARGET_OF[args.env]}`;
777
+ console.log(`✔ \`${args.key}\` en \`${location.env}:/\` de la bóveda: ${outcome}.`);
778
+ if (outcome !== 'sin cambios') {
779
+ const syncs = route.railway ? 'railway' : readVaultVercelSyncStatus(rootDir);
780
+ console.log(syncs === 'pending'
781
+ ? `ℹ️ Los syncs de la bóveda hacia Vercel están pendientes: el valor no llega a ${destination} ` +
782
+ 'hasta que existan (`factory provision --resume`).'
783
+ : `ℹ️ El sync de la bóveda lo lleva a ${destination}; aplica en el SIGUIENTE deploy ` +
784
+ '(el que ya corre no cambia).');
785
+ }
786
+ return;
787
+ }
788
+ // Without the vault — Vercel, the requested target only; the other target keeps its value.
789
+ const target = VERCEL_TARGET_OF[args.env];
790
+ try {
791
+ const { projectId, teamId, token, values } = await readVercelValues(rootDir, deps);
792
+ const vercelDeps = deps.vercel ?? {};
793
+ const withValue = recordFor(values, args.key, target);
794
+ let outcome;
795
+ if (withValue && withValue.value === args.value) {
796
+ outcome = 'sin cambios';
797
+ }
798
+ else if (!withValue) {
799
+ // Nothing covers this target (the other one may have its own record — not touched).
800
+ await upsertEnvVar(projectId, token, teamId, { key: args.key, value: args.value, target: [target] }, [], vercelDeps);
801
+ outcome = 'creada';
802
+ }
803
+ else {
804
+ const withId = recordFor((await listEnvVars(projectId, token, teamId, vercelDeps)), args.key, target);
805
+ if (!withId) {
806
+ throw new CLIError(`Vercel listó \`${args.key}\` en ${target} con valor pero no con id: no escribo a ciegas. Vuelve a intentar.`);
807
+ }
808
+ const others = (withId.target ?? []).filter((t) => t !== target);
809
+ if (others.length === 0) {
810
+ // A record of this target alone: update it in place.
811
+ await upsertEnvVar(projectId, token, teamId, { key: args.key, value: args.value, target: [target] }, [withId], vercelDeps);
812
+ }
813
+ else {
814
+ // A COMBINED record (e.g. production + preview). Re-pointing it whole would drag the other
815
+ // target along, so it is SPLIT: the record keeps its value for the other target(s), and the
816
+ // requested target gets a record of its own. Needs the current value to keep it.
817
+ if (withValue.value === null) {
818
+ throw new CLIError(`\`${args.key}\` vive en un solo registro para ${(withId.target ?? []).join(' y ')} y Vercel no ` +
819
+ 'devuelve su valor, así que no puedo separarlo sin perder el de los otros entornos. Sepáralo en ' +
820
+ 'el dashboard de Vercel (un registro por entorno) y repite. No escribí nada.');
821
+ }
822
+ await upsertEnvVar(projectId, token, teamId, { key: args.key, value: withValue.value, target: others }, [withId], vercelDeps);
823
+ try {
824
+ await upsertEnvVar(projectId, token, teamId, { key: args.key, value: args.value, target: [target] }, [], vercelDeps);
825
+ }
826
+ catch (error) {
827
+ throw new CLIError(`Separé el registro combinado de \`${args.key}\` (${others.join(', ')} conserva su valor), pero no ` +
828
+ `pude crear el de ${target}: ${target} quedó SIN \`${args.key}\`. Repite el mismo comando para crearlo.` +
829
+ (error instanceof VercelApiError ? ` (Vercel status ${error.status}.)` : ''));
830
+ }
831
+ }
832
+ outcome = 'actualizada';
833
+ }
834
+ console.log(`✔ \`${args.key}\` en Vercel ${target}: ${outcome}.`);
835
+ if (outcome !== 'sin cambios')
836
+ console.log(REDEPLOY_NOTICE);
837
+ }
838
+ catch (error) {
839
+ vercelFailure(error, `escribir \`${args.key}\` en ${target}`);
840
+ }
841
+ }
@@ -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
@@ -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,20 +1407,23 @@ 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));
@@ -1459,6 +1488,18 @@ async function vaultWrite(ctx, location, entries, mode) {
1459
1488
  return;
1460
1489
  reportVaultWrite(location, await ctx.client.writeSecrets(ctx.projectId, location, entries, mode));
1461
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
+ }
1462
1503
  /** Reference each `keys` from `location` to the same key in `target`, and narrate. */
1463
1504
  async function vaultReference(ctx, location, keys, target) {
1464
1505
  for (const key of keys) {
@@ -2070,6 +2111,9 @@ async function ensureProjectResendKey(rootDir, state, ctx, rail, fetchImpl, pers
2070
2111
  * `EMAIL_PROVIDER=resend` (without it the runtime defaults to `'none'`: mail OFF in the
2071
2112
  * deploys) — plus its own `NEXT_PUBLIC_APP_URL`/`_ENV`;
2072
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});
2073
2117
  * - `local` holds only the machine's own values (`NEXT_PUBLIC_APP_URL` and `WEBAUTHN_RP_ID` of
2074
2118
  * localhost) and references the rest.
2075
2119
  *
@@ -2078,7 +2122,7 @@ async function ensureProjectResendKey(rootDir, state, ctx, rail, fetchImpl, pers
2078
2122
  * MFA key would leave every stored TOTP secret unreadable — and never moves a live rpID. The
2079
2123
  * values are READ BACK from `main:/`, so the Vercel push below carries the vault's values.
2080
2124
  */
2081
- async function writeRuntimeToVault(rootDir, state, ctx, hosts, rail, fetchImpl) {
2125
+ async function writeRuntimeToVault(rootDir, state, ctx, hosts, rail, fetchImpl, opts = {}) {
2082
2126
  await vaultWrite(ctx, VAULT_MAIN_ROOT, {
2083
2127
  AUTH_SECRET: generateAuthSecret(),
2084
2128
  MFA_ENCRYPTION_KEY: generateMfaEncryptionKey(),
@@ -2099,6 +2143,7 @@ async function writeRuntimeToVault(rootDir, state, ctx, hosts, rail, fetchImpl)
2099
2143
  NEXT_PUBLIC_APP_ENV: 'preview',
2100
2144
  }, 'create-if-missing');
2101
2145
  await vaultReference(ctx, VAULT_DEVELOP_ROOT, sharedKeysFor(VAULT_SHARED_KEYS, Boolean(resendKey)), VAULT_MAIN_ROOT);
2146
+ await writePerEnvFlagsToVault(ctx, opts);
2102
2147
  const local = {
2103
2148
  NEXT_PUBLIC_APP_URL: exampleDefault(rootDir, 'NEXT_PUBLIC_APP_URL', LOCAL_APP_URL_FALLBACK),
2104
2149
  WEBAUTHN_RP_ID: exampleDefault(rootDir, 'WEBAUTHN_RP_ID', LOCAL_RP_ID_FALLBACK),
@@ -3008,7 +3053,9 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
3008
3053
  const fetchImpl = deps.fetchImpl ?? fetch;
3009
3054
  const resendCarrier = deps.resendCarrier ?? {};
3010
3055
  const vaultValues = deps.vault
3011
- ? 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
+ })
3012
3059
  : null;
3013
3060
  // The project's own Resend key — from the vault, or (no vault) minted for this run. The rail's
3014
3061
  // TEAM key only mints: it never reaches a deploy nor `.env.local` (fx-secrets-vault §2).
@@ -3029,6 +3076,7 @@ export async function provisionVercel(rootDir, state, teamId, deps = {}) {
3029
3076
  prodHost: rpDomains.prod,
3030
3077
  previewHost: rpDomains.dev,
3031
3078
  shared: resendKey ? { RESEND_API_KEY: resendKey, EMAIL_PROVIDER: RESEND_EMAIL_PROVIDER } : {},
3079
+ mcp: state.mcp === true,
3032
3080
  });
3033
3081
  let result;
3034
3082
  try {
@@ -3629,22 +3677,161 @@ async function commitAndPushWorkflows(rootDir, runGit) {
3629
3677
  * the full state (all service IDs) is on disk. Best-effort + idempotent: an unchanged
3630
3678
  * state stages nothing → skips.
3631
3679
  */
3632
- async function commitProvisionState(rootDir) {
3680
+ async function commitProvisionState(rootDir, runGit = gitInDir) {
3633
3681
  const statePath = join(PROVISION_DIR, PROVISION_STATE_FILE);
3634
3682
  try {
3635
- await gitInDir(['add', statePath], rootDir);
3636
- 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();
3637
3685
  if (!staged)
3638
3686
  return;
3639
- await gitInDir(['commit', '-m', 'chore: persist provision state'], rootDir);
3640
- await gitInDir(['push', 'origin', 'main'], rootDir);
3641
- 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);
3642
3690
  console.log(' ↑ .timekast/provision.json commiteado (main + develop)');
3643
3691
  }
3644
3692
  catch {
3645
3693
  // Non-fatal: the local state is intact; the dev can commit it later.
3646
3694
  }
3647
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
+ }
3648
3835
  /** Default `pnpm db:generate` runner — `execa` in the derived dir, no shell. */
3649
3836
  async function drizzleGenerate(cwd) {
3650
3837
  await execa('pnpm', ['db:generate'], { cwd });
@@ -4874,6 +5061,9 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
4874
5061
  clientDomainStatus: 'live',
4875
5062
  }, clientMail);
4876
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);
4877
5067
  }
4878
5068
  /** RWAY-005 — the two records the owner creates by hand for `--dns=registrar` on Railway. */
4879
5069
  function printRailwayManualRecords(dns) {
@@ -5034,6 +5224,9 @@ async function provisionRailwayClientDomain(rootDir, state, domain, deps) {
5034
5224
  // 7. Live.
5035
5225
  state.dns = withMailChoice({ ...dnsBlock(), clientDomainStatus: 'live' }, clientMail);
5036
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);
5037
5230
  }
5038
5231
  /**
5039
5232
  * Retire the interim prod domain (`{slug}.timekast.mx`) AFTER the client domain
@@ -8185,7 +8378,7 @@ export async function provisionRailway(rootDir, state, deps) {
8185
8378
  console.log(` + Railway: build → pre-deploy (\`db:migrate\`) → start (Next directo, ${RAILWAY_DRAINING_SECONDS} s para apagarse)` +
8186
8379
  (region ? ` · región ${region}` : ''));
8187
8380
  // 8. The vault is the only writer: runtime values + PORT/AUTH_TRUST_HOST/AUTH_URL, before any sync.
8188
- await writeRuntimeToVault(rootDir, state, vault, existingProvisionDomains(projectName, state.dns), rail, fetchImpl);
8381
+ await writeRuntimeToVault(rootDir, state, vault, existingProvisionDomains(projectName, state.dns), rail, fetchImpl, { mcp: state.mcp === true });
8189
8382
  await vaultWrite(vault, VAULT_MAIN_ROOT, { ...RAILWAY_VAULT_VALUES }, 'create-if-missing');
8190
8383
  await vaultReference(vault, VAULT_DEVELOP_ROOT, Object.keys(RAILWAY_VAULT_VALUES), VAULT_MAIN_ROOT);
8191
8384
  // `create-if-missing`: an AUTH_URL a team already set by hand is never replaced.
@@ -8938,6 +9131,11 @@ export async function runProvision(flags, deps = {}) {
8938
9131
  ? await resolveRepoSlug(rootDir, projectName, state?.github?.repo, flags.repo, gitInDir)
8939
9132
  : undefined;
8940
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');
8941
9139
  const result = {
8942
9140
  projectName,
8943
9141
  lifecycle: state?.lifecycle ?? lifecycle,
@@ -8966,7 +9164,10 @@ export async function runProvision(flags, deps = {}) {
8966
9164
  };
8967
9165
  }
8968
9166
  if (flags.services.includes('vault')) {
8969
- 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) };
8970
9171
  }
8971
9172
  if (vaultDecision.skipped) {
8972
9173
  result.vaultSkipped = vaultDecision.skipped;
@@ -9006,6 +9207,8 @@ export async function runProvision(flags, deps = {}) {
9006
9207
  const dev = onRailway && !hasRailwayKitDomain(state, 'develop') ? undefined : hosts.dev;
9007
9208
  printClientDomainPlan(flags.domain, { interim, dev }, flags.dns, flags.confirm, target, flags.clientMail === true);
9008
9209
  }
9210
+ if (result.mcpJson)
9211
+ printMcpJsonPlan(result.mcpJson, hasDomain ? flags.domain : undefined);
9009
9212
  }
9010
9213
  return result;
9011
9214
  }
@@ -9039,6 +9242,9 @@ export async function runProvision(flags, deps = {}) {
9039
9242
  // RWAY-003 — the destination goes with the state (the `--force` restart keeps the saved one).
9040
9243
  if (target)
9041
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;
9042
9248
  // BOOT-001: record the resolved repo BEFORE the steps run, so the Neon E2E-secret
9043
9249
  // push (`neonSecretRepo`, which runs before the github step) targets the resolved
9044
9250
  // repo — not the historical `${FACTORY_ORG}/${projectName}` fallback. Never
@@ -9235,10 +9441,31 @@ export async function runProvision(flags, deps = {}) {
9235
9441
  sync: deps.sync,
9236
9442
  });
9237
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;
9238
9457
  // 5c. Persist the provision state to the repo (resource IDs) so a `--resume` from
9239
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;
9240
9461
  if (runState.github?.done) {
9241
- 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.');
9242
9469
  }
9243
9470
  // 6. Report.
9244
9471
  if (flags.json) {
@@ -57,6 +57,7 @@ import { printProvisionPointer } from '../lib/provision-pointer.js';
57
57
  import { planPruneMaintenance } from '../lib/prune-update.js';
58
58
  import { isRestricted, PRUNE_MARKER_PATH } from '../lib/prune-tiers.js';
59
59
  import { printRetrofitReminder } from '../lib/retrofit-reminder.js';
60
+ import { printWhatsNew } from '../lib/whats-new.js';
60
61
  import { hasProvisionState } from '../lib/provision-state.js';
61
62
  import { loginCommand } from '../lib/rail.js';
62
63
  import { resolveBrainRoot } from '../lib/repo-detection.js';
@@ -1017,6 +1018,10 @@ export async function runUpdate(flags, deps = {}) {
1017
1018
  // `oldLock.version` is the version BEFORE this update, which is what separates the guides
1018
1019
  // that just arrived from the ones that were already sitting there. A legacy repo carries
1019
1020
  // `0.0.0`, so every guide reads as new — correct: it has seen none of them.
1021
+ // What changed first, then what still needs hands: the headlines of every kit release
1022
+ // this update crossed, from the changelog that just arrived. Same reasons as the reminder
1023
+ // for printing on every outcome and headless.
1024
+ printWhatsNew({ rootDir, fromVersion: oldLock.version });
1020
1025
  printRetrofitReminder({ rootDir, fromVersion: oldLock.version, guiSignal: flags.launcher });
1021
1026
  await maybeOfferEmptyE2EBranch(rootDir, flags);
1022
1027
  // End-pointer to the next step (PVSKILL-002). Reached only after a clean apply;
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import { CLIError } from './lib/cli-error.js';
13
13
  import { resolveSuccessExitCode } from './lib/exit-code.js';
14
14
  import { parseAddFlags, runAdd } from './commands/add.js';
15
15
  import { parseBacklogPushFlags, parseBacklogSyncFlags, runBacklogPush, runBacklogSync, } from './commands/backlog.js';
16
- import { parseEnvPushFlags, parseEnvWizardFlags, runEnvPush, runEnvWizardApply, runEnvWizardGenVapid, runEnvWizardPlan, runEnvWizardReview, } from './commands/env.js';
16
+ import { parseEnvPushFlags, parseEnvWizardFlags, runEnvGet, runEnvPush, runEnvSet, runEnvWizardApply, runEnvWizardGenVapid, runEnvWizardPlan, runEnvWizardReview, } from './commands/env.js';
17
17
  import { parseInviteAdminFlags, runInviteAdmin } from './commands/invite-admin.js';
18
18
  import { envWizardFlow } from './tui/env-wizard-flow.js';
19
19
  import { parseDoctorFlags, runDoctor, runLauncherDoctor } from './commands/doctor.js';
@@ -54,7 +54,8 @@ Comandos:
54
54
  backlog <sub> push: refleja el backlog local en backlog.timekast.mx · sync: status + self-healing
55
55
  ticket <sub> push: entrega un factory-ticket de project/factory/ al repo del Factory
56
56
  env <sub> push: sube product vars a Vercel (day-2) · wizard: configura las variables
57
- (en la bóveda si el repo la tiene; sin bóveda, en .env.local)
57
+ (en la bóveda si el repo la tiene; sin bóveda, en .env.local) · get/set:
58
+ lee o cambia un flag público de UN entorno (main|develop)
58
59
  invite-admin Crea una invitación de super_admin para un derivado (sin password en texto plano)
59
60
  prune <sub> Comparte el repo con colaboradores externos sin la metodología (lo guía /prune):
60
61
  prepare: aplica el tier (.gitignore + .timekast/prune.json) y deja de trackear
@@ -142,6 +143,9 @@ Sobre \`provision\`:
142
143
  completo (revoca la vigente, sube los 3 secrets de E2E y la
143
144
  reescribe en develop:/ci de la bóveda — sin bóveda, en .env.local).
144
145
  Para una key revocada por fuera
146
+ --mcp El proyecto lleva servidor MCP: prende MCP_ENABLED y escribe .mcp.json
147
+ (una entrada a producción, en su propio commit). Queda guardado: un
148
+ --resume no necesita repetirlo
145
149
  --domain=<dom> Dominio definitivo del cliente (diferible; standalone o inicial)
146
150
  --dns=<ruta> Ruta DNS para --domain: org (default) | cliente-cf | registrar
147
151
  --verify-timeout=<min> Timeout del sondeo de verificación de Vercel (default 10)
@@ -196,6 +200,16 @@ Sobre \`env\`:
196
200
  \`--push\` no aplica: los despliegues las reciben de la bóveda)
197
201
  --review [--json] Resumen enmascarado (con bóveda: del entorno \`local\`)
198
202
  --gen-vapid Genera las llaves VAPID (con bóveda, se guardan ahí; la privada no se imprime)
203
+ \`env get <KEY> --env main|develop\` imprime en stdout SOLO el valor de una clave pública
204
+ (public-env-keys.json; un secreto se rechaza antes de consultar nada). \`env set <KEY>
205
+ <true|false> --env main|develop\` cambia SOLO QUESTIONS_ENABLED o MCP_ENABLED, y sólo a
206
+ true/false, en ese entorno y nada más. \`--env\` es obligatorio (sin default). Con bóveda
207
+ operan main:/ o develop:/ (el sync lleva el valor; aplica en el siguiente deploy); sin
208
+ bóveda, Vercel (main → production, develop → preview; el otro target conserva su valor);
209
+ Railway sin bóveda se rechaza (\`factory vault adopt\`). Sin prompts. Salida:
210
+ 0 ok · 1 no se pudo leer/escribir (o un CLI viejo que no conoce get/set) ·
211
+ 20 la clave no existe en ese entorno · 21 clave (o valor de set) rechazado ·
212
+ 22 (get) en ese entorno la clave es una referencia a otro, no un valor propio
199
213
 
200
214
  Sobre \`backlog\`:
201
215
  \`backlog push\` refleja el backlog local (\`project/backlog/*\`) en
@@ -435,8 +449,17 @@ async function main(argv) {
435
449
  await envWizardFlow();
436
450
  return;
437
451
  }
452
+ // `factory env get|set` (PVMCP-003) — one public flag of one deploy environment.
453
+ if (sub === 'get') {
454
+ await runEnvGet(rest.slice(1));
455
+ return;
456
+ }
457
+ if (sub === 'set') {
458
+ await runEnvSet(rest.slice(1));
459
+ return;
460
+ }
438
461
  throw new CLIError(`Subcomando de env desconocido: \`${sub ?? '(ninguno)'}\`. ` +
439
- 'Uso: `factory env push` o `factory env wizard`.');
462
+ 'Uso: `factory env push`, `factory env wizard`, `factory env get` o `factory env set`.');
440
463
  }
441
464
  case 'vault': {
442
465
  // `factory vault adopt [--apply]` (ADOPT-001) — move a repo born before the vault into it.
@@ -51,6 +51,10 @@ export const PROVISION_OWNED = new Set([
51
51
  // both; provision writes them per-derivative to the gitignored `.env.local`.
52
52
  'BACKLOG_API_KEY',
53
53
  'BACKLOG_PROJECT_ID',
54
+ // Per-env feature flags (PVMCP-001): they differ by environment — `/deploy` turns the
55
+ // questions section off in production only — so only provision (and `env set`) writes them.
56
+ 'MCP_ENABLED',
57
+ 'QUESTIONS_ENABLED',
54
58
  ]);
55
59
  /**
56
60
  * The LOCAL-ONLY subset of {@link PROVISION_OWNED}: keys that must NEVER land in a vault folder
@@ -224,7 +224,7 @@ export function applyWizardAnswers(rootDir, answers) {
224
224
  * their suffix looks like (`NEXT_PUBLIC_AUTH_PASSWORD` is a boolean feature flag).
225
225
  * - `RATE_LIMIT_` is a growing FAMILY of tuning numbers; a derivative that adds
226
226
  * `RATE_LIMIT_CHECKOUT_REQUESTS` should not have to edit a list to see its value.
227
- * The 22 explicit keys ARE judgement, one name at a time — which is why they are
227
+ * The explicit keys ARE judgement, one name at a time — which is why they are
228
228
  * enumerated instead of matched.
229
229
  */
230
230
  export function isPublicEnvKey(key) {
@@ -13,10 +13,12 @@
13
13
  "EMAIL_SERVER_PORT",
14
14
  "EMAIL_SERVER_SECURE",
15
15
  "EMAIL_SERVER_USER",
16
+ "MCP_ENABLED",
16
17
  "MFA_ENABLED",
17
18
  "MFA_REQUIRED_ALL",
18
19
  "NEXTAUTH_URL",
19
20
  "NODE_ENV",
21
+ "QUESTIONS_ENABLED",
20
22
  "SENTRY_ORG",
21
23
  "SENTRY_PROJECT",
22
24
  "SUPPORT_EMAIL",
@@ -849,6 +849,14 @@ export function buildEnvPlan(input) {
849
849
  { key: 'NEXT_PUBLIC_APP_URL', value: `https://${input.prodHost}`, target: ['production'] },
850
850
  { key: 'NEXT_PUBLIC_APP_URL', value: `https://${input.previewHost}`, target: ['preview'] },
851
851
  ];
852
+ // Per-env feature flags (PVMCP-001) — one record PER TARGET, like NEXT_PUBLIC_APP_ENV, so a later
853
+ // change in one target (`/deploy` turns questions off in production) never drags the other.
854
+ // Without MCP, `MCP_ENABLED` is not written at all: absent means off.
855
+ const flags = ['QUESTIONS_ENABLED', ...(input.mcp ? ['MCP_ENABLED'] : [])];
856
+ for (const key of flags) {
857
+ plan.push({ key, value: 'true', target: ['production'] });
858
+ plan.push({ key, value: 'true', target: ['preview'] });
859
+ }
852
860
  for (const [key, value] of Object.entries(input.shared ?? {})) {
853
861
  if (!value)
854
862
  continue; // skip empty shared values
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The "what's new" block printed after every applied `factory update`.
3
+ *
4
+ * WHY IT EXISTS. A version number tells the dev that something changed, not what. The kit's
5
+ * changelog already travels with the brain (`.claude/docs/CHANGELOG.md`, tracked), but nobody
6
+ * opens it after an update: the dev reads "✔ v14.0.0" and moves on without knowing the kit
7
+ * now has a client-questions section or a new rule they are expected to follow. This block
8
+ * puts the headline of every release they just crossed in front of them, before the retrofit
9
+ * reminder — first what changed, then what still needs hands.
10
+ *
11
+ * Read from disk AFTER the apply, like the retrofit reminder: the changelog that just arrived
12
+ * is the source, never a list baked into this binary. Headless too — an agent running the
13
+ * update is the reader who has to relay it to the dev.
14
+ */
15
+ import { readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { compareVersions } from './retrofit-reminder.js';
18
+ /** Where the kit's changelog lives inside the derivative. */
19
+ export const KIT_CHANGELOG = '.claude/docs/CHANGELOG.md';
20
+ /** Releases shown in full; older ones crossed by the same update collapse into one line. */
21
+ export const MAX_RELEASES = 5;
22
+ /** Headlines shown per release; the rest collapse into "+N más". */
23
+ export const MAX_ITEMS_PER_RELEASE = 8;
24
+ /** How much of a headline survives — enough to understand, short enough to scan. */
25
+ const ITEM_MAX = 110;
26
+ /** `## [13.6.0] - 2026-10-14 — Title` → version + title. `[Unreleased]` and junk → null. */
27
+ function parseHeading(line) {
28
+ const match = line.match(/^##\s+\[(\d+\.\d+\.\d+)\]\s*(.*)$/);
29
+ if (!match)
30
+ return null;
31
+ const rest = match[2];
32
+ const dash = rest.indexOf('—');
33
+ const title = dash === -1 ? null : rest.slice(dash + 1).trim() || null;
34
+ return { version: match[1], title };
35
+ }
36
+ /** Markdown inline marks out: the block is plain terminal text. */
37
+ function plain(text) {
38
+ return text
39
+ .replace(/\*\*/g, '')
40
+ .replace(/`/g, '')
41
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
42
+ .replace(/\s+/g, ' ')
43
+ .trim();
44
+ }
45
+ /** A bullet's headline: its leading bold span, else its first sentence. Capped. */
46
+ export function headline(bullet) {
47
+ const body = bullet.replace(/^-\s+/, '');
48
+ const bold = body.match(/^\*\*(.+?)\*\*/);
49
+ let text = plain(bold ? bold[1] : (body.match(/^(.+?[.!?])(\s|$)/)?.[1] ?? body));
50
+ text = text.replace(/[.:]$/, '');
51
+ return text.length > ITEM_MAX ? `${text.slice(0, ITEM_MAX - 1)}…` : text;
52
+ }
53
+ /**
54
+ * Every numbered release in the changelog, newest first, with its top-level bullets.
55
+ * Sub-bullets, `###` group headings and prose are skipped: the headline is the bullet itself.
56
+ */
57
+ export function parseChangelog(markdown) {
58
+ const releases = [];
59
+ let current = null;
60
+ for (const line of markdown.split('\n')) {
61
+ if (line.startsWith('## ')) {
62
+ const heading = parseHeading(line);
63
+ current = heading ? { ...heading, items: [] } : null;
64
+ if (current)
65
+ releases.push(current);
66
+ continue;
67
+ }
68
+ if (current && /^-\s+\S/.test(line))
69
+ current.items.push(headline(line));
70
+ }
71
+ return releases.sort((a, b) => compareVersions(b.version, a.version));
72
+ }
73
+ /**
74
+ * The releases this update crossed: newer than `fromVersion`. Without a `fromVersion` (first
75
+ * install) there is nothing to compare against and the block stays silent — "everything is
76
+ * new" helps nobody. A legacy `0.0.0` lockfile crosses every release, and the cap handles it.
77
+ */
78
+ export function releasesSince(releases, fromVersion) {
79
+ if (!fromVersion)
80
+ return [];
81
+ return releases.filter((r) => compareVersions(r.version, fromVersion) > 0);
82
+ }
83
+ /** Render the block, or `null` when the update crossed no documented release. */
84
+ export function renderWhatsNew(crossed) {
85
+ if (crossed.length === 0)
86
+ return null;
87
+ const rule = '═'.repeat(74);
88
+ const lines = ['', rule, ' ✨ NOVEDADES DEL KIT EN ESTE UPDATE', rule, ''];
89
+ for (const release of crossed.slice(0, MAX_RELEASES)) {
90
+ lines.push(` ▸ v${release.version}${release.title ? ` — ${release.title}` : ''}`, '');
91
+ for (const item of release.items.slice(0, MAX_ITEMS_PER_RELEASE))
92
+ lines.push(` · ${item}`);
93
+ const extra = release.items.length - MAX_ITEMS_PER_RELEASE;
94
+ if (extra > 0)
95
+ lines.push(` · +${extra} más`);
96
+ lines.push('');
97
+ }
98
+ const older = crossed.length - MAX_RELEASES;
99
+ if (older > 0) {
100
+ const oldest = crossed[crossed.length - 1].version;
101
+ lines.push(` ▸ Y ${older} versión(es) anteriores, hasta v${oldest}.`, '');
102
+ }
103
+ lines.push(` El detalle de cada punto está en ${KIT_CHANGELOG}.`, ' Si trabajas con un agente, pídele que te explique qué cambia para tu proyecto.', rule, '');
104
+ return lines.join('\n');
105
+ }
106
+ /**
107
+ * Print the block. A missing or unreadable changelog prints nothing — a `core` derivative may
108
+ * not carry it, and a notice must never break the update that already succeeded.
109
+ */
110
+ export function printWhatsNew(opts) {
111
+ const readFile = opts.readFile ?? ((file) => readFileSync(file, 'utf8'));
112
+ const log = opts.log ?? ((m) => console.log(m));
113
+ let markdown;
114
+ try {
115
+ markdown = readFile(join(opts.rootDir, KIT_CHANGELOG));
116
+ }
117
+ catch {
118
+ return;
119
+ }
120
+ const rendered = renderWhatsNew(releasesSince(parseChangelog(markdown), opts.fromVersion));
121
+ if (rendered)
122
+ log(rendered);
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",