@timekast/factory 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/doctor.js +46 -6
- package/dist/commands/env.js +324 -4
- package/dist/commands/provision.js +242 -15
- package/dist/commands/update.js +5 -0
- package/dist/index.js +26 -3
- package/dist/lib/env-file.js +4 -0
- package/dist/lib/env-wizard.js +1 -1
- package/dist/lib/public-env-keys.json +2 -0
- package/dist/lib/vercel-env.js +8 -0
- package/dist/lib/whats-new.js +123 -0
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
14
|
-
* update
|
|
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.
|
|
82
|
-
'
|
|
83
|
-
'dependencias al día por tu cuenta
|
|
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
|
|
@@ -261,6 +261,7 @@ export function computeDoctor(deps = {}) {
|
|
|
261
261
|
if (!hasLockfile(rootDir)) {
|
|
262
262
|
return {
|
|
263
263
|
orphans: [],
|
|
264
|
+
externalSkills: [],
|
|
264
265
|
conflicts: [],
|
|
265
266
|
formatterDrift: [],
|
|
266
267
|
repoOwned: [],
|
|
@@ -285,7 +286,7 @@ export function computeDoctor(deps = {}) {
|
|
|
285
286
|
const manifestPaths = new Set(lock.files.map((f) => f.path));
|
|
286
287
|
// Orphans: files under `.claude/` on disk that the manifest does not track.
|
|
287
288
|
const claudePaths = collectClaudePaths(rootDir);
|
|
288
|
-
const orphans = claudePaths.filter((p) => !manifestPaths.has(p)).sort();
|
|
289
|
+
const { orphans, externalSkills } = splitExternalSkills(claudePaths.filter((p) => !manifestPaths.has(p)).sort(), manifestPaths);
|
|
289
290
|
// Pending conflicts: tracked file whose normalized on-disk hash drifted from
|
|
290
291
|
// the recorded hash (a local modification not yet resolved by `update`).
|
|
291
292
|
const conflicts = [];
|
|
@@ -335,6 +336,7 @@ export function computeDoctor(deps = {}) {
|
|
|
335
336
|
}
|
|
336
337
|
return {
|
|
337
338
|
orphans,
|
|
339
|
+
externalSkills,
|
|
338
340
|
conflicts,
|
|
339
341
|
formatterDrift,
|
|
340
342
|
repoOwned,
|
|
@@ -350,6 +352,37 @@ export function computeDoctor(deps = {}) {
|
|
|
350
352
|
neonRevokePending,
|
|
351
353
|
};
|
|
352
354
|
}
|
|
355
|
+
const SKILLS_DIR_PREFIX = '.claude/skills/';
|
|
356
|
+
/**
|
|
357
|
+
* Move out of `orphans` every file that lives in a skill directory the manifest knows nothing
|
|
358
|
+
* about, and name those directories. A loose file inside a KIT skill directory stays an orphan:
|
|
359
|
+
* that one is a retired or stray kit file, not someone else's skill.
|
|
360
|
+
*/
|
|
361
|
+
export function splitExternalSkills(allOrphans, manifestPaths) {
|
|
362
|
+
const skillDirOf = (p) => {
|
|
363
|
+
if (!p.startsWith(SKILLS_DIR_PREFIX))
|
|
364
|
+
return null;
|
|
365
|
+
const rest = p.slice(SKILLS_DIR_PREFIX.length);
|
|
366
|
+
const slash = rest.indexOf('/');
|
|
367
|
+
return slash > 0 ? rest.slice(0, slash) : null;
|
|
368
|
+
};
|
|
369
|
+
const kitSkillDirs = new Set();
|
|
370
|
+
for (const p of manifestPaths) {
|
|
371
|
+
const dir = skillDirOf(p);
|
|
372
|
+
if (dir)
|
|
373
|
+
kitSkillDirs.add(dir);
|
|
374
|
+
}
|
|
375
|
+
const orphans = [];
|
|
376
|
+
const external = new Set();
|
|
377
|
+
for (const p of allOrphans) {
|
|
378
|
+
const dir = skillDirOf(p);
|
|
379
|
+
if (dir && !kitSkillDirs.has(dir))
|
|
380
|
+
external.add(dir);
|
|
381
|
+
else
|
|
382
|
+
orphans.push(p);
|
|
383
|
+
}
|
|
384
|
+
return { orphans, externalSkills: [...external].sort() };
|
|
385
|
+
}
|
|
353
386
|
/** Render the doctor result to the console. The A1 notice is ALWAYS last. */
|
|
354
387
|
function renderDoctor(result, repoVault) {
|
|
355
388
|
const vaultLines = renderRepoVault(repoVault, { suggestAdopt: result.suggestAdopt });
|
|
@@ -410,6 +443,13 @@ function renderDoctor(result, repoVault) {
|
|
|
410
443
|
}
|
|
411
444
|
}
|
|
412
445
|
}
|
|
446
|
+
// Skills the kit does not manage (#110): informative, never a problem. Shown whatever the rest says.
|
|
447
|
+
if (result.externalSkills.length > 0) {
|
|
448
|
+
console.log('\nSkills que no gestiona el kit (propias del proyecto o add-ons con su instalador):');
|
|
449
|
+
for (const name of result.externalSkills) {
|
|
450
|
+
console.log(` • .claude/skills/${name}/ — no es huérfana: no la borres; \`factory update\` no la toca`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
413
453
|
// Repo-owned kit files (#76): informative, never a conflict. Shown whatever the rest says.
|
|
414
454
|
if (result.repoOwned.length > 0) {
|
|
415
455
|
console.log('\nArchivos propios del repo (el kit dejó su versión al lado, en `.kit-example`):');
|
package/dist/commands/env.js
CHANGED
|
@@ -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
|
|
3636
|
-
const staged = (await
|
|
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
|
|
3640
|
-
await
|
|
3641
|
-
await
|
|
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) {
|
package/dist/commands/update.js
CHANGED
|
@@ -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
|
|
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.
|
package/dist/lib/env-file.js
CHANGED
|
@@ -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
|
package/dist/lib/env-wizard.js
CHANGED
|
@@ -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
|
|
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",
|
package/dist/lib/vercel-env.js
CHANGED
|
@@ -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
|
+
}
|