@timekast/factory 0.1.5 → 0.1.7

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.
@@ -37,7 +37,7 @@ import prompts from 'prompts';
37
37
  import { applyPlan, clearUpdateState, defaultBackupDir, hasUpdateState, readUpdateState, validateStagedManifest, writeUpdateState, } from '../lib/atomic-swap.js';
38
38
  import { CLIError } from '../lib/cli-error.js';
39
39
  import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
40
- import { insertFactoryUpdateScript, pruneFactoryMeta } from '../lib/package-json.js';
40
+ import { insertFactoryUpdateScript, setAgentKitVersion } from '../lib/package-json.js';
41
41
  import { runPreflight } from '../lib/preflight.js';
42
42
  import { downloadProfileTarball, stageProfileTarball } from '../lib/unpack.js';
43
43
  /** Parse `update`'s argv slice into flags. Unknown flags are ignored here. */
@@ -186,7 +186,7 @@ function runResume(rootDir) {
186
186
  clearUpdateState(rootDir);
187
187
  const pkgPath = path.join(rootDir, 'package.json');
188
188
  if (existsSync(pkgPath))
189
- maintainDerivedPkg(pkgPath);
189
+ maintainDerivedPkg(pkgPath, newLock.version);
190
190
  rmSync(state.stagedDir, { recursive: true, force: true });
191
191
  console.log('✔ `update` retomado y completado.');
192
192
  }
@@ -208,15 +208,16 @@ function warnOnScriptConflict(action, _pkgPath) {
208
208
  }
209
209
  /**
210
210
  * After a sync, maintain the derived project's package.json: ensure the
211
- * `factory:update` script (warn on a divergent value) and prune the stale
212
- * `agentKitVersion` leaked by the template. The lockfile not package.json — is
213
- * the live-version SSOT, so that copy only drifts; pruning it here also cleans
214
- * derivatives that predate this fix on their next update. `factoryVersion` (the
215
- * frozen birth stamp) is kept.
211
+ * `factory:update` script (warn on a divergent value) and set `agentKitVersion`
212
+ * to the just-installed brain version, mirroring the lockfile's `version`. Run on
213
+ * every update, so the field tracks each `factory:update` (and a derivative whose
214
+ * field was missing or stale gets reconciled same shape as the Factory's own
215
+ * package.json). `factoryVersion` (the frozen birth stamp) and `version` (the app
216
+ * semver) are untouched.
216
217
  */
217
- function maintainDerivedPkg(pkgPath) {
218
+ function maintainDerivedPkg(pkgPath, agentKitVersion) {
218
219
  warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
219
- pruneFactoryMeta(pkgPath);
220
+ setAgentKitVersion(pkgPath, agentKitVersion);
220
221
  }
221
222
  /** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
222
223
  function reportSummary(diff) {
@@ -312,7 +313,7 @@ export async function runUpdate(flags, deps = {}) {
312
313
  clearUpdateState(rootDir);
313
314
  const pkgPath = path.join(rootDir, 'package.json');
314
315
  if (existsSync(pkgPath))
315
- maintainDerivedPkg(pkgPath);
316
+ maintainDerivedPkg(pkgPath, manifest.version);
316
317
  reportSummary(diff);
317
318
  }
318
319
  finally {
@@ -352,7 +353,7 @@ async function applyLegacy(rootDir, stagedDir, manifest) {
352
353
  writeLockfile(rootDir, { ...manifest, factoryVersion: manifest.version });
353
354
  const pkgPath = path.join(rootDir, 'package.json');
354
355
  if (existsSync(pkgPath))
355
- maintainDerivedPkg(pkgPath);
356
+ maintainDerivedPkg(pkgPath, manifest.version);
356
357
  if (plan.ambiguous.length > 0) {
357
358
  console.log('\nArchivos en `.claude/` que no están en el manifest (revisar manualmente):');
358
359
  for (const p of plan.ambiguous)
@@ -20,7 +20,7 @@
20
20
  * paths). `src/` and dev-owned files are never in the plan, so they are never
21
21
  * read, written, or deleted here (design §7.2, §9 out-of-scope).
22
22
  */
23
- import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync, } from 'node:fs';
24
24
  import path from 'node:path';
25
25
  import { CLIError } from './cli-error.js';
26
26
  import { TIMEKAST_DIR } from './constants.js';
@@ -87,6 +87,33 @@ export function validateStagedManifest(stagedDir, manifestRaw) {
87
87
  }
88
88
  return manifest;
89
89
  }
