@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.
- 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 +85 -27
- package/dist/commands/prune.js +60 -10
- package/dist/commands/update.js +104 -85
- package/dist/commands/usage.js +303 -0
- package/dist/commands/vault.js +81 -18
- package/dist/index.js +26 -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 +26 -0
- package/dist/lib/derived-dotfiles.js +165 -0
- package/dist/lib/email-from.js +12 -4
- package/dist/lib/initial-writes.js +113 -0
- package/dist/lib/kit-deps.js +163 -0
- package/dist/lib/kit-settings-history.json +13 -0
- package/dist/lib/lockfile.js +10 -5
- package/dist/lib/prune-remote.js +8 -6
- package/dist/lib/prune-vercel.js +137 -0
- package/dist/lib/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
|
}
|
|
@@ -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
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
}
|
|
3531
|
-
else if (e2e === 'no-playwright') {
|
|
3532
|
-
console.log(' ⏭ E2E no activado (el proyecto no usa @playwright/test)');
|
|
3533
|
-
}
|
|
3534
|
-
else {
|
|
3535
|
-
console.log(' ⏭ E2E no activado (no se encontró e2e.yml.example en el repo)');
|
|
3536
|
-
}
|
|
3537
|
-
// 7. Commit + push the activated E2E workflow. `factory new` pushed the initial
|
|
3538
|
-
// scaffold commit BEFORE this workflow existed, so it lives only in the working
|
|
3539
|
-
// tree until committed here. Idempotent: a `--resume` with it already committed
|
|
3540
|
-
// stages nothing → skips.
|
|
3541
|
-
if (e2e === 'activated') {
|
|
3542
|
-
await commitAndPushWorkflows(rootDir, runGit);
|
|
3543
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3576
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
package/dist/commands/prune.js
CHANGED
|
@@ -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
|
|
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 \`${
|
|
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.
|
|
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
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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': {
|