@timekast/factory 0.1.6 → 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.
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "0.1.6",
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",