@timekast/factory 2.3.1 → 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
  }
@@ -44,6 +44,7 @@ import { RAIL_TIMEKAST, RailError, RailKeyMissingError } from '../lib/rail.js';
44
44
  import { classifyDeploymentState, connectedProjects, createRailwayClient, RailwayApiError, RailwayNameTakenError, RAILWAY_SERVICE_NAME, sameRepo as sameRailwayRepo, } from '../lib/railway-api.js';
45
45
  import { isRailwayProvided, isRailwaySyncTo, railwaySyncDifferences, railwaySyncLabel, railwaySyncName, } from '../lib/vault-adopt.js';
46
46
  import { createVaultClient, formatLocation, VAULT_CI_PATH, VAULT_ENV_DEVELOP, VAULT_ENV_LOCAL, VAULT_ENV_MAIN, VAULT_LAYOUT_ENVIRONMENTS, VAULT_ROOT_PATH, VAULT_TEST_ISOLATION_KEY, } from '../lib/vault-api.js';
47
+ import { readValueFlag } from '../lib/flags.js';
47
48
  import { wireVaultWrapper } from '../lib/wrapper-wiring.js';
48
49
  /**
49
50
  * The THREE GitHub Secrets the E2E runner (`pnpm test:e2e` → `e2e-runner.ts` +
@@ -143,20 +144,6 @@ const KNOWN_DNS_ROUTES = ['org', 'cliente-cf', 'registrar'];
143
144
  /** Default verification-poll timeout (minutes) and interval (seconds) — issue §4. */
144
145
  export const DEFAULT_VERIFY_TIMEOUT_MIN = 10;
145
146
  export const VERIFY_POLL_INTERVAL_SEC = 30;
146
- /** Read the value of a `--key=value` or `--key value` flag from an argv slice. */
147
- function readValueFlag(argv, name) {
148
- const prefix = `--${name}=`;
149
- const inline = argv.find((arg) => arg.startsWith(prefix));
150
- if (inline)
151
- return inline.slice(prefix.length);
152
- const idx = argv.indexOf(`--${name}`);
153
- if (idx !== -1) {
154
- const next = argv[idx + 1];
155
- if (next && !next.startsWith('-'))
156
- return next;
157
- }
158
- return undefined;
159
- }
160
147
  /**
161
148
  * Parse `--services=a,b,c` into the typed service list, rejecting unknown tokens
162
149
  * with a `CLIError` (a typo must not silently provision nothing). An empty/absent
@@ -345,7 +332,7 @@ function printPlan(result, mode = 'plan') {
345
332
  if (step.service === 'railway' && step.action === 'create') {
346
333
  console.log(` · proyecto \`${result.projectName}\` · entornos main + develop · servicio \`${RAILWAY_SERVICE_NAME}\` sin fuente`);
347
334
  console.log(` · build → pre-deploy → start: ${RAILWAY_BUILD_COMMAND} · ${RAILWAY_PREDEPLOY_COMMAND} · ${RAILWAY_START_COMMAND}`);
348
- 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; ' +
349
336
  'el repo se conecta cuando llegaron los nombres');
350
337
  console.log(` · un primer deploy por entorno + sonda anónima de ${RAILWAY_HEALTH_PATH} (healthcheck)`);
351
338
  }
@@ -1412,6 +1399,8 @@ export function buildVaultPlan(projectName, services, target) {
1412
1399
  if (services.includes('railway')) {
1413
1400
  add(VAULT_MAIN_ROOT, Object.keys(RAILWAY_VAULT_VALUES));
1414
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);
1415
1404
  }
1416
1405
  if (services.includes('backlog')) {
1417
1406
  add(VAULT_LOCAL_ROOT, ['BACKLOG_API_KEY', 'BACKLOG_PROJECT_ID']);
@@ -3537,23 +3526,9 @@ export async function provisionGithubVercel(rootDir, state, teamId, deps = {}) {
3537
3526
  // AFTER building (in Vercel `build` goes first; Railway's pre-deploy runs once the
3538
3527
  // build finished), so a failed build never touches the DB — no migrate.yml, no
3539
3528
  // Deploy Hook, no manual step.
3540
- const e2e = writeE2eWorkflow(rootDir);
3541
- if (e2e === 'activated') {
3542
- console.log(` + ${E2E_WORKFLOW_PATH} (E2E activado — dispara en main: push + PRs)`);
3543
- }
3544
- else if (e2e === 'no-playwright') {
3545
- console.log(' ⏭ E2E no activado (el proyecto no usa @playwright/test)');
3546
- }
3547
- else {
3548
- console.log(' ⏭ E2E no activado (no se encontró e2e.yml.example en el repo)');
3549
- }
3550
- // 7. Commit + push the activated E2E workflow. `factory new` pushed the initial
3551
- // scaffold commit BEFORE this workflow existed, so it lives only in the working
3552
- // tree until committed here. Idempotent: a `--resume` with it already committed
3553
- // stages nothing → skips.
3554
- if (e2e === 'activated') {
3555
- await commitAndPushWorkflows(rootDir, runGit);
3556
- }
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));
3557
3532
  // 8. Mark the github step DONE — the substrate, Vercel project, git connect,
3558
3533
  // workflows and secrets are all in place. The early returns above (e.g. the
3559
3534
  // GitHub-App-not-installed HITL pause) never reach here, so a step that did
@@ -3574,10 +3549,15 @@ export async function provisionGithubSubstrate(rootDir, state, deps = {}) {
3574
3549
  state.github = { repo: repoSlug };
3575
3550
  writeProvisionState(rootDir, state);
3576
3551
  await buildGitSubstrate(rootDir, repoSlug, runGit, deps.force ?? false);
3577
- 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) {
3578
3558
  if (e2e === 'activated') {
3579
- console.log(` + ${E2E_WORKFLOW_PATH} (E2E activado — dispara en main: push + PRs)`);
3580
- 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)');
3581
3561
  }
3582
3562
  else if (e2e === 'no-playwright') {
3583
3563
  console.log(' ⏭ E2E no activado (el proyecto no usa @playwright/test)');
@@ -3585,8 +3565,37 @@ export async function provisionGithubSubstrate(rootDir, state, deps = {}) {
3585
3565
  else {
3586
3566
  console.log(' ⏭ E2E no activado (no se encontró e2e.yml.example en el repo)');
3587
3567
  }
3588
- state.github = { repo: repoSlug, done: true };
3589
- 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';
3590
3599
  }
3591
3600
  /**
3592
3601
  * Commit the activated E2E workflow (`e2e.yml`) and push it to BOTH `main` and
@@ -5162,7 +5171,8 @@ export async function updateDomainEnv(projectId, token, teamId, domain, deps, ho
5162
5171
  ...(clientMail
5163
5172
  ? [{ key: 'EMAIL_FROM', value: emailFrom, target: ['production', 'preview'] }]
5164
5173
  : []),
5165
- // 🔴 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.)
5166
5176
  // `next-auth/lib/env.js` rewrites the origin of every auth request to their value,
5167
5177
  // so scoping either to `preview` pins all branch deployments' callbacks to this one
5168
5178
  // host and kills their sign-in. `trustHost` (auth.config.ts) already infers the
@@ -7623,7 +7633,16 @@ export const RAILWAY_LEGACY_HEALTH_PATH = '/api/health';
7623
7633
  */
