@timekast/factory 2.4.0 → 2.5.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.
@@ -12,7 +12,9 @@
12
12
  * legacy auto-register lives with its safeguards) → preflight (gh) → resolve
13
13
  * profile → download + stage + validate the manifest → install ONLY the manifest
14
14
  * files via `applyPlan` (so a `full` tarball never lays down `src/`; `track`
15
- * excludes it) → write `.timekast/lockfile.json` → ensure `factory:update` (H5).
15
+ * excludes it) → write `.timekast/lockfile.json` → sync the managed
16
+ * `.gitattributes` / `.prettierignore` blocks → ensure `factory:update` (H5) →
17
+ * offer the npm packages the kit's files need and the repo lacks (`kit-deps.ts`).
16
18
  *
17
19
  * Invariants:
18
20
  * - `add` never touches `src/`, `pj-*`, or any file outside the manifest. It
@@ -23,8 +25,11 @@
23
25
  * is preserved (filtered out of writes) + a warning; only written when absent.
24
26
  * - `add` is for a repo WITHOUT the brain. A repo that already has `.claude/`
25
27
  * or a lockfile is routed to `update` (never a raw re-install).
26
- * - `add` overwrites manifest files without prompting — the conflict prompt is
27
- * exclusive to `update` (DIST-005).
28
+ * - `add` never prompts about FILES — the conflict prompt is exclusive to `update`
29
+ * (DIST-005). Its one question is the kit-deps install offer, asked only in a terminal.
30
+ * It writes the brain (`.claude/`, `scripts/tools/`) without looking; a kit file
31
+ * outside it that the repo already has with its own content (`ci.yml`, `.husky/*`)
32
+ * is KEPT, and the kit's copy goes to `<path>.kit-example` (`initial-writes.ts`).
28
33
  * - Atomicity (§7.5): extract + validate in a temp dir before touching the cwd.
29
34
  *
30
35
  * Expected failures throw `CLIError`; the top-level handler prints + exits.
@@ -35,6 +40,9 @@ import path from 'node:path';
35
40
  import { applyPlan, defaultBackupDir, validateStagedManifest } from '../lib/atomic-swap.js';
36
41
  import { CLIError } from '../lib/cli-error.js';
37
42
  import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
43
+ import { syncDerivedDotfiles } from '../lib/derived-dotfiles.js';
44
+ import { partitionInitialWrites, reportKeptRepoFiles, writeKitExamples, } from '../lib/initial-writes.js';
45
+ import { offerKitDeps, readKitDeps } from '../lib/kit-deps.js';
38
46
  import { hasLockfile, writeInitialLockfile } from '../lib/lockfile.js';
39
47
  import { detectProfile, maintainKitScripts } from '../lib/package-json.js';
40
48
  import { runPreflight } from '../lib/preflight.js';
