@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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's reader of `.claude/policy/prune-tiers.json` (`factory prune`) — which kit paths a
|
|
3
|
+
* pruned repo shares with its collaborators and which stay local to the members.
|
|
4
|
+
*
|
|
5
|
+
* TWIN of `scripts/tools/lib/prune-tiers.mjs`, which the pre-commit guard and skill-lint use:
|
|
6
|
+
* the published CLI cannot import repo code, so the rule lives twice and
|
|
7
|
+
* `cli/tests/unit/prune-tiers-parity.test.ts` runs one corpus through both. Change one, change
|
|
8
|
+
* the other.
|
|
9
|
+
*
|
|
10
|
+
* The rule: an entry is a path pattern; a trailing `/` names a directory (everything below
|
|
11
|
+
* it), otherwise one file; `*` matches within a single segment. The matching entry with the
|
|
12
|
+
* most segments decides (`restrict` | `share`); none → `unclassified`; a tie between the two
|
|
13
|
+
* kinds → `ambiguous`.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { CLIError } from './cli-error.js';
|
|
18
|
+
import { MANAGED_BLOCK_END, MANAGED_BLOCK_START, PRUNE_FILE, TIMEKAST_DIR } from './constants.js';
|
|
19
|
+
/** Repo-relative path of the registry. */
|
|
20
|
+
export const PRUNE_TIERS_PATH = '.claude/policy/prune-tiers.json';
|
|
21
|
+
/** Repo-relative path of the switch. */
|
|
22
|
+
export const PRUNE_MARKER_PATH = `${TIMEKAST_DIR}/${PRUNE_FILE}`;
|
|
23
|
+
/** The tier `prepare` applies when none is named — the only one today. */
|
|
24
|
+
export const DEFAULT_PRUNE_TIER = 'collab';
|
|
25
|
+
/**
|
|
26
|
+
* Markers of the block CMLL's cut wrote by hand before this command existed. `syncManagedBlock`
|
|
27
|
+
* only knows the kit's markers, so it would add a second block beside this one; `prepare`
|
|
28
|
+
* removes it first.
|
|
29
|
+
*/
|
|
30
|
+
export const LEGACY_COLLAB_START = '# >>> timekast:collab';
|
|
31
|
+
export const LEGACY_COLLAB_END = '# <<< timekast:collab';
|
|
32
|
+
function segmentsOf(entry) {
|
|
33
|
+
return entry.replace(/\/$/, '').split('/');
|
|
34
|
+
}
|
|
35
|
+
function segmentMatches(pattern, segment) {
|
|
36
|
+
if (!pattern.includes('*'))
|
|
37
|
+
return pattern === segment;
|
|
38
|
+
const rx = new RegExp(`^${pattern
|
|
39
|
+
.split('*')
|
|
40
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
41
|
+
.join('[^/]*')}$`);
|
|
42
|
+
return rx.test(segment);
|
|
43
|
+
}
|
|
44
|
+
/** Does `entry` match `relPath`? A directory entry matches anything strictly below it. */
|
|
45
|
+
export function entryMatches(entry, relPath) {
|
|
46
|
+
const isDir = entry.endsWith('/');
|
|
47
|
+
const want = segmentsOf(entry);
|
|
48
|
+
const have = relPath.split('/');
|
|
49
|
+
if (isDir ? have.length <= want.length : have.length !== want.length)
|
|
50
|
+
return false;
|
|
51
|
+
return want.every((pattern, i) => segmentMatches(pattern, have[i]));
|
|
52
|
+
}
|
|
53
|
+
/** Classify one repo-relative POSIX path under a tier. */
|
|
54
|
+
export function classifyPath(tier, relPath) {
|
|
55
|
+
let bestDepth = -1;
|
|
56
|
+
let kinds = new Set();
|
|
57
|
+
for (const kind of ['restrict', 'share']) {
|
|
58
|
+
for (const entry of tier[kind]) {
|
|
59
|
+
if (!entryMatches(entry, relPath))
|
|
60
|
+
continue;
|
|
61
|
+
const depth = segmentsOf(entry).length;
|
|
62
|
+
if (depth > bestDepth) {
|
|
63
|
+
bestDepth = depth;
|
|
64
|
+
kinds = new Set([kind]);
|
|
65
|
+
}
|
|
66
|
+
else if (depth === bestDepth) {
|
|
67
|
+
kinds.add(kind);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (kinds.size === 0)
|
|
72
|
+
return 'unclassified';
|
|
73
|
+
if (kinds.size > 1)
|
|
74
|
+
return 'ambiguous';
|
|
75
|
+
return [...kinds][0];
|
|
76
|
+
}
|
|
77
|
+
export function isRestricted(tier, relPath) {
|
|
78
|
+
return classifyPath(tier, relPath) === 'restrict';
|
|
79
|
+
}
|
|
80
|
+
/** Form errors of a parsed registry (empty = valid). Mirror of the `.mjs` validator. */
|
|
81
|
+
export function validatePruneTiers(data) {
|
|
82
|
+
const errors = [];
|
|
83
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
84
|
+
return ['(root) — must be an object'];
|
|
85
|
+
}
|
|
86
|
+
const tiers = data.tiers;
|
|
87
|
+
if (typeof tiers !== 'object' || tiers === null || Array.isArray(tiers)) {
|
|
88
|
+
return ['tiers — must be an object of named tiers'];
|
|
89
|
+
}
|
|
90
|
+
if (Object.keys(tiers).length === 0)
|
|
91
|
+
errors.push('tiers — must declare at least one tier');
|
|
92
|
+
for (const [name, raw] of Object.entries(tiers)) {
|
|
93
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
94
|
+
errors.push(`tiers.${name} — the tier name must be kebab-case`);
|
|
95
|
+
}
|
|
96
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
97
|
+
errors.push(`tiers.${name} — must be an object`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const tier = raw;
|
|
101
|
+
for (const key of Object.keys(tier)) {
|
|
102
|
+
if (!['description', 'restrict', 'share'].includes(key)) {
|
|
103
|
+
errors.push(`tiers.${name}.${key} — unknown key (allowed: description, restrict, share)`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const kind of ['restrict', 'share']) {
|
|
107
|
+
const list = tier[kind];
|
|
108
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
109
|
+
errors.push(`tiers.${name}.${kind} — must be a non-empty array of path patterns`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
list.forEach((entry, i) => {
|
|
113
|
+
if (typeof entry !== 'string' || entry.trim() !== entry || entry === '') {
|
|
114
|
+
errors.push(`tiers.${name}.${kind}[${i}] — must be a non-empty string without surrounding spaces`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (entry.startsWith('/') || entry.includes('\\') || entry.includes('//')) {
|
|
118
|
+
errors.push(`tiers.${name}.${kind}[${i}] — "${entry}" must be repo-relative, POSIX`);
|
|
119
|
+
}
|
|
120
|
+
if (/[?[\]{}!]/.test(entry) || entry.includes('**')) {
|
|
121
|
+
errors.push(`tiers.${name}.${kind}[${i}] — "${entry}" may only use \`*\` within one segment`);
|
|
122
|
+
}
|
|
123
|
+
if (segmentsOf(entry).some((seg) => seg === '' || seg === '.' || seg === '..')) {
|
|
124
|
+
errors.push(`tiers.${name}.${kind}[${i}] — "${entry}" has an empty or relative segment`);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(tier.restrict) || !Array.isArray(tier.share))
|
|
129
|
+
continue;
|
|
130
|
+
const restrict = tier.restrict;
|
|
131
|
+
const share = tier.share;
|
|
132
|
+
const all = [...restrict, ...share];
|
|
133
|
+
const dupes = all.filter((e, i) => all.indexOf(e) !== i);
|
|
134
|
+
for (const dupe of new Set(dupes)) {
|
|
135
|
+
errors.push(`tiers.${name} — "${dupe}" is listed more than once`);
|
|
136
|
+
}
|
|
137
|
+
for (const entry of share) {
|
|
138
|
+
for (const dir of restrict.filter((r) => r.endsWith('/'))) {
|
|
139
|
+
if (!entry.startsWith(dir))
|
|
140
|
+
continue;
|
|
141
|
+
if (segmentsOf(entry).length !== segmentsOf(dir).length + 1) {
|
|
142
|
+
errors.push(`tiers.${name}.share — "${entry}" sits more than one level below the restricted ` +
|
|
143
|
+
`directory "${dir}"; a re-include only works for a direct child`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const entry of restrict) {
|
|
148
|
+
for (const dir of restrict.filter((r) => r !== entry && r.endsWith('/'))) {
|
|
149
|
+
if (entry.startsWith(dir)) {
|
|
150
|
+
errors.push(`tiers.${name}.restrict — "${entry}" is already covered by "${dir}"; drop one`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return errors;
|
|
156
|
+
}
|
|
157
|
+
/** The tier's `.gitignore` lines, without markers. Mirror of the `.mjs` `gitignoreLines`. */
|
|
158
|
+
export function gitignoreLines(tier) {
|
|
159
|
+
const depth = (e) => segmentsOf(e).length;
|
|
160
|
+
const ordered = [...tier.restrict].sort((a, b) => depth(a) - depth(b) || a.localeCompare(b));
|
|
161
|
+
const lines = [];
|
|
162
|
+
for (const entry of ordered) {
|
|
163
|
+
const children = entry.endsWith('/')
|
|
164
|
+
? tier.share.filter((s) => s.startsWith(entry)).sort()
|
|
165
|
+
: [];
|
|
166
|
+
if (children.length === 0) {
|
|
167
|
+
lines.push(`/${entry}`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
lines.push(`/${entry}*`);
|
|
171
|
+
for (const child of children)
|
|
172
|
+
lines.push(`!/${child}`);
|
|
173
|
+
}
|
|
174
|
+
return lines;
|
|
175
|
+
}
|
|
176
|
+
/** The managed `.gitignore` block (markers inclusive) of one tier. */
|
|
177
|
+
export function renderGitignoreBlock(tierName, tier) {
|
|
178
|
+
return [
|
|
179
|
+
`${MANAGED_BLOCK_START} (prune tier \`${tierName}\` — regenerated by \`factory prune\` and \`factory update\`; do not edit) >>>`,
|
|
180
|
+
'# Kit paths kept LOCAL to the members of the org: they never enter this repository, so a',
|
|
181
|
+
'# collaborator never sees them. Generated from .claude/policy/prune-tiers.json; the switch',
|
|
182
|
+
'# is .timekast/prune.json. Anything not listed here is shared.',
|
|
183
|
+
...gitignoreLines(tier),
|
|
184
|
+
`${MANAGED_BLOCK_END} <<<`,
|
|
185
|
+
].join('\n');
|
|
186
|
+
}
|
|
187
|
+
/** Remove the hand-written `timekast:collab` block (markers inclusive), if present. */
|
|
188
|
+
export function stripLegacyCollabBlock(content) {
|
|
189
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
190
|
+
const start = lines.findIndex((l) => l.trimStart().startsWith(LEGACY_COLLAB_START));
|
|
191
|
+
if (start === -1)
|
|
192
|
+
return { content, removed: false };
|
|
193
|
+
let end = lines.length - 1;
|
|
194
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
195
|
+
if (lines[i].trimStart().startsWith(LEGACY_COLLAB_END)) {
|
|
196
|
+
end = i;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const kept = [...lines.slice(0, start), ...lines.slice(end + 1)];
|
|
201
|
+
while (kept.length > 0 && kept[kept.length - 1].trim() === '')
|
|
202
|
+
kept.pop();
|
|
203
|
+
return { content: kept.length > 0 ? `${kept.join('\n')}\n` : '', removed: true };
|
|
204
|
+
}
|
|
205
|
+
/** Read + validate the registry under `dir` (a repo root or a staged tarball). */
|
|
206
|
+
export function readPruneTiers(dir) {
|
|
207
|
+
const file = path.join(dir, PRUNE_TIERS_PATH);
|
|
208
|
+
if (!existsSync(file)) {
|
|
209
|
+
throw new CLIError(`No existe \`${PRUNE_TIERS_PATH}\`. Corre \`factory update\` para traer la versión del kit que lo incluye.`);
|
|
210
|
+
}
|
|
211
|
+
let data;
|
|
212
|
+
try {
|
|
213
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
throw new CLIError(`\`${PRUNE_TIERS_PATH}\` no es JSON válido: ${err instanceof Error ? err.message : String(err)}`);
|
|
217
|
+
}
|
|
218
|
+
const errors = validatePruneTiers(data);
|
|
219
|
+
if (errors.length > 0) {
|
|
220
|
+
throw new CLIError(`\`${PRUNE_TIERS_PATH}\` tiene errores de forma:\n • ${errors.join('\n • ')}`);
|
|
221
|
+
}
|
|
222
|
+
return data;
|
|
223
|
+
}
|
|
224
|
+
/** Look up one tier, naming the valid ones when it does not exist. */
|
|
225
|
+
export function getTier(tiers, name) {
|
|
226
|
+
const tier = tiers.tiers[name];
|
|
227
|
+
if (!tier) {
|
|
228
|
+
throw new CLIError(`El tier \`${name}\` no existe en \`${PRUNE_TIERS_PATH}\`. Tiers disponibles: ${Object.keys(tiers.tiers).join(', ')}.`);
|
|
229
|
+
}
|
|
230
|
+
return tier;
|
|
231
|
+
}
|
|
232
|
+
/** The switch, or `null` when this repo is not pruned. */
|
|
233
|
+
export function readPruneMarker(rootDir) {
|
|
234
|
+
const file = path.join(rootDir, PRUNE_MARKER_PATH);
|
|
235
|
+
if (!existsSync(file))
|
|
236
|
+
return null;
|
|
237
|
+
let data;
|
|
238
|
+
try {
|
|
239
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
throw new CLIError(`\`${PRUNE_MARKER_PATH}\` no es JSON válido; corrígelo o vuelve a correr \`factory prune prepare\`.`);
|
|
243
|
+
}
|
|
244
|
+
const marker = data;
|
|
245
|
+
if (typeof marker?.tier !== 'string' || typeof marker.at !== 'string') {
|
|
246
|
+
throw new CLIError(`\`${PRUNE_MARKER_PATH}\` debe tener la forma { "tier": "<nombre>", "at": "<fecha ISO>" }.`);
|
|
247
|
+
}
|
|
248
|
+
return { tier: marker.tier, at: marker.at };
|
|
249
|
+
}
|
|
250
|
+
export function writePruneMarker(rootDir, marker) {
|
|
251
|
+
mkdirSync(path.join(rootDir, TIMEKAST_DIR), { recursive: true });
|
|
252
|
+
writeFileSync(path.join(rootDir, PRUNE_MARKER_PATH), `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
|
|
253
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory update` in a pruned repo (`factory prune`): keep the tier's `.gitignore` block in
|
|
3
|
+
* step with the kit, and compute what the brain commit must untrack and publish.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is gated on `.timekast/prune.json`. Without it `planPruneMaintenance`
|
|
6
|
+
* returns `null` and the update behaves exactly as in a repo that was never pruned.
|
|
7
|
+
*
|
|
8
|
+
* The registry is read from the STAGED tarball, not from the repo: the tier the release ships
|
|
9
|
+
* is the one the commit has to honor, including a path that moved from restricted to shared.
|
|
10
|
+
* Such a path is in the `unchanged` bucket when its content did not change, so the update
|
|
11
|
+
* would never write it — it sits on the member's disk, untracked, and would never be
|
|
12
|
+
* published unless this module names it.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { GITIGNORE_FILE, MANAGED_BLOCK_START } from './constants.js';
|
|
17
|
+
import { syncManagedBlock } from './gitattributes.js';
|
|
18
|
+
import { trackedFiles } from './prune-git.js';
|
|
19
|
+
import { LEGACY_COLLAB_START, PRUNE_MARKER_PATH, PRUNE_TIERS_PATH, getTier, isRestricted, readPruneMarker, readPruneTiers, renderGitignoreBlock, stripLegacyCollabBlock, } from './prune-tiers.js';
|
|
20
|
+
/** Does `.gitignore` carry a prune block (the kit's or CMLL's hand-written one)? */
|
|
21
|
+
function hasPruneBlock(rootDir) {
|
|
22
|
+
const file = path.join(rootDir, GITIGNORE_FILE);
|
|
23
|
+
if (!existsSync(file))
|
|
24
|
+
return false;
|
|
25
|
+
const content = readFileSync(file, 'utf8');
|
|
26
|
+
return (content.includes(LEGACY_COLLAB_START) ||
|
|
27
|
+
content.split('\n').some((l) => l.startsWith(MANAGED_BLOCK_START) && l.includes('prune tier')));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Regenerate the block and plan the commit's untrack/publish sets. Best-effort like the other
|
|
31
|
+
* dotfile syncs: a problem is reported and the update (already applied) stands.
|
|
32
|
+
*
|
|
33
|
+
* @param manifestPaths every path of the NEW manifest (what the kit ships now).
|
|
34
|
+
*/
|
|
35
|
+
export async function planPruneMaintenance(rootDir, stagedDir, manifestPaths) {
|
|
36
|
+
let marker;
|
|
37
|
+
try {
|
|
38
|
+
marker = readPruneMarker(rootDir);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.warn(`Aviso: ${err instanceof Error ? err.message : String(err)} No mantuve el tier.`);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (!marker) {
|
|
45
|
+
if (hasPruneBlock(rootDir)) {
|
|
46
|
+
console.warn(`Aviso: \`${GITIGNORE_FILE}\` tiene el bloque de un repo compartido pero falta ` +
|
|
47
|
+
`\`${PRUNE_MARKER_PATH}\`, así que el guard del pre-commit está apagado. ` +
|
|
48
|
+
'Si el repo se comparte con colaboradores, corre `factory prune prepare`.');
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const source = existsSync(path.join(stagedDir, PRUNE_TIERS_PATH)) ? stagedDir : rootDir;
|
|
54
|
+
const tier = getTier(readPruneTiers(source), marker.tier);
|
|
55
|
+
const gitignorePath = path.join(rootDir, GITIGNORE_FILE);
|
|
56
|
+
if (existsSync(gitignorePath)) {
|
|
57
|
+
const stripped = stripLegacyCollabBlock(readFileSync(gitignorePath, 'utf8'));
|
|
58
|
+
if (stripped.removed)
|
|
59
|
+
writeFileSync(gitignorePath, stripped.content, 'utf8');
|
|
60
|
+
}
|
|
61
|
+
const { action } = syncManagedBlock(rootDir, renderGitignoreBlock(marker.tier, tier), GITIGNORE_FILE);
|
|
62
|
+
if (action !== 'unchanged') {
|
|
63
|
+
console.log(`✔ \`${GITIGNORE_FILE}\`: bloque del tier \`${marker.tier}\` sincronizado.`);
|
|
64
|
+
}
|
|
65
|
+
const tracked = new Set(await trackedFiles(rootDir));
|
|
66
|
+
return {
|
|
67
|
+
tierName: marker.tier,
|
|
68
|
+
tier,
|
|
69
|
+
untrack: [...tracked].filter((p) => isRestricted(tier, p)),
|
|
70
|
+
publish: manifestPaths.filter((p) => !tracked.has(p) && !isRestricted(tier, p) && existsSync(path.join(rootDir, p))),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
console.warn(`Aviso: no se pudo mantener el tier \`${marker.tier}\` ` +
|
|
75
|
+
`(${err instanceof Error ? err.message : String(err)}); el cerebro se instaló igual.`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|