@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.
- package/dist/commands/add.js +29 -8
- package/dist/commands/doctor.js +32 -3
- package/dist/commands/env.js +16 -4
- package/dist/commands/provision.js +86 -41
- package/dist/commands/prune.js +305 -0
- package/dist/commands/update.js +164 -91
- package/dist/commands/usage.js +303 -0
- package/dist/commands/vault.js +81 -18
- package/dist/index.js +40 -0
- 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 +39 -0
- package/dist/lib/derived-dotfiles.js +165 -0
- package/dist/lib/email-from.js +12 -4
- package/dist/lib/flags.js +14 -0
- 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-git.js +81 -0
- package/dist/lib/prune-pr-check.js +119 -0
- package/dist/lib/prune-remote.js +466 -0
- package/dist/lib/prune-tiers.js +253 -0
- package/dist/lib/prune-update.js +78 -0
- package/dist/lib/prune-vercel.js +137 -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 +21 -0
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -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` →
|
|
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`
|
|
27
|
-
*
|
|
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/`).
|
|
142
|
-
//
|
|
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
|
|
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
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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`.
|
package/dist/commands/env.js
CHANGED
|
@@ -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
|
|
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 &&
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3589
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|