@timekast/factory 2.3.0 → 2.4.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/provision.js +26 -23
- package/dist/commands/prune.js +255 -0
- package/dist/commands/update.js +62 -8
- package/dist/index.js +14 -0
- package/dist/lib/constants.js +13 -0
- package/dist/lib/flags.js +14 -0
- package/dist/lib/prune-git.js +81 -0
- package/dist/lib/prune-pr-check.js +119 -0
- package/dist/lib/prune-remote.js +464 -0
- package/dist/lib/prune-tiers.js +253 -0
- package/dist/lib/prune-update.js +78 -0
- package/package.json +1 -1
|
@@ -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
|
|
@@ -1376,7 +1363,7 @@ const VAULT_PER_ENV_MINTED = ['AUTH_SECRET'];
|
|
|
1376
1363
|
* Which keys each destination gets depends on the services the run includes: `neon` writes the
|
|
1377
1364
|
* connection strings and the CI keys, `vercel` the runtime values, `backlog` its local creds.
|
|
1378
1365
|
*/
|
|
1379
|
-
export function buildVaultPlan(projectName, services) {
|
|
1366
|
+
export function buildVaultPlan(projectName, services, target) {
|
|
1380
1367
|
const keys = {};
|
|
1381
1368
|
const references = {};
|
|
1382
1369
|
const add = (loc, names, asRef = false) => {
|
|
@@ -1416,7 +1403,9 @@ export function buildVaultPlan(projectName, services) {
|
|
|
1416
1403
|
if (services.includes('backlog')) {
|
|
1417
1404
|
add(VAULT_LOCAL_ROOT, ['BACKLOG_API_KEY', 'BACKLOG_PROJECT_ID']);
|
|
1418
1405
|
}
|
|
1419
|
-
|
|
1406
|
+
// The syncs follow the DESTINATION, not this run's steps: a run with `vault` but without the
|
|
1407
|
+
// `railway` step still deploys to Railway, and never gets the Vercel syncs.
|
|
1408
|
+
const railway = services.includes('railway') || target === 'railway';
|
|
1420
1409
|
return {
|
|
1421
1410
|
project: projectName,
|
|
1422
1411
|
environments: VAULT_LAYOUT_ENVIRONMENTS.map((env) => env.slug),
|
|
@@ -5436,12 +5425,17 @@ function goLiveRetryHint(mailDomain) {
|
|
|
5436
5425
|
const client = mailDomain.startsWith(prefix) ? mailDomain.slice(prefix.length) : '<tu dominio>';
|
|
5437
5426
|
return `repite el go-live (\`factory provision --domain=${client}\`)`;
|
|
5438
5427
|
}
|
|
5428
|
+
/** Whether the state records a kit domain in that Railway environment (created, or found by adopt). */
|
|
5429
|
+
function hasRailwayKitDomain(state, role) {
|
|
5430
|
+
const rw = state?.railway;
|
|
5431
|
+
return Boolean(rw?.customDomains?.[role] || rw?.adoptedDomains?.some((d) => d.role === role));
|
|
5432
|
+
}
|
|
5439
5433
|
/**
|
|
5440
5434
|
* Print the resolved client-domain operation tree for `--domain --dry-run`
|
|
5441
5435
|
* (PROV-006 §8): the exact domain, the DNS route, whether the CNAME is created or
|
|
5442
5436
|
* printed, the interim-retire intent, and the env updates — mutating NOTHING.
|
|
5443
5437
|
*/
|
|
5444
|
-
function printClientDomainPlan(domain,
|
|
5438
|
+
function printClientDomainPlan(domain, hosts, route, confirm, target, clientMail = false) {
|
|
5445
5439
|
console.log(`\nPlan de \`--domain=${domain}\` (ruta DNS: ${route}):`);
|
|
5446
5440
|
if (target === 'railway') {
|
|
5447
5441
|
// RWAY-005: the CNAME's destination and the TXT are what Railway returns for the domain.
|
|
@@ -5466,8 +5460,11 @@ function printClientDomainPlan(domain, interim, route, confirm, target, clientMa
|
|
|
5466
5460
|
}
|
|
5467
5461
|
console.log(` … sondea GET /v9/projects/.../domains/${domain} hasta verified:true (o timeout)`);
|
|
5468
5462
|
}
|
|
5469
|
-
|
|
5470
|
-
|
|
5463
|
+
const { interim, dev } = hosts;
|
|
5464
|
+
console.log(interim
|
|
5465
|
+
? ` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim}` +
|
|
5466
|
+
(dev ? ` (el dev ${dev} se conserva)` : '')
|
|
5467
|
+
: ' · sin dominio interino registrado: no hay nada que retirar');
|
|
5471
5468
|
console.log(clientMail
|
|
5472
5469
|
? ` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · NEXT_PUBLIC_APP_URL=https://${domain}`
|
|
5473
5470
|
: ` ↻ NEXT_PUBLIC_APP_URL=https://${domain}`);
|
|
@@ -8923,7 +8920,7 @@ export async function runProvision(flags, deps = {}) {
|
|
|
8923
8920
|
};
|
|
8924
8921
|
}
|
|
8925
8922
|
if (flags.services.includes('vault')) {
|
|
8926
|
-
result.vault = buildVaultPlan(projectName, flags.services);
|
|
8923
|
+
result.vault = buildVaultPlan(projectName, flags.services, target);
|
|
8927
8924
|
}
|
|
8928
8925
|
if (vaultDecision.skipped) {
|
|
8929
8926
|
result.vaultSkipped = vaultDecision.skipped;
|
|
@@ -8956,8 +8953,12 @@ export async function runProvision(flags, deps = {}) {
|
|
|
8956
8953
|
if (hasDomain) {
|
|
8957
8954
|
// Same rule as the real retire (DOM-001): the plan must print the interim the
|
|
8958
8955
|
// project ACTUALLY has, not one re-derived in the current org zone.
|
|
8959
|
-
const
|
|
8960
|
-
|
|
8956
|
+
const hosts = existingProvisionDomains(projectName, state?.dns);
|
|
8957
|
+
// Railway records its kit domains by environment: without one there is nothing to retire.
|
|
8958
|
+
const onRailway = target === 'railway';
|
|
8959
|
+
const interim = onRailway && !hasRailwayKitDomain(state, 'main') ? undefined : hosts.prod;
|
|
8960
|
+
const dev = onRailway && !hasRailwayKitDomain(state, 'develop') ? undefined : hosts.dev;
|
|
8961
|
+
printClientDomainPlan(flags.domain, { interim, dev }, flags.dns, flags.confirm, target, flags.clientMail === true);
|
|
8961
8962
|
}
|
|
8962
8963
|
}
|
|
8963
8964
|
return result;
|
|
@@ -8968,7 +8969,9 @@ export async function runProvision(flags, deps = {}) {
|
|
|
8968
8969
|
const preflight = await runProvisionPreflight({
|
|
8969
8970
|
services: preflightServices,
|
|
8970
8971
|
teamId: flags.team,
|
|
8971
|
-
|
|
8972
|
+
// The repo the steps target (state → `--repo` → `origin` → default), never one re-derived
|
|
8973
|
+
// from the slug: a repo named apart from its slug would fail a check the run would pass.
|
|
8974
|
+
...(repoSlug ? { repoSlug } : {}),
|
|
8972
8975
|
rootDir,
|
|
8973
8976
|
...(target ? { target } : {}),
|
|
8974
8977
|
}, { rail });
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory prune <sub>` — share a derived repo with outside collaborators WITHOUT the
|
|
3
|
+
* methodology. The member who runs it acts with their own `gh` session and permissions.
|
|
4
|
+
*
|
|
5
|
+
* Model (`.claude/docs/working-with-collaborators.md`): members of the org clone the repo and
|
|
6
|
+
* run `factory update`, which drops the whole kit on their disk — the restricted part is
|
|
7
|
+
* gitignored, so it never enters the repository. Collaborators fork and open PRs; they see the
|
|
8
|
+
* product, the always-on rules and the kit systems, never the content of the procedures.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is switched on per repo by `.timekast/prune.json`, which only `prepare`
|
|
11
|
+
* writes. A repo without it behaves exactly as before.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { execa } from 'execa';
|
|
16
|
+
import { CLIError } from '../lib/cli-error.js';
|
|
17
|
+
import { GITIGNORE_FILE } from '../lib/constants.js';
|
|
18
|
+
import { readValueFlag } from '../lib/flags.js';
|
|
19
|
+
import { commitWithPrivateIndex, trackedFiles } from '../lib/prune-git.js';
|
|
20
|
+
import { checkPr, REJECTED_PACKAGE_FIELDS } from '../lib/prune-pr-check.js';
|
|
21
|
+
import { applyTierFiles, defaultPruneDeps, originRepo, pruneAudit, pruneCutover, pruneFinish, pruneInvite, } from '../lib/prune-remote.js';
|
|
22
|
+
import { DEFAULT_PRUNE_TIER, PRUNE_MARKER_PATH, getTier, isRestricted, readPruneMarker, readPruneTiers, } from '../lib/prune-tiers.js';
|
|
23
|
+
import { detectRepo } from '../lib/repo-detection.js';
|
|
24
|
+
const PRUNE_USAGE = 'Uso: `factory prune prepare [--tier=collab] [--skip-verify]` · ' +
|
|
25
|
+
'`factory prune cutover [--confirm] [--discard-develop] [--resume] [--repo=owner/nombre]` · ' +
|
|
26
|
+
'`factory prune invite <usuario-github> [--repo=owner/nombre]` · ' +
|
|
27
|
+
'`factory prune finish [--repo=owner/nombre]` · `factory prune audit [--repo=owner/nombre]` · ' +
|
|
28
|
+
'`factory prune check-pr <número> [--repo=owner/nombre] [--json]`.';
|
|
29
|
+
export function parsePrunePrepareFlags(argv) {
|
|
30
|
+
return {
|
|
31
|
+
tier: readValueFlag(argv, 'tier') ?? DEFAULT_PRUNE_TIER,
|
|
32
|
+
skipVerify: argv.includes('--skip-verify'),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Write the tier's `.gitignore` block and the switch, untrack what the block now ignores, and
|
|
37
|
+
* commit all of it in one go. Idempotent: a second run on a prepared repo changes nothing and
|
|
38
|
+
* commits nothing.
|
|
39
|
+
*/
|
|
40
|
+
export async function prunePrepare(rootDir, flags) {
|
|
41
|
+
const head = await execa('git', ['rev-parse', '--verify', '-q', 'HEAD'], {
|
|
42
|
+
cwd: rootDir,
|
|
43
|
+
reject: false,
|
|
44
|
+
});
|
|
45
|
+
if (head.exitCode !== 0) {
|
|
46
|
+
throw new CLIError('El repo no tiene commits todavía; haz el primer commit antes de `prune`.');
|
|
47
|
+
}
|
|
48
|
+
const tier = getTier(readPruneTiers(rootDir), flags.tier);
|
|
49
|
+
// The two files this command owns must not carry someone's uncommitted work: the commit
|
|
50
|
+
// below takes them whole.
|
|
51
|
+
const dirty = await execa('git', ['status', '--porcelain', '--', GITIGNORE_FILE, PRUNE_MARKER_PATH], { cwd: rootDir, reject: false });
|
|
52
|
+
if (dirty.stdout.trim() !== '') {
|
|
53
|
+
throw new CLIError(`\`${GITIGNORE_FILE}\` o \`${PRUNE_MARKER_PATH}\` tienen cambios sin commitear. ` +
|
|
54
|
+
'Commitéalos o descártalos y vuelve a correr `factory prune prepare`.');
|
|
55
|
+
}
|
|
56
|
+
// 1. The block and the switch — `at` survives a re-run with the same tier, so a re-run
|
|
57
|
+
// changes nothing.
|
|
58
|
+
const prior = readPruneMarker(rootDir);
|
|
59
|
+
const legacyBlockRemoved = applyTierFiles(rootDir, flags.tier, tier, prior?.tier === flags.tier ? prior.at : new Date().toISOString());
|
|
60
|
+
// 2. Untrack what the tier restricts, and commit it with the block and the switch.
|
|
61
|
+
const untracked = (await trackedFiles(rootDir)).filter((p) => isRestricted(tier, p));
|
|
62
|
+
const commit = await commitWithPrivateIndex(rootDir, {
|
|
63
|
+
remove: untracked,
|
|
64
|
+
add: [GITIGNORE_FILE, PRUNE_MARKER_PATH],
|
|
65
|
+
message: `chore(prune): keep the methodology local (tier ${flags.tier})`,
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
tier: flags.tier,
|
|
69
|
+
untracked,
|
|
70
|
+
legacyBlockRemoved,
|
|
71
|
+
...(commit.committed ? { commit: commit.sha } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Group untracked paths by their first two segments, for a summary a person can read. */
|
|
75
|
+
function summarizePaths(paths) {
|
|
76
|
+
const groups = new Map();
|
|
77
|
+
for (const p of paths) {
|
|
78
|
+
const key = p.split('/').slice(0, 2).join('/');
|
|
79
|
+
groups.set(key, (groups.get(key) ?? 0) + 1);
|
|
80
|
+
}
|
|
81
|
+
return [...groups].sort().map(([k, n]) => ` • ${k}/ (${n})`);
|
|
82
|
+
}
|
|
83
|
+
async function runPrunePrepare(flags) {
|
|
84
|
+
const rootDir = requireRepoRoot();
|
|
85
|
+
const result = await prunePrepare(rootDir, flags);
|
|
86
|
+
if (result.legacyBlockRemoved) {
|
|
87
|
+
console.log('✔ Quité el bloque `timekast:collab` hecho a mano; lo reemplaza el bloque del kit.');
|
|
88
|
+
}
|
|
89
|
+
if (!result.commit) {
|
|
90
|
+
console.log(`✔ El repo ya estaba preparado con el tier \`${result.tier}\`; no cambió nada.`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
console.log(`✔ Tier \`${result.tier}\` aplicado y commiteado (${result.commit}, sin push). ` +
|
|
94
|
+
`Dejé de trackear ${result.untracked.length} archivo(s); siguen en tu disco:`);
|
|
95
|
+
for (const line of summarizePaths(result.untracked))
|
|
96
|
+
console.log(line);
|
|
97
|
+
}
|
|
98
|
+
if (flags.skipVerify)
|
|
99
|
+
return;
|
|
100
|
+
const pkgPath = path.join(rootDir, 'package.json');
|
|
101
|
+
const hasVerify = existsSync(pkgPath) &&
|
|
102
|
+
Boolean(JSON.parse(readFileSync(pkgPath, 'utf8')).scripts
|
|
103
|
+
?.verify);
|
|
104
|
+
if (!hasVerify) {
|
|
105
|
+
console.log('ℹ️ Este repo no tiene `pnpm verify`; no corrí la verificación.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
console.log('\n▸ Corriendo `pnpm verify`…');
|
|
109
|
+
const verify = await execa('pnpm', ['verify'], { cwd: rootDir, stdio: 'inherit', reject: false });
|
|
110
|
+
if (verify.exitCode !== 0) {
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
console.error('✖ `pnpm verify` falló. Revísalo antes de seguir con el corte.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// cutover · invite · finish · audit (GitHub, with the member's `gh` session)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
export function parseCutoverFlags(argv) {
|
|
119
|
+
return {
|
|
120
|
+
repo: readValueFlag(argv, 'repo'),
|
|
121
|
+
tier: readValueFlag(argv, 'tier') ?? DEFAULT_PRUNE_TIER,
|
|
122
|
+
confirm: argv.includes('--confirm'),
|
|
123
|
+
discardDevelop: argv.includes('--discard-develop'),
|
|
124
|
+
resume: argv.includes('--resume'),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async function runPruneCutover(flags, deps) {
|
|
128
|
+
const rootDir = requireRepoRoot();
|
|
129
|
+
const { preflight, result } = await pruneCutover(rootDir, flags, deps);
|
|
130
|
+
if (!result) {
|
|
131
|
+
console.log([
|
|
132
|
+
`✔ Preflight del corte de \`${preflight.repo}\` en verde. No toqué nada.`,
|
|
133
|
+
'',
|
|
134
|
+
'Plan:',
|
|
135
|
+
` 1. Snapshot de \`main\` (${preflight.mainSha.slice(0, 7)}) con el tier aplicado, validado sin rutas restringidas.`,
|
|
136
|
+
` 2. Crear \`${preflight.next}\` (privado) y subir el snapshot como \`main\` y \`develop\` — un solo commit.`,
|
|
137
|
+
` 3. Renombrar \`${preflight.repo}\` → \`${preflight.legacy}\` y \`${preflight.next}\` → \`${preflight.repo}\`.`,
|
|
138
|
+
' 4. Permitir forks SOLO en el repo nuevo; sin workflows de PRs de forks.',
|
|
139
|
+
...(preflight.developAhead.length > 0
|
|
140
|
+
? [
|
|
141
|
+
` ⚠ Se pierden ${preflight.developAhead.length} commit(s) de \`develop\` (--discard-develop).`,
|
|
142
|
+
]
|
|
143
|
+
: []),
|
|
144
|
+
'',
|
|
145
|
+
'Para ejecutarlo: `factory prune cutover --confirm`.',
|
|
146
|
+
].join('\n'));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
console.log(`✔ Corte hecho: \`${result.repo}\` es el repo nuevo (un commit, ${result.snapshot}); ` +
|
|
150
|
+
`el anterior quedó como \`${result.legacy}\`.`);
|
|
151
|
+
for (const w of result.warnings)
|
|
152
|
+
console.warn(`⚠ ${w}`);
|
|
153
|
+
if (result.notMigrated.length > 0) {
|
|
154
|
+
console.log(`\nNo migró (sigue en \`${result.legacy}\`):`);
|
|
155
|
+
for (const line of result.notMigrated)
|
|
156
|
+
console.log(` • ${line}`);
|
|
157
|
+
}
|
|
158
|
+
console.log('\nSiguiente: reconecta el proyecto de Vercel al repo nuevo en el dashboard (Settings → Git), ' +
|
|
159
|
+
'pide a los socios volver a clonar, y corre `factory prune finish` cuando Production despliegue.');
|
|
160
|
+
}
|
|
161
|
+
function printAudit(repo, audit) {
|
|
162
|
+
const clean = audit.roots.length === 1 && audit.leaks.length === 0;
|
|
163
|
+
if (clean) {
|
|
164
|
+
console.log(`✔ Historia de \`${repo}\` limpia: una raíz, ninguna ruta del tier \`${audit.tierName}\`.`);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (audit.roots.length !== 1) {
|
|
168
|
+
console.error(`✖ \`${repo}\` tiene ${audit.roots.length} commits raíz (esperado: 1) — ` +
|
|
169
|
+
'probablemente un clon viejo pusheó la historia anterior.');
|
|
170
|
+
}
|
|
171
|
+
if (audit.leaks.length > 0) {
|
|
172
|
+
console.error(`✖ ${audit.leaks.length} ruta(s) restringida(s) en la historia (incluye refs de PRs):`);
|
|
173
|
+
for (const p of audit.leaks)
|
|
174
|
+
console.error(` ${p}`);
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Dispatch
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
function requireRepoRoot() {
|
|
182
|
+
const root = detectRepo(process.cwd()).repoRoot;
|
|
183
|
+
if (!root)
|
|
184
|
+
throw new CLIError('Corre `factory prune` dentro de un repo git.');
|
|
185
|
+
return root;
|
|
186
|
+
}
|
|
187
|
+
async function repoFrom(argv) {
|
|
188
|
+
return readValueFlag(argv, 'repo') ?? (await originRepo(requireRepoRoot()));
|
|
189
|
+
}
|
|
190
|
+
/** `factory prune <sub> [...]` — dispatch lives here so every branch is testable. */
|
|
191
|
+
export async function runPruneCommand(args, deps = defaultPruneDeps) {
|
|
192
|
+
const sub = args[0];
|
|
193
|
+
const rest = args.slice(1);
|
|
194
|
+
switch (sub) {
|
|
195
|
+
case 'prepare':
|
|
196
|
+
await runPrunePrepare(parsePrunePrepareFlags(rest));
|
|
197
|
+
return;
|
|
198
|
+
case 'cutover':
|
|
199
|
+
await runPruneCutover(parseCutoverFlags(rest), deps);
|
|
200
|
+
return;
|
|
201
|
+
case 'invite': {
|
|
202
|
+
const user = rest.find((a) => !a.startsWith('-') && rest[rest.indexOf(a) - 1] !== '--repo');
|
|
203
|
+
if (!user)
|
|
204
|
+
throw new CLIError(`Falta el usuario de GitHub del colaborador. ${PRUNE_USAGE}`);
|
|
205
|
+
const repo = await repoFrom(rest);
|
|
206
|
+
await pruneInvite(repo, user, deps);
|
|
207
|
+
console.log(`✔ Invité a \`${user}\` a \`${repo}\` con lectura. Tiene que aceptar la invitación; ` +
|
|
208
|
+
'después forkea y abre PRs a la rama de trabajo.');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
case 'finish': {
|
|
212
|
+
const repo = await repoFrom(rest);
|
|
213
|
+
const done = await pruneFinish(repo, deps);
|
|
214
|
+
console.log(`✔ \`${repo}-legacy\`: forks apagados, ${done.removedCollaborators.length} colaborador(es) ` +
|
|
215
|
+
'externo(s) quitado(s) y archivado.');
|
|
216
|
+
if (!printAudit(repo, done.audit))
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
case 'check-pr': {
|
|
221
|
+
const raw = rest.find((a) => /^\d+$/.test(a));
|
|
222
|
+
if (!raw)
|
|
223
|
+
throw new CLIError(`Falta el número del PR. ${PRUNE_USAGE}`);
|
|
224
|
+
const repo = await repoFrom(rest);
|
|
225
|
+
const result = await checkPr(repo, Number(raw), deps);
|
|
226
|
+
const refused = result.rejected.length > 0 || result.packageFields.length > 0;
|
|
227
|
+
if (rest.includes('--json')) {
|
|
228
|
+
console.log(JSON.stringify({ refused, ...result }, null, 2));
|
|
229
|
+
}
|
|
230
|
+
else if (!refused) {
|
|
231
|
+
console.log(`✔ PR #${raw}: ningún archivo fuera del producto (${result.files.length} archivo(s)).`);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
console.error(`✖ PR #${raw} rechazado antes del checkout — toca lo que corre alrededor del producto:`);
|
|
235
|
+
for (const r of result.rejected)
|
|
236
|
+
console.error(` ${r.path} — ${r.why}`);
|
|
237
|
+
for (const f of result.packageFields) {
|
|
238
|
+
console.error(` package.json#${f} — ${REJECTED_PACKAGE_FIELDS.includes(f) ? 'comandos que corren en tu máquina' : 'revísalo a mano'}`);
|
|
239
|
+
}
|
|
240
|
+
console.error(' Regrésalo al colaborador, o que un socio rehaga ese cambio.');
|
|
241
|
+
}
|
|
242
|
+
if (refused)
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
case 'audit': {
|
|
247
|
+
const repo = await repoFrom(rest);
|
|
248
|
+
if (!printAudit(repo, await pruneAudit(repo, deps)))
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
default:
|
|
253
|
+
throw new CLIError(`Subcomando de prune desconocido: \`${sub ?? '(ninguno)'}\`. ${PRUNE_USAGE}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
package/dist/commands/update.js
CHANGED
|
@@ -42,13 +42,15 @@ import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpda
|
|
|
42
42
|
import { applyClaudeMdRetrofit, assessClaudeMd, reportClaudeMd, } from '../lib/claude-md.js';
|
|
43
43
|
import { collectClaudePaths } from '../lib/claude-paths.js';
|
|
44
44
|
import { CLIError } from '../lib/cli-error.js';
|
|
45
|
-
import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, LOCKFILE_FILE, PRETTIERIGNORE_FILE, PROFILES, TIMEKAST_DIR, } from '../lib/constants.js';
|
|
45
|
+
import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, GITIGNORE_FILE, LOCKFILE_FILE, PRETTIERIGNORE_FILE, PROFILES, TIMEKAST_DIR, } from '../lib/constants.js';
|
|
46
46
|
import { decideCommit } from '../lib/commit-decision.js';
|
|
47
47
|
import { extractManagedBlock, syncManagedBlock } from '../lib/gitattributes.js';
|
|
48
48
|
import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
|
|
49
49
|
import { detectProfile, maintainKitScripts, setAgentKitVersion } from '../lib/package-json.js';
|
|
50
50
|
import { runPreflight } from '../lib/preflight.js';
|
|
51
51
|
import { printProvisionPointer } from '../lib/provision-pointer.js';
|
|
52
|
+
import { planPruneMaintenance } from '../lib/prune-update.js';
|
|
53
|
+
import { isRestricted, PRUNE_MARKER_PATH } from '../lib/prune-tiers.js';
|
|
52
54
|
import { printRetrofitReminder } from '../lib/retrofit-reminder.js';
|
|
53
55
|
import { hasProvisionState } from '../lib/provision-state.js';
|
|
54
56
|
import { loginCommand } from '../lib/rail.js';
|
|
@@ -332,15 +334,20 @@ function runVerify(rootDir) {
|
|
|
332
334
|
* → prompt y/n · headless → commit by default (a headless run needs it). Stages ONLY
|
|
333
335
|
* what the update touched (the applied manifest paths + lockfile + package.json +
|
|
334
336
|
* .gitattributes) — never `git add -A`. Best-effort: no identity / no repo → warn + skip.
|
|
337
|
+
* In a pruned repo (`prune` given) it also untracks what the tier restricts, leaves the
|
|
338
|
+
* restricted applied paths out, and stages `.gitignore`, the switch and the shared kit paths
|
|
339
|
+
* that are not tracked yet.
|
|
335
340
|
*/
|
|
336
|
-
async function maybeCommitBrain(rootDir, version, appliedPaths, flags) {
|
|
341
|
+
async function maybeCommitBrain(rootDir, version, appliedPaths, flags, prune = null) {
|
|
337
342
|
const decision = decideCommit({
|
|
338
343
|
commit: flags.commit,
|
|
339
344
|
noCommit: flags.noCommit,
|
|
340
345
|
isTTY: Boolean(process.stdout.isTTY),
|
|
341
346
|
});
|
|
342
|
-
if (decision === 'skip')
|
|
347
|
+
if (decision === 'skip') {
|
|
348
|
+
reportPendingPrune(prune);
|
|
343
349
|
return;
|
|
350
|
+
}
|
|
344
351
|
let doCommit = decision === 'commit';
|
|
345
352
|
if (decision === 'prompt') {
|
|
346
353
|
const { yes } = await prompts({
|
|
@@ -351,17 +358,31 @@ async function maybeCommitBrain(rootDir, version, appliedPaths, flags) {
|
|
|
351
358
|
});
|
|
352
359
|
doCommit = yes === true;
|
|
353
360
|
}
|
|
354
|
-
if (!doCommit)
|
|
361
|
+
if (!doCommit) {
|
|
362
|
+
reportPendingPrune(prune);
|
|
355
363
|
return;
|
|
364
|
+
}
|
|
356
365
|
const candidates = [
|
|
357
|
-
|
|
366
|
+
// A pruned repo never stages what its tier keeps local (it is ignored, and the guard would
|
|
367
|
+
// refuse it); it DOES stage the shared kit paths not yet tracked (`prune-update.ts`).
|
|
368
|
+
...(prune ? appliedPaths.filter((p) => !isRestricted(prune.tier, p)) : appliedPaths),
|
|
369
|
+
...(prune ? [...prune.publish, GITIGNORE_FILE, PRUNE_MARKER_PATH] : []),
|
|
358
370
|
`${TIMEKAST_DIR}/${LOCKFILE_FILE}`,
|
|
359
371
|
...(existsSync(path.join(rootDir, 'package.json')) ? ['package.json'] : []),
|
|
360
372
|
...(existsSync(path.join(rootDir, GITATTRIBUTES_FILE)) ? [GITATTRIBUTES_FILE] : []),
|
|
361
373
|
...(existsSync(path.join(rootDir, PRETTIERIGNORE_FILE)) ? [PRETTIERIGNORE_FILE] : []),
|
|
362
374
|
];
|
|
363
375
|
try {
|
|
364
|
-
|
|
376
|
+
const stageable = await filterStageable(rootDir, candidates);
|
|
377
|
+
if (prune && prune.untrack.length > 0) {
|
|
378
|
+
await execa('git', ['rm', '--cached', '-q', '--ignore-unmatch', '--', ...prune.untrack], {
|
|
379
|
+
cwd: rootDir,
|
|
380
|
+
reject: false,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
if (stageable.length > 0) {
|
|
384
|
+
await execa('git', ['add', '--', ...stageable], { cwd: rootDir, reject: false });
|
|
385
|
+
}
|
|
365
386
|
// Nothing staged (e.g. a no-op update) → no empty commit.
|
|
366
387
|
const staged = await execa('git', ['diff', '--cached', '--quiet'], {
|
|
367
388
|
cwd: rootDir,
|
|
@@ -382,6 +403,34 @@ async function maybeCommitBrain(rootDir, version, appliedPaths, flags) {
|
|
|
382
403
|
console.warn('Aviso: no se pudo commitear; el cerebro se instaló igual.');
|
|
383
404
|
}
|
|
384
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* The brain commit was skipped in a pruned repo: whatever the tier asks the index to change
|
|
408
|
+
* (untrack a restricted path, publish a newly shared one) is still pending. `prune prepare`
|
|
409
|
+
* does exactly that and is idempotent, so it is the one instruction to give.
|
|
410
|
+
*/
|
|
411
|
+
function reportPendingPrune(prune) {
|
|
412
|
+
if (!prune || (prune.untrack.length === 0 && prune.publish.length === 0))
|
|
413
|
+
return;
|
|
414
|
+
console.log(`ℹ️ Repo compartido (tier \`${prune.tierName}\`): quedan ${prune.untrack.length} ruta(s) por ` +
|
|
415
|
+
`dejar de trackear y ${prune.publish.length} por publicar. Corre \`factory prune prepare\` ` +
|
|
416
|
+
'para commitearlo.');
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Keep only the paths `git add` can take: on disk, or in the index (a deletion to stage).
|
|
420
|
+
*
|
|
421
|
+
* ONE path that is in neither — a retired file the dev never committed, which the update just
|
|
422
|
+
* removed from disk — makes `git add -- a b c` exit 128 having staged NOTHING, so the brain
|
|
423
|
+
* commit was lost in silence (the warning below only fires when `git commit` fails, and a
|
|
424
|
+
* clean index commits nothing). Exported for the CLI suite.
|
|
425
|
+
*/
|
|
426
|
+
export async function filterStageable(rootDir, candidates) {
|
|
427
|
+
const listed = await execa('git', ['ls-files', '-z', '--cached', '--', ...candidates], {
|
|
428
|
+
cwd: rootDir,
|
|
429
|
+
reject: false,
|
|
430
|
+
});
|
|
431
|
+
const inIndex = new Set(listed.exitCode === 0 ? listed.stdout.split('\0').filter(Boolean) : []);
|
|
432
|
+
return [...new Set(candidates)].filter((rel) => inIndex.has(rel) || existsSync(path.join(rootDir, rel)));
|
|
433
|
+
}
|
|
385
434
|
/**
|
|
386
435
|
* `--resume`: re-apply a persisted mid-flight state without re-downloading
|
|
387
436
|
* (design §7.5). Reads the staged dir + plan from `.timekast/.update-state.json`.
|
|
@@ -422,7 +471,8 @@ async function runResume(rootDir, flags) {
|
|
|
422
471
|
const claudeMdInserted = claudeMd ? applyClaudeMdRetrofit(rootDir, claudeMd) : false;
|
|
423
472
|
// Maintain dotfiles BEFORE removing the staged dir (the .gitattributes source).
|
|
424
473
|
const pkgMutations = maintainDerivedDotfiles(rootDir, state.stagedDir, newLock.version);
|
|
425
|
-
await
|
|
474
|
+
const prune = await planPruneMaintenance(rootDir, state.stagedDir, newLock.files.map((f) => f.path));
|
|
475
|
+
await maybeCommitBrain(rootDir, newLock.version, [...state.plan.writes, ...state.plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags, prune);
|
|
426
476
|
rmSync(state.stagedDir, { recursive: true, force: true });
|
|
427
477
|
// A resumed update maintains package.json exactly like a normal one, so it owes the dev the
|
|
428
478
|
// same account of what it moved there (`BND-004`).
|
|
@@ -940,7 +990,8 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
940
990
|
// in case B it is in `ignoreLocal`, so the plan never names it).
|
|
941
991
|
const claudeMdInserted = applyClaudeMdRetrofit(rootDir, claudeMd);
|
|
942
992
|
const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
|
|
943
|
-
await
|
|
993
|
+
const prune = await planPruneMaintenance(rootDir, stagedDir, manifest.files.map((f) => f.path));
|
|
994
|
+
await maybeCommitBrain(rootDir, manifest.version, [...plan.writes, ...plan.deletes, ...(claudeMdInserted ? [CLAUDE_MD_FILE] : [])], flags, prune);
|
|
944
995
|
// `theirsConflicts` IS the overwritten-local list: same source whether the dev answered
|
|
945
996
|
// the prompt, passed `--theirs-all`, or ran headless — there is no second path. The
|
|
946
997
|
// package.json mutations ride the SAME closing summary, not three loose notices.
|
|
@@ -1056,6 +1107,9 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
|
|
|
1056
1107
|
// first sync this repo records, so the birth seal = the manifest's version.
|
|
1057
1108
|
writeLockfile(rootDir, { ...manifest, factoryVersion: manifest.version });
|
|
1058
1109
|
const pkgMutations = maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
|
|
1110
|
+
// No brain commit on this path; the block is still kept in step (the index is left to
|
|
1111
|
+
// `prune prepare`, which the pending notice names).
|
|
1112
|
+
reportPendingPrune(await planPruneMaintenance(rootDir, stagedDir, manifest.files.map((f) => f.path)));
|
|
1059
1113
|
reportPkgMutations(pkgMutations);
|
|
1060
1114
|
if (claudeMdExists) {
|
|
1061
1115
|
console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { envWizardFlow } from './tui/env-wizard-flow.js';
|
|
|
19
19
|
import { parseDoctorFlags, runDoctor, runLauncherDoctor } from './commands/doctor.js';
|
|
20
20
|
import { parseNewFlags, runNew } from './commands/new.js';
|
|
21
21
|
import { parseProvisionFlags, runProvision } from './commands/provision.js';
|
|
22
|
+
import { runPruneCommand } from './commands/prune.js';
|
|
22
23
|
import { parsePublishArgs, runPublish } from './commands/publish.js';
|
|
23
24
|
import { parseStatusFlags, runStatus } from './commands/status.js';
|
|
24
25
|
import { runTicketCommand } from './commands/ticket.js';
|
|
@@ -52,6 +53,13 @@ Comandos:
|
|
|
52
53
|
env <sub> push: sube product vars a Vercel (day-2) · wizard: configura las variables
|
|
53
54
|
(en la bóveda si el repo la tiene; sin bóveda, en .env.local)
|
|
54
55
|
invite-admin Crea una invitación de super_admin para un derivado (sin password en texto plano)
|
|
56
|
+
prune <sub> Comparte el repo con colaboradores externos sin la metodología (lo guía /prune):
|
|
57
|
+
prepare: aplica el tier (.gitignore + .timekast/prune.json) y deja de trackear
|
|
58
|
+
lo restringido · cutover: corte a un repo cuya historia nunca tuvo la
|
|
59
|
+
metodología (sin --confirm sólo corre el preflight) · invite <usuario>:
|
|
60
|
+
colaborador externo con lectura · finish: archiva el -legacy y audita ·
|
|
61
|
+
audit: revisa que la historia del repo no tenga rutas restringidas ·
|
|
62
|
+
check-pr <n>: rechaza un PR de colaborador que toque algo fuera del producto
|
|
55
63
|
vault <sub> adopt: pasa a la bóveda un repo que nació antes de ella (sin --apply sólo
|
|
56
64
|
enseña el plan, por nombre y huella — nunca valores; con varios proyectos
|
|
57
65
|
de Vercel, --vercel-project <id> elige el principal) · sync railway: crea
|
|
@@ -410,6 +418,12 @@ async function main(argv) {
|
|
|
410
418
|
await runVaultCommand(rest);
|
|
411
419
|
return;
|
|
412
420
|
}
|
|
421
|
+
case 'prune': {
|
|
422
|
+
// `factory prune <sub>` — share a repo with outside collaborators without the
|
|
423
|
+
// methodology. Dispatch lives in the command module (testable branches).
|
|
424
|
+
await runPruneCommand(rest);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
413
427
|
case 'invite-admin': {
|
|
414
428
|
// Mint a single-use super_admin invite against the target branch's DB.
|
|
415
429
|
// Spawns the kit script `scripts/tools/invite-admin.ts` with the resolved
|
package/dist/lib/constants.js
CHANGED
|
@@ -104,6 +104,19 @@ export const GITATTRIBUTES_FILE = '.gitattributes';
|
|
|
104
104
|
* turns every later `factory:update` into false conflicts (A2).
|
|
105
105
|
*/
|
|
106
106
|
export const PRETTIERIGNORE_FILE = '.prettierignore';
|
|
107
|
+
/**
|
|
108
|
+
* Repo-root `.gitignore`. Same managed-block treatment, but ONLY in a pruned repo
|
|
109
|
+
* (`factory prune`): the block lists the kit paths kept local to the members, generated
|
|
110
|
+
* from `.claude/policy/prune-tiers.json`. A repo without the switch below never gets one.
|
|
111
|
+
*/
|
|
112
|
+
export const GITIGNORE_FILE = '.gitignore';
|
|
113
|
+
/**
|
|
114
|
+
* File name, under `.timekast/`, of the per-repo switch written by `factory prune prepare`
|
|
115
|
+
* (`{ tier, at }`). Its OWN file, not a lockfile field: `update` rewrites the lockfile whole.
|
|
116
|
+
* Without it every prune behavior — the `.gitignore` block, the untracking in `update`, the
|
|
117
|
+
* pre-commit guard — stays off.
|
|
118
|
+
*/
|
|
119
|
+
export const PRUNE_FILE = 'prune.json';
|
|
107
120
|
/**
|
|
108
121
|
* Managed-block sentinels. These are PREFIXES (the live source lines carry extra
|
|
109
122
|
* descriptive text + trailing `>>>`/`<<<`), matched with `startsWith` so detection
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Read the value of a `--key=value` or `--key value` flag from an argv slice. */
|
|
2
|
+
export function readValueFlag(argv, name) {
|
|
3
|
+
const prefix = `--${name}=`;
|
|
4
|
+
const inline = argv.find((arg) => arg.startsWith(prefix));
|
|
5
|
+
if (inline)
|
|
6
|
+
return inline.slice(prefix.length);
|
|
7
|
+
const idx = argv.indexOf(`--${name}`);
|
|
8
|
+
if (idx !== -1) {
|
|
9
|
+
const next = argv[idx + 1];
|
|
10
|
+
if (next && !next.startsWith('-'))
|
|
11
|
+
return next;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|