7624
7634
  export const RAILWAY_BUILD_COMMAND = 'TK_VAULT=off pnpm build';
7625
7635
  export const RAILWAY_PREDEPLOY_COMMAND = 'TK_VAULT=off pnpm db:migrate';
7626
- 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;
7627
7646
  /**
7628
7647
  * What the VAULT writes for Railway (Decision 10) — one copy in `main:/`, referenced from
7629
7648
  * `develop:/`; the vault stays the only writer of the deploys. Never written with destination
@@ -7633,6 +7652,15 @@ export const RAILWAY_VAULT_VALUES = {
7633
7652
  PORT: String(RAILWAY_APP_PORT),
7634
7653
  AUTH_TRUST_HOST: 'true',
7635
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';
7636
7664
  /** Budget and poll interval of the wait for NAMES before the repo is connected. */
7637
7665
  export const RAILWAY_NAMES_TIMEOUT_MS = FIRST_SYNC_TIMEOUT_MS;
7638
7666
  export const RAILWAY_NAMES_POLL_MS = FIRST_SYNC_POLL_MS;
@@ -8149,16 +8177,21 @@ export async function provisionRailway(rootDir, state, deps) {
8149
8177
  buildCommand: RAILWAY_BUILD_COMMAND,
8150
8178
  preDeployCommand: RAILWAY_PREDEPLOY_COMMAND,
8151
8179
  startCommand: RAILWAY_START_COMMAND,
8180
+ drainingSeconds: RAILWAY_DRAINING_SECONDS,
8152
8181
  ...(region ? { region } : {}),
8153
8182
  },
8154
8183
  });
8155
8184
  }
8156
- 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)` +
8157
8186
  (region ? ` · región ${region}` : ''));
8158
- // 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.
8159
8188
  await writeRuntimeToVault(rootDir, state, vault, existingProvisionDomains(projectName, state.dns), rail, fetchImpl);
8160
8189
  await vaultWrite(vault, VAULT_MAIN_ROOT, { ...RAILWAY_VAULT_VALUES }, 'create-if-missing');
8161
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
+ }
8162
8195
  // 9. The two vault → Railway syncs (ADOPT-002's primitive), ids recorded.
8163
8196
  const ids = { ...rw.syncs?.ids };
8164
8197
  const pending = [];
@@ -9190,6 +9223,18 @@ export async function runProvision(flags, deps = {}) {
9190
9223
  railway: runCtx.railway,
9191
9224
  });
9192
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
+ }
9193
9238
  // 5c. Persist the provision state to the repo (resource IDs) so a `--resume` from
9194
9239
  // a fresh clone has them. Only when the git substrate exists; best-effort.
9195
9240
  if (runState.github?.done) {