@timeax/scaffold 0.0.10 → 0.0.12

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@timeax/scaffold",
3
3
  "private": false,
4
- "version": "0.0.10",
4
+ "version": "0.0.12",
5
5
  "description": "A CLI tool that scaffolds project file structures based on a user-defined, type-safe configuration file.",
6
6
  "keywords": [
7
7
  "scaffold"
@@ -1,255 +1,306 @@
1
- // src/core/apply-structure.ts
2
-
3
- import fs from 'fs';
4
- import path from 'path';
5
- import type {
6
- ScaffoldConfig,
7
- StructureEntry,
8
- FileEntry,
9
- DirEntry,
10
- HookContext,
11
- } from '../schema';
12
- import { CacheManager } from './cache-manager';
13
- import { HookRunner } from './hook-runner';
14
- import {
15
- ensureDirSync,
16
- statSafeSync,
17
- toProjectRelativePath,
18
- toPosixPath,
19
- } from '../util/fs-utils';
20
- import type { Logger } from '../util/logger';
21
- import { defaultLogger } from '../util/logger';
22
-
23
- export interface InteractiveDeleteParams {
24
- absolutePath: string;
25
- relativePath: string; // project-root relative, POSIX
26
- size: number;
27
- createdByStub?: string;
28
- groupName?: string;
29
- }
30
-
31
- export interface ApplyOptions {
32
- config: ScaffoldConfig;
33
-
34
- /**
35
- * Global project root for this run.
36
- */
37
- projectRoot: string;
38
-
39
- /**
40
- * Absolute directory where this structure group should be applied.
41
- * For grouped mode, this is projectRoot + group.root.
42
- * For single mode, this will simply be projectRoot.
43
- */
44
- baseDir: string;
45
-
46
- /**
47
- * Which structure entries to apply (already resolved from txt or inline).
48
- */
49
- structure: StructureEntry[];
50
-
51
- cache: CacheManager;
52
- hooks: HookRunner;
53
-
54
- /**
55
- * Optional group metadata (only set for groups).
56
- */
57
- groupName?: string;
58
- groupRoot?: string;
59
-
60
- /**
61
- * Optional override for deletion threshold.
62
- * Falls back to config.sizePromptThreshold or internal default.
63
- */
64
- sizePromptThreshold?: number;
65
-
66
- /**
67
- * Optional interactive delete callback.
68
- * Should ask the user and return 'delete' or 'keep'.
69
- */
70
- interactiveDelete?: (
71
- params: InteractiveDeleteParams,
72
- ) => Promise<'delete' | 'keep'>;
73
-
74
- /**
75
- * Optional logger; defaults to defaultLogger.child('[apply]').
76
- */
77
- logger?: Logger;
78
- }
79
-
80
- export async function applyStructure(opts: ApplyOptions): Promise<void> {
81
- const {
82
- config,
83
- projectRoot,
84
- baseDir,
85
- structure,
86
- cache,
87
- hooks,
88
- groupName,
89
- groupRoot,
90
- sizePromptThreshold,
91
- interactiveDelete,
92
- } = opts;
93
-
94
- const logger =
95
- opts.logger ?? defaultLogger.child(groupName ? `[apply:${groupName}]` : '[apply]');
96
-
97
- const desiredPaths = new Set<string>(); // project-root relative, POSIX
98
-
99
- const threshold = sizePromptThreshold ?? config.sizePromptThreshold ?? 128 * 1024;
100
-
101
- async function walk(entry: StructureEntry, inheritedStub?: string): Promise<void> {
102
- const effectiveStub = entry.stub ?? inheritedStub;
103
- if (entry.type === 'dir') {
104
- await handleDir(entry as DirEntry, effectiveStub);
105
- } else {
106
- await handleFile(entry as FileEntry, effectiveStub);
107
- }
108
- }
109
-
110
- async function handleDir(entry: DirEntry, inheritedStub?: string): Promise<void> {
111
- const relFromBase = entry.path.replace(/^[./]+/, '');
112
- const absDir = path.resolve(baseDir, relFromBase);
113
- const relFromRoot = toPosixPath(
114
- toProjectRelativePath(projectRoot, absDir),
115
- );
116
-
117
- desiredPaths.add(relFromRoot);
118
-
119
- ensureDirSync(absDir);
120
-
121
- const nextStub = entry.stub ?? inheritedStub;
122
-
123
- if (entry.children) {
124
- for (const child of entry.children) {
125
- // eslint-disable-next-line no-await-in-loop
126
- await walk(child, nextStub);
127
- }
128
- }
129
- }
130
-
131
- async function handleFile(entry: FileEntry, inheritedStub?: string): Promise<void> {
132
- const relFromBase = entry.path.replace(/^[./]+/, '');
133
- const absFile = path.resolve(baseDir, relFromBase);
134
- const relFromRoot = toPosixPath(
135
- toProjectRelativePath(projectRoot, absFile),
136
- );
137
-
138
- desiredPaths.add(relFromRoot);
139
-
140
- const stubName = entry.stub ?? inheritedStub;
141
-
142
- const ctx: HookContext = {
143
- projectRoot,
144
- targetPath: relFromRoot,
145
- absolutePath: absFile,
146
- isDirectory: false,
147
- stubName,
148
- };
149
-
150
- // If file already exists, do not overwrite; just ensure hooks
151
- if (fs.existsSync(absFile)) {
152
- return;
153
- }
154
-
155
- await hooks.runRegular('preCreateFile', ctx);
156
-
157
- const dir = path.dirname(absFile);
158
- ensureDirSync(dir);
159
-
160
- if (stubName) {
161
- await hooks.runStub('preStub', ctx);
162
- }
163
-
164
- let content = '';
165
- const stubContent = await hooks.renderStubContent(ctx);
166
- if (typeof stubContent === 'string') {
167
- content = stubContent;
168
- }
169
-
170
- fs.writeFileSync(absFile, content, 'utf8');
171
- const stats = fs.statSync(absFile);
172
-
173
- cache.set({
174
- path: relFromRoot,
175
- createdAt: new Date().toISOString(),
176
- sizeAtCreate: stats.size,
177
- createdByStub: stubName,
178
- groupName,
179
- groupRoot,
180
- });
181
-
182
- logger.info(`created ${relFromRoot}`);
183
-
184
- if (stubName) {
185
- await hooks.runStub('postStub', ctx);
186
- }
187
-
188
- await hooks.runRegular('postCreateFile', ctx);
189
- }
190
-
191
- // 1) Create/update from structure
192
- for (const entry of structure) {
193
- // eslint-disable-next-line no-await-in-loop
194
- await walk(entry);
195
- }
196
-
197
- // 2) Handle deletions: any cached path not in desiredPaths
198
- for (const cachedPath of cache.allPaths()) {
199
- if (desiredPaths.has(cachedPath)) continue;
200
-
201
- const abs = path.resolve(projectRoot, cachedPath);
202
- const stats = statSafeSync(abs);
203
-
204
- if (!stats) {
205
- cache.delete(cachedPath);
206
- continue;
207
- }
208
-
209
- // Only handle files here; dirs are not tracked in cache.
210
- if (!stats.isFile()) {
211
- cache.delete(cachedPath);
212
- continue;
213
- }
214
-
215
- const entry = cache.get(cachedPath);
216
- const ctx: HookContext = {
217
- projectRoot,
218
- targetPath: cachedPath,
219
- absolutePath: abs,
220
- isDirectory: false,
221
- stubName: entry?.createdByStub,
222
- };
223
-
224
- await hooks.runRegular('preDeleteFile', ctx);
225
-
226
- let shouldDelete = true;
227
- if (stats.size > threshold && interactiveDelete) {
228
- const res = await interactiveDelete({
229
- absolutePath: abs,
230
- relativePath: cachedPath,
231
- size: stats.size,
232
- createdByStub: entry?.createdByStub,
233
- groupName: entry?.groupName,
234
- });
235
-
236
- if (res === 'keep') {
237
- shouldDelete = false;
238
- cache.delete(cachedPath); // user takes ownership
239
- logger.info(`keeping ${cachedPath} (removed from cache)`);
240
- }
241
- }
242
-
243
- if (shouldDelete) {
244
- try {
245
- fs.unlinkSync(abs);
246
- logger.info(`deleted ${cachedPath}`);
247
- } catch (err) {
248
- logger.warn(`failed to delete ${cachedPath}`, err);
249
- }
250
-
251
- cache.delete(cachedPath);
252
- await hooks.runRegular('postDeleteFile', ctx);
253
- }
254
- }
255
- }
1
+ // src/core/apply-structure.ts
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import type {
6
+ ScaffoldConfig,
7
+ StructureEntry,
8
+ FileEntry,
9
+ DirEntry,
10
+ HookContext,
11
+ } from "../schema";
12
+ import { CacheManager } from "./cache-manager";
13
+ import { HookRunner } from "./hook-runner";
14
+ import {
15
+ ensureDirSync,
16
+ statSafeSync,
17
+ toProjectRelativePath,
18
+ toPosixPath,
19
+ } from "../util/fs-utils";
20
+ import type { Logger } from "../util/logger";
21
+ import { defaultLogger } from "../util/logger";
22
+
23
+ export interface InteractiveDeleteParams {
24
+ absolutePath: string;
25
+ relativePath: string; // project-root relative, POSIX
26
+ size: number;
27
+ createdByStub?: string;
28
+ groupName?: string;
29
+ }
30
+
31
+ export interface ApplyOptions {
32
+ config: ScaffoldConfig;
33
+
34
+ /**
35
+ * Global project root for this run (absolute or relative to CWD).
36
+ */
37
+ projectRoot: string;
38
+
39
+ /**
40
+ * Absolute directory where this structure group should be applied.
41
+ * For grouped mode, this is projectRoot + group.root.
42
+ * For single mode, this will simply be projectRoot.
43
+ */
44
+ baseDir: string;
45
+
46
+ /**
47
+ * Which structure entries to apply (already resolved from txt or inline).
48
+ */
49
+ structure: StructureEntry[];
50
+
51
+ cache: CacheManager;
52
+ hooks: HookRunner;
53
+
54
+ /**
55
+ * Optional group metadata (only set for groups).
56
+ */
57
+ groupName?: string;
58
+ groupRoot?: string;
59
+
60
+ /**
61
+ * Optional override for deletion threshold.
62
+ * Falls back to config.sizePromptThreshold or internal default.
63
+ */
64
+ sizePromptThreshold?: number;
65
+
66
+ /**
67
+ * Optional interactive delete callback.
68
+ * Should ask the user and return 'delete' or 'keep'.
69
+ */
70
+ interactiveDelete?: (
71
+ params: InteractiveDeleteParams,
72
+ ) => Promise<"delete" | "keep">;
73
+
74
+ /**
75
+ * Optional logger; defaults to defaultLogger.child('[apply]').
76
+ */
77
+ logger?: Logger;
78
+ }
79
+
80
+ export async function applyStructure(opts: ApplyOptions): Promise<void> {
81
+ const {
82
+ config,
83
+ projectRoot,
84
+ baseDir,
85
+ structure,
86
+ cache,
87
+ hooks,
88
+ groupName,
89
+ groupRoot,
90
+ sizePromptThreshold,
91
+ interactiveDelete,
92
+ } = opts;
93
+
94
+ const logger =
95
+ opts.logger ??
96
+ defaultLogger.child(groupName ? `[apply:${groupName}]` : "[apply]");
97
+
98
+ // Normalize roots to absolute, consistent paths
99
+ const projectRootAbs = path.resolve(projectRoot);
100
+ const baseDirAbs = path.resolve(baseDir);
101
+
102
+ // Helper for “is this absolute path inside this baseDir?”
103
+ const baseDirAbsWithSep = baseDirAbs.endsWith(path.sep)
104
+ ? baseDirAbs
105
+ : baseDirAbs + path.sep;
106
+
107
+ function isUnderBaseDir(absPath: string): boolean {
108
+ const norm = path.resolve(absPath);
109
+ return norm === baseDirAbs || norm.startsWith(baseDirAbsWithSep);
110
+ }
111
+
112
+ const desiredPaths = new Set<string>(); // project-root relative, POSIX
113
+
114
+ const threshold =
115
+ sizePromptThreshold ?? config.sizePromptThreshold ?? 128 * 1024;
116
+
117
+ async function walk(
118
+ entry: StructureEntry,
119
+ inheritedStub?: string,
120
+ ): Promise<void> {
121
+ const effectiveStub = entry.stub ?? inheritedStub;
122
+ if (entry.type === "dir") {
123
+ await handleDir(entry as DirEntry, effectiveStub);
124
+ } else {
125
+ await handleFile(entry as FileEntry, effectiveStub);
126
+ }
127
+ }
128
+
129
+ async function handleDir(
130
+ entry: DirEntry,
131
+ inheritedStub?: string,
132
+ ): Promise<void> {
133
+ const relFromBase = entry.path.replace(/^[./]+/, "");
134
+ const absDir = path.resolve(baseDirAbs, relFromBase);
135
+ const relFromRoot = toPosixPath(
136
+ toProjectRelativePath(projectRootAbs, absDir),
137
+ );
138
+
139
+ desiredPaths.add(relFromRoot);
140
+
141
+ ensureDirSync(absDir);
142
+
143
+ const nextStub = entry.stub ?? inheritedStub;
144
+
145
+ if (entry.children) {
146
+ for (const child of entry.children) {
147
+ // eslint-disable-next-line no-await-in-loop
148
+ await walk(child, nextStub);
149
+ }
150
+ }
151
+ }
152
+
153
+ async function handleFile(
154
+ entry: FileEntry,
155
+ inheritedStub?: string,
156
+ ): Promise<void> {
157
+ const relFromBase = entry.path.replace(/^[./]+/, "");
158
+ const absFile = path.resolve(baseDirAbs, relFromBase);
159
+ const relFromRoot = toPosixPath(
160
+ toProjectRelativePath(projectRootAbs, absFile),
161
+ );
162
+
163
+ desiredPaths.add(relFromRoot);
164
+
165
+ const stubName = entry.stub ?? inheritedStub;
166
+
167
+ const ctx: HookContext = {
168
+ projectRoot: projectRootAbs,
169
+ targetPath: relFromRoot,
170
+ absolutePath: absFile,
171
+ isDirectory: false,
172
+ stubName,
173
+ };
174
+
175
+ // If file already exists, do not overwrite; just ensure hooks (later we might
176
+ // add an "onExistingFile" hook, but right now we simply skip creation).
177
+ if (fs.existsSync(absFile)) {
178
+ return;
179
+ }
180
+
181
+ await hooks.runRegular("preCreateFile", ctx);
182
+
183
+ const dir = path.dirname(absFile);
184
+ ensureDirSync(dir);
185
+
186
+ if (stubName) {
187
+ await hooks.runStub("preStub", ctx);
188
+ }
189
+
190
+ let content = "";
191
+ const stubContent = await hooks.renderStubContent(ctx);
192
+ if (typeof stubContent === "string") {
193
+ content = stubContent;
194
+ }
195
+
196
+ fs.writeFileSync(absFile, content, "utf8");
197
+ const stats = fs.statSync(absFile);
198
+
199
+ cache.set({
200
+ path: relFromRoot,
201
+ createdAt: new Date().toISOString(),
202
+ sizeAtCreate: stats.size,
203
+ createdByStub: stubName,
204
+ groupName,
205
+ groupRoot,
206
+ });
207
+
208
+ logger.info(`created ${relFromRoot}`);
209
+
210
+ if (stubName) {
211
+ await hooks.runStub("postStub", ctx);
212
+ }
213
+
214
+ await hooks.runRegular("postCreateFile", ctx);
215
+ }
216
+
217
+ // 1) Create/update from structure
218
+ for (const entry of structure) {
219
+ // eslint-disable-next-line no-await-in-loop
220
+ await walk(entry);
221
+ }
222
+
223
+ // 2) Handle deletions: any cached path not in desiredPaths
224
+ //
225
+ // IMPORTANT:
226
+ // We *only* consider cached files that live under this run's baseDir.
227
+ // This prevents group A from deleting files owned by group B when
228
+ // applyStructure is called multiple times with different baseDir values.
229
+ // 2) Handle deletions: any cached path not in desiredPaths
230
+ for (const cachedPath of cache.allPaths()) {
231
+ const entry = cache.get(cachedPath);
232
+
233
+ // Group-aware deletion:
234
+ // - If we're in a group, only touch entries for this group.
235
+ // - If we're in single-root mode (no groupName), only touch entries
236
+ // that also have no groupName (legacy / single-structure runs).
237
+ if (groupName) {
238
+ if (!entry || entry.groupName !== groupName) {
239
+ continue;
240
+ }
241
+ } else {
242
+ if (entry && entry.groupName) {
243
+ continue;
244
+ }
245
+ }
246
+
247
+ // If this path is still desired within this group, skip it.
248
+ if (desiredPaths.has(cachedPath)) {
249
+ continue;
250
+ }
251
+
252
+ const abs = path.resolve(projectRoot, cachedPath);
253
+ const stats = statSafeSync(abs);
254
+
255
+ if (!stats) {
256
+ // File disappeared on disk – just clean cache.
257
+ cache.delete(cachedPath);
258
+ continue;
259
+ }
260
+
261
+ // Only handle files here; dirs are not tracked in cache.
262
+ if (!stats.isFile()) {
263
+ cache.delete(cachedPath);
264
+ continue;
265
+ }
266
+
267
+ const ctx: HookContext = {
268
+ projectRoot,
269
+ targetPath: cachedPath,
270
+ absolutePath: abs,
271
+ isDirectory: false,
272
+ stubName: entry?.createdByStub,
273
+ };
274
+
275
+ await hooks.runRegular("preDeleteFile", ctx);
276
+
277
+ let shouldDelete = true;
278
+ if (stats.size > threshold && interactiveDelete) {
279
+ const res = await interactiveDelete({
280
+ absolutePath: abs,
281
+ relativePath: cachedPath,
282
+ size: stats.size,
283
+ createdByStub: entry?.createdByStub,
284
+ groupName: entry?.groupName,
285
+ });
286
+
287
+ if (res === "keep") {
288
+ shouldDelete = false;
289
+ cache.delete(cachedPath); // user takes ownership
290
+ logger.info(`keeping ${cachedPath} (removed from cache)`);
291
+ }
292
+ }
293
+
294
+ if (shouldDelete) {
295
+ try {
296
+ fs.unlinkSync(abs);
297
+ logger.info(`deleted ${cachedPath}`);
298
+ } catch (err) {
299
+ logger.warn(`failed to delete ${cachedPath}`, err);
300
+ }
301
+
302
+ cache.delete(cachedPath);
303
+ await hooks.runRegular("postDeleteFile", ctx);
304
+ }
305
+ }
306
+ }
@@ -2,9 +2,9 @@
2
2
 
3
3
  import path from 'path';
4
4
  import chokidar from 'chokidar';
5
- import {runOnce, type RunOptions} from './runner';
6
- import {defaultLogger, type Logger} from '../util/logger';
7
- import {SCAFFOLD_ROOT_DIR} from '..';
5
+ import { runOnce, type RunOptions } from './runner';
6
+ import { defaultLogger, type Logger } from '../util/logger';
7
+ import { SCAFFOLD_ROOT_DIR } from '..';
8
8
 
9
9
  export interface WatchOptions extends RunOptions {
10
10
  /**
@@ -24,11 +24,11 @@ export interface WatchOptions extends RunOptions {
24
24
  /**
25
25
  * Watch the scaffold directory and re-run scaffold on changes.
26
26
  *
27
- * This watches:
28
- * - .scaffold/config.* files
29
- * - .scaffold/*.txt / *.tss / *.stx files (structures)
27
+ * This watches the entire .scaffold folder and then filters events
28
+ * in-process to:
29
+ * - config.* files
30
+ * - *.txt / *.tss / *.stx
30
31
  *
31
- * CLI can call this when `--watch` is enabled.
32
32
  * Any `format` options in RunOptions are passed straight through to `runOnce`,
33
33
  * so formatting from config / CLI is applied on each re-run.
34
34
  */
@@ -60,7 +60,7 @@ export function watchScaffold(cwd: string, options: WatchOptions = {}): void {
60
60
  // we already resolved scaffoldDir for watcher; pass it down
61
61
  scaffoldDir,
62
62
  });
63
- logger.info('Scaffold run completed.');
63
+ logger.info('Scaffold run completed');
64
64
  } catch (err) {
65
65
  logger.error('Scaffold run failed:', err);
66
66
  } finally {
@@ -77,32 +77,29 @@ export function watchScaffold(cwd: string, options: WatchOptions = {}): void {
77
77
  timer = setTimeout(run, debounceMs);
78
78
  }
79
79
 
80
- const watcher = chokidar.watch(
81
- [
82
- // config files (ts/js/etc.)
83
- path.join(scaffoldDir, 'config.*'),
84
-
85
- // structure files: plain txt + our custom extensions
86
- path.join(scaffoldDir, '*.txt'),
87
- path.join(scaffoldDir, '*.tss'),
88
- path.join(scaffoldDir, '*.stx'),
89
- ],
90
- {
91
- ignoreInitial: false,
92
- },
93
- );
80
+ // Only react to config.* and structure files inside scaffoldDir
81
+ function isInteresting(filePath: string): boolean {
82
+ const rel = path.relative(scaffoldDir, filePath);
83
+ // Outside .scaffold or in parent → ignore
84
+ if (rel.startsWith('..')) return false;
85
+
86
+ const base = path.basename(filePath).toLowerCase();
87
+ // config.ts / config.js / config.mts / etc.
88
+ if (base.startsWith('config.')) return true;
89
+
90
+ const ext = path.extname(base);
91
+ return ext === '.txt' || ext === '.tss' || ext === '.stx';
92
+ }
93
+
94
+ const watcher = chokidar.watch(scaffoldDir, {
95
+ ignoreInitial: false,
96
+ persistent: true,
97
+ });
94
98
 
95
99
  watcher
96
- .on('add', (filePath) => {
97
- logger.debug(`File added: ${filePath}`);
98
- scheduleRun();
99
- })
100
- .on('change', (filePath) => {
101
- logger.debug(`File changed: ${filePath}`);
102
- scheduleRun();
103
- })
104
- .on('unlink', (filePath) => {
105
- logger.debug(`File removed: ${filePath}`);
100
+ .on('all', (event, filePath) => {
101
+ if (!isInteresting(filePath)) return;
102
+ logger.debug(`Event ${event} on ${filePath}`);
106
103
  scheduleRun();
107
104
  })
108
105
  .on('error', (error) => {