@timeax/scaffold 0.0.5 → 0.0.6
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/.idea/dictionaries/project.xml +7 -0
- package/.idea/modules.xml +8 -0
- package/.idea/php.xml +34 -0
- package/.idea/scaffold.iml +8 -0
- package/.idea/vcs.xml +6 -0
- package/dist/ast.d.cts +4 -2
- package/dist/ast.d.ts +4 -2
- package/dist/cli.cjs +1673 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.mjs +1662 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/config-C0067l3c.d.cts +383 -0
- package/dist/config-C0067l3c.d.ts +383 -0
- package/dist/index.cjs +1311 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +8 -347
- package/dist/index.d.ts +8 -347
- package/dist/index.mjs +1295 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +1 -1
- package/src/cli/main.ts +3 -1
- package/tsup.config.ts +24 -60
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,1662 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
import path2 from 'path';
|
|
3
|
+
import fs8 from 'fs';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import { pathToFileURL } from 'url';
|
|
8
|
+
import { transform } from 'esbuild';
|
|
9
|
+
import { minimatch } from 'minimatch';
|
|
10
|
+
import chokidar from 'chokidar';
|
|
11
|
+
|
|
12
|
+
// src/cli/main.ts
|
|
13
|
+
|
|
14
|
+
// src/schema/index.ts
|
|
15
|
+
var SCAFFOLD_ROOT_DIR = ".scaffold";
|
|
16
|
+
|
|
17
|
+
// src/util/logger.ts
|
|
18
|
+
var supportsColor = typeof process !== "undefined" && process.stdout && process.stdout.isTTY && process.env.NO_COLOR !== "1";
|
|
19
|
+
function wrap(code) {
|
|
20
|
+
const open = `\x1B[${code}m`;
|
|
21
|
+
const close = `\x1B[0m`;
|
|
22
|
+
return (text) => supportsColor ? `${open}${text}${close}` : text;
|
|
23
|
+
}
|
|
24
|
+
var color = {
|
|
25
|
+
red: wrap(31),
|
|
26
|
+
yellow: wrap(33),
|
|
27
|
+
green: wrap(32),
|
|
28
|
+
cyan: wrap(36),
|
|
29
|
+
magenta: wrap(35),
|
|
30
|
+
dim: wrap(2),
|
|
31
|
+
bold: wrap(1),
|
|
32
|
+
gray: wrap(90)
|
|
33
|
+
};
|
|
34
|
+
function colorForLevel(level) {
|
|
35
|
+
switch (level) {
|
|
36
|
+
case "error":
|
|
37
|
+
return color.red;
|
|
38
|
+
case "warn":
|
|
39
|
+
return color.yellow;
|
|
40
|
+
case "info":
|
|
41
|
+
return color.cyan;
|
|
42
|
+
case "debug":
|
|
43
|
+
return color.gray;
|
|
44
|
+
default:
|
|
45
|
+
return (s) => s;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
var Logger = class _Logger {
|
|
49
|
+
level;
|
|
50
|
+
prefix;
|
|
51
|
+
constructor(options = {}) {
|
|
52
|
+
this.level = options.level ?? "info";
|
|
53
|
+
this.prefix = options.prefix;
|
|
54
|
+
}
|
|
55
|
+
setLevel(level) {
|
|
56
|
+
this.level = level;
|
|
57
|
+
}
|
|
58
|
+
getLevel() {
|
|
59
|
+
return this.level;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Create a child logger with an additional prefix.
|
|
63
|
+
*/
|
|
64
|
+
child(prefix) {
|
|
65
|
+
const combined = this.prefix ? `${this.prefix}${prefix}` : prefix;
|
|
66
|
+
return new _Logger({ level: this.level, prefix: combined });
|
|
67
|
+
}
|
|
68
|
+
formatMessage(msg, lvl) {
|
|
69
|
+
const text = typeof msg === "string" ? msg : msg instanceof Error ? msg.message : String(msg);
|
|
70
|
+
const levelColor = colorForLevel(lvl);
|
|
71
|
+
const prefixColored = this.prefix ? color.magenta(this.prefix) : void 0;
|
|
72
|
+
const textColored = lvl === "debug" ? color.dim(text) : levelColor(text);
|
|
73
|
+
if (prefixColored) {
|
|
74
|
+
return `${prefixColored} ${textColored}`;
|
|
75
|
+
}
|
|
76
|
+
return textColored;
|
|
77
|
+
}
|
|
78
|
+
shouldLog(targetLevel) {
|
|
79
|
+
const order = ["silent", "error", "warn", "info", "debug"];
|
|
80
|
+
const currentIdx = order.indexOf(this.level);
|
|
81
|
+
const targetIdx = order.indexOf(targetLevel);
|
|
82
|
+
if (currentIdx === -1 || targetIdx === -1) return true;
|
|
83
|
+
if (this.level === "silent") return false;
|
|
84
|
+
return targetIdx <= currentIdx || targetLevel === "error";
|
|
85
|
+
}
|
|
86
|
+
error(msg, ...rest) {
|
|
87
|
+
if (!this.shouldLog("error")) return;
|
|
88
|
+
console.error(this.formatMessage(msg, "error"), ...rest);
|
|
89
|
+
}
|
|
90
|
+
warn(msg, ...rest) {
|
|
91
|
+
if (!this.shouldLog("warn")) return;
|
|
92
|
+
console.warn(this.formatMessage(msg, "warn"), ...rest);
|
|
93
|
+
}
|
|
94
|
+
info(msg, ...rest) {
|
|
95
|
+
if (!this.shouldLog("info")) return;
|
|
96
|
+
console.log(this.formatMessage(msg, "info"), ...rest);
|
|
97
|
+
}
|
|
98
|
+
debug(msg, ...rest) {
|
|
99
|
+
if (!this.shouldLog("debug")) return;
|
|
100
|
+
console.debug(this.formatMessage(msg, "debug"), ...rest);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
var defaultLogger = new Logger({
|
|
104
|
+
level: process.env.SCAFFOLD_LOG_LEVEL ?? "info",
|
|
105
|
+
prefix: "[scaffold]"
|
|
106
|
+
});
|
|
107
|
+
function toPosixPath(p) {
|
|
108
|
+
return p.replace(/\\/g, "/");
|
|
109
|
+
}
|
|
110
|
+
function ensureDirSync(dirPath) {
|
|
111
|
+
if (!fs8.existsSync(dirPath)) {
|
|
112
|
+
fs8.mkdirSync(dirPath, { recursive: true });
|
|
113
|
+
}
|
|
114
|
+
return dirPath;
|
|
115
|
+
}
|
|
116
|
+
function statSafeSync(targetPath) {
|
|
117
|
+
try {
|
|
118
|
+
return fs8.statSync(targetPath);
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function toProjectRelativePath(projectRoot, absolutePath) {
|
|
124
|
+
const absRoot = path2.resolve(projectRoot);
|
|
125
|
+
const absTarget = path2.resolve(absolutePath);
|
|
126
|
+
const rootWithSep = absRoot.endsWith(path2.sep) ? absRoot : absRoot + path2.sep;
|
|
127
|
+
if (!absTarget.startsWith(rootWithSep) && absTarget !== absRoot) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Path "${absTarget}" is not inside project root "${absRoot}".`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const rel = path2.relative(absRoot, absTarget);
|
|
133
|
+
return toPosixPath(rel);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/core/config-loader.ts
|
|
137
|
+
var logger = defaultLogger.child("[config]");
|
|
138
|
+
async function loadScaffoldConfig(cwd, options = {}) {
|
|
139
|
+
const absCwd = path2.resolve(cwd);
|
|
140
|
+
const initialScaffoldDir = options.scaffoldDir ? path2.resolve(absCwd, options.scaffoldDir) : path2.join(absCwd, SCAFFOLD_ROOT_DIR);
|
|
141
|
+
const configPath = options.configPath ?? resolveConfigPath(initialScaffoldDir);
|
|
142
|
+
const config = await importConfig(configPath);
|
|
143
|
+
let configRoot = absCwd;
|
|
144
|
+
if (config.root) {
|
|
145
|
+
configRoot = path2.resolve(absCwd, config.root);
|
|
146
|
+
}
|
|
147
|
+
const scaffoldDir = options.scaffoldDir ? path2.resolve(absCwd, options.scaffoldDir) : path2.join(configRoot, SCAFFOLD_ROOT_DIR);
|
|
148
|
+
const baseRoot = config.base ? path2.resolve(configRoot, config.base) : configRoot;
|
|
149
|
+
logger.debug(
|
|
150
|
+
`Loaded config: configRoot=${configRoot}, baseRoot=${baseRoot}, scaffoldDir=${scaffoldDir}`
|
|
151
|
+
);
|
|
152
|
+
return {
|
|
153
|
+
config,
|
|
154
|
+
scaffoldDir,
|
|
155
|
+
projectRoot: baseRoot
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function resolveConfigPath(scaffoldDir) {
|
|
159
|
+
const candidates = [
|
|
160
|
+
"config.ts",
|
|
161
|
+
"config.mts",
|
|
162
|
+
"config.mjs",
|
|
163
|
+
"config.js",
|
|
164
|
+
"config.cjs"
|
|
165
|
+
];
|
|
166
|
+
for (const file of candidates) {
|
|
167
|
+
const full = path2.join(scaffoldDir, file);
|
|
168
|
+
if (fs8.existsSync(full)) {
|
|
169
|
+
return full;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Could not find scaffold config in ${scaffoldDir}. Looked for: ${candidates.join(
|
|
174
|
+
", "
|
|
175
|
+
)}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
async function importConfig(configPath) {
|
|
179
|
+
const ext = path2.extname(configPath).toLowerCase();
|
|
180
|
+
if (ext === ".ts" || ext === ".tsx") {
|
|
181
|
+
return importTsConfig(configPath);
|
|
182
|
+
}
|
|
183
|
+
const url = pathToFileURL(configPath).href;
|
|
184
|
+
const mod = await import(url);
|
|
185
|
+
return mod.default ?? mod;
|
|
186
|
+
}
|
|
187
|
+
async function importTsConfig(configPath) {
|
|
188
|
+
const source = fs8.readFileSync(configPath, "utf8");
|
|
189
|
+
const stat = fs8.statSync(configPath);
|
|
190
|
+
const hash = crypto.createHash("sha1").update(configPath).update(String(stat.mtimeMs)).digest("hex");
|
|
191
|
+
const tmpDir = path2.join(os.tmpdir(), "timeax-scaffold-config");
|
|
192
|
+
ensureDirSync(tmpDir);
|
|
193
|
+
const tmpFile = path2.join(tmpDir, `${hash}.mjs`);
|
|
194
|
+
if (!fs8.existsSync(tmpFile)) {
|
|
195
|
+
const result = await transform(source, {
|
|
196
|
+
loader: "ts",
|
|
197
|
+
format: "esm",
|
|
198
|
+
sourcemap: "inline",
|
|
199
|
+
target: "ESNext",
|
|
200
|
+
tsconfigRaw: {
|
|
201
|
+
compilerOptions: {}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
fs8.writeFileSync(tmpFile, result.code, "utf8");
|
|
205
|
+
}
|
|
206
|
+
const url = pathToFileURL(tmpFile).href;
|
|
207
|
+
const mod = await import(url);
|
|
208
|
+
return mod.default ?? mod;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/ast/parser.ts
|
|
212
|
+
function parseStructureAst(text, opts = {}) {
|
|
213
|
+
const indentStep = opts.indentStep ?? 2;
|
|
214
|
+
const mode = opts.mode ?? "loose";
|
|
215
|
+
const diagnostics = [];
|
|
216
|
+
const lines = [];
|
|
217
|
+
const rawLines = text.split(/\r?\n/);
|
|
218
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
219
|
+
const raw = rawLines[i];
|
|
220
|
+
const lineNo = i + 1;
|
|
221
|
+
const m = raw.match(/^(\s*)(.*)$/);
|
|
222
|
+
const indentRaw = m ? m[1] : "";
|
|
223
|
+
const content = m ? m[2] : "";
|
|
224
|
+
const { indentSpaces, hasTabs } = measureIndent(indentRaw, indentStep);
|
|
225
|
+
if (hasTabs) {
|
|
226
|
+
diagnostics.push({
|
|
227
|
+
line: lineNo,
|
|
228
|
+
message: "Tabs detected in indentation. Consider using spaces only for consistent levels.",
|
|
229
|
+
severity: mode === "strict" ? "warning" : "info",
|
|
230
|
+
code: "indent-tabs"
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const trimmed = content.trim();
|
|
234
|
+
let kind;
|
|
235
|
+
if (!trimmed) {
|
|
236
|
+
kind = "blank";
|
|
237
|
+
} else if (trimmed.startsWith("#") || trimmed.startsWith("//")) {
|
|
238
|
+
kind = "comment";
|
|
239
|
+
} else {
|
|
240
|
+
kind = "entry";
|
|
241
|
+
}
|
|
242
|
+
lines.push({
|
|
243
|
+
index: i,
|
|
244
|
+
lineNo,
|
|
245
|
+
raw,
|
|
246
|
+
kind,
|
|
247
|
+
indentSpaces,
|
|
248
|
+
content
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const rootNodes = [];
|
|
252
|
+
const stack = [];
|
|
253
|
+
const depthCtx = {
|
|
254
|
+
lastIndentSpaces: null,
|
|
255
|
+
lastDepth: null,
|
|
256
|
+
lastWasFile: false
|
|
257
|
+
};
|
|
258
|
+
for (const line of lines) {
|
|
259
|
+
if (line.kind !== "entry") continue;
|
|
260
|
+
const { entry, depth, diags } = parseEntryLine(
|
|
261
|
+
line,
|
|
262
|
+
indentStep,
|
|
263
|
+
mode,
|
|
264
|
+
depthCtx
|
|
265
|
+
);
|
|
266
|
+
diagnostics.push(...diags);
|
|
267
|
+
if (!entry) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
attachNode(entry, depth, line, rootNodes, stack, diagnostics, mode);
|
|
271
|
+
depthCtx.lastWasFile = !entry.isDir;
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
rootNodes,
|
|
275
|
+
lines,
|
|
276
|
+
diagnostics,
|
|
277
|
+
options: {
|
|
278
|
+
indentStep,
|
|
279
|
+
mode
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function measureIndent(rawIndent, indentStep) {
|
|
284
|
+
let spaces = 0;
|
|
285
|
+
let hasTabs = false;
|
|
286
|
+
for (const ch of rawIndent) {
|
|
287
|
+
if (ch === " ") {
|
|
288
|
+
spaces += 1;
|
|
289
|
+
} else if (ch === " ") {
|
|
290
|
+
hasTabs = true;
|
|
291
|
+
spaces += indentStep;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return { indentSpaces: spaces, hasTabs };
|
|
295
|
+
}
|
|
296
|
+
function computeDepth(line, indentStep, mode, ctx, diagnostics) {
|
|
297
|
+
let spaces = line.indentSpaces;
|
|
298
|
+
if (spaces < 0) spaces = 0;
|
|
299
|
+
let depth;
|
|
300
|
+
if (ctx.lastIndentSpaces == null || ctx.lastDepth == null) {
|
|
301
|
+
depth = 0;
|
|
302
|
+
} else {
|
|
303
|
+
const prevSpaces = ctx.lastIndentSpaces;
|
|
304
|
+
const prevDepth = ctx.lastDepth;
|
|
305
|
+
if (spaces > prevSpaces) {
|
|
306
|
+
const diff = spaces - prevSpaces;
|
|
307
|
+
if (ctx.lastWasFile) {
|
|
308
|
+
diagnostics.push({
|
|
309
|
+
line: line.lineNo,
|
|
310
|
+
message: "Entry appears indented under a file; treating it as a sibling of the file instead of a child.",
|
|
311
|
+
severity: mode === "strict" ? "error" : "warning",
|
|
312
|
+
code: "child-of-file-loose"
|
|
313
|
+
});
|
|
314
|
+
depth = prevDepth;
|
|
315
|
+
} else {
|
|
316
|
+
if (diff > indentStep) {
|
|
317
|
+
diagnostics.push({
|
|
318
|
+
line: line.lineNo,
|
|
319
|
+
message: `Indentation jumps from ${prevSpaces} to ${spaces} spaces; treating as one level deeper.`,
|
|
320
|
+
severity: mode === "strict" ? "error" : "warning",
|
|
321
|
+
code: "indent-skip-level"
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
depth = prevDepth + 1;
|
|
325
|
+
}
|
|
326
|
+
} else if (spaces === prevSpaces) {
|
|
327
|
+
depth = prevDepth;
|
|
328
|
+
} else {
|
|
329
|
+
const diff = prevSpaces - spaces;
|
|
330
|
+
const steps = Math.round(diff / indentStep);
|
|
331
|
+
if (diff % indentStep !== 0) {
|
|
332
|
+
diagnostics.push({
|
|
333
|
+
line: line.lineNo,
|
|
334
|
+
message: `Indentation decreases from ${prevSpaces} to ${spaces} spaces, which is not a multiple of indent step (${indentStep}).`,
|
|
335
|
+
severity: mode === "strict" ? "error" : "warning",
|
|
336
|
+
code: "indent-misaligned"
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
depth = Math.max(prevDepth - steps, 0);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
ctx.lastIndentSpaces = spaces;
|
|
343
|
+
ctx.lastDepth = depth;
|
|
344
|
+
return depth;
|
|
345
|
+
}
|
|
346
|
+
function parseEntryLine(line, indentStep, mode, ctx) {
|
|
347
|
+
const diags = [];
|
|
348
|
+
const depth = computeDepth(line, indentStep, mode, ctx, diags);
|
|
349
|
+
const { contentWithoutComment } = extractInlineCommentParts(line.content);
|
|
350
|
+
const trimmed = contentWithoutComment.trim();
|
|
351
|
+
if (!trimmed) {
|
|
352
|
+
return { entry: null, depth, diags };
|
|
353
|
+
}
|
|
354
|
+
const parts = trimmed.split(/\s+/);
|
|
355
|
+
const pathToken = parts[0];
|
|
356
|
+
const annotationTokens = parts.slice(1);
|
|
357
|
+
if (pathToken.includes(":")) {
|
|
358
|
+
diags.push({
|
|
359
|
+
line: line.lineNo,
|
|
360
|
+
message: 'Path token contains ":" which is reserved for annotations. This is likely a mistake.',
|
|
361
|
+
severity: mode === "strict" ? "error" : "warning",
|
|
362
|
+
code: "path-colon"
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const isDir = pathToken.endsWith("/");
|
|
366
|
+
const segmentName = pathToken;
|
|
367
|
+
let stub;
|
|
368
|
+
const include = [];
|
|
369
|
+
const exclude = [];
|
|
370
|
+
for (const token of annotationTokens) {
|
|
371
|
+
if (token.startsWith("@stub:")) {
|
|
372
|
+
stub = token.slice("@stub:".length);
|
|
373
|
+
} else if (token.startsWith("@include:")) {
|
|
374
|
+
const val = token.slice("@include:".length);
|
|
375
|
+
if (val) {
|
|
376
|
+
include.push(
|
|
377
|
+
...val.split(",").map((s) => s.trim()).filter(Boolean)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
} else if (token.startsWith("@exclude:")) {
|
|
381
|
+
const val = token.slice("@exclude:".length);
|
|
382
|
+
if (val) {
|
|
383
|
+
exclude.push(
|
|
384
|
+
...val.split(",").map((s) => s.trim()).filter(Boolean)
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
} else if (token.startsWith("@")) {
|
|
388
|
+
diags.push({
|
|
389
|
+
line: line.lineNo,
|
|
390
|
+
message: `Unknown annotation token "${token}".`,
|
|
391
|
+
severity: "info",
|
|
392
|
+
code: "unknown-annotation"
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const entry = {
|
|
397
|
+
segmentName,
|
|
398
|
+
isDir,
|
|
399
|
+
stub,
|
|
400
|
+
include: include.length ? include : void 0,
|
|
401
|
+
exclude: exclude.length ? exclude : void 0
|
|
402
|
+
};
|
|
403
|
+
return { entry, depth, diags };
|
|
404
|
+
}
|
|
405
|
+
function mapThrough(content) {
|
|
406
|
+
let cutIndex = -1;
|
|
407
|
+
const len = content.length;
|
|
408
|
+
for (let i = 0; i < len; i++) {
|
|
409
|
+
const ch = content[i];
|
|
410
|
+
const prev = i > 0 ? content[i - 1] : "";
|
|
411
|
+
if (ch === "#") {
|
|
412
|
+
if (i === 0) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (prev === " " || prev === " ") {
|
|
416
|
+
cutIndex = i;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (ch === "/" && i + 1 < len && content[i + 1] === "/" && (prev === " " || prev === " ")) {
|
|
421
|
+
cutIndex = i;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return cutIndex;
|
|
426
|
+
}
|
|
427
|
+
function extractInlineCommentParts(content) {
|
|
428
|
+
const cutIndex = mapThrough(content);
|
|
429
|
+
if (cutIndex === -1) {
|
|
430
|
+
return {
|
|
431
|
+
contentWithoutComment: content,
|
|
432
|
+
inlineComment: null
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
contentWithoutComment: content.slice(0, cutIndex),
|
|
437
|
+
inlineComment: content.slice(cutIndex)
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function attachNode(entry, depth, line, rootNodes, stack, diagnostics, mode) {
|
|
441
|
+
const lineNo = line.lineNo;
|
|
442
|
+
while (stack.length > depth) {
|
|
443
|
+
stack.pop();
|
|
444
|
+
}
|
|
445
|
+
let parent = null;
|
|
446
|
+
if (depth > 0) {
|
|
447
|
+
const candidate = stack[depth - 1];
|
|
448
|
+
if (!candidate) {
|
|
449
|
+
diagnostics.push({
|
|
450
|
+
line: lineNo,
|
|
451
|
+
message: `Entry has indent depth ${depth} but no parent at depth ${depth - 1}. Treating as root.`,
|
|
452
|
+
severity: mode === "strict" ? "error" : "warning",
|
|
453
|
+
code: "missing-parent"
|
|
454
|
+
});
|
|
455
|
+
} else if (candidate.type === "file") {
|
|
456
|
+
if (mode === "strict") {
|
|
457
|
+
diagnostics.push({
|
|
458
|
+
line: lineNo,
|
|
459
|
+
message: `Cannot attach child under file "${candidate.path}".`,
|
|
460
|
+
severity: "error",
|
|
461
|
+
code: "child-of-file"
|
|
462
|
+
});
|
|
463
|
+
} else {
|
|
464
|
+
diagnostics.push({
|
|
465
|
+
line: lineNo,
|
|
466
|
+
message: `Entry appears under file "${candidate.path}". Attaching as sibling at depth ${candidate.depth}.`,
|
|
467
|
+
severity: "warning",
|
|
468
|
+
code: "child-of-file-loose"
|
|
469
|
+
});
|
|
470
|
+
while (stack.length > candidate.depth) {
|
|
471
|
+
stack.pop();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
parent = candidate;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const parentPath = parent ? parent.path.replace(/\/$/, "") : "";
|
|
479
|
+
const normalizedSegment = toPosixPath(entry.segmentName.replace(/\/+$/, ""));
|
|
480
|
+
const fullPath = parentPath ? `${parentPath}/${normalizedSegment}${entry.isDir ? "/" : ""}` : `${normalizedSegment}${entry.isDir ? "/" : ""}`;
|
|
481
|
+
const baseNode = {
|
|
482
|
+
type: entry.isDir ? "dir" : "file",
|
|
483
|
+
name: entry.segmentName,
|
|
484
|
+
depth,
|
|
485
|
+
line: lineNo,
|
|
486
|
+
path: fullPath,
|
|
487
|
+
parent,
|
|
488
|
+
...entry.stub ? { stub: entry.stub } : {},
|
|
489
|
+
...entry.include ? { include: entry.include } : {},
|
|
490
|
+
...entry.exclude ? { exclude: entry.exclude } : {}
|
|
491
|
+
};
|
|
492
|
+
if (entry.isDir) {
|
|
493
|
+
const dirNode = {
|
|
494
|
+
...baseNode,
|
|
495
|
+
type: "dir",
|
|
496
|
+
children: []
|
|
497
|
+
};
|
|
498
|
+
if (parent) {
|
|
499
|
+
parent.children.push(dirNode);
|
|
500
|
+
} else {
|
|
501
|
+
rootNodes.push(dirNode);
|
|
502
|
+
}
|
|
503
|
+
while (stack.length > depth) {
|
|
504
|
+
stack.pop();
|
|
505
|
+
}
|
|
506
|
+
stack[depth] = dirNode;
|
|
507
|
+
} else {
|
|
508
|
+
const fileNode = {
|
|
509
|
+
...baseNode,
|
|
510
|
+
type: "file"
|
|
511
|
+
};
|
|
512
|
+
if (parent) {
|
|
513
|
+
parent.children.push(fileNode);
|
|
514
|
+
} else {
|
|
515
|
+
rootNodes.push(fileNode);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// src/ast/format.ts
|
|
521
|
+
function formatStructureText(text, options = {}) {
|
|
522
|
+
const indentStep = options.indentStep ?? 2;
|
|
523
|
+
const mode = options.mode ?? "loose";
|
|
524
|
+
const normalizeNewlines = options.normalizeNewlines === void 0 ? true : options.normalizeNewlines;
|
|
525
|
+
const trimTrailingWhitespace = options.trimTrailingWhitespace === void 0 ? true : options.trimTrailingWhitespace;
|
|
526
|
+
const normalizeAnnotations = options.normalizeAnnotations === void 0 ? true : options.normalizeAnnotations;
|
|
527
|
+
const ast = parseStructureAst(text, {
|
|
528
|
+
indentStep,
|
|
529
|
+
mode
|
|
530
|
+
});
|
|
531
|
+
const rawLines = text.split(/\r?\n/);
|
|
532
|
+
const lineCount = rawLines.length;
|
|
533
|
+
if (ast.lines.length !== lineCount) {
|
|
534
|
+
return {
|
|
535
|
+
text: basicNormalize(text, { normalizeNewlines, trimTrailingWhitespace }),
|
|
536
|
+
ast
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
const entryLineIndexes = [];
|
|
540
|
+
const inlineComments = [];
|
|
541
|
+
for (let i = 0; i < lineCount; i++) {
|
|
542
|
+
const lineMeta = ast.lines[i];
|
|
543
|
+
if (lineMeta.kind === "entry") {
|
|
544
|
+
entryLineIndexes.push(i);
|
|
545
|
+
const { inlineComment } = extractInlineCommentParts(lineMeta.content);
|
|
546
|
+
inlineComments.push(inlineComment);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const flattened = [];
|
|
550
|
+
flattenAstNodes(ast.rootNodes, 0, flattened);
|
|
551
|
+
if (flattened.length !== entryLineIndexes.length) {
|
|
552
|
+
return {
|
|
553
|
+
text: basicNormalize(text, { normalizeNewlines, trimTrailingWhitespace }),
|
|
554
|
+
ast
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const canonicalEntryLines = flattened.map(
|
|
558
|
+
({ node, level }) => formatAstNodeLine(node, level, indentStep, normalizeAnnotations)
|
|
559
|
+
);
|
|
560
|
+
const resultLines = [];
|
|
561
|
+
let entryIdx = 0;
|
|
562
|
+
for (let i = 0; i < lineCount; i++) {
|
|
563
|
+
const lineMeta = ast.lines[i];
|
|
564
|
+
const originalLine = rawLines[i];
|
|
565
|
+
if (lineMeta.kind === "entry") {
|
|
566
|
+
const base = canonicalEntryLines[entryIdx].replace(/[ \t]+$/g, "");
|
|
567
|
+
const inline = inlineComments[entryIdx];
|
|
568
|
+
entryIdx++;
|
|
569
|
+
if (inline) {
|
|
570
|
+
resultLines.push(base + " " + inline);
|
|
571
|
+
} else {
|
|
572
|
+
resultLines.push(base);
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
let out = originalLine;
|
|
576
|
+
if (trimTrailingWhitespace) {
|
|
577
|
+
out = out.replace(/[ \t]+$/g, "");
|
|
578
|
+
}
|
|
579
|
+
resultLines.push(out);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const eol = normalizeNewlines ? detectPreferredEol(text) : getRawEol(text);
|
|
583
|
+
return {
|
|
584
|
+
text: resultLines.join(eol),
|
|
585
|
+
ast
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function basicNormalize(text, opts) {
|
|
589
|
+
const lines = text.split(/\r?\n/);
|
|
590
|
+
const normalizedLines = opts.trimTrailingWhitespace ? lines.map((line) => line.replace(/[ \t]+$/g, "")) : lines;
|
|
591
|
+
const eol = opts.normalizeNewlines ? detectPreferredEol(text) : getRawEol(text);
|
|
592
|
+
return normalizedLines.join(eol);
|
|
593
|
+
}
|
|
594
|
+
function detectPreferredEol(text) {
|
|
595
|
+
const crlfCount = (text.match(/\r\n/g) || []).length;
|
|
596
|
+
const lfCount = (text.match(/(?<!\r)\n/g) || []).length;
|
|
597
|
+
if (crlfCount === 0 && lfCount === 0) {
|
|
598
|
+
return "\n";
|
|
599
|
+
}
|
|
600
|
+
if (crlfCount > lfCount) {
|
|
601
|
+
return "\r\n";
|
|
602
|
+
}
|
|
603
|
+
return "\n";
|
|
604
|
+
}
|
|
605
|
+
function getRawEol(text) {
|
|
606
|
+
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
607
|
+
}
|
|
608
|
+
function flattenAstNodes(nodes, level, out) {
|
|
609
|
+
for (const node of nodes) {
|
|
610
|
+
out.push({ node, level });
|
|
611
|
+
if (node.type === "dir" && node.children && node.children.length) {
|
|
612
|
+
flattenAstNodes(node.children, level + 1, out);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function formatAstNodeLine(node, level, indentStep, normalizeAnnotations) {
|
|
617
|
+
const indent = " ".repeat(indentStep * level);
|
|
618
|
+
const baseName = node.name;
|
|
619
|
+
if (!normalizeAnnotations) {
|
|
620
|
+
return indent + baseName;
|
|
621
|
+
}
|
|
622
|
+
const tokens = [];
|
|
623
|
+
if (node.stub) {
|
|
624
|
+
tokens.push(`@stub:${node.stub}`);
|
|
625
|
+
}
|
|
626
|
+
if (node.include && node.include.length > 0) {
|
|
627
|
+
tokens.push(`@include:${node.include.join(",")}`);
|
|
628
|
+
}
|
|
629
|
+
if (node.exclude && node.exclude.length > 0) {
|
|
630
|
+
tokens.push(`@exclude:${node.exclude.join(",")}`);
|
|
631
|
+
}
|
|
632
|
+
const annotations = tokens.length ? " " + tokens.join(" ") : "";
|
|
633
|
+
return indent + baseName + annotations;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// src/core/structure-txt.ts
|
|
637
|
+
function stripInlineComment(content) {
|
|
638
|
+
const cutIndex = mapThrough(content);
|
|
639
|
+
if (cutIndex === -1) {
|
|
640
|
+
return content.trimEnd();
|
|
641
|
+
}
|
|
642
|
+
return content.slice(0, cutIndex).trimEnd();
|
|
643
|
+
}
|
|
644
|
+
function parseLine(line, lineNo) {
|
|
645
|
+
const match = line.match(/^(\s*)(.*)$/);
|
|
646
|
+
if (!match) return null;
|
|
647
|
+
const indentSpaces = match[1].length;
|
|
648
|
+
let rest = match[2];
|
|
649
|
+
if (!rest.trim()) return null;
|
|
650
|
+
const trimmedRest = rest.trimStart();
|
|
651
|
+
if (trimmedRest.startsWith("#") || trimmedRest.startsWith("//")) {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
const stripped = stripInlineComment(rest);
|
|
655
|
+
const trimmed = stripped.trim();
|
|
656
|
+
if (!trimmed) return null;
|
|
657
|
+
const parts = trimmed.split(/\s+/);
|
|
658
|
+
if (!parts.length) return null;
|
|
659
|
+
const pathToken = parts[0];
|
|
660
|
+
if (pathToken.includes(":")) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`structure.txt: ":" is reserved for annotations (@stub:, @include:, etc). Invalid path "${pathToken}" on line ${lineNo}.`
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
let stub;
|
|
666
|
+
const include = [];
|
|
667
|
+
const exclude = [];
|
|
668
|
+
for (const token of parts.slice(1)) {
|
|
669
|
+
if (token.startsWith("@stub:")) {
|
|
670
|
+
stub = token.slice("@stub:".length);
|
|
671
|
+
} else if (token.startsWith("@include:")) {
|
|
672
|
+
const val = token.slice("@include:".length);
|
|
673
|
+
if (val) {
|
|
674
|
+
include.push(
|
|
675
|
+
...val.split(",").map((s) => s.trim()).filter(Boolean)
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
} else if (token.startsWith("@exclude:")) {
|
|
679
|
+
const val = token.slice("@exclude:".length);
|
|
680
|
+
if (val) {
|
|
681
|
+
exclude.push(
|
|
682
|
+
...val.split(",").map((s) => s.trim()).filter(Boolean)
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
lineNo,
|
|
689
|
+
indentSpaces,
|
|
690
|
+
rawPath: pathToken,
|
|
691
|
+
stub,
|
|
692
|
+
include: include.length ? include : void 0,
|
|
693
|
+
exclude: exclude.length ? exclude : void 0
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function parseStructureText(text, indentStep = 2) {
|
|
697
|
+
const lines = text.split(/\r?\n/);
|
|
698
|
+
const parsed = [];
|
|
699
|
+
for (let i = 0; i < lines.length; i++) {
|
|
700
|
+
const lineNo = i + 1;
|
|
701
|
+
const p = parseLine(lines[i], lineNo);
|
|
702
|
+
if (p) parsed.push(p);
|
|
703
|
+
}
|
|
704
|
+
const rootEntries = [];
|
|
705
|
+
const stack = [];
|
|
706
|
+
for (const p of parsed) {
|
|
707
|
+
const { indentSpaces, lineNo } = p;
|
|
708
|
+
if (indentSpaces % indentStep !== 0) {
|
|
709
|
+
throw new Error(
|
|
710
|
+
`structure.txt: Invalid indent on line ${lineNo}. Indent must be multiples of ${indentStep} spaces.`
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
const level = indentSpaces / indentStep;
|
|
714
|
+
if (level > stack.length) {
|
|
715
|
+
if (level !== stack.length + 1) {
|
|
716
|
+
throw new Error(
|
|
717
|
+
`structure.txt: Invalid indentation on line ${lineNo}. You cannot jump more than one level at a time. Previous depth: ${stack.length}, this line depth: ${level}.`
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (level > 0) {
|
|
722
|
+
const parent2 = stack[level - 1];
|
|
723
|
+
if (!parent2) {
|
|
724
|
+
throw new Error(
|
|
725
|
+
`structure.txt: Indented entry without a parent on line ${lineNo}.`
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
if (!parent2.isDir) {
|
|
729
|
+
throw new Error(
|
|
730
|
+
`structure.txt: Cannot indent under a file on line ${lineNo}. Files cannot have children. Parent: "${parent2.entry.path}".`
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const isDir = p.rawPath.endsWith("/");
|
|
735
|
+
const clean = p.rawPath.replace(/\/$/, "");
|
|
736
|
+
const basePath = toPosixPath(clean);
|
|
737
|
+
while (stack.length > level) {
|
|
738
|
+
stack.pop();
|
|
739
|
+
}
|
|
740
|
+
const parent = stack[stack.length - 1]?.entry;
|
|
741
|
+
const parentPath = parent ? parent.path.replace(/\/$/, "") : "";
|
|
742
|
+
const fullPath = parentPath ? `${parentPath}/${basePath}${isDir ? "/" : ""}` : `${basePath}${isDir ? "/" : ""}`;
|
|
743
|
+
if (isDir) {
|
|
744
|
+
const dirEntry = {
|
|
745
|
+
type: "dir",
|
|
746
|
+
path: fullPath,
|
|
747
|
+
children: [],
|
|
748
|
+
...p.stub ? { stub: p.stub } : {},
|
|
749
|
+
...p.include ? { include: p.include } : {},
|
|
750
|
+
...p.exclude ? { exclude: p.exclude } : {}
|
|
751
|
+
};
|
|
752
|
+
if (parent && parent.type === "dir") {
|
|
753
|
+
parent.children = parent.children ?? [];
|
|
754
|
+
parent.children.push(dirEntry);
|
|
755
|
+
} else if (!parent) {
|
|
756
|
+
rootEntries.push(dirEntry);
|
|
757
|
+
}
|
|
758
|
+
stack.push({ level, entry: dirEntry, isDir: true });
|
|
759
|
+
} else {
|
|
760
|
+
const fileEntry = {
|
|
761
|
+
type: "file",
|
|
762
|
+
path: fullPath,
|
|
763
|
+
...p.stub ? { stub: p.stub } : {},
|
|
764
|
+
...p.include ? { include: p.include } : {},
|
|
765
|
+
...p.exclude ? { exclude: p.exclude } : {}
|
|
766
|
+
};
|
|
767
|
+
if (parent && parent.type === "dir") {
|
|
768
|
+
parent.children = parent.children ?? [];
|
|
769
|
+
parent.children.push(fileEntry);
|
|
770
|
+
} else if (!parent) {
|
|
771
|
+
rootEntries.push(fileEntry);
|
|
772
|
+
}
|
|
773
|
+
stack.push({ level, entry: fileEntry, isDir: false });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return rootEntries;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/core/resolve-structure.ts
|
|
780
|
+
var logger2 = defaultLogger.child("[structure]");
|
|
781
|
+
function resolveGroupStructure(scaffoldDir, group) {
|
|
782
|
+
if (group.structure && group.structure.length) {
|
|
783
|
+
logger2.debug(`Using inline structure for group "${group.name}"`);
|
|
784
|
+
return group.structure;
|
|
785
|
+
}
|
|
786
|
+
const fileName = group.structureFile ?? `${group.name}.txt`;
|
|
787
|
+
const filePath = path2.join(scaffoldDir, fileName);
|
|
788
|
+
if (!fs8.existsSync(filePath)) {
|
|
789
|
+
throw new Error(
|
|
790
|
+
`@timeax/scaffold: Group "${group.name}" has no structure. Expected file "${fileName}" in "${scaffoldDir}".`
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
logger2.debug(`Reading structure for group "${group.name}" from ${filePath}`);
|
|
794
|
+
const raw = fs8.readFileSync(filePath, "utf8");
|
|
795
|
+
return parseStructureText(raw);
|
|
796
|
+
}
|
|
797
|
+
function resolveSingleStructure(scaffoldDir, config) {
|
|
798
|
+
if (config.structure && config.structure.length) {
|
|
799
|
+
logger2.debug("Using inline single structure (no groups)");
|
|
800
|
+
return config.structure;
|
|
801
|
+
}
|
|
802
|
+
const fileName = config.structureFile ?? "structure.txt";
|
|
803
|
+
const filePath = path2.join(scaffoldDir, fileName);
|
|
804
|
+
if (!fs8.existsSync(filePath)) {
|
|
805
|
+
throw new Error(
|
|
806
|
+
`@timeax/scaffold: No structure defined. Expected "${fileName}" in "${scaffoldDir}".`
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
logger2.debug(`Reading single structure from ${filePath}`);
|
|
810
|
+
const raw = fs8.readFileSync(filePath, "utf8");
|
|
811
|
+
return parseStructureText(raw);
|
|
812
|
+
}
|
|
813
|
+
var logger3 = defaultLogger.child("[cache]");
|
|
814
|
+
var DEFAULT_CACHE = {
|
|
815
|
+
version: 1,
|
|
816
|
+
entries: {}
|
|
817
|
+
};
|
|
818
|
+
var CacheManager = class {
|
|
819
|
+
constructor(projectRoot, cacheFileRelPath) {
|
|
820
|
+
this.projectRoot = projectRoot;
|
|
821
|
+
this.cacheFileRelPath = cacheFileRelPath;
|
|
822
|
+
}
|
|
823
|
+
cache = DEFAULT_CACHE;
|
|
824
|
+
get cachePathAbs() {
|
|
825
|
+
return path2.resolve(this.projectRoot, this.cacheFileRelPath);
|
|
826
|
+
}
|
|
827
|
+
load() {
|
|
828
|
+
const cachePath = this.cachePathAbs;
|
|
829
|
+
if (!fs8.existsSync(cachePath)) {
|
|
830
|
+
this.cache = { ...DEFAULT_CACHE, entries: {} };
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
try {
|
|
834
|
+
const raw = fs8.readFileSync(cachePath, "utf8");
|
|
835
|
+
const parsed = JSON.parse(raw);
|
|
836
|
+
if (parsed.version === 1 && parsed.entries) {
|
|
837
|
+
this.cache = parsed;
|
|
838
|
+
} else {
|
|
839
|
+
logger3.warn("Cache file version mismatch or invalid, resetting cache.");
|
|
840
|
+
this.cache = { ...DEFAULT_CACHE, entries: {} };
|
|
841
|
+
}
|
|
842
|
+
} catch (err) {
|
|
843
|
+
logger3.warn("Failed to read cache file, resetting cache.", err);
|
|
844
|
+
this.cache = { ...DEFAULT_CACHE, entries: {} };
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
save() {
|
|
848
|
+
const cachePath = this.cachePathAbs;
|
|
849
|
+
const dir = path2.dirname(cachePath);
|
|
850
|
+
ensureDirSync(dir);
|
|
851
|
+
fs8.writeFileSync(cachePath, JSON.stringify(this.cache, null, 2), "utf8");
|
|
852
|
+
}
|
|
853
|
+
get(relPath) {
|
|
854
|
+
const key = toPosixPath(relPath);
|
|
855
|
+
return this.cache.entries[key];
|
|
856
|
+
}
|
|
857
|
+
set(entry) {
|
|
858
|
+
const key = toPosixPath(entry.path);
|
|
859
|
+
this.cache.entries[key] = {
|
|
860
|
+
...entry,
|
|
861
|
+
path: key
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
delete(relPath) {
|
|
865
|
+
const key = toPosixPath(relPath);
|
|
866
|
+
delete this.cache.entries[key];
|
|
867
|
+
}
|
|
868
|
+
allPaths() {
|
|
869
|
+
return Object.keys(this.cache.entries);
|
|
870
|
+
}
|
|
871
|
+
allEntries() {
|
|
872
|
+
return Object.values(this.cache.entries);
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
function matchesFilter(pathRel, cfg) {
|
|
876
|
+
const { include, exclude, files } = cfg;
|
|
877
|
+
const patterns = [];
|
|
878
|
+
if (include?.length) patterns.push(...include);
|
|
879
|
+
if (files?.length) patterns.push(...files);
|
|
880
|
+
if (patterns.length) {
|
|
881
|
+
const ok = patterns.some((p) => minimatch(pathRel, p));
|
|
882
|
+
if (!ok) return false;
|
|
883
|
+
}
|
|
884
|
+
if (exclude?.length) {
|
|
885
|
+
const blocked = exclude.some((p) => minimatch(pathRel, p));
|
|
886
|
+
if (blocked) return false;
|
|
887
|
+
}
|
|
888
|
+
return true;
|
|
889
|
+
}
|
|
890
|
+
var HookRunner = class {
|
|
891
|
+
constructor(config) {
|
|
892
|
+
this.config = config;
|
|
893
|
+
}
|
|
894
|
+
async runRegular(kind, ctx) {
|
|
895
|
+
const configs = this.config.hooks?.[kind] ?? [];
|
|
896
|
+
for (const cfg of configs) {
|
|
897
|
+
if (!matchesFilter(ctx.targetPath, cfg)) continue;
|
|
898
|
+
await cfg.fn(ctx);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
getStubConfig(stubName) {
|
|
902
|
+
if (!stubName) return void 0;
|
|
903
|
+
return this.config.stubs?.[stubName];
|
|
904
|
+
}
|
|
905
|
+
async runStub(kind, ctx) {
|
|
906
|
+
const stub = this.getStubConfig(ctx.stubName);
|
|
907
|
+
if (!stub?.hooks) return;
|
|
908
|
+
const configs = kind === "preStub" ? stub.hooks.preStub ?? [] : stub.hooks.postStub ?? [];
|
|
909
|
+
for (const cfg of configs) {
|
|
910
|
+
if (!matchesFilter(ctx.targetPath, cfg)) continue;
|
|
911
|
+
await cfg.fn(ctx);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
async renderStubContent(ctx) {
|
|
915
|
+
const stub = this.getStubConfig(ctx.stubName);
|
|
916
|
+
if (!stub?.getContent) return void 0;
|
|
917
|
+
return stub.getContent(ctx);
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
async function applyStructure(opts) {
|
|
921
|
+
const {
|
|
922
|
+
config,
|
|
923
|
+
projectRoot,
|
|
924
|
+
baseDir,
|
|
925
|
+
structure,
|
|
926
|
+
cache,
|
|
927
|
+
hooks,
|
|
928
|
+
groupName,
|
|
929
|
+
groupRoot,
|
|
930
|
+
sizePromptThreshold,
|
|
931
|
+
interactiveDelete
|
|
932
|
+
} = opts;
|
|
933
|
+
const logger6 = opts.logger ?? defaultLogger.child(groupName ? `[apply:${groupName}]` : "[apply]");
|
|
934
|
+
const desiredPaths = /* @__PURE__ */ new Set();
|
|
935
|
+
const threshold = sizePromptThreshold ?? config.sizePromptThreshold ?? 128 * 1024;
|
|
936
|
+
async function walk(entry, inheritedStub) {
|
|
937
|
+
const effectiveStub = entry.stub ?? inheritedStub;
|
|
938
|
+
if (entry.type === "dir") {
|
|
939
|
+
await handleDir(entry, effectiveStub);
|
|
940
|
+
} else {
|
|
941
|
+
await handleFile(entry, effectiveStub);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
async function handleDir(entry, inheritedStub) {
|
|
945
|
+
const relFromBase = entry.path.replace(/^[./]+/, "");
|
|
946
|
+
const absDir = path2.resolve(baseDir, relFromBase);
|
|
947
|
+
const relFromRoot = toPosixPath(
|
|
948
|
+
toProjectRelativePath(projectRoot, absDir)
|
|
949
|
+
);
|
|
950
|
+
desiredPaths.add(relFromRoot);
|
|
951
|
+
ensureDirSync(absDir);
|
|
952
|
+
const nextStub = entry.stub ?? inheritedStub;
|
|
953
|
+
if (entry.children) {
|
|
954
|
+
for (const child of entry.children) {
|
|
955
|
+
await walk(child, nextStub);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
async function handleFile(entry, inheritedStub) {
|
|
960
|
+
const relFromBase = entry.path.replace(/^[./]+/, "");
|
|
961
|
+
const absFile = path2.resolve(baseDir, relFromBase);
|
|
962
|
+
const relFromRoot = toPosixPath(
|
|
963
|
+
toProjectRelativePath(projectRoot, absFile)
|
|
964
|
+
);
|
|
965
|
+
desiredPaths.add(relFromRoot);
|
|
966
|
+
const stubName = entry.stub ?? inheritedStub;
|
|
967
|
+
const ctx = {
|
|
968
|
+
projectRoot,
|
|
969
|
+
targetPath: relFromRoot,
|
|
970
|
+
absolutePath: absFile,
|
|
971
|
+
isDirectory: false,
|
|
972
|
+
stubName
|
|
973
|
+
};
|
|
974
|
+
if (fs8.existsSync(absFile)) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
await hooks.runRegular("preCreateFile", ctx);
|
|
978
|
+
const dir = path2.dirname(absFile);
|
|
979
|
+
ensureDirSync(dir);
|
|
980
|
+
if (stubName) {
|
|
981
|
+
await hooks.runStub("preStub", ctx);
|
|
982
|
+
}
|
|
983
|
+
let content = "";
|
|
984
|
+
const stubContent = await hooks.renderStubContent(ctx);
|
|
985
|
+
if (typeof stubContent === "string") {
|
|
986
|
+
content = stubContent;
|
|
987
|
+
}
|
|
988
|
+
fs8.writeFileSync(absFile, content, "utf8");
|
|
989
|
+
const stats = fs8.statSync(absFile);
|
|
990
|
+
cache.set({
|
|
991
|
+
path: relFromRoot,
|
|
992
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
993
|
+
sizeAtCreate: stats.size,
|
|
994
|
+
createdByStub: stubName,
|
|
995
|
+
groupName,
|
|
996
|
+
groupRoot
|
|
997
|
+
});
|
|
998
|
+
logger6.info(`created ${relFromRoot}`);
|
|
999
|
+
if (stubName) {
|
|
1000
|
+
await hooks.runStub("postStub", ctx);
|
|
1001
|
+
}
|
|
1002
|
+
await hooks.runRegular("postCreateFile", ctx);
|
|
1003
|
+
}
|
|
1004
|
+
for (const entry of structure) {
|
|
1005
|
+
await walk(entry);
|
|
1006
|
+
}
|
|
1007
|
+
for (const cachedPath of cache.allPaths()) {
|
|
1008
|
+
if (desiredPaths.has(cachedPath)) continue;
|
|
1009
|
+
const abs = path2.resolve(projectRoot, cachedPath);
|
|
1010
|
+
const stats = statSafeSync(abs);
|
|
1011
|
+
if (!stats) {
|
|
1012
|
+
cache.delete(cachedPath);
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
if (!stats.isFile()) {
|
|
1016
|
+
cache.delete(cachedPath);
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
const entry = cache.get(cachedPath);
|
|
1020
|
+
const ctx = {
|
|
1021
|
+
projectRoot,
|
|
1022
|
+
targetPath: cachedPath,
|
|
1023
|
+
absolutePath: abs,
|
|
1024
|
+
isDirectory: false,
|
|
1025
|
+
stubName: entry?.createdByStub
|
|
1026
|
+
};
|
|
1027
|
+
await hooks.runRegular("preDeleteFile", ctx);
|
|
1028
|
+
let shouldDelete = true;
|
|
1029
|
+
if (stats.size > threshold && interactiveDelete) {
|
|
1030
|
+
const res = await interactiveDelete({
|
|
1031
|
+
absolutePath: abs,
|
|
1032
|
+
relativePath: cachedPath,
|
|
1033
|
+
size: stats.size,
|
|
1034
|
+
createdByStub: entry?.createdByStub,
|
|
1035
|
+
groupName: entry?.groupName
|
|
1036
|
+
});
|
|
1037
|
+
if (res === "keep") {
|
|
1038
|
+
shouldDelete = false;
|
|
1039
|
+
cache.delete(cachedPath);
|
|
1040
|
+
logger6.info(`keeping ${cachedPath} (removed from cache)`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (shouldDelete) {
|
|
1044
|
+
try {
|
|
1045
|
+
fs8.unlinkSync(abs);
|
|
1046
|
+
logger6.info(`deleted ${cachedPath}`);
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
logger6.warn(`failed to delete ${cachedPath}`, err);
|
|
1049
|
+
}
|
|
1050
|
+
cache.delete(cachedPath);
|
|
1051
|
+
await hooks.runRegular("postDeleteFile", ctx);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
var logger4 = defaultLogger.child("[scan]");
|
|
1056
|
+
var DEFAULT_IGNORE = [
|
|
1057
|
+
"node_modules/**",
|
|
1058
|
+
".git/**",
|
|
1059
|
+
"dist/**",
|
|
1060
|
+
"build/**",
|
|
1061
|
+
".turbo/**",
|
|
1062
|
+
".next/**",
|
|
1063
|
+
"coverage/**"
|
|
1064
|
+
];
|
|
1065
|
+
function scanDirectoryToStructureText(rootDir, options = {}) {
|
|
1066
|
+
const absRoot = path2.resolve(rootDir);
|
|
1067
|
+
const lines = [];
|
|
1068
|
+
const ignorePatterns = options.ignore ?? DEFAULT_IGNORE;
|
|
1069
|
+
const maxDepth = options.maxDepth ?? Infinity;
|
|
1070
|
+
function isIgnored(absPath) {
|
|
1071
|
+
const rel = toPosixPath(path2.relative(absRoot, absPath));
|
|
1072
|
+
if (!rel || rel === ".") return false;
|
|
1073
|
+
return ignorePatterns.some(
|
|
1074
|
+
(pattern) => minimatch(rel, pattern, { dot: true })
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
function walk(currentAbs, depth) {
|
|
1078
|
+
if (depth > maxDepth) return;
|
|
1079
|
+
let dirents;
|
|
1080
|
+
try {
|
|
1081
|
+
dirents = fs8.readdirSync(currentAbs, { withFileTypes: true });
|
|
1082
|
+
} catch {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
dirents.sort((a, b) => {
|
|
1086
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
1087
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
1088
|
+
return a.name.localeCompare(b.name);
|
|
1089
|
+
});
|
|
1090
|
+
for (const dirent of dirents) {
|
|
1091
|
+
const name = dirent.name;
|
|
1092
|
+
const absPath = path2.join(currentAbs, name);
|
|
1093
|
+
if (isIgnored(absPath)) continue;
|
|
1094
|
+
const indent = " ".repeat(depth);
|
|
1095
|
+
if (dirent.isDirectory()) {
|
|
1096
|
+
lines.push(`${indent}${name}/`);
|
|
1097
|
+
walk(absPath, depth + 1);
|
|
1098
|
+
} else if (dirent.isFile()) {
|
|
1099
|
+
lines.push(`${indent}${name}`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
walk(absRoot, 0);
|
|
1104
|
+
return lines.join("\n");
|
|
1105
|
+
}
|
|
1106
|
+
async function scanProjectFromConfig(cwd, options = {}) {
|
|
1107
|
+
const { config, scaffoldDir, projectRoot } = await loadScaffoldConfig(cwd, {
|
|
1108
|
+
scaffoldDir: options.scaffoldDir
|
|
1109
|
+
});
|
|
1110
|
+
const ignorePatterns = options.ignore ?? DEFAULT_IGNORE;
|
|
1111
|
+
const maxDepth = options.maxDepth ?? Infinity;
|
|
1112
|
+
const onlyGroups = options.groups;
|
|
1113
|
+
const results = [];
|
|
1114
|
+
function scanGroup(cfg, group) {
|
|
1115
|
+
const rootAbs = path2.resolve(projectRoot, group.root);
|
|
1116
|
+
const text = scanDirectoryToStructureText(rootAbs, {
|
|
1117
|
+
ignore: ignorePatterns,
|
|
1118
|
+
maxDepth
|
|
1119
|
+
});
|
|
1120
|
+
const structureFileName = group.structureFile ?? `${group.name}.txt`;
|
|
1121
|
+
const structureFilePath = path2.join(scaffoldDir, structureFileName);
|
|
1122
|
+
return {
|
|
1123
|
+
groupName: group.name,
|
|
1124
|
+
groupRoot: group.root,
|
|
1125
|
+
structureFileName,
|
|
1126
|
+
structureFilePath,
|
|
1127
|
+
text
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
if (config.groups && config.groups.length > 0) {
|
|
1131
|
+
logger4.debug(
|
|
1132
|
+
`Scanning project from config with ${config.groups.length} group(s).`
|
|
1133
|
+
);
|
|
1134
|
+
for (const group of config.groups) {
|
|
1135
|
+
if (onlyGroups && !onlyGroups.includes(group.name)) {
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
const result = scanGroup(config, group);
|
|
1139
|
+
results.push(result);
|
|
1140
|
+
}
|
|
1141
|
+
} else {
|
|
1142
|
+
logger4.debug("Scanning project in single-root mode (no groups).");
|
|
1143
|
+
const text = scanDirectoryToStructureText(projectRoot, {
|
|
1144
|
+
ignore: ignorePatterns,
|
|
1145
|
+
maxDepth
|
|
1146
|
+
});
|
|
1147
|
+
const structureFileName = config.structureFile ?? "structure.txt";
|
|
1148
|
+
const structureFilePath = path2.join(scaffoldDir, structureFileName);
|
|
1149
|
+
results.push({
|
|
1150
|
+
groupName: "default",
|
|
1151
|
+
groupRoot: ".",
|
|
1152
|
+
structureFileName,
|
|
1153
|
+
structureFilePath,
|
|
1154
|
+
text
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
return results;
|
|
1158
|
+
}
|
|
1159
|
+
async function writeScannedStructuresFromConfig(cwd, options = {}) {
|
|
1160
|
+
const { scaffoldDir } = await loadScaffoldConfig(cwd, {
|
|
1161
|
+
scaffoldDir: options.scaffoldDir
|
|
1162
|
+
});
|
|
1163
|
+
ensureDirSync(scaffoldDir);
|
|
1164
|
+
const results = await scanProjectFromConfig(cwd, options);
|
|
1165
|
+
for (const result of results) {
|
|
1166
|
+
fs8.writeFileSync(result.structureFilePath, result.text, "utf8");
|
|
1167
|
+
logger4.info(
|
|
1168
|
+
`Wrote structure for group "${result.groupName}" to ${result.structureFilePath}`
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
async function ensureStructureFilesFromConfig(cwd, options = {}) {
|
|
1173
|
+
const { config, scaffoldDir } = await loadScaffoldConfig(cwd, {
|
|
1174
|
+
scaffoldDir: options.scaffoldDirOverride
|
|
1175
|
+
});
|
|
1176
|
+
ensureDirSync(scaffoldDir);
|
|
1177
|
+
const created = [];
|
|
1178
|
+
const existing = [];
|
|
1179
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1180
|
+
const ensureFile = (fileName) => {
|
|
1181
|
+
if (!fileName) return;
|
|
1182
|
+
const filePath = path2.join(scaffoldDir, fileName);
|
|
1183
|
+
const key = path2.resolve(filePath);
|
|
1184
|
+
if (seen.has(key)) return;
|
|
1185
|
+
seen.add(key);
|
|
1186
|
+
if (fs8.existsSync(filePath)) {
|
|
1187
|
+
existing.push(filePath);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
const header = `# ${fileName}
|
|
1191
|
+
# Structure file for @timeax/scaffold
|
|
1192
|
+
# Define your desired folders/files here.
|
|
1193
|
+
`;
|
|
1194
|
+
fs8.writeFileSync(filePath, header, "utf8");
|
|
1195
|
+
created.push(filePath);
|
|
1196
|
+
};
|
|
1197
|
+
if (config.groups && config.groups.length > 0) {
|
|
1198
|
+
for (const group of config.groups) {
|
|
1199
|
+
const fileName = group.structureFile ?? `${group.name}.txt`;
|
|
1200
|
+
ensureFile(fileName);
|
|
1201
|
+
}
|
|
1202
|
+
} else {
|
|
1203
|
+
const fileName = config.structureFile ?? "structure.txt";
|
|
1204
|
+
ensureFile(fileName);
|
|
1205
|
+
}
|
|
1206
|
+
logger4.debug(
|
|
1207
|
+
`ensureStructureFilesFromConfig: created=${created.length}, existing=${existing.length}`
|
|
1208
|
+
);
|
|
1209
|
+
return { created, existing };
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// src/core/format.ts
|
|
1213
|
+
function getStructureFilesFromConfig(projectRoot, scaffoldDir, config) {
|
|
1214
|
+
const baseDir = path2.resolve(projectRoot, scaffoldDir || SCAFFOLD_ROOT_DIR);
|
|
1215
|
+
const files = [];
|
|
1216
|
+
if (config.groups && config.groups.length > 0) {
|
|
1217
|
+
for (const group of config.groups) {
|
|
1218
|
+
const structureFile = group.structureFile && group.structureFile.trim().length ? group.structureFile : `${group.name}.txt`;
|
|
1219
|
+
files.push(path2.join(baseDir, structureFile));
|
|
1220
|
+
}
|
|
1221
|
+
} else {
|
|
1222
|
+
const structureFile = config.structureFile || "structure.txt";
|
|
1223
|
+
files.push(path2.join(baseDir, structureFile));
|
|
1224
|
+
}
|
|
1225
|
+
return files;
|
|
1226
|
+
}
|
|
1227
|
+
async function formatStructureFilesFromConfig(projectRoot, scaffoldDir, config, opts = {}) {
|
|
1228
|
+
const formatCfg = config.format;
|
|
1229
|
+
const enabled = !!(formatCfg?.enabled || opts.force);
|
|
1230
|
+
if (!enabled) return;
|
|
1231
|
+
const files = getStructureFilesFromConfig(projectRoot, scaffoldDir, config);
|
|
1232
|
+
const indentStep = formatCfg?.indentStep ?? config.indentStep ?? 2;
|
|
1233
|
+
const mode = formatCfg?.mode ?? "loose";
|
|
1234
|
+
!!formatCfg?.sortEntries;
|
|
1235
|
+
for (const filePath of files) {
|
|
1236
|
+
let text;
|
|
1237
|
+
try {
|
|
1238
|
+
text = fs8.readFileSync(filePath, "utf8");
|
|
1239
|
+
} catch {
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
const { text: formatted } = formatStructureText(text, {
|
|
1243
|
+
indentStep,
|
|
1244
|
+
mode});
|
|
1245
|
+
if (formatted !== text) {
|
|
1246
|
+
fs8.writeFileSync(filePath, formatted, "utf8");
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// src/core/runner.ts
|
|
1252
|
+
async function runOnce(cwd, options = {}) {
|
|
1253
|
+
const logger6 = options.logger ?? defaultLogger.child("[runner]");
|
|
1254
|
+
const { config, scaffoldDir, projectRoot } = await loadScaffoldConfig(cwd, {
|
|
1255
|
+
scaffoldDir: options.scaffoldDir,
|
|
1256
|
+
configPath: options.configPath
|
|
1257
|
+
});
|
|
1258
|
+
await formatStructureFilesFromConfig(projectRoot, scaffoldDir, config, { force: options.format });
|
|
1259
|
+
const cachePath = config.cacheFile ?? ".scaffold-cache.json";
|
|
1260
|
+
const cache = new CacheManager(projectRoot, cachePath);
|
|
1261
|
+
cache.load();
|
|
1262
|
+
const hooks = new HookRunner(config);
|
|
1263
|
+
if (config.groups && config.groups.length > 0) {
|
|
1264
|
+
for (const group of config.groups) {
|
|
1265
|
+
const groupRootAbs = path2.resolve(projectRoot, group.root);
|
|
1266
|
+
const structure = resolveGroupStructure(scaffoldDir, group);
|
|
1267
|
+
const groupLogger = logger6.child(`[group:${group.name}]`);
|
|
1268
|
+
await applyStructure({
|
|
1269
|
+
config,
|
|
1270
|
+
projectRoot,
|
|
1271
|
+
baseDir: groupRootAbs,
|
|
1272
|
+
structure,
|
|
1273
|
+
cache,
|
|
1274
|
+
hooks,
|
|
1275
|
+
groupName: group.name,
|
|
1276
|
+
groupRoot: group.root,
|
|
1277
|
+
interactiveDelete: options.interactiveDelete,
|
|
1278
|
+
logger: groupLogger
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
} else {
|
|
1282
|
+
const structure = resolveSingleStructure(scaffoldDir, config);
|
|
1283
|
+
const baseLogger = logger6.child("[group:default]");
|
|
1284
|
+
await applyStructure({
|
|
1285
|
+
config,
|
|
1286
|
+
projectRoot,
|
|
1287
|
+
baseDir: projectRoot,
|
|
1288
|
+
structure,
|
|
1289
|
+
cache,
|
|
1290
|
+
hooks,
|
|
1291
|
+
groupName: "default",
|
|
1292
|
+
groupRoot: ".",
|
|
1293
|
+
interactiveDelete: options.interactiveDelete,
|
|
1294
|
+
logger: baseLogger
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
cache.save();
|
|
1298
|
+
}
|
|
1299
|
+
function watchScaffold(cwd, options = {}) {
|
|
1300
|
+
const logger6 = options.logger ?? defaultLogger.child("[watch]");
|
|
1301
|
+
const scaffoldDir = options.scaffoldDir ? path2.resolve(cwd, options.scaffoldDir) : path2.resolve(cwd, SCAFFOLD_ROOT_DIR);
|
|
1302
|
+
const debounceMs = options.debounceMs ?? 150;
|
|
1303
|
+
logger6.info(`Watching scaffold directory: ${scaffoldDir}`);
|
|
1304
|
+
let timer;
|
|
1305
|
+
let running = false;
|
|
1306
|
+
let pending = false;
|
|
1307
|
+
async function run() {
|
|
1308
|
+
if (running) {
|
|
1309
|
+
pending = true;
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
running = true;
|
|
1313
|
+
try {
|
|
1314
|
+
logger6.info("Change detected \u2192 running scaffold...");
|
|
1315
|
+
await runOnce(cwd, {
|
|
1316
|
+
...options,
|
|
1317
|
+
// we already resolved scaffoldDir for watcher; pass it down
|
|
1318
|
+
scaffoldDir
|
|
1319
|
+
});
|
|
1320
|
+
logger6.info("Scaffold run completed.");
|
|
1321
|
+
} catch (err) {
|
|
1322
|
+
logger6.error("Scaffold run failed:", err);
|
|
1323
|
+
} finally {
|
|
1324
|
+
running = false;
|
|
1325
|
+
if (pending) {
|
|
1326
|
+
pending = false;
|
|
1327
|
+
timer = setTimeout(run, debounceMs);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
function scheduleRun() {
|
|
1332
|
+
if (timer) clearTimeout(timer);
|
|
1333
|
+
timer = setTimeout(run, debounceMs);
|
|
1334
|
+
}
|
|
1335
|
+
const watcher = chokidar.watch(
|
|
1336
|
+
[
|
|
1337
|
+
// config files (ts/js/etc.)
|
|
1338
|
+
path2.join(scaffoldDir, "config.*"),
|
|
1339
|
+
// structure files: plain txt + our custom extensions
|
|
1340
|
+
path2.join(scaffoldDir, "*.txt"),
|
|
1341
|
+
path2.join(scaffoldDir, "*.tss"),
|
|
1342
|
+
path2.join(scaffoldDir, "*.stx")
|
|
1343
|
+
],
|
|
1344
|
+
{
|
|
1345
|
+
ignoreInitial: false
|
|
1346
|
+
}
|
|
1347
|
+
);
|
|
1348
|
+
watcher.on("add", (filePath) => {
|
|
1349
|
+
logger6.debug(`File added: ${filePath}`);
|
|
1350
|
+
scheduleRun();
|
|
1351
|
+
}).on("change", (filePath) => {
|
|
1352
|
+
logger6.debug(`File changed: ${filePath}`);
|
|
1353
|
+
scheduleRun();
|
|
1354
|
+
}).on("unlink", (filePath) => {
|
|
1355
|
+
logger6.debug(`File removed: ${filePath}`);
|
|
1356
|
+
scheduleRun();
|
|
1357
|
+
}).on("error", (error) => {
|
|
1358
|
+
logger6.error("Watcher error:", error);
|
|
1359
|
+
});
|
|
1360
|
+
scheduleRun();
|
|
1361
|
+
}
|
|
1362
|
+
var logger5 = defaultLogger.child("[init]");
|
|
1363
|
+
var DEFAULT_CONFIG_TS = `import type { ScaffoldConfig } from '@timeax/scaffold';
|
|
1364
|
+
|
|
1365
|
+
const config: ScaffoldConfig = {
|
|
1366
|
+
// Root for resolving the .scaffold folder & this config file.
|
|
1367
|
+
// By default, this is the directory where you run \`scaffold\`.
|
|
1368
|
+
// Example:
|
|
1369
|
+
// root: '.', // .scaffold at <cwd>/.scaffold
|
|
1370
|
+
// root: 'tools', // .scaffold at <cwd>/tools/.scaffold
|
|
1371
|
+
// root: '.',
|
|
1372
|
+
|
|
1373
|
+
// Base directory where structures are applied and files/folders are created.
|
|
1374
|
+
// This is resolved relative to \`root\` above. Defaults to the same as root.
|
|
1375
|
+
// Example:
|
|
1376
|
+
// base: '.', // apply to <root>
|
|
1377
|
+
// base: 'src', // apply to <root>/src
|
|
1378
|
+
// base: '..', // apply to parent of <root>
|
|
1379
|
+
// base: '.',
|
|
1380
|
+
|
|
1381
|
+
// Number of spaces per indent level in structure files (default: 2).
|
|
1382
|
+
// This also informs the formatter when indenting entries.
|
|
1383
|
+
// indentStep: 2,
|
|
1384
|
+
|
|
1385
|
+
// Cache file path, relative to base.
|
|
1386
|
+
// cacheFile: '.scaffold-cache.json',
|
|
1387
|
+
|
|
1388
|
+
// Formatting options for structure files.
|
|
1389
|
+
// These are used by:
|
|
1390
|
+
// - \`scaffold --format\` (forces formatting before apply)
|
|
1391
|
+
// - \`scaffold --watch\` when \`formatOnWatch\` is true
|
|
1392
|
+
//
|
|
1393
|
+
// format: {
|
|
1394
|
+
// // Enable config-driven formatting in general.
|
|
1395
|
+
// // \`scaffold --format\` always forces formatting even if this is false.
|
|
1396
|
+
// enabled: true,
|
|
1397
|
+
//
|
|
1398
|
+
// // Override indent step specifically for formatting (falls back to
|
|
1399
|
+
// // top-level \`indentStep\` if omitted).
|
|
1400
|
+
// indentStep: 2,
|
|
1401
|
+
//
|
|
1402
|
+
// // AST mode:
|
|
1403
|
+
// // - 'loose' (default): tries to repair mild indentation issues.
|
|
1404
|
+
// // - 'strict': mostly cosmetic changes (trims trailing whitespace, etc.).
|
|
1405
|
+
// mode: 'loose',
|
|
1406
|
+
//
|
|
1407
|
+
// // Sort non-comment entries lexicographically within their parent block.
|
|
1408
|
+
// // Comments and blank lines keep their relative positions.
|
|
1409
|
+
// sortEntries: true,
|
|
1410
|
+
//
|
|
1411
|
+
// // When running \`scaffold --watch\`, format structure files on each
|
|
1412
|
+
// // detected change before applying scaffold.
|
|
1413
|
+
// formatOnWatch: true,
|
|
1414
|
+
// },
|
|
1415
|
+
|
|
1416
|
+
// --- Single-structure mode (simple) ---
|
|
1417
|
+
// structureFile: 'structure.txt',
|
|
1418
|
+
|
|
1419
|
+
// --- Grouped mode (uncomment and adjust) ---
|
|
1420
|
+
// groups: [
|
|
1421
|
+
// { name: 'app', root: 'app', structureFile: 'app.txt' },
|
|
1422
|
+
// { name: 'frontend', root: 'resources/js', structureFile: 'frontend.txt' },
|
|
1423
|
+
// ],
|
|
1424
|
+
|
|
1425
|
+
hooks: {
|
|
1426
|
+
// preCreateFile: [],
|
|
1427
|
+
// postCreateFile: [],
|
|
1428
|
+
// preDeleteFile: [],
|
|
1429
|
+
// postDeleteFile: [],
|
|
1430
|
+
},
|
|
1431
|
+
|
|
1432
|
+
stubs: {
|
|
1433
|
+
// Example:
|
|
1434
|
+
// page: {
|
|
1435
|
+
// name: 'page',
|
|
1436
|
+
// getContent: (ctx) =>
|
|
1437
|
+
// \`export default function Page() { return <div>\${ctx.targetPath}</div>; }\`,
|
|
1438
|
+
// },
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
export default config;
|
|
1443
|
+
`;
|
|
1444
|
+
var DEFAULT_STRUCTURE_TXT = `# ${SCAFFOLD_ROOT_DIR}/structure.txt
|
|
1445
|
+
# Example structure definition.
|
|
1446
|
+
# - Indent with 2 spaces per level (or your configured indentStep)
|
|
1447
|
+
# - Directories must end with "/"
|
|
1448
|
+
# - Files do not
|
|
1449
|
+
# - Lines starting with "#" are comments and ignored by parser
|
|
1450
|
+
# - Inline comments are allowed after "#" or "//" separated by whitespace
|
|
1451
|
+
#
|
|
1452
|
+
# The formatter (when enabled via config.format or --format) will:
|
|
1453
|
+
# - Normalize indentation based on indentStep
|
|
1454
|
+
# - Preserve blank lines and comments
|
|
1455
|
+
# - Keep inline comments attached to their entries
|
|
1456
|
+
#
|
|
1457
|
+
# Example:
|
|
1458
|
+
# src/
|
|
1459
|
+
# index.ts
|
|
1460
|
+
`;
|
|
1461
|
+
async function initScaffold(cwd, options = {}) {
|
|
1462
|
+
const scaffoldDirRel = options.scaffoldDir ?? SCAFFOLD_ROOT_DIR;
|
|
1463
|
+
const scaffoldDirAbs = path2.resolve(cwd, scaffoldDirRel);
|
|
1464
|
+
const configFileName = options.configFileName ?? "config.ts";
|
|
1465
|
+
const structureFileName = options.structureFileName ?? "structure.txt";
|
|
1466
|
+
ensureDirSync(scaffoldDirAbs);
|
|
1467
|
+
const configPath = path2.join(scaffoldDirAbs, configFileName);
|
|
1468
|
+
const structurePath = path2.join(scaffoldDirAbs, structureFileName);
|
|
1469
|
+
let createdConfig = false;
|
|
1470
|
+
let createdStructure = false;
|
|
1471
|
+
if (fs8.existsSync(configPath) && !options.force) {
|
|
1472
|
+
logger5.info(
|
|
1473
|
+
`Config already exists at ${configPath} (use --force to overwrite).`
|
|
1474
|
+
);
|
|
1475
|
+
} else {
|
|
1476
|
+
fs8.writeFileSync(configPath, DEFAULT_CONFIG_TS, "utf8");
|
|
1477
|
+
createdConfig = true;
|
|
1478
|
+
logger5.info(
|
|
1479
|
+
`${fs8.existsSync(configPath) ? "Overwrote" : "Created"} config at ${configPath}`
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
if (fs8.existsSync(structurePath) && !options.force) {
|
|
1483
|
+
logger5.info(
|
|
1484
|
+
`Structure file already exists at ${structurePath} (use --force to overwrite).`
|
|
1485
|
+
);
|
|
1486
|
+
} else {
|
|
1487
|
+
fs8.writeFileSync(structurePath, DEFAULT_STRUCTURE_TXT, "utf8");
|
|
1488
|
+
createdStructure = true;
|
|
1489
|
+
logger5.info(
|
|
1490
|
+
`${fs8.existsSync(structurePath) ? "Overwrote" : "Created"} structure file at ${structurePath}`
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
return {
|
|
1494
|
+
scaffoldDir: scaffoldDirAbs,
|
|
1495
|
+
configPath,
|
|
1496
|
+
structurePath,
|
|
1497
|
+
created: {
|
|
1498
|
+
config: createdConfig,
|
|
1499
|
+
structure: createdStructure
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// src/cli/main.ts
|
|
1505
|
+
function createCliLogger(opts) {
|
|
1506
|
+
if (opts.quiet) {
|
|
1507
|
+
defaultLogger.setLevel("silent");
|
|
1508
|
+
} else if (opts.debug) {
|
|
1509
|
+
defaultLogger.setLevel("debug");
|
|
1510
|
+
}
|
|
1511
|
+
return defaultLogger.child("[cli]");
|
|
1512
|
+
}
|
|
1513
|
+
function askYesNo(question) {
|
|
1514
|
+
const rl = readline.createInterface({
|
|
1515
|
+
input: process.stdin,
|
|
1516
|
+
output: process.stdout
|
|
1517
|
+
});
|
|
1518
|
+
return new Promise((resolve) => {
|
|
1519
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
1520
|
+
rl.close();
|
|
1521
|
+
const val = answer.trim().toLowerCase();
|
|
1522
|
+
if (val === "y" || val === "yes") {
|
|
1523
|
+
resolve("delete");
|
|
1524
|
+
} else {
|
|
1525
|
+
resolve("keep");
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
async function handleRunCommand(cwd, baseOpts) {
|
|
1531
|
+
const logger6 = createCliLogger(baseOpts);
|
|
1532
|
+
const configPath = baseOpts.config ? path2.resolve(cwd, baseOpts.config) : void 0;
|
|
1533
|
+
const scaffoldDir = baseOpts.dir ? path2.resolve(cwd, baseOpts.dir) : void 0;
|
|
1534
|
+
logger6.debug(
|
|
1535
|
+
`Starting scaffold (cwd=${cwd}, config=${configPath ?? "auto"}, dir=${scaffoldDir ?? "scaffold/"}, watch=${baseOpts.watch ? "yes" : "no"})`
|
|
1536
|
+
);
|
|
1537
|
+
const runnerOptions = {
|
|
1538
|
+
configPath,
|
|
1539
|
+
scaffoldDir,
|
|
1540
|
+
logger: logger6,
|
|
1541
|
+
interactiveDelete: async ({
|
|
1542
|
+
relativePath,
|
|
1543
|
+
size,
|
|
1544
|
+
createdByStub,
|
|
1545
|
+
groupName
|
|
1546
|
+
}) => {
|
|
1547
|
+
const sizeKb = (size / 1024).toFixed(1);
|
|
1548
|
+
const stubInfo = createdByStub ? ` (stub: ${createdByStub})` : "";
|
|
1549
|
+
const groupInfo = groupName ? ` [group: ${groupName}]` : "";
|
|
1550
|
+
const question = `File "${relativePath}"${groupInfo} is ~${sizeKb}KB and no longer in structure${stubInfo}. Delete it?`;
|
|
1551
|
+
return askYesNo(question);
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
if (baseOpts.watch) {
|
|
1555
|
+
watchScaffold(cwd, runnerOptions);
|
|
1556
|
+
} else {
|
|
1557
|
+
await runOnce(cwd, runnerOptions);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
async function handleScanCommand(cwd, scanOpts, baseOpts) {
|
|
1561
|
+
const logger6 = createCliLogger(baseOpts);
|
|
1562
|
+
const useConfigMode = scanOpts.fromConfig || !scanOpts.root && !scanOpts.out;
|
|
1563
|
+
if (useConfigMode) {
|
|
1564
|
+
logger6.info("Scanning project using scaffold config/groups...");
|
|
1565
|
+
await writeScannedStructuresFromConfig(cwd, {
|
|
1566
|
+
ignore: scanOpts.ignore,
|
|
1567
|
+
groups: scanOpts.groups
|
|
1568
|
+
});
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const rootDir = path2.resolve(cwd, scanOpts.root ?? ".");
|
|
1572
|
+
const ignore = scanOpts.ignore ?? [];
|
|
1573
|
+
logger6.info(`Scanning directory for structure: ${rootDir}`);
|
|
1574
|
+
const text = scanDirectoryToStructureText(rootDir, {
|
|
1575
|
+
ignore
|
|
1576
|
+
});
|
|
1577
|
+
if (scanOpts.out) {
|
|
1578
|
+
const outPath = path2.resolve(cwd, scanOpts.out);
|
|
1579
|
+
const dir = path2.dirname(outPath);
|
|
1580
|
+
ensureDirSync(dir);
|
|
1581
|
+
fs8.writeFileSync(outPath, text, "utf8");
|
|
1582
|
+
logger6.info(`Wrote structure to ${outPath}`);
|
|
1583
|
+
} else {
|
|
1584
|
+
process.stdout.write(text + "\n");
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
async function handleInitCommand(cwd, initOpts, baseOpts) {
|
|
1588
|
+
const logger6 = createCliLogger(baseOpts);
|
|
1589
|
+
const scaffoldDirRel = baseOpts.dir ?? "scaffold";
|
|
1590
|
+
logger6.info(`Initializing scaffold directory at "${scaffoldDirRel}"...`);
|
|
1591
|
+
const result = await initScaffold(cwd, {
|
|
1592
|
+
scaffoldDir: scaffoldDirRel,
|
|
1593
|
+
force: initOpts.force
|
|
1594
|
+
});
|
|
1595
|
+
logger6.info(
|
|
1596
|
+
`Done. Config: ${result.configPath}, Structure: ${result.structurePath}`
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
async function handleStructuresCommand(cwd, baseOpts) {
|
|
1600
|
+
const logger6 = createCliLogger(baseOpts);
|
|
1601
|
+
logger6.info("Ensuring structure files declared in config exist...");
|
|
1602
|
+
const { created, existing } = await ensureStructureFilesFromConfig(cwd, {
|
|
1603
|
+
scaffoldDirOverride: baseOpts.dir
|
|
1604
|
+
});
|
|
1605
|
+
if (created.length === 0) {
|
|
1606
|
+
logger6.info("All structure files already exist. Nothing to do.");
|
|
1607
|
+
} else {
|
|
1608
|
+
for (const filePath of created) {
|
|
1609
|
+
logger6.info(`Created structure file: ${filePath}`);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
existing.forEach((p) => logger6.debug(`Structure file already exists: ${p}`));
|
|
1613
|
+
}
|
|
1614
|
+
async function main() {
|
|
1615
|
+
const cwd = process.cwd();
|
|
1616
|
+
const program = new Command();
|
|
1617
|
+
program.name("scaffold").description("@timeax/scaffold \u2013 structure-based project scaffolding").option("-c, --config <path>", "Path to scaffold config file").option("-d, --dir <path>", "Path to scaffold directory (default: ./scaffold)").option("-w, --watch", "Watch scaffold directory for changes").option("--quiet", "Silence logs").option("--debug", "Enable debug logging");
|
|
1618
|
+
program.command("scan").description(
|
|
1619
|
+
"Generate structure.txt-style output (config-aware by default, or manual root/out)"
|
|
1620
|
+
).option(
|
|
1621
|
+
"--from-config",
|
|
1622
|
+
"Scan based on scaffold config/groups and write structure files into scaffold/ (default if no root/out specified)"
|
|
1623
|
+
).option(
|
|
1624
|
+
"-r, --root <path>",
|
|
1625
|
+
"Root directory to scan (manual mode)"
|
|
1626
|
+
).option(
|
|
1627
|
+
"-o, --out <path>",
|
|
1628
|
+
"Output file path (manual mode)"
|
|
1629
|
+
).option(
|
|
1630
|
+
"--ignore <patterns...>",
|
|
1631
|
+
"Additional glob patterns to ignore (relative to root)"
|
|
1632
|
+
).option(
|
|
1633
|
+
"--groups <names...>",
|
|
1634
|
+
"Limit config-based scanning to specific groups (by name)"
|
|
1635
|
+
).action(async (scanOpts, cmd) => {
|
|
1636
|
+
const baseOpts = cmd.parent?.opts() ?? {};
|
|
1637
|
+
await handleScanCommand(cwd, scanOpts, baseOpts);
|
|
1638
|
+
});
|
|
1639
|
+
program.command("init").description("Initialize scaffold folder and config/structure files").option(
|
|
1640
|
+
"--force",
|
|
1641
|
+
"Overwrite existing config/structure files if they already exist"
|
|
1642
|
+
).action(async (initOpts, cmd) => {
|
|
1643
|
+
const baseOpts = cmd.parent?.opts() ?? {};
|
|
1644
|
+
await handleInitCommand(cwd, initOpts, baseOpts);
|
|
1645
|
+
});
|
|
1646
|
+
program.action(async (opts) => {
|
|
1647
|
+
await handleRunCommand(cwd, opts);
|
|
1648
|
+
});
|
|
1649
|
+
program.command("structures").description(
|
|
1650
|
+
"Create missing structure files specified in the config (does not overwrite existing files)"
|
|
1651
|
+
).action(async (_opts, cmd) => {
|
|
1652
|
+
const baseOpts = cmd.parent?.opts() ?? {};
|
|
1653
|
+
await handleStructuresCommand(cwd, baseOpts);
|
|
1654
|
+
});
|
|
1655
|
+
await program.parseAsync(process.argv);
|
|
1656
|
+
}
|
|
1657
|
+
main().catch((err) => {
|
|
1658
|
+
defaultLogger.error(err);
|
|
1659
|
+
process.exit(1);
|
|
1660
|
+
});
|
|
1661
|
+
//# sourceMappingURL=cli.mjs.map
|
|
1662
|
+
//# sourceMappingURL=cli.mjs.map
|