@@ -125,6 +133,9 @@ export async function runAdd(flags = { core: false, full: false }) {
125
133
  process.once('SIGINT', onSignal);
126
134
  process.once('SIGTERM', onSignal);
127
135
  let version;
136
+ // The packages the kit's files need (Factory#78), read from the staged tarball before the
137
+ // `finally` deletes it; offered at the very end, once the brain is in place.
138
+ let kitDeps = null;
128
139
  try {
129
140
  const tarball = await downloadProfileTarball(profile, tmpDir);
130
141
  const { stagedDir, manifestRaw } = await stageProfileTarball(tarball, tmpDir);
@@ -138,19 +149,26 @@ export async function runAdd(flags = { core: false, full: false }) {
138
149
  }
139
150
  version = manifest.version;
140
151
  // Install ONLY the manifest's files (never the raw tarball payload — so a
141
- // `full` tarball cannot lay down `src/`). CLAUDE.md is dev-owned: filtered out
142
- // if it already exists on disk (write-if-absent + warning).
152
+ // `full` tarball cannot lay down `src/`). The brain is always written; CLAUDE.md
153
+ // only when absent; any other kit file the repo already has with its OWN content
154
+ // is kept, and the kit's copy lands next to it as `<path>.kit-example` (#76).
143
155
  const claudeMdExists = existsSync(path.join(root, CLAUDE_MD_FILE));
144
- const writes = manifest.files
145
- .map((f) => f.path)
146
- .filter((p) => !(p === CLAUDE_MD_FILE && claudeMdExists));
156
+ const { writes, kept } = partitionInitialWrites(root, manifest);
147
157
  applyPlan(root, stagedDir, { writes, deletes: [] }, defaultBackupDir(tmpDir));
158
+ writeKitExamples(root, stagedDir, kept);
148
159
  // Write the lockfile = the tarball's embedded manifest (no hash recompute).
149
160
  writeInitialLockfile(root, manifestRaw);
161
+ // The managed `.gitattributes` / `.prettierignore` blocks, the same sync `update` runs. Here,
162
+ // inside the `try`, because `stagedDir` (their source) is deleted by the `finally`. Without
163
+ // it the repo's first commit ran its own `prettier --write` over every kit file and drifted
164
+ // them all from the lockfile it had just written.
165
+ syncDerivedDotfiles(root, stagedDir, manifest);
166
+ kitDeps = readKitDeps(stagedDir);
150
167
  if (claudeMdExists) {
151
168
  console.log('Conservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
152
169
  '(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
153
170
  }
171
+ reportKeptRepoFiles(kept);
154
172
  }
155
173
  finally {
156
174
  process.removeListener('SIGINT', onSignal);
@@ -205,4 +223,7 @@ export async function runAdd(flags = { core: false, full: false }) {
205
223
  console.log('Para actualizar: `npx @timekast/factory update` desde la raíz del repo,');
206
224
  console.log('o instala el CLI una vez (`npm i -g @timekast/factory`) y corre `factory update`.');
207
225
  }
226
+ // Factory#78 — the packages the kit's files import that this repo does not declare. A
227
+ // terminal is asked; without one only the exact install command is printed.
228
+ await offerKitDeps(root, kitDeps);
208
229
  }
@@ -45,8 +45,10 @@ import path from 'node:path';
45
45
  import { CLIError } from '../lib/cli-error.js';
46
46
  import { KIT_RULE_PATH_RE, RULES_DIR, bareImportPaths, resolveImportFrom, } from '../lib/claude-md.js';
47
47
  import { collectClaudePaths } from '../lib/claude-paths.js';
48
- import { CLAUDE_MD_FILE } from '../lib/constants.js';
48
+ import { effectiveSettingsHash, KIT_SETTINGS_PATH } from '../lib/claude-settings.js';
49
+ import { BRAIN_PREFIXES, CLAUDE_MD_FILE } from '../lib/constants.js';
49
50
  import { MIGRATIONS_DIR, SCHEMA_DIR, needsInitialMigration, readMigrationFacts, } from '../lib/initial-migration.js';
51
+ import { kitExamplePath } from '../lib/initial-writes.js';
50
52
  import { hasLockfile, normalizeThenHash, readLockfile } from '../lib/lockfile.js';
51
53
  import { LAUNCHER_RAIL_TIMEOUT_MS, runLauncherPreflight, } from '../lib/preflight.js';
52
54
  import { readNeonRevokePending } from '../lib/provision-state.js';
@@ -260,6 +262,8 @@ export function computeDoctor(deps = {}) {
260
262
  return {
261
263
  orphans: [],
262
264
  conflicts: [],
265
+ formatterDrift: [],
266
+ repoOwned: [],
263
267
  unimportedRules: [],
264
268
  claudeMdMissing: false,
265
269
  a1Warning: A1_WARNING,
@@ -285,16 +289,26 @@ export function computeDoctor(deps = {}) {
285
289
  // Pending conflicts: tracked file whose normalized on-disk hash drifted from
286
290
  // the recorded hash (a local modification not yet resolved by `update`).
287
291
  const conflicts = [];
292
+ const repoOwned = [];
288
293
  for (const entry of lock.files) {
289
294
  const abs = path.join(rootDir, entry.path);
290
295
  if (!existsSync(abs) || !statSync(abs).isFile())
291
296
  continue;
292
- const localHash = normalizeThenHash(readFileSync(abs, 'utf8'));
297
+ const raw = readFileSync(abs, 'utf8');
298
+ // settings.json: only the kit's keys count (the project's own are not a conflict).
299
+ const localHash = entry.path === KIT_SETTINGS_PATH
300
+ ? effectiveSettingsHash(raw, [entry])
301
+ : normalizeThenHash(raw);
293
302
  if (localHash !== entry.hash) {
294
- conflicts.push(entry.path);
303
+ if (existsSync(path.join(rootDir, kitExamplePath(entry.path))))
304
+ repoOwned.push(entry.path);
305
+ else
306
+ conflicts.push(entry.path);
295
307
  }
296
308
  }
297
309
  conflicts.sort();
310
+ repoOwned.sort();
311
+ const formatterDrift = conflicts.filter((p) => p !== CLAUDE_MD_FILE && !BRAIN_PREFIXES.some((prefix) => p.startsWith(prefix)));
298
312
  // Unimported kit rules: a kit-shipped always-on rule (`.claude/rules/*.md`)
299
313
  // that is NOT `@import`ed by CLAUDE.md → it does NOT load at runtime. The check
300
314
  // is strict on the `@import` directive (a prose mention does NOT count — a
@@ -322,6 +336,8 @@ export function computeDoctor(deps = {}) {
322
336
  return {
323
337
  orphans,
324
338
  conflicts,
339
+ formatterDrift,
340
+ repoOwned,
325
341
  unimportedRules,
326
342
  claudeMdMissing,
327
343
  a1Warning: A1_WARNING,
@@ -373,6 +389,12 @@ function renderDoctor(result, repoVault) {
373
389
  for (const p of result.conflicts) {
374
390
  console.log(` • ${p} — modificado localmente respecto al lockfile`);
375
391
  }
392
+ if (result.formatterDrift.length > 0) {
393
+ console.log(` Pista: ${result.formatterDrift.map((p) => `\`${p}\``).join(', ')} ` +
394
+ 'vive(n) fuera de `.claude/` y `scripts/tools/`. Si solo cambió el formato, ' +
395
+ 'probablemente lo reformateó el prettier del repo. Guía: ' +
396
+ '.claude/docs/retrofits/prettier-drifted-kit-files.md');
397
+ }
376
398
  console.log(' Sugerencia: corre `factory:update --verify` para re-chequear, o `factory:update` para reconciliar.');
377
399
  }
378
400
  if (result.unimportedRules.length > 0) {
@@ -388,6 +410,13 @@ function renderDoctor(result, repoVault) {
388
410
  }
389
411
  }
390
412
  }
413
+ // Repo-owned kit files (#76): informative, never a conflict. Shown whatever the rest says.
414
+ if (result.repoOwned.length > 0) {
415
+ console.log('\nArchivos propios del repo (el kit dejó su versión al lado, en `.kit-example`):');
416
+ for (const p of result.repoOwned) {
417
+ console.log(` • ${p} — archivo propio del repo; compáralo con \`${kitExamplePath(p)}\``);
418
+ }
419
+ }
391
420
  // Initial-migration gap (GAP-002): reported, never enforced. It sits outside the
392
421
  // orphans/conflicts block because it is about the derivative's `src/`, not about
393
422
  // brain drift — a repo with a spotless brain can still be missing its `0000`.
@@ -14,6 +14,7 @@
14
14
  import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { CLIError } from '../lib/cli-error.js';
17
+ import { senderAddressOf } from '../lib/email-from.js';
17
18
  import { PER_ENV_MARKER, PER_ENV_REGISTRY_FILE, PROVISION_OWNED, addToPerEnvRegistry, findInlinePerEnvMarkers, findPerEnvMarkedKeys, listEnvKeys, readPerEnvRegistry, setEnvLine, } from '../lib/env-file.js';
18
19
  import { applyDefaultOverrides, applyWizardAnswers, applyWizardAnswersToVault, buildEnvReview, buildEnvReviewFromEnv, buildQuestionPlan, checkEnvCompleteness, generateVapidKeys, isQuestionActive, parseEnvBody, parseEnvExample, provisionEmailDefaults, readVaultLocalEnv, readVaultMain, writeSharedToVault, } from '../lib/env-wizard.js';
19
20
  import { defaultRail, railTokenRejectedHint, readVercelToken } from '../lib/provision-preflight.js';
@@ -479,16 +480,27 @@ export function runEnvWizardGenVapid(rootDir = process.cwd(), deps = {}) {
479
480
  const subjectMissing = !env['VAPID_SUBJECT'];
480
481
  if (subjectMissing) {
481
482
  const from = env['EMAIL_FROM'];
482
- body = setEnvLine(body, 'VAPID_SUBJECT', from ? `mailto:${from}` : 'mailto:admin@example.com');
483
+ body = setEnvLine(body, 'VAPID_SUBJECT', vapidSubjectFor(from));
483
484
  }
484
485
  writeFileSync(localPath, body, 'utf8');
485
486
  chmodSync(localPath, 0o600); // holds VAPID_PRIVATE_KEY → owner-only.
486
487
  console.log('✔ Llaves VAPID generadas y escritas a `.env.local` (la privada queda oculta).');
487
488
  console.log(` NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
488
- if (subjectMissing && !env['EMAIL_FROM']) {
489
+ if (subjectMissing && vapidSubjectFor(env['EMAIL_FROM']) === VAPID_SUBJECT_PLACEHOLDER) {
489
490
  console.log(' ℹ️ Revisa VAPID_SUBJECT — puse un placeholder (correo de contacto para push).');
490
491
  }
491
492
  }
493
+ /** Placeholder push contact when there is no sender to derive one from. */
494
+ const VAPID_SUBJECT_PLACEHOLDER = 'mailto:admin@example.com';
495
+ /**
496
+ * The push contact (`VAPID_SUBJECT`) derived from `EMAIL_FROM`. RFC 8292 wants a `mailto:` URL,
497
+ * so only the bare address goes in: the display-name form (`Name <addr>`) would yield
498
+ * `mailto:Name <addr>`, which push services reject (Factory#62).
499
+ */
500
+ export function vapidSubjectFor(emailFrom) {
501
+ const address = emailFrom ? senderAddressOf(emailFrom) : '';
502
+ return address.includes('@') ? `mailto:${address}` : VAPID_SUBJECT_PLACEHOLDER;
503
+ }
492
504
  /**
493
505
  * `--gen-vapid` in a vault repo (VAULT-002): the keypair is upserted (a new pair per run, as on
494
506
  * the file path) and `VAPID_SUBJECT` is seeded only when missing, from `main:/EMAIL_FROM`.
@@ -499,11 +511,11 @@ async function genVapidIntoVault(vault) {
499
511
  const main = await readVaultMain(vault);
500
512
  const subjectMissing = !main['VAPID_SUBJECT'];
501
513
  const from = main['EMAIL_FROM'];
502
- await writeSharedToVault(vault, { VAPID_SUBJECT: from ? `mailto:${from}` : 'mailto:admin@example.com' }, 'create-if-missing');
514
+ await writeSharedToVault(vault, { VAPID_SUBJECT: vapidSubjectFor(from) }, 'create-if-missing');
503
515
  console.log('✔ Llaves VAPID generadas y escritas en la bóveda (main:/, referenciadas desde develop:/ y local; ' +
504
516
  'la privada queda oculta).');
505
517
  console.log(` NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
506
- if (subjectMissing && !from) {
518
+ if (subjectMissing && vapidSubjectFor(from) === VAPID_SUBJECT_PLACEHOLDER) {
507
519
  console.log(' ℹ️ Revisa VAPID_SUBJECT — puse un placeholder (correo de contacto para push).');
508
520
  }
509
521
  }
@@ -332,7 +332,7 @@ function printPlan(result, mode = 'plan') {
332
332
  if (step.service === 'railway' && step.action === 'create') {
333
333
  console.log(` · proyecto \`${result.projectName}\` · entornos main + develop · servicio \`${RAILWAY_SERVICE_NAME}\` sin fuente`);
334
334
  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; ' +
335
+ console.log(' · la bóveda escribe PORT + AUTH_TRUST_HOST + AUTH_URL y sincroniza main:/ y develop:/ a Railway; ' +
336
336
  'el repo se conecta cuando llegaron los nombres');
337
337
  console.log(` · un primer deploy por entorno + sonda anónima de ${RAILWAY_HEALTH_PATH} (healthcheck)`);
338
338
  }
@@ -1399,6 +1399,8 @@ export function buildVaultPlan(projectName, services, target) {
1399
1399
  if (services.includes('railway')) {
1400
1400
  add(VAULT_MAIN_ROOT, Object.keys(RAILWAY_VAULT_VALUES));
1401
1401
  add(VAULT_DEVELOP_ROOT, Object.keys(RAILWAY_VAULT_VALUES), true);
1402
+ add(VAULT_MAIN_ROOT, [RAILWAY_AUTH_URL_KEY], true);
1403
+ add(VAULT_DEVELOP_ROOT, [RAILWAY_AUTH_URL_KEY], true);
1402
1404
  }
1403
1405
  if (services.includes('backlog')) {
1404
1406
  add(VAULT_LOCAL_ROOT, ['BACKLOG_API_KEY', 'BACKLOG_PROJECT_ID']);
@@ -3524,23 +3526,9 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
3524
3526
  // AFTER building (in Vercel `build` goes first; Railway's pre-deploy runs once the
3525
3527
  // build finished), so a failed build never touches the DB — no migrate.yml, no
3526
3528
  // Deploy Hook, no manual step.
3527
- const e2e = writeE2eWorkflow(rootDir);
3528
- if (e2e === 'activated') {
3529
- console.log(` + ${E2E_WORKFLOW_PATH} (E2E activado — dispara en main: push + PRs)`);
3530
- }
3531
- else if (e2e === 'no-playwright') {
3532
- console.log(' ⏭ E2E no activado (el proyecto no usa @playwright/test)');
3533
- }
3534
- else {
3535
- console.log(' ⏭ E2E no activado (no se encontró e2e.yml.example en el repo)');
3536
- }
3537
- // 7. Commit + push the activated E2E workflow. `factory new` pushed the initial
3538
- // scaffold commit BEFORE this workflow existed, so it lives only in the working
3539
- // tree until committed here. Idempotent: a `--resume` with it already committed
3540
- // stages nothing → skips.
3541
- if (e2e === 'activated') {
3542
- await commitAndPushWorkflows(rootDir, runGit);
3543
- }
3529
+ // It is NOT pushed here: the push triggers the workflow, and CI's secrets reach GitHub
3530
+ // later in the run — `pushE2eWorkflowWhenReady` pushes it once they did.
3531
+ logE2eActivation(writeE2eWorkflow(rootDir));
3544
3532
  // 8. Mark the github step DONE — the substrate, Vercel project, git connect,
3545
3533
  // workflows and secrets are all in place. The early returns above (e.g. the
3546
3534
  // GitHub-App-not-installed HITL pause) never reach here, so a step that did
@@ -3561,10 +3549,15 @@ export async function provisionGithubSubstrate(rootDir, state, deps = {}) {
3561
3549
  state.github = { repo: repoSlug };
3562
3550
  writeProvisionState(rootDir, state);
3563
3551
  await buildGitSubstrate(rootDir, repoSlug, runGit, deps.force ?? false);
3564
- const e2e = writeE2eWorkflow(rootDir);
3552
+ // Written, not pushed: `pushE2eWorkflowWhenReady` pushes it once CI's secrets are in GitHub.
3553
+ logE2eActivation(writeE2eWorkflow(rootDir));
3554
+ state.github = { repo: repoSlug, done: true };
3555
+ writeProvisionState(rootDir, state);
3556
+ }
3557
+ function logE2eActivation(e2e) {
3565
3558
  if (e2e === 'activated') {
3566
- console.log(` + ${E2E_WORKFLOW_PATH} (E2E activado — dispara en main: push + PRs)`);
3567
- await commitAndPushWorkflows(rootDir, runGit);
3559
+ console.log(` + ${E2E_WORKFLOW_PATH} (E2E activado — dispara en main: push + PRs; se pushea al cierre, ` +
3560
+ 'cuando CI ya tenga sus secrets)');
3568
3561
  }
3569
3562
  else if (e2e === 'no-playwright') {
3570
3563
  console.log(' ⏭ E2E no activado (el proyecto no usa @playwright/test)');
@@ -3572,8 +3565,37 @@ export async function provisionGithubSubstrate(rootDir, state, deps = {}) {
3572
3565
  else {
3573
3566
  console.log(' ⏭ E2E no activado (no se encontró e2e.yml.example en el repo)');
3574
3567
  }
3575
- state.github = { repo: repoSlug, done: true };
3576
- writeProvisionState(rootDir, state);
3568
+ }
3569
+ /**
3570
+ * Push the activated `e2e.yml` only once CI can run it. Pushing it triggers the workflow on
3571
+ * `main`, and its preflight aborts without `DATABASE_URL` / `NEON_API_KEY` / `NEON_PROJECT_ID`:
3572
+ * pushed before they reach GitHub, the project's first day opens with a red "E2E failed" that
3573
+ * is not a defect of the app. With a vault, the proof is the `develop:/ci` sync's delivery
3574
+ * ({@link waitForGithubDelivery}, bounded); without one, the `neon` step having set the secrets.
3575
+ * No proof → the file stays in the working tree, unpushed, and a `--resume` pushes it.
3576
+ * Never pushes blind. Idempotent: an already committed workflow is left alone.
3577
+ */
3578
+ export async function pushE2eWorkflowWhenReady(rootDir, options) {
3579
+ const runGit = options.runGit ?? gitInDir;
3580
+ if (!existsSync(join(rootDir, E2E_WORKFLOW_PATH)))
3581
+ return 'nothing';
3582
+ const pending = (await runGit(['status', '--porcelain', '--', E2E_WORKFLOW_PATH], rootDir)).stdout.trim();
3583
+ if (!pending)
3584
+ return 'nothing';
3585
+ const ready = options.vault
3586
+ ? await waitForGithubDelivery(options.vault, {}, options.repo, options.sync)
3587
+ : !options.secretsPending;
3588
+ if (!ready) {
3589
+ console.warn(`⚠ ${E2E_WORKFLOW_PATH} queda sin pushear: los secrets de E2E todavía no llegan a GitHub ` +
3590
+ (options.vault
3591
+ ? `(el sync ${syncLabels(['github-ci'])} no ha entregado). `
3592
+ : '(el paso `neon` no pudo escribirlos). ') +
3593
+ 'Pushearlo ahora haría fallar la primera corrida de E2E. Repite con `--resume` y se pushea ' +
3594
+ 'en cuanto lleguen.');
3595
+ return 'held';
3596
+ }
3597
+ await commitAndPushWorkflows(rootDir, runGit);
3598
+ return 'pushed';
3577
3599
  }
3578
3600
  /**
3579
3601
  * Commit the activated E2E workflow (`e2e.yml`) and push it to BOTH `main` and
@@ -5149,7 +5171,8 @@ export async function updateDomainEnv(projectId, token, teamId, domain, deps, ho
5149
5171
  ...(clientMail
5150
5172
  ? [{ key: 'EMAIL_FROM', value: emailFrom, target: ['production', 'preview'] }]
5151
5173
  : []),
5152
- // 🔴 AUTH_URL / NEXTAUTH_URL are DELIBERATELY ABSENT — do not re-add them.
5174
+ // 🔴 AUTH_URL / NEXTAUTH_URL are DELIBERATELY ABSENT on Vercel — do not re-add them here.
5175
+ // (Railway is the opposite case and lives apart: see RAILWAY_AUTH_URL_KEY.)
5153
5176
  // `next-auth/lib/env.js` rewrites the origin of every auth request to their value,
5154
5177
  // so scoping either to `preview` pins all branch deployments' callbacks to this one
5155
5178
  // host and kills their sign-in. `trustHost` (auth.config.ts) already infers the
@@ -7610,7 +7633,16 @@ export const RAILWAY_LEGACY_HEALTH_PATH = '/api/health';
7610
7633
  */
7611
7634
  export const RAILWAY_BUILD_COMMAND = 'TK_VAULT=off pnpm build';
7612
7635
  export const RAILWAY_PREDEPLOY_COMMAND = 'TK_VAULT=off pnpm db:migrate';
7613
- export const RAILWAY_START_COMMAND = 'TK_VAULT=off pnpm start';
7636
+ /**
7637
+ * Next as the container's main process — no shell, no `pnpm`, no wrapper. A start with a prepended
7638
+ * assignment runs in a shell and `pnpm` adds a `sh -c`; Railway's SIGTERM never reaches Next through
7639
+ * that chain, so a graceful shutdown (closing streams, releasing leases) never runs (Factory#66). The
7640
+ * wrapper adds nothing here: it already passes through on Railway. `railway.json` is not a way out —
7641
+ * Railway deprecated config-as-code and ignores it; the setting lives on the service instance.
7642
+ */
7643
+ export const RAILWAY_START_COMMAND = 'node node_modules/next/dist/bin/next start';
7644
+ /** Time Railway gives the old deployment between SIGTERM and SIGKILL, so it can shut down. */
7645
+ export const RAILWAY_DRAINING_SECONDS = 30;
7614
7646
  /**
7615
7647
  * What the VAULT writes for Railway (Decision 10) — one copy in `main:/`, referenced from
7616
7648
  * `develop:/`; the vault stays the only writer of the deploys. Never written with destination
@@ -7620,6 +7652,15 @@ export const RAILWAY_VAULT_VALUES = {
7620
7652
  PORT: String(RAILWAY_APP_PORT),
7621
7653
  AUTH_TRUST_HOST: 'true',
7622
7654
  };
7655
+ /**
7656
+ * Behind Railway's proxy `next start` sees the container's bind host, so even with `trustHost`
7657
+ * Auth.js would build its URLs on `localhost:3000` (Factory#60). `AUTH_URL` pins them to the public
7658
+ * origin: written in `main:/` and `develop:/` as a reference to `NEXT_PUBLIC_APP_URL` of the SAME
7659
+ * folder, so the go-live moving that origin moves this one too. Never with destination Vercel,
7660
+ * where it would pin every branch preview to one host (`sk-security § trustHost`).
7661
+ */
7662
+ export const RAILWAY_AUTH_URL_KEY = 'AUTH_URL';
7663
+ const RAILWAY_AUTH_URL_SOURCE_KEY = 'NEXT_PUBLIC_APP_URL';
7623
7664
  /** Budget and poll interval of the wait for NAMES before the repo is connected. */
7624
7665
  export const RAILWAY_NAMES_TIMEOUT_MS = FIRST_SYNC_TIMEOUT_MS;
7625
7666
  export const RAILWAY_NAMES_POLL_MS = FIRST_SYNC_POLL_MS;
@@ -8136,16 +8177,21 @@ export async function provisionRailway(rootDir, state, deps) {
8136
8177
  buildCommand: RAILWAY_BUILD_COMMAND,
8137
8178
  preDeployCommand: RAILWAY_PREDEPLOY_COMMAND,
8138
8179
  startCommand: RAILWAY_START_COMMAND,
8180
+ drainingSeconds: RAILWAY_DRAINING_SECONDS,
8139
8181
  ...(region ? { region } : {}),
8140
8182
  },
8141
8183
  });
8142
8184
  }
8143
- console.log(` + Railway: build → pre-deploy (\`db:migrate\`) → start con TK_VAULT=off` +
8185
+ console.log(` + Railway: build → pre-deploy (\`db:migrate\`) → start (Next directo, ${RAILWAY_DRAINING_SECONDS} s para apagarse)` +
8144
8186
  (region ? ` · región ${region}` : ''));
8145
- // 8. The vault is the only writer: runtime values + PORT/AUTH_TRUST_HOST, before any sync.
8187
+ // 8. The vault is the only writer: runtime values + PORT/AUTH_TRUST_HOST/AUTH_URL, before any sync.
8146
8188
  await writeRuntimeToVault(rootDir, state, vault, existingProvisionDomains(projectName, state.dns), rail, fetchImpl);
8147
8189
  await vaultWrite(vault, VAULT_MAIN_ROOT, { ...RAILWAY_VAULT_VALUES }, 'create-if-missing');
8148
8190
  await vaultReference(vault, VAULT_DEVELOP_ROOT, Object.keys(RAILWAY_VAULT_VALUES), VAULT_MAIN_ROOT);
8191
+ // `create-if-missing`: an AUTH_URL a team already set by hand is never replaced.
8192
+ for (const location of [VAULT_MAIN_ROOT, VAULT_DEVELOP_ROOT]) {
8193
+ reportVaultWrite(location, await vault.client.writeReference(vault.projectId, location, RAILWAY_AUTH_URL_KEY, { ...location, key: RAILWAY_AUTH_URL_SOURCE_KEY }, 'create-if-missing'));
8194
+ }
8149
8195
  // 9. The two vault → Railway syncs (ADOPT-002's primitive), ids recorded.
8150
8196
  const ids = { ...rw.syncs?.ids };
8151
8197
  const pending = [];
@@ -9177,6 +9223,18 @@ export async function runProvision(flags, deps = {}) {
9177
9223
  railway: runCtx.railway,
9178
9224
  });
9179
9225
  }
9226
+ // 5b.1. The E2E workflow, pushed only now that CI's secrets are in GitHub (#96): the
9227
+ // `github` step wrote it, and pushing it there triggered a run that failed for lack of
9228
+ // them. Held (never pushed blind) when there is no proof yet; `--resume` retries.
9229
+ if (!flags.dryRun && runState.github?.done) {
9230
+ await pushE2eWorkflowWhenReady(rootDir, {
9231
+ ...(runCtx.railway?.runGit ? { runGit: runCtx.railway.runGit } : {}),
9232
+ vault: runCtx.vault ?? null,
9233
+ repo: runState.github.repo,
9234
+ secretsPending: runState.neon?.apiKeySecretPending === true,
9235
+ sync: deps.sync,
9236
+ });
9237
+ }
9180
9238
  // 5c. Persist the provision state to the repo (resource IDs) so a `--resume` from
9181
9239
  // a fresh clone has them. Only when the git substrate exists; best-effort.
9182
9240
  if (runState.github?.done) {
@@ -18,12 +18,15 @@ import { GITIGNORE_FILE } from '../lib/constants.js';
18
18
  import { readValueFlag } from '../lib/flags.js';
19
19
  import { commitWithPrivateIndex, trackedFiles } from '../lib/prune-git.js';
20
20
  import { checkPr, REJECTED_PACKAGE_FIELDS } from '../lib/prune-pr-check.js';
21
+ import { BRANCH_DEPLOY_RULE, VERCEL_JSON, ensureBranchDeployRule, hasBranchDeployRule, pruneVercelRelink, resolveVercelProject, } from '../lib/prune-vercel.js';
22
+ import { readVercelToken } from '../lib/provision-preflight.js';
21
23
  import { applyTierFiles, defaultPruneDeps, originRepo, pruneAudit, pruneCutover, pruneFinish, pruneInvite, } from '../lib/prune-remote.js';
22
24
  import { DEFAULT_PRUNE_TIER, PRUNE_MARKER_PATH, getTier, isRestricted, readPruneMarker, readPruneTiers, } from '../lib/prune-tiers.js';
23
25
  import { detectRepo } from '../lib/repo-detection.js';
24
26
  const PRUNE_USAGE = 'Uso: `factory prune prepare [--tier=collab] [--skip-verify]` · ' +
25
27
  '`factory prune cutover [--confirm] [--discard-develop] [--resume] [--repo=owner/nombre]` · ' +
26
- '`factory prune invite <usuario-github> [--repo=owner/nombre]` · ' +
28
+ '`factory prune vercel [--confirm] [--repo=owner/nombre] [--project=prj_…] [--team=team_…]` · ' +
29
+ '`factory prune invite <usuario-github> [--read] [--repo=owner/nombre]` · ' +
27
30
  '`factory prune finish [--repo=owner/nombre]` · `factory prune audit [--repo=owner/nombre]` · ' +
28
31
  '`factory prune check-pr <número> [--repo=owner/nombre] [--json]`.';
29
32
  export function parsePrunePrepareFlags(argv) {
@@ -48,25 +51,29 @@ export async function prunePrepare(rootDir, flags) {
48
51
  const tier = getTier(readPruneTiers(rootDir), flags.tier);
49
52
  // The two files this command owns must not carry someone's uncommitted work: the commit
50
53
  // below takes them whole.
51
- const dirty = await execa('git', ['status', '--porcelain', '--', GITIGNORE_FILE, PRUNE_MARKER_PATH], { cwd: rootDir, reject: false });
54
+ const dirty = await execa('git', ['status', '--porcelain', '--', GITIGNORE_FILE, PRUNE_MARKER_PATH, VERCEL_JSON], { cwd: rootDir, reject: false });
52
55
  if (dirty.stdout.trim() !== '') {
53
- throw new CLIError(`\`${GITIGNORE_FILE}\` o \`${PRUNE_MARKER_PATH}\` tienen cambios sin commitear. ` +
56
+ throw new CLIError(`\`${GITIGNORE_FILE}\`, \`${PRUNE_MARKER_PATH}\` o \`${VERCEL_JSON}\` tienen cambios sin commitear. ` +
54
57
  'Commitéalos o descártalos y vuelve a correr `factory prune prepare`.');
55
58
  }
56
59
  // 1. The block and the switch — `at` survives a re-run with the same tier, so a re-run
57
60
  // changes nothing.
58
61
  const prior = readPruneMarker(rootDir);
59
62
  const legacyBlockRemoved = applyTierFiles(rootDir, flags.tier, tier, prior?.tier === flags.tier ? prior.at : new Date().toISOString());
60
- // 2. Untrack what the tier restricts, and commit it with the block and the switch.
63
+ // 2. On Vercel, only `main` and `develop` deploy on push: a collaborator's branch would
64
+ // start a deployment Vercel blocks (author without a seat).
65
+ const branchDeployRuleAdded = ensureBranchDeployRule(rootDir) === 'added';
66
+ // 3. Untrack what the tier restricts, and commit it with the block, the switch and the rule.
61
67
  const untracked = (await trackedFiles(rootDir)).filter((p) => isRestricted(tier, p));
62
68
  const commit = await commitWithPrivateIndex(rootDir, {
63
69
  remove: untracked,
64
- add: [GITIGNORE_FILE, PRUNE_MARKER_PATH],
70
+ add: [GITIGNORE_FILE, PRUNE_MARKER_PATH, ...(branchDeployRuleAdded ? [VERCEL_JSON] : [])],
65
71
  message: `chore(prune): keep the methodology local (tier ${flags.tier})`,
66
72
  });
67
73
  return {
68
74
  tier: flags.tier,
69
75
  untracked,
76
+ branchDeployRuleAdded,
70
77
  legacyBlockRemoved,
71
78
  ...(commit.committed ? { commit: commit.sha } : {}),
72
79
  };
@@ -94,6 +101,10 @@ async function runPrunePrepare(flags) {
94
101
  `Dejé de trackear ${result.untracked.length} archivo(s); siguen en tu disco:`);
95
102
  for (const line of summarizePaths(result.untracked))
96
103
  console.log(line);
104
+ if (result.branchDeployRuleAdded) {
105
+ console.log(`✔ \`${VERCEL_JSON}\`: sólo \`main\` y \`develop\` despliegan con cada push — las ramas de ` +
106
+ 'los colaboradores no intentan un deploy que Vercel bloquearía.');
107
+ }
97
108
  }
98
109
  if (flags.skipVerify)
99
110
  return;
@@ -155,7 +166,7 @@ async function runPruneCutover(flags, deps) {
155
166
  for (const line of result.notMigrated)
156
167
  console.log(` • ${line}`);
157
168
  }
158
- console.log('\nSiguiente: reconecta el proyecto de Vercel al repo nuevo en el dashboard (Settings → Git), ' +
169
+ console.log('\nSiguiente: reconecta el proyecto de Vercel al repo nuevo (`factory prune vercel`), ' +
159
170
  'pide a los socios volver a clonar, y corre `factory prune finish` cuando Production despliegue.');
160
171
  }
161
172
  function printAudit(repo, audit) {
@@ -188,7 +199,7 @@ async function repoFrom(argv) {
188
199
  return readValueFlag(argv, 'repo') ?? (await originRepo(requireRepoRoot()));
189
200
  }
190
201
  /** `factory prune <sub> [...]` — dispatch lives here so every branch is testable. */
191
- export async function runPruneCommand(args, deps = defaultPruneDeps) {
202
+ export async function runPruneCommand(args, deps = defaultPruneDeps, vercel = {}) {
192
203
  const sub = args[0];
193
204
  const rest = args.slice(1);
194
205
  switch (sub) {
@@ -203,9 +214,48 @@ export async function runPruneCommand(args, deps = defaultPruneDeps) {
203
214
  if (!user)
204
215
  throw new CLIError(`Falta el usuario de GitHub del colaborador. ${PRUNE_USAGE}`);
205
216
  const repo = await repoFrom(rest);
206
- await pruneInvite(repo, user, deps);
207
- console.log(`✔ Invité a \`${user}\` a \`${repo}\` con lectura. Tiene que aceptar la invitación; ` +
208
- 'después forkea y abre PRs a la rama de trabajo.');
217
+ const read = rest.includes('--read');
218
+ await pruneInvite(repo, user, deps, read ? 'pull' : 'push');
219
+ console.log(read
220
+ ? `✔ Invité a \`${user}\` a \`${repo}\` con lectura. Tiene que aceptar la invitación; ` +
221
+ 'después forkea y abre PRs a la rama de trabajo.'
222
+ : `✔ Invité a \`${user}\` a \`${repo}\` con escritura. Tiene que aceptar la invitación; ` +
223
+ 'después trabaja en ramas del repo y abre PRs a la rama de trabajo. El hook `pre-push` ' +
224
+ 'del repo le rechaza el push a `main` y `develop` (no cubre la web de GitHub).');
225
+ return;
226
+ }
227
+ case 'vercel': {
228
+ const repo = await repoFrom(rest);
229
+ const { projectId, teamId } = resolveVercelProject(requireRepoRoot(), {
230
+ project: readValueFlag(rest, 'project'),
231
+ team: readValueFlag(rest, 'team'),
232
+ });
233
+ const token = await (vercel.token ?? (() => readVercelToken()))();
234
+ const result = await pruneVercelRelink({ repo, projectId, teamId, token, confirm: rest.includes('--confirm') }, { gh: deps.gh, ...(vercel.fetchImpl ? { fetchImpl: vercel.fetchImpl } : {}) });
235
+ if (hasBranchDeployRule(requireRepoRoot()) === false) {
236
+ console.warn(`⚠ \`${VERCEL_JSON}\` no limita los deploys por push: cada rama de un colaborador intentará ` +
237
+ 'un deploy que Vercel bloquea (autor sin asiento). Agrega y commitea:\n' +
238
+ ` "git": { "deploymentEnabled": ${JSON.stringify(BRANCH_DEPLOY_RULE)} }`);
239
+ }
240
+ if (result.status === 'already-linked') {
241
+ console.log(`✔ El proyecto de Vercel ya despliega desde \`${result.link.repo}\`. Nada que hacer.`);
242
+ }
243
+ else if (result.status === 'plan') {
244
+ console.log([
245
+ `Plan: el proyecto de Vercel (\`${projectId}\`) despliega desde ` +
246
+ `${result.from ? `\`${result.from.repo}\`` : 'ningún repo'}.`,
247
+ ` 1. Desconectarlo${result.from ? ` de \`${result.from.repo}\`` : ''} (variables, dominios y deploys se quedan).`,
248
+ ` 2. Conectarlo a \`${result.to}\` y releer el vínculo para confirmarlo.`,
249
+ ' No dispara ningún deploy.',
250
+ '',
251
+ 'Para ejecutarlo: `factory prune vercel --confirm`.',
252
+ ].join('\n'));
253
+ }
254
+ else {
255
+ console.log(`✔ El proyecto de Vercel ahora despliega desde \`${result.to.repo}\`` +
256
+ `${result.from ? ` (antes: \`${result.from.repo}\`)` : ''}. ` +
257
+ 'El primer deploy sale con el siguiente push a `main` (p. ej. un commit vacío).');
258
+ }
209
259
  return;
210
260
  }
211
261
  case 'finish': {