90
+ /**
91
+ * After deleting a tracked file, remove now-empty parent directories so a removed
92
+ * skill/dir does not leave an empty husk behind (e.g. deleting
93
+ * `.claude/skills/<name>/SKILL.md` should also drop the empty `<name>/` dir).
94
+ *
95
+ * Walks up from the file's directory, removing each empty dir, and stops at the
96
+ * first non-empty dir OR at a structural level — only directories at depth ≥ 3
97
+ * relative to the root are eligible, so `.claude`, `.claude/skills`, `scripts/tools`
98
+ * and the like always survive even if momentarily empty. Best-effort: any fs error
99
+ * just stops the walk. Path-separator agnostic (Windows-safe).
100
+ */
101
+ function pruneEmptyDirs(rootDir, rel) {
102
+ const root = path.resolve(rootDir);
103
+ let dir = path.dirname(rel);
104
+ while (dir && dir !== '.' && dir.split(/[\\/]/).filter(Boolean).length >= 3) {
105
+ const abs = path.join(root, dir);
106
+ try {
107
+ if (!existsSync(abs) || readdirSync(abs).length > 0)
108
+ break;
109
+ rmdirSync(abs); // empty-dir only; throws (→ caught) if a race re-fills it
110
+ }
111
+ catch {
112
+ break;
113
+ }
114
+ dir = path.dirname(dir);
115
+ }
116
+ }
90
117
  /**
91
118
  * Apply the plan atomically with a backup-and-rollback guard.
92
119
  *
@@ -147,6 +174,7 @@ export function applyPlan(rootDir, stagedDir, plan, backupDir, hooks) {
147
174
  hooks?.afterWrites?.();
148
175
  for (const rel of plan.deletes) {
149
176
  rmSync(path.join(rootDir, rel), { force: true, recursive: true });
177
+ pruneEmptyDirs(rootDir, rel);
150
178
  }
151
179
  }
152
180
  catch (error) {
@@ -31,31 +31,6 @@ export function parsePackageJson(raw) {
31
31
  throw new CLIError('El `package.json` desempacado no es JSON válido; no se puede continuar de forma segura.');
32
32
  }
33
33
  }
34
- /**
35
- * Stale Factory version metadata to prune from a derivative's `package.json`.
36
- *
37
- * The `full` template's `package.json` carries `factoryVersion` + `agentKitVersion`
38
- * for the Factory's dual-version release model, and both leak into a derivative on
39
- * unpack. Only `agentKitVersion` is pruned: it is the LIVE brain version, whose
40
- * SSOT in a derivative is `.timekast/lockfile.json` (`version`), so a `package.json`
41
- * copy just goes stale on every `update` and misleads (what `status`/`doctor` report
42
- * comes from the lockfile, never from here). `factoryVersion` is deliberately KEPT —
43
- * it is the FROZEN birth stamp (never changes after install) and the canonical
44
- * boilerplate marker (DISTRIBUTION_DESIGN §10). `version` (the derivative's own app
45
- * semver) is untouched.
46
- */
47
- const FACTORY_META_KEYS = ['agentKitVersion'];
48
- /** Delete stale Factory version metadata in place. Returns true if a key was removed. */
49
- function stripFactoryMeta(pkg) {
50
- let changed = false;
51
- for (const key of FACTORY_META_KEYS) {
52
- if (key in pkg) {
53
- delete pkg[key];
54
- changed = true;
55
- }
56
- }
57
- return changed;
58
- }
59
34
  /**
60
35
  * Pure core of the script insertion: mutate `pkg.scripts` in place to ensure
61
36
  * `factory:update` exists, never overwriting a divergent value. Shared by
@@ -88,9 +63,6 @@ export function applyPackageJsonEdits(raw, name) {
88
63
  const pkg = parsePackageJson(raw);
89
64
  pkg.name = name;
90
65
  const result = ensureUpdateScript(pkg);
91
- // Prune the stale `agentKitVersion` leaked by the unpacked template; the
92
- // derivative's live brain version lives in the lockfile, not here.
93
- stripFactoryMeta(pkg);
94
66
  const content = `${JSON.stringify(pkg, null, indent)}\n`;
95
67
  return { content, scriptAlreadyPresent: result.action === 'conflict' };
96
68
  }
@@ -117,24 +89,27 @@ export function insertFactoryUpdateScript(pkgPath) {
117
89
  return result;
118
90
  }
119
91
  /**
120
- * Surgically prune the stale `agentKitVersion` leaked into a derived project's
121
- * `package.json` when the `full` profile is unpacked. It is NOT the version SSOT
122
- * the lockfile is so it goes stale on every update and misleads. Reads,
123
- * deletes only that key, and re-serializes with the original indentation, writing
124
- * only when something changed. NEVER touches `name`, `version`, `factoryVersion`
125
- * (the frozen birth stamp), deps, or scripts.
126
- *
127
- * Idempotent: a derivative already free of the key is left byte-identical.
92
+ * Surgically set the derived project's `package.json.agentKitVersion` to the
93
+ * installed brain version, mirroring the lockfile's `version`. The Factory's own
94
+ * `package.json` carries `agentKitVersion` and bumps it on every release; a
95
+ * derivative mirrors it so its `package.json` shows the live brain version and
96
+ * stays consistent with the Factory's shape. `update` calls this on every run, so
97
+ * the field tracks each `factory:update` and a derivative whose field was missing
98
+ * or stale gets reconciled. Reads, sets only that key, re-serializes with the
99
+ * original indentation, and writes only when the value changed. NEVER touches
100
+ * `name`, `version` (the app semver), `factoryVersion` (birth stamp), deps, or scripts.
128
101
  *
129
102
  * @param pkgPath Absolute path to the derived project's `package.json`.
130
- * @returns true when a field was removed (and the file rewritten).
103
+ * @param version The installed brain version (= the lockfile's `version`).
104
+ * @returns true when the field changed (and the file was rewritten).
131
105
  */
132
- export function pruneFactoryMeta(pkgPath) {
106
+ export function setAgentKitVersion(pkgPath, version) {
133
107
  const raw = readFileSync(pkgPath, 'utf8');
134
108
  const indent = detectIndent(raw);
135
109
  const pkg = parsePackageJson(raw);
136
- if (!stripFactoryMeta(pkg))
110
+ if (pkg.agentKitVersion === version)
137
111
  return false;
112
+ pkg.agentKitVersion = version;
138
113
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
139
114
  return true;
140
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",