@timeax/scaffold 0.0.10 → 0.0.11

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.11",
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
+ }