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