@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
|
@@ -0,0 +1,305 @@
|
|
|
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 { BRANCH_DEPLOY_RULE, VERCEL_JSON, ensureBranchDeployRule, hasBranchDeployRule, pruneVercelRelink, resolveVercelProject, } from '../lib/prune-vercel.js';
|
|
22
|
+
import { readVercelToken } from '../lib/provision-preflight.js';
|
|
23
|
+
import { applyTierFiles, defaultPruneDeps, originRepo, pruneAudit, pruneCutover, pruneFinish, pruneInvite, } from '../lib/prune-remote.js';
|
|
24
|
+
import { DEFAULT_PRUNE_TIER, PRUNE_MARKER_PATH, getTier, isRestricted, readPruneMarker, readPruneTiers, } from '../lib/prune-tiers.js';
|
|
25
|
+
import { detectRepo } from '../lib/repo-detection.js';
|
|
26
|
+
const PRUNE_USAGE = 'Uso: `factory prune prepare [--tier=collab] [--skip-verify]` · ' +
|
|
27
|
+
'`factory prune cutover [--confirm] [--discard-develop] [--resume] [--repo=owner/nombre]` · ' +
|
|
28
|
+
'`factory prune vercel [--confirm] [--repo=owner/nombre] [--project=prj_…] [--team=team_…]` · ' +
|
|
29
|
+
'`factory prune invite <usuario-github> [--read] [--repo=owner/nombre]` · ' +
|
|
30
|
+
'`factory prune finish [--repo=owner/nombre]` · `factory prune audit [--repo=owner/nombre]` · ' +
|
|
31
|
+
'`factory prune check-pr <número> [--repo=owner/nombre] [--json]`.';
|
|
32
|
+
export function parsePrunePrepareFlags(argv) {
|
|
33
|
+
return {
|
|
34
|
+
tier: readValueFlag(argv, 'tier') ?? DEFAULT_PRUNE_TIER,
|
|
35
|
+
skipVerify: argv.includes('--skip-verify'),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Write the tier's `.gitignore` block and the switch, untrack what the block now ignores, and
|
|
40
|
+
* commit all of it in one go. Idempotent: a second run on a prepared repo changes nothing and
|
|
41
|
+
* commits nothing.
|
|
42
|
+
*/
|
|
43
|
+
export async function prunePrepare(rootDir, flags) {
|
|
44
|
+
const head = await execa('git', ['rev-parse', '--verify', '-q', 'HEAD'], {
|
|
45
|
+
cwd: rootDir,
|
|
46
|
+
reject: false,
|
|
47
|
+
});
|
|
48
|
+
if (head.exitCode !== 0) {
|
|
49
|
+
throw new CLIError('El repo no tiene commits todavía; haz el primer commit antes de `prune`.');
|
|
50
|
+
}
|
|
51
|
+
const tier = getTier(readPruneTiers(rootDir), flags.tier);
|
|
52
|
+
// The two files this command owns must not carry someone's uncommitted work: the commit
|
|
53
|
+
// below takes them whole.
|
|
54
|
+
const dirty = await execa('git', ['status', '--porcelain', '--', GITIGNORE_FILE, PRUNE_MARKER_PATH, VERCEL_JSON], { cwd: rootDir, reject: false });
|
|
55
|
+
if (dirty.stdout.trim() !== '') {
|
|
56
|
+
throw new CLIError(`\`${GITIGNORE_FILE}\`, \`${PRUNE_MARKER_PATH}\` o \`${VERCEL_JSON}\` tienen cambios sin commitear. ` +
|
|
57
|
+
'Commitéalos o descártalos y vuelve a correr `factory prune prepare`.');
|
|
58
|
+
}
|
|
59
|
+
// 1. The block and the switch — `at` survives a re-run with the same tier, so a re-run
|
|
60
|
+
// changes nothing.
|
|
61
|
+
const prior = readPruneMarker(rootDir);
|
|
62
|
+
const legacyBlockRemoved = applyTierFiles(rootDir, flags.tier, tier, prior?.tier === flags.tier ? prior.at : new Date().toISOString());
|
|
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.
|
|
67
|
+
const untracked = (await trackedFiles(rootDir)).filter((p) => isRestricted(tier, p));
|
|
68
|
+
const commit = await commitWithPrivateIndex(rootDir, {
|
|
69
|
+
remove: untracked,
|
|
70
|
+
add: [GITIGNORE_FILE, PRUNE_MARKER_PATH, ...(branchDeployRuleAdded ? [VERCEL_JSON] : [])],
|
|
71
|
+
message: `chore(prune): keep the methodology local (tier ${flags.tier})`,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
tier: flags.tier,
|
|
75
|
+
untracked,
|
|
76
|
+
branchDeployRuleAdded,
|
|
77
|
+
legacyBlockRemoved,
|
|
78
|
+
...(commit.committed ? { commit: commit.sha } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Group untracked paths by their first two segments, for a summary a person can read. */
|
|
82
|
+
function summarizePaths(paths) {
|
|
83
|
+
const groups = new Map();
|
|
84
|
+
for (const p of paths) {
|
|
85
|
+
const key = p.split('/').slice(0, 2).join('/');
|
|
86
|
+
groups.set(key, (groups.get(key) ?? 0) + 1);
|
|
87
|
+
}
|
|
88
|
+
return [...groups].sort().map(([k, n]) => ` • ${k}/ (${n})`);
|
|
89
|
+
}
|
|
90
|
+
async function runPrunePrepare(flags) {
|
|
91
|
+
const rootDir = requireRepoRoot();
|
|
92
|
+
const result = await prunePrepare(rootDir, flags);
|
|
93
|
+
if (result.legacyBlockRemoved) {
|
|
94
|
+
console.log('✔ Quité el bloque `timekast:collab` hecho a mano; lo reemplaza el bloque del kit.');
|
|
95
|
+
}
|
|
96
|
+
if (!result.commit) {
|
|
97
|
+
console.log(`✔ El repo ya estaba preparado con el tier \`${result.tier}\`; no cambió nada.`);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
console.log(`✔ Tier \`${result.tier}\` aplicado y commiteado (${result.commit}, sin push). ` +
|
|
101
|
+
`Dejé de trackear ${result.untracked.length} archivo(s); siguen en tu disco:`);
|
|
102
|
+
for (const line of summarizePaths(result.untracked))
|
|
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
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (flags.skipVerify)
|
|
110
|
+
return;
|
|
111
|
+
const pkgPath = path.join(rootDir, 'package.json');
|
|
112
|
+
const hasVerify = existsSync(pkgPath) &&
|
|
113
|
+
Boolean(JSON.parse(readFileSync(pkgPath, 'utf8')).scripts
|
|
114
|
+
?.verify);
|
|
115
|
+
if (!hasVerify) {
|
|
116
|
+
console.log('ℹ️ Este repo no tiene `pnpm verify`; no corrí la verificación.');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
console.log('\n▸ Corriendo `pnpm verify`…');
|
|
120
|
+
const verify = await execa('pnpm', ['verify'], { cwd: rootDir, stdio: 'inherit', reject: false });
|
|
121
|
+
if (verify.exitCode !== 0) {
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
console.error('✖ `pnpm verify` falló. Revísalo antes de seguir con el corte.');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// cutover · invite · finish · audit (GitHub, with the member's `gh` session)
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
export function parseCutoverFlags(argv) {
|
|
130
|
+
return {
|
|
131
|
+
repo: readValueFlag(argv, 'repo'),
|
|
132
|
+
tier: readValueFlag(argv, 'tier') ?? DEFAULT_PRUNE_TIER,
|
|
133
|
+
confirm: argv.includes('--confirm'),
|
|
134
|
+
discardDevelop: argv.includes('--discard-develop'),
|
|
135
|
+
resume: argv.includes('--resume'),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async function runPruneCutover(flags, deps) {
|
|
139
|
+
const rootDir = requireRepoRoot();
|
|
140
|
+
const { preflight, result } = await pruneCutover(rootDir, flags, deps);
|
|
141
|
+
if (!result) {
|
|
142
|
+
console.log([
|
|
143
|
+
`✔ Preflight del corte de \`${preflight.repo}\` en verde. No toqué nada.`,
|
|
144
|
+
'',
|
|
145
|
+
'Plan:',
|
|
146
|
+
` 1. Snapshot de \`main\` (${preflight.mainSha.slice(0, 7)}) con el tier aplicado, validado sin rutas restringidas.`,
|
|
147
|
+
` 2. Crear \`${preflight.next}\` (privado) y subir el snapshot como \`main\` y \`develop\` — un solo commit.`,
|
|
148
|
+
` 3. Renombrar \`${preflight.repo}\` → \`${preflight.legacy}\` y \`${preflight.next}\` → \`${preflight.repo}\`.`,
|
|
149
|
+
' 4. Permitir forks SOLO en el repo nuevo; sin workflows de PRs de forks.',
|
|
150
|
+
...(preflight.developAhead.length > 0
|
|
151
|
+
? [
|
|
152
|
+
` ⚠ Se pierden ${preflight.developAhead.length} commit(s) de \`develop\` (--discard-develop).`,
|
|
153
|
+
]
|
|
154
|
+
: []),
|
|
155
|
+
'',
|
|
156
|
+
'Para ejecutarlo: `factory prune cutover --confirm`.',
|
|
157
|
+
].join('\n'));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
console.log(`✔ Corte hecho: \`${result.repo}\` es el repo nuevo (un commit, ${result.snapshot}); ` +
|
|
161
|
+
`el anterior quedó como \`${result.legacy}\`.`);
|
|
162
|
+
for (const w of result.warnings)
|
|
163
|
+
console.warn(`⚠ ${w}`);
|
|
164
|
+
if (result.notMigrated.length > 0) {
|
|
165
|
+
console.log(`\nNo migró (sigue en \`${result.legacy}\`):`);
|
|
166
|
+
for (const line of result.notMigrated)
|
|
167
|
+
console.log(` • ${line}`);
|
|
168
|
+
}
|
|
169
|
+
console.log('\nSiguiente: reconecta el proyecto de Vercel al repo nuevo (`factory prune vercel`), ' +
|
|
170
|
+
'pide a los socios volver a clonar, y corre `factory prune finish` cuando Production despliegue.');
|
|
171
|
+
}
|
|
172
|
+
function printAudit(repo, audit) {
|
|
173
|
+
const clean = audit.roots.length === 1 && audit.leaks.length === 0;
|
|
174
|
+
if (clean) {
|
|
175
|
+
console.log(`✔ Historia de \`${repo}\` limpia: una raíz, ninguna ruta del tier \`${audit.tierName}\`.`);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
if (audit.roots.length !== 1) {
|
|
179
|
+
console.error(`✖ \`${repo}\` tiene ${audit.roots.length} commits raíz (esperado: 1) — ` +
|
|
180
|
+
'probablemente un clon viejo pusheó la historia anterior.');
|
|
181
|
+
}
|
|
182
|
+
if (audit.leaks.length > 0) {
|
|
183
|
+
console.error(`✖ ${audit.leaks.length} ruta(s) restringida(s) en la historia (incluye refs de PRs):`);
|
|
184
|
+
for (const p of audit.leaks)
|
|
185
|
+
console.error(` ${p}`);
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Dispatch
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
function requireRepoRoot() {
|
|
193
|
+
const root = detectRepo(process.cwd()).repoRoot;
|
|
194
|
+
if (!root)
|
|
195
|
+
throw new CLIError('Corre `factory prune` dentro de un repo git.');
|
|
196
|
+
return root;
|
|
197
|
+
}
|
|
198
|
+
async function repoFrom(argv) {
|
|
199
|
+
return readValueFlag(argv, 'repo') ?? (await originRepo(requireRepoRoot()));
|
|
200
|
+
}
|
|
201
|
+
/** `factory prune <sub> [...]` — dispatch lives here so every branch is testable. */
|
|
202
|
+
export async function runPruneCommand(args, deps = defaultPruneDeps, vercel = {}) {
|
|
203
|
+
const sub = args[0];
|
|
204
|
+
const rest = args.slice(1);
|
|
205
|
+
switch (sub) {
|
|
206
|
+
case 'prepare':
|
|
207
|
+
await runPrunePrepare(parsePrunePrepareFlags(rest));
|
|
208
|
+
return;
|
|
209
|
+
case 'cutover':
|
|
210
|
+
await runPruneCutover(parseCutoverFlags(rest), deps);
|
|
211
|
+
return;
|
|
212
|
+
case 'invite': {
|
|
213
|
+
const user = rest.find((a) => !a.startsWith('-') && rest[rest.indexOf(a) - 1] !== '--repo');
|
|
214
|
+
if (!user)
|
|
215
|
+
throw new CLIError(`Falta el usuario de GitHub del colaborador. ${PRUNE_USAGE}`);
|
|
216
|
+
const repo = await repoFrom(rest);
|
|
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
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
case 'finish': {
|
|
262
|
+
const repo = await repoFrom(rest);
|
|
263
|
+
const done = await pruneFinish(repo, deps);
|
|
264
|
+
console.log(`✔ \`${repo}-legacy\`: forks apagados, ${done.removedCollaborators.length} colaborador(es) ` +
|
|
265
|
+
'externo(s) quitado(s) y archivado.');
|
|
266
|
+
if (!printAudit(repo, done.audit))
|
|
267
|
+
process.exitCode = 1;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
case 'check-pr': {
|
|
271
|
+
const raw = rest.find((a) => /^\d+$/.test(a));
|
|
272
|
+
if (!raw)
|
|
273
|
+
throw new CLIError(`Falta el número del PR. ${PRUNE_USAGE}`);
|
|
274
|
+
const repo = await repoFrom(rest);
|
|
275
|
+
const result = await checkPr(repo, Number(raw), deps);
|
|
276
|
+
const refused = result.rejected.length > 0 || result.packageFields.length > 0;
|
|
277
|
+
if (rest.includes('--json')) {
|
|
278
|
+
console.log(JSON.stringify({ refused, ...result }, null, 2));
|
|
279
|
+
}
|
|
280
|
+
else if (!refused) {
|
|
281
|
+
console.log(`✔ PR #${raw}: ningún archivo fuera del producto (${result.files.length} archivo(s)).`);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
console.error(`✖ PR #${raw} rechazado antes del checkout — toca lo que corre alrededor del producto:`);
|
|
285
|
+
for (const r of result.rejected)
|
|
286
|
+
console.error(` ${r.path} — ${r.why}`);
|
|
287
|
+
for (const f of result.packageFields) {
|
|
288
|
+
console.error(` package.json#${f} — ${REJECTED_PACKAGE_FIELDS.includes(f) ? 'comandos que corren en tu máquina' : 'revísalo a mano'}`);
|
|
289
|
+
}
|
|
290
|
+
console.error(' Regrésalo al colaborador, o que un socio rehaga ese cambio.');
|
|
291
|
+
}
|
|
292
|
+
if (refused)
|
|
293
|
+
process.exitCode = 1;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
case 'audit': {
|
|
297
|
+
const repo = await repoFrom(rest);
|
|
298
|
+
if (!printAudit(repo, await pruneAudit(repo, deps)))
|
|
299
|
+
process.exitCode = 1;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
default:
|
|
303
|
+
throw new CLIError(`Subcomando de prune desconocido: \`${sub ?? '(ninguno)'}\`. ${PRUNE_USAGE}`);
|
|
304
|
+
}
|
|
305
|
+
}
|