@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.
@@ -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
- const pkgPath = path.join(rootDir, 'package.json');
172
- if (existsSync(pkgPath))
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: ensure the
195
- * `factory:update` script (warn on a divergent value) and set `agentKitVersion`
196
- * to the just-installed brain version, mirroring the lockfile's `version`. Run on
197
- * every update, so the field tracks each `factory:update` (and a derivative whose
198
- * field was missing or stale gets reconciled same shape as the Factory's own
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(pkgPath, agentKitVersion) {
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
- const pkgPath = path.join(rootDir, 'package.json');
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
- const pkgPath = path.join(rootDir, 'package.json');
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.');
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",