@timekast/factory 1.2.0 → 1.3.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/update.js +58 -17
- package/dist/lib/constants.js +17 -0
- package/dist/lib/gitattributes.js +101 -0
- package/package.json +1 -1
package/dist/commands/update.js
CHANGED
|
@@ -40,7 +40,8 @@ import prompts from 'prompts';
|
|
|
40
40
|
import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpdateState, validateStagedManifest, writeUpdateState, } from '../lib/atomic-swap.js';
|
|
41
41
|
import { collectClaudePaths } from '../lib/claude-paths.js';
|
|
42
42
|
import { CLIError } from '../lib/cli-error.js';
|
|
43
|
-
import { CLAUDE_MD_FILE, PROFILES } from '../lib/constants.js';
|
|
43
|
+
import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, PROFILES, } from '../lib/constants.js';
|
|
44
|
+
import { extractManagedBlock, syncManagedBlock } from '../lib/gitattributes.js';
|
|
44
45
|
import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
|
|
45
46
|
import { detectProfile, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
|
|
46
47
|
import { runPreflight } from '../lib/preflight.js';
|
|
@@ -168,9 +169,8 @@ function runResume(rootDir) {
|
|
|
168
169
|
applyPlan(rootDir, state.stagedDir, state.plan, state.backupDir);
|
|
169
170
|
writeLockfile(rootDir, newLock);
|
|
170
171
|
clearUpdateState(rootDir);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
maintainDerivedPkg(pkgPath, newLock.version);
|
|
172
|
+
// Maintain dotfiles BEFORE removing the staged dir (the .gitattributes source).
|
|
173
|
+
maintainDerivedDotfiles(rootDir, state.stagedDir, newLock.version);
|
|
174
174
|
rmSync(state.stagedDir, { recursive: true, force: true });
|
|
175
175
|
console.log('✔ `update` retomado y completado.');
|
|
176
176
|
}
|
|
@@ -191,15 +191,31 @@ function warnOnScriptConflict(action, _pkgPath) {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
/**
|
|
194
|
-
* After a sync, maintain the derived project's package.json
|
|
195
|
-
* `
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
194
|
+
* After a sync, maintain the derived project's co-owned dotfiles — `package.json`
|
|
195
|
+
* AND `.gitattributes` — in one place. Centralized (not scattered per command) so
|
|
196
|
+
* all THREE update paths (main, legacy auto-register, resume) are covered by
|
|
197
|
+
* construction: a per-command call already regressed once (legacy + resume were
|
|
198
|
+
* missed). `stagedDir` is the freshly-unpacked tarball, the source of the
|
|
199
|
+
* `.gitattributes` managed block.
|
|
200
|
+
*/
|
|
201
|
+
function maintainDerivedDotfiles(rootDir, stagedDir, agentKitVersion) {
|
|
202
|
+
maintainDerivedPkg(rootDir, agentKitVersion);
|
|
203
|
+
syncDerivedGitattributes(rootDir, stagedDir);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Maintain the derived project's package.json: ensure the `factory:*` scripts
|
|
207
|
+
* (warn on a divergent `factory:update`) and set `agentKitVersion` to the
|
|
208
|
+
* just-installed brain version, mirroring the lockfile's `version`. Run on every
|
|
209
|
+
* update, so the field tracks each `factory:update` (and a derivative whose field
|
|
210
|
+
* was missing or stale gets reconciled — same shape as the Factory's own
|
|
199
211
|
* package.json). `factoryVersion` (the frozen birth stamp) and `version` (the app
|
|
200
|
-
* semver) are untouched.
|
|
212
|
+
* semver) are untouched. No-op (not an error) when the repo has no package.json
|
|
213
|
+
* (a non-Node derivative).
|
|
201
214
|
*/
|
|
202
|
-
function maintainDerivedPkg(
|
|
215
|
+
function maintainDerivedPkg(rootDir, agentKitVersion) {
|
|
216
|
+
const pkgPath = path.join(rootDir, 'package.json');
|
|
217
|
+
if (!existsSync(pkgPath))
|
|
218
|
+
return;
|
|
203
219
|
// Tolerant (mirrors `add`): a malformed package.json must NOT throw AFTER the
|
|
204
220
|
// brain was already applied + the lockfile written — the install succeeded; the
|
|
205
221
|
// script/version mirror is best-effort. Warn and move on.
|
|
@@ -212,6 +228,35 @@ function maintainDerivedPkg(pkgPath, agentKitVersion) {
|
|
|
212
228
|
'Corrige package.json y vuelve a correr `factory update` para sincronizar agentKitVersion.');
|
|
213
229
|
}
|
|
214
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Sync the Factory's managed `.gitattributes` block (the EOL/binary rules that
|
|
233
|
+
* keep the kit's committed binaries from being corrupted by CRLF→LF normalization)
|
|
234
|
+
* into the derived repo, preserving the dev's own rules. The canonical block is
|
|
235
|
+
* READ from the staged tarball (single SSOT), so the rules are never duplicated in
|
|
236
|
+
* the CLI. Runs even without package.json (a non-Node derivative needs the rules
|
|
237
|
+
* too). Best-effort like the package.json mirror: a tarball that predates the
|
|
238
|
+
* managed block (no markers → no block) is a silent no-op, and a write failure
|
|
239
|
+
* never fails an already-applied update.
|
|
240
|
+
*/
|
|
241
|
+
function syncDerivedGitattributes(rootDir, stagedDir) {
|
|
242
|
+
try {
|
|
243
|
+
const srcPath = path.join(stagedDir, GITATTRIBUTES_FILE);
|
|
244
|
+
if (!existsSync(srcPath))
|
|
245
|
+
return;
|
|
246
|
+
const block = extractManagedBlock(readFileSync(srcPath, 'utf8'));
|
|
247
|
+
if (!block)
|
|
248
|
+
return;
|
|
249
|
+
const { action } = syncManagedBlock(rootDir, block);
|
|
250
|
+
if (action === 'unchanged')
|
|
251
|
+
return;
|
|
252
|
+
console.log(action === 'created'
|
|
253
|
+
? '✔ `.gitattributes` creado con las reglas de normalización del kit.'
|
|
254
|
+
: '✔ `.gitattributes`: bloque de reglas del kit sincronizado.');
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
console.warn('Aviso: no se pudo sincronizar `.gitattributes`; el cerebro se instaló igual.');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
215
260
|
/** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
|
|
216
261
|
function reportSummary(diff, manifest) {
|
|
217
262
|
if (diff.deleteSilent.length > 0) {
|
|
@@ -335,9 +380,7 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
335
380
|
// (agentKitVersion advances; factoryVersion stays frozen).
|
|
336
381
|
writeLockfile(rootDir, stampBirth(oldLock, manifest));
|
|
337
382
|
clearUpdateState(rootDir);
|
|
338
|
-
|
|
339
|
-
if (existsSync(pkgPath))
|
|
340
|
-
maintainDerivedPkg(pkgPath, manifest.version);
|
|
383
|
+
maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
|
|
341
384
|
reportSummary(diff, manifest);
|
|
342
385
|
}
|
|
343
386
|
finally {
|
|
@@ -394,9 +437,7 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
|
|
|
394
437
|
// the just-installed files) PLUS the birth stamp. A legacy auto-register is the
|
|
395
438
|
// first sync this repo records, so the birth seal = the manifest's version.
|
|
396
439
|
writeLockfile(rootDir, { ...manifest, factoryVersion: manifest.version });
|
|
397
|
-
|
|
398
|
-
if (existsSync(pkgPath))
|
|
399
|
-
maintainDerivedPkg(pkgPath, manifest.version);
|
|
440
|
+
maintainDerivedDotfiles(rootDir, stagedDir, manifest.version);
|
|
400
441
|
if (claudeMdExists) {
|
|
401
442
|
console.log('\nConservé tu `CLAUDE.md` (no se sobrescribió). Verifica que importe las rules del kit ' +
|
|
402
443
|
'(`@.claude/rules/*`); corre `factory doctor` para detectar rules sin importar.');
|
package/dist/lib/constants.js
CHANGED
|
@@ -30,6 +30,23 @@ export const LOCKFILE_FILE = 'lockfile.json';
|
|
|
30
30
|
* exists on disk (path-match path). See `diffLockfiles` + the install commands.
|
|
31
31
|
*/
|
|
32
32
|
export const CLAUDE_MD_FILE = 'CLAUDE.md';
|
|
33
|
+
/**
|
|
34
|
+
* Repo-root `.gitattributes`. NOT a tracked manifest file (absent from `track`),
|
|
35
|
+
* so it never flows through the diff/lockfile engine — a derived repo's own rules
|
|
36
|
+
* would otherwise read as "locally edited" and conflict on every update. Instead a
|
|
37
|
+
* delimited managed block (markers below) is synced surgically post-apply, like
|
|
38
|
+
* `package.json` scripts: the block is replaced verbatim, everything outside it is
|
|
39
|
+
* preserved byte-for-byte. See `syncManagedBlock` in `lib/gitattributes.ts`.
|
|
40
|
+
*/
|
|
41
|
+
export const GITATTRIBUTES_FILE = '.gitattributes';
|
|
42
|
+
/**
|
|
43
|
+
* Managed-block sentinels. These are PREFIXES (the live source lines carry extra
|
|
44
|
+
* descriptive text + trailing `>>>`/`<<<`), matched with `startsWith` so detection
|
|
45
|
+
* survives edits to the comment copy. Do not change without a migration: a derived
|
|
46
|
+
* repo's existing block is located by these prefixes.
|
|
47
|
+
*/
|
|
48
|
+
export const MANAGED_BLOCK_START = '# >>> timekast-factory managed';
|
|
49
|
+
export const MANAGED_BLOCK_END = '# <<< timekast-factory managed';
|
|
33
50
|
/** The scripts the CLI injects into a derived project's package.json. */
|
|
34
51
|
export const UPDATE_SCRIPT_NAME = 'factory:update';
|
|
35
52
|
// `npx` so the script resolves in a fresh derived repo where @timekast/factory
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surgical sync of the Factory's managed `.gitattributes` block into a derived
|
|
3
|
+
* project, mirroring the `package.json` script-insertion pattern (`package-json.ts`):
|
|
4
|
+
* the delimited block is replaced verbatim, everything outside it is preserved.
|
|
5
|
+
*
|
|
6
|
+
* Why not the lockfile/`track` engine: `.gitattributes` is dual-owned at the
|
|
7
|
+
* intra-file level (the Factory block + the dev's own rules share one file). The
|
|
8
|
+
* lockfile hashes whole files, so tracking it would read the dev's rules as a
|
|
9
|
+
* local edit and conflict on every update. Instead the block is synced as an
|
|
10
|
+
* idempotent post-apply step, outside `diffLockfiles`/`applyPlan`.
|
|
11
|
+
*
|
|
12
|
+
* The canonical block is the single SSOT: it is READ from the `.gitattributes`
|
|
13
|
+
* that ships in the staged tarball (see `extractManagedBlock`), never duplicated
|
|
14
|
+
* as a CLI constant. A derived repo's existing block is located by the
|
|
15
|
+
* `MANAGED_BLOCK_START` / `_END` sentinels (prefix match), so the descriptive
|
|
16
|
+
* comment copy can evolve without breaking detection.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { GITATTRIBUTES_FILE, MANAGED_BLOCK_END, MANAGED_BLOCK_START } from './constants.js';
|
|
21
|
+
const isStart = (line) => line.trimStart().startsWith(MANAGED_BLOCK_START);
|
|
22
|
+
const isEnd = (line) => line.trimStart().startsWith(MANAGED_BLOCK_END);
|
|
23
|
+
/** Drop trailing all-whitespace lines (so appends don't pile up blank lines). */
|
|
24
|
+
function stripTrailingEmpty(lines) {
|
|
25
|
+
const out = [...lines];
|
|
26
|
+
while (out.length > 0 && out[out.length - 1].trim() === '')
|
|
27
|
+
out.pop();
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Extract the managed block (markers inclusive) from a `.gitattributes` content,
|
|
32
|
+
* or `null` if no start marker is present. Used on the well-formed staged source
|
|
33
|
+
* to obtain the canonical block to sync. If a start marker is found with no end
|
|
34
|
+
* marker, returns from start to EOF (defensive — the source is well-formed).
|
|
35
|
+
*/
|
|
36
|
+
export function extractManagedBlock(content) {
|
|
37
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
38
|
+
const start = lines.findIndex(isStart);
|
|
39
|
+
if (start === -1)
|
|
40
|
+
return null;
|
|
41
|
+
let end = -1;
|
|
42
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
43
|
+
if (isEnd(lines[i])) {
|
|
44
|
+
end = i;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const slice = end === -1 ? lines.slice(start) : lines.slice(start, end + 1);
|
|
49
|
+
return slice.join('\n');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Sync `sourceBlock` (markers inclusive) into `<rootDir>/.gitattributes`,
|
|
53
|
+
* preserving every rule outside the managed block. The whole file is normalized
|
|
54
|
+
* to LF (it lives at repo root, outside `.claude/** text eol=lf`, so a source
|
|
55
|
+
* packed with CRLF on an autocrlf machine is normalized here). Idempotent:
|
|
56
|
+
* writes only when the bytes actually change.
|
|
57
|
+
*
|
|
58
|
+
* - no file / empty file → `created` (block only)
|
|
59
|
+
* - file without a start marker → `inserted` (block appended after a blank line)
|
|
60
|
+
* - start + end markers → `updated` (content between markers replaced)
|
|
61
|
+
* - start without end (dev clobbered half a line) → `updated`, regenerated
|
|
62
|
+
* cleanly from start to EOF (NOT appended — avoids a dangling start marker)
|
|
63
|
+
*/
|
|
64
|
+
export function syncManagedBlock(rootDir, sourceBlock) {
|
|
65
|
+
const filePath = path.join(rootDir, GITATTRIBUTES_FILE);
|
|
66
|
+
const raw = existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
|
|
67
|
+
const block = sourceBlock.replace(/\r\n/g, '\n').replace(/\n+$/, '');
|
|
68
|
+
let result;
|
|
69
|
+
let action;
|
|
70
|
+
if (raw.trim() === '') {
|
|
71
|
+
result = `${block}\n`;
|
|
72
|
+
action = 'created';
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const lines = raw.replace(/\r\n/g, '\n').split('\n');
|
|
76
|
+
const start = lines.findIndex(isStart);
|
|
77
|
+
if (start === -1) {
|
|
78
|
+
const before = stripTrailingEmpty(lines);
|
|
79
|
+
result = `${before.join('\n')}\n\n${block}\n`;
|
|
80
|
+
action = 'inserted';
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Replace from start to the end marker, or to EOF when the end marker is
|
|
84
|
+
// missing (malformed block → regenerate clean rather than append).
|
|
85
|
+
let end = lines.length - 1;
|
|
86
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
87
|
+
if (isEnd(lines[i])) {
|
|
88
|
+
end = i;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const merged = [...lines.slice(0, start), ...block.split('\n'), ...lines.slice(end + 1)];
|
|
93
|
+
result = `${stripTrailingEmpty(merged).join('\n')}\n`;
|
|
94
|
+
action = 'updated';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (result === raw)
|
|
98
|
+
return { action: 'unchanged' };
|
|
99
|
+
writeFileSync(filePath, result, 'utf8');
|
|
100
|
+
return { action };
|
|
101
|
+
}
|