@timeax/scaffold 0.0.1
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/.gitattributes +2 -0
- package/dist/cli.cjs +1081 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.mjs +1070 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.cjs +785 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +439 -0
- package/dist/index.d.ts +439 -0
- package/dist/index.mjs +773 -0
- package/dist/index.mjs.map +1 -0
- package/docs/structure.txt +25 -0
- package/package.json +49 -0
- package/readme.md +424 -0
- package/src/cli/main.ts +244 -0
- package/src/core/apply-structure.ts +255 -0
- package/src/core/cache-manager.ts +99 -0
- package/src/core/config-loader.ts +184 -0
- package/src/core/hook-runner.ts +73 -0
- package/src/core/init-scaffold.ts +162 -0
- package/src/core/resolve-structure.ts +64 -0
- package/src/core/runner.ts +94 -0
- package/src/core/scan-structure.ts +214 -0
- package/src/core/structure-txt.ts +203 -0
- package/src/core/watcher.ts +106 -0
- package/src/index.ts +5 -0
- package/src/schema/config.ts +180 -0
- package/src/schema/hooks.ts +139 -0
- package/src/schema/index.ts +4 -0
- package/src/schema/structure.ts +77 -0
- package/src/util/fs-utils.ts +126 -0
- package/src/util/logger.ts +144 -0
- package/tsconfig.json +24 -0
- package/tsup.config.ts +48 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// src/core/scan-structure.ts
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { minimatch } from 'minimatch';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
ScanStructureOptions,
|
|
9
|
+
ScanFromConfigOptions,
|
|
10
|
+
StructureGroupConfig,
|
|
11
|
+
ScaffoldConfig,
|
|
12
|
+
} from '../schema';
|
|
13
|
+
import { toPosixPath, ensureDirSync } from '../util/fs-utils';
|
|
14
|
+
import { loadScaffoldConfig } from './config-loader';
|
|
15
|
+
import { defaultLogger } from '../util/logger';
|
|
16
|
+
|
|
17
|
+
const logger = defaultLogger.child('[scan]');
|
|
18
|
+
|
|
19
|
+
const DEFAULT_IGNORE: string[] = [
|
|
20
|
+
'node_modules/**',
|
|
21
|
+
'.git/**',
|
|
22
|
+
'dist/**',
|
|
23
|
+
'build/**',
|
|
24
|
+
'.turbo/**',
|
|
25
|
+
'.next/**',
|
|
26
|
+
'coverage/**',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate a structure.txt-style tree from an existing directory.
|
|
31
|
+
*
|
|
32
|
+
* Indenting:
|
|
33
|
+
* - 2 spaces per level.
|
|
34
|
+
* - Directories suffixed with "/".
|
|
35
|
+
* - No stub/include/exclude annotations are guessed (plain tree).
|
|
36
|
+
*/
|
|
37
|
+
export function scanDirectoryToStructureText(
|
|
38
|
+
rootDir: string,
|
|
39
|
+
options: ScanStructureOptions = {},
|
|
40
|
+
): string {
|
|
41
|
+
const absRoot = path.resolve(rootDir);
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
|
|
44
|
+
const ignorePatterns = options.ignore ?? DEFAULT_IGNORE;
|
|
45
|
+
const maxDepth = options.maxDepth ?? Infinity;
|
|
46
|
+
|
|
47
|
+
function isIgnored(absPath: string): boolean {
|
|
48
|
+
const rel = toPosixPath(path.relative(absRoot, absPath));
|
|
49
|
+
if (!rel || rel === '.') return false;
|
|
50
|
+
return ignorePatterns.some((pattern) =>
|
|
51
|
+
minimatch(rel, pattern, { dot: true }),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function walk(currentAbs: string, depth: number) {
|
|
56
|
+
if (depth > maxDepth) return;
|
|
57
|
+
|
|
58
|
+
let dirents: fs.Dirent[];
|
|
59
|
+
try {
|
|
60
|
+
dirents = fs.readdirSync(currentAbs, { withFileTypes: true });
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Sort: directories first, then files, both alphabetically
|
|
66
|
+
dirents.sort((a, b) => {
|
|
67
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
68
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
69
|
+
return a.name.localeCompare(b.name);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
for (const dirent of dirents) {
|
|
73
|
+
const name = dirent.name;
|
|
74
|
+
const absPath = path.join(currentAbs, name);
|
|
75
|
+
|
|
76
|
+
if (isIgnored(absPath)) continue;
|
|
77
|
+
|
|
78
|
+
const indent = ' '.repeat(depth);
|
|
79
|
+
if (dirent.isDirectory()) {
|
|
80
|
+
lines.push(`${indent}${name}/`);
|
|
81
|
+
walk(absPath, depth + 1);
|
|
82
|
+
} else if (dirent.isFile()) {
|
|
83
|
+
lines.push(`${indent}${name}`);
|
|
84
|
+
}
|
|
85
|
+
// symlinks etc. are skipped for now
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
walk(absRoot, 0);
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Result of scanning based on the scaffold config.
|
|
95
|
+
*
|
|
96
|
+
* You can use `structureFilePath` + `text` to write out group structure files.
|
|
97
|
+
*/
|
|
98
|
+
export interface ScanFromConfigResult {
|
|
99
|
+
groupName: string;
|
|
100
|
+
groupRoot: string;
|
|
101
|
+
structureFileName: string;
|
|
102
|
+
structureFilePath: string;
|
|
103
|
+
text: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Scan the project using the scaffold config and its groups.
|
|
108
|
+
*
|
|
109
|
+
* - If `config.groups` exists and is non-empty:
|
|
110
|
+
* - scans each group's `root` (relative to projectRoot)
|
|
111
|
+
* - produces text suitable for that group's structure file
|
|
112
|
+
* - Otherwise:
|
|
113
|
+
* - scans the single `projectRoot` and produces text for a single structure file.
|
|
114
|
+
*
|
|
115
|
+
* NOTE: This function does NOT write files; it just returns what should be written.
|
|
116
|
+
* The CLI (or caller) decides whether/where to save.
|
|
117
|
+
*/
|
|
118
|
+
export async function scanProjectFromConfig(
|
|
119
|
+
cwd: string,
|
|
120
|
+
options: ScanFromConfigOptions = {},
|
|
121
|
+
): Promise<ScanFromConfigResult[]> {
|
|
122
|
+
const { config, scaffoldDir, projectRoot } = await loadScaffoldConfig(cwd, {
|
|
123
|
+
scaffoldDir: options.scaffoldDir,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const ignorePatterns = options.ignore ?? DEFAULT_IGNORE;
|
|
127
|
+
const maxDepth = options.maxDepth ?? Infinity;
|
|
128
|
+
const onlyGroups = options.groups;
|
|
129
|
+
|
|
130
|
+
const results: ScanFromConfigResult[] = [];
|
|
131
|
+
|
|
132
|
+
function scanGroup(
|
|
133
|
+
cfg: ScaffoldConfig,
|
|
134
|
+
group: StructureGroupConfig,
|
|
135
|
+
): ScanFromConfigResult {
|
|
136
|
+
const rootAbs = path.resolve(projectRoot, group.root);
|
|
137
|
+
const text = scanDirectoryToStructureText(rootAbs, {
|
|
138
|
+
ignore: ignorePatterns,
|
|
139
|
+
maxDepth,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const structureFileName = group.structureFile ?? `${group.name}.txt`;
|
|
143
|
+
const structureFilePath = path.join(scaffoldDir, structureFileName);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
groupName: group.name,
|
|
147
|
+
groupRoot: group.root,
|
|
148
|
+
structureFileName,
|
|
149
|
+
structureFilePath,
|
|
150
|
+
text,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (config.groups && config.groups.length > 0) {
|
|
155
|
+
logger.debug(
|
|
156
|
+
`Scanning project from config with ${config.groups.length} group(s).`,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
for (const group of config.groups) {
|
|
160
|
+
if (onlyGroups && !onlyGroups.includes(group.name)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const result = scanGroup(config, group);
|
|
164
|
+
results.push(result);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
// Single-root mode: scan the whole projectRoot
|
|
168
|
+
logger.debug('Scanning project in single-root mode (no groups).');
|
|
169
|
+
|
|
170
|
+
const text = scanDirectoryToStructureText(projectRoot, {
|
|
171
|
+
ignore: ignorePatterns,
|
|
172
|
+
maxDepth,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const structureFileName = config.structureFile ?? 'structure.txt';
|
|
176
|
+
const structureFilePath = path.join(scaffoldDir, structureFileName);
|
|
177
|
+
|
|
178
|
+
results.push({
|
|
179
|
+
groupName: 'default',
|
|
180
|
+
groupRoot: '.',
|
|
181
|
+
structureFileName,
|
|
182
|
+
structureFilePath,
|
|
183
|
+
text,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return results;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Convenience helper: write scan results to their structure files.
|
|
192
|
+
*
|
|
193
|
+
* This will ensure the scaffold directory exists and overwrite existing
|
|
194
|
+
* structure files.
|
|
195
|
+
*/
|
|
196
|
+
export async function writeScannedStructuresFromConfig(
|
|
197
|
+
cwd: string,
|
|
198
|
+
options: ScanFromConfigOptions = {},
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const { scaffoldDir } = await loadScaffoldConfig(cwd, {
|
|
201
|
+
scaffoldDir: options.scaffoldDir,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
ensureDirSync(scaffoldDir);
|
|
205
|
+
|
|
206
|
+
const results = await scanProjectFromConfig(cwd, options);
|
|
207
|
+
|
|
208
|
+
for (const result of results) {
|
|
209
|
+
fs.writeFileSync(result.structureFilePath, result.text, 'utf8');
|
|
210
|
+
logger.info(
|
|
211
|
+
`Wrote structure for group "${result.groupName}" to ${result.structureFilePath}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// src/core/structure-txt.ts
|
|
2
|
+
|
|
3
|
+
import type { StructureEntry, DirEntry, FileEntry } from '../schema';
|
|
4
|
+
import { toPosixPath } from '../util/fs-utils';
|
|
5
|
+
|
|
6
|
+
interface ParsedLine {
|
|
7
|
+
lineNo: number;
|
|
8
|
+
indentSpaces: number;
|
|
9
|
+
rawPath: string;
|
|
10
|
+
stub?: string;
|
|
11
|
+
include?: string[];
|
|
12
|
+
exclude?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a single non-empty, non-comment line into a ParsedLine.
|
|
17
|
+
* Supports inline annotations:
|
|
18
|
+
* - @stub:name
|
|
19
|
+
* - @include:pattern,pattern2
|
|
20
|
+
* - @exclude:pattern,pattern2
|
|
21
|
+
*/
|
|
22
|
+
function parseLine(line: string, lineNo: number): ParsedLine | null {
|
|
23
|
+
const match = line.match(/^(\s*)(.+)$/);
|
|
24
|
+
if (!match) return null;
|
|
25
|
+
|
|
26
|
+
const indentSpaces = match[1].length;
|
|
27
|
+
const rest = match[2].trim();
|
|
28
|
+
if (!rest || rest.startsWith('#')) return null;
|
|
29
|
+
|
|
30
|
+
const parts = rest.split(/\s+/);
|
|
31
|
+
const pathToken = parts[0];
|
|
32
|
+
|
|
33
|
+
let stub: string | undefined;
|
|
34
|
+
const include: string[] = [];
|
|
35
|
+
const exclude: string[] = [];
|
|
36
|
+
|
|
37
|
+
for (const token of parts.slice(1)) {
|
|
38
|
+
if (token.startsWith('@stub:')) {
|
|
39
|
+
stub = token.slice('@stub:'.length);
|
|
40
|
+
} else if (token.startsWith('@include:')) {
|
|
41
|
+
const val = token.slice('@include:'.length);
|
|
42
|
+
if (val) {
|
|
43
|
+
include.push(
|
|
44
|
+
...val
|
|
45
|
+
.split(',')
|
|
46
|
+
.map((s) => s.trim())
|
|
47
|
+
.filter(Boolean),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
} else if (token.startsWith('@exclude:')) {
|
|
51
|
+
const val = token.slice('@exclude:'.length);
|
|
52
|
+
if (val) {
|
|
53
|
+
exclude.push(
|
|
54
|
+
...val
|
|
55
|
+
.split(',')
|
|
56
|
+
.map((s) => s.trim())
|
|
57
|
+
.filter(Boolean),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
lineNo,
|
|
65
|
+
indentSpaces,
|
|
66
|
+
rawPath: pathToken,
|
|
67
|
+
stub,
|
|
68
|
+
include: include.length ? include : undefined,
|
|
69
|
+
exclude: exclude.length ? exclude : undefined,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert a structure.txt content into a nested StructureEntry[].
|
|
75
|
+
*
|
|
76
|
+
* Rules:
|
|
77
|
+
* - Indentation is **2 spaces per level** (strict).
|
|
78
|
+
* - Indent must be a multiple of 2.
|
|
79
|
+
* - You cannot "skip" levels (no jumping from level 0 to 2 directly).
|
|
80
|
+
* - **Only directories can have children**:
|
|
81
|
+
* - If you indent under a file, an error is thrown.
|
|
82
|
+
* - Folders must end with "/" in the txt; paths are normalized to POSIX.
|
|
83
|
+
*/
|
|
84
|
+
export function parseStructureText(text: string): StructureEntry[] {
|
|
85
|
+
const lines = text.split(/\r?\n/);
|
|
86
|
+
const parsed: ParsedLine[] = [];
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const lineNo = i + 1;
|
|
90
|
+
const p = parseLine(lines[i], lineNo);
|
|
91
|
+
if (p) parsed.push(p);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const rootEntries: StructureEntry[] = [];
|
|
95
|
+
|
|
96
|
+
type StackItem = {
|
|
97
|
+
level: number;
|
|
98
|
+
entry: DirEntry | FileEntry;
|
|
99
|
+
isDir: boolean;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const stack: StackItem[] = [];
|
|
103
|
+
const INDENT_STEP = 2;
|
|
104
|
+
|
|
105
|
+
for (const p of parsed) {
|
|
106
|
+
const { indentSpaces, lineNo } = p;
|
|
107
|
+
|
|
108
|
+
if (indentSpaces % INDENT_STEP !== 0) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`structure.txt: Invalid indent on line ${lineNo}. ` +
|
|
111
|
+
`Indent must be multiples of ${INDENT_STEP} spaces.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const level = indentSpaces / INDENT_STEP;
|
|
116
|
+
|
|
117
|
+
// Determine parent level and enforce no skipping
|
|
118
|
+
if (level > stack.length) {
|
|
119
|
+
// e.g. current stack depth 1, but line level=2+ is invalid
|
|
120
|
+
if (level !== stack.length + 1) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`structure.txt: Invalid indentation on line ${lineNo}. ` +
|
|
123
|
+
`You cannot jump more than one level at a time. ` +
|
|
124
|
+
`Previous depth: ${stack.length}, this line depth: ${level}.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// If this line is indented (level > 0), parent must exist and must be dir
|
|
130
|
+
if (level > 0) {
|
|
131
|
+
const parent = stack[level - 1]; // parent level is (level - 1)
|
|
132
|
+
if (!parent) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`structure.txt: Indented entry without a parent on line ${lineNo}.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (!parent.isDir) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`structure.txt: Cannot indent under a file on line ${lineNo}. ` +
|
|
140
|
+
`Files cannot have children. Parent: "${parent.entry.path}".`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const isDir = p.rawPath.endsWith('/');
|
|
146
|
+
const clean = p.rawPath.replace(/\/$/, '');
|
|
147
|
+
const basePath = toPosixPath(clean);
|
|
148
|
+
|
|
149
|
+
// Determine parent based on level
|
|
150
|
+
// Pop stack until we are at the correct depth
|
|
151
|
+
while (stack.length > level) {
|
|
152
|
+
stack.pop();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const parent = stack[stack.length - 1]?.entry as DirEntry | undefined;
|
|
156
|
+
const parentPath = parent ? parent.path.replace(/\/$/, '') : '';
|
|
157
|
+
|
|
158
|
+
const fullPath = parentPath
|
|
159
|
+
? `${parentPath}/${basePath}${isDir ? '/' : ''}`
|
|
160
|
+
: `${basePath}${isDir ? '/' : ''}`;
|
|
161
|
+
|
|
162
|
+
if (isDir) {
|
|
163
|
+
const dirEntry: DirEntry = {
|
|
164
|
+
type: 'dir',
|
|
165
|
+
path: fullPath,
|
|
166
|
+
children: [],
|
|
167
|
+
...(p.stub ? { stub: p.stub } : {}),
|
|
168
|
+
...(p.include ? { include: p.include } : {}),
|
|
169
|
+
...(p.exclude ? { exclude: p.exclude } : {}),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (parent && parent.type === 'dir') {
|
|
173
|
+
parent.children = parent.children ?? [];
|
|
174
|
+
parent.children.push(dirEntry);
|
|
175
|
+
} else if (!parent) {
|
|
176
|
+
rootEntries.push(dirEntry);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
stack.push({ level, entry: dirEntry, isDir: true });
|
|
180
|
+
} else {
|
|
181
|
+
const fileEntry: FileEntry = {
|
|
182
|
+
type: 'file',
|
|
183
|
+
path: fullPath,
|
|
184
|
+
...(p.stub ? { stub: p.stub } : {}),
|
|
185
|
+
...(p.include ? { include: p.include } : {}),
|
|
186
|
+
...(p.exclude ? { exclude: p.exclude } : {}),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (parent && parent.type === 'dir') {
|
|
190
|
+
parent.children = parent.children ?? [];
|
|
191
|
+
parent.children.push(fileEntry);
|
|
192
|
+
} else if (!parent) {
|
|
193
|
+
rootEntries.push(fileEntry);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// files are not added to the stack; they cannot have children
|
|
197
|
+
stack.push({ level, entry: fileEntry, isDir: false });
|
|
198
|
+
// but next lines at same or lower level will pop correctly
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return rootEntries;
|
|
203
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// src/core/watcher.ts
|
|
2
|
+
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import chokidar from 'chokidar';
|
|
5
|
+
import { runOnce, type RunOptions } from './runner';
|
|
6
|
+
import { defaultLogger, type Logger } from '../util/logger';
|
|
7
|
+
|
|
8
|
+
export interface WatchOptions extends RunOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Debounce delay in milliseconds between detected changes
|
|
11
|
+
* and a scaffold re-run.
|
|
12
|
+
*
|
|
13
|
+
* Default: 150 ms
|
|
14
|
+
*/
|
|
15
|
+
debounceMs?: number;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Optional logger; falls back to defaultLogger.child('[watch]').
|
|
19
|
+
*/
|
|
20
|
+
logger?: Logger;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Watch the scaffold directory and re-run scaffold on changes.
|
|
25
|
+
*
|
|
26
|
+
* This watches:
|
|
27
|
+
* - scaffold/config.* files
|
|
28
|
+
* - scaffold/*.txt files (structures)
|
|
29
|
+
*
|
|
30
|
+
* CLI can call this when `--watch` is enabled.
|
|
31
|
+
*/
|
|
32
|
+
export function watchScaffold(cwd: string, options: WatchOptions = {}): void {
|
|
33
|
+
const logger = options.logger ?? defaultLogger.child('[watch]');
|
|
34
|
+
|
|
35
|
+
const scaffoldDir = options.scaffoldDir
|
|
36
|
+
? path.resolve(cwd, options.scaffoldDir)
|
|
37
|
+
: path.resolve(cwd, 'scaffold');
|
|
38
|
+
|
|
39
|
+
const debounceMs = options.debounceMs ?? 150;
|
|
40
|
+
|
|
41
|
+
logger.info(`Watching scaffold directory: ${scaffoldDir}`);
|
|
42
|
+
|
|
43
|
+
let timer: NodeJS.Timeout | undefined;
|
|
44
|
+
let running = false;
|
|
45
|
+
let pending = false;
|
|
46
|
+
|
|
47
|
+
async function run() {
|
|
48
|
+
if (running) {
|
|
49
|
+
pending = true;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
running = true;
|
|
53
|
+
try {
|
|
54
|
+
logger.info('Change detected → running scaffold...');
|
|
55
|
+
await runOnce(cwd, {
|
|
56
|
+
...options,
|
|
57
|
+
// we already resolved scaffoldDir for watcher; pass it down
|
|
58
|
+
scaffoldDir,
|
|
59
|
+
});
|
|
60
|
+
logger.info('Scaffold run completed.');
|
|
61
|
+
} catch (err) {
|
|
62
|
+
logger.error('Scaffold run failed:', err);
|
|
63
|
+
} finally {
|
|
64
|
+
running = false;
|
|
65
|
+
if (pending) {
|
|
66
|
+
pending = false;
|
|
67
|
+
timer = setTimeout(run, debounceMs);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function scheduleRun() {
|
|
73
|
+
if (timer) clearTimeout(timer);
|
|
74
|
+
timer = setTimeout(run, debounceMs);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const watcher = chokidar.watch(
|
|
78
|
+
[
|
|
79
|
+
path.join(scaffoldDir, 'config.*'),
|
|
80
|
+
path.join(scaffoldDir, '*.txt'),
|
|
81
|
+
],
|
|
82
|
+
{
|
|
83
|
+
ignoreInitial: false,
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
watcher
|
|
88
|
+
.on('add', (filePath) => {
|
|
89
|
+
logger.debug(`File added: ${filePath}`);
|
|
90
|
+
scheduleRun();
|
|
91
|
+
})
|
|
92
|
+
.on('change', (filePath) => {
|
|
93
|
+
logger.debug(`File changed: ${filePath}`);
|
|
94
|
+
scheduleRun();
|
|
95
|
+
})
|
|
96
|
+
.on('unlink', (filePath) => {
|
|
97
|
+
logger.debug(`File removed: ${filePath}`);
|
|
98
|
+
scheduleRun();
|
|
99
|
+
})
|
|
100
|
+
.on('error', (error) => {
|
|
101
|
+
logger.error('Watcher error:', error);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Initial run
|
|
105
|
+
scheduleRun();
|
|
106
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// src/schema/config.ts
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
RegularHookConfig,
|
|
5
|
+
RegularHookKind,
|
|
6
|
+
StubConfig,
|
|
7
|
+
} from './hooks';
|
|
8
|
+
import type { StructureEntry } from './structure';
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Configuration for a single structure group.
|
|
14
|
+
*
|
|
15
|
+
* Groups allow you to clearly separate different roots in a project,
|
|
16
|
+
* such as "app", "routes", "resources/js", etc., each with its own
|
|
17
|
+
* structure definition.
|
|
18
|
+
*/
|
|
19
|
+
export interface StructureGroupConfig {
|
|
20
|
+
/**
|
|
21
|
+
* Human-readable identifier for the group (e.g. "app", "routes", "frontend").
|
|
22
|
+
* Used mainly for logging and, optionally, cache metadata.
|
|
23
|
+
*/
|
|
24
|
+
name: string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Root directory for this group, relative to the overall project root.
|
|
28
|
+
*
|
|
29
|
+
* Example: "app", "routes", "resources/js".
|
|
30
|
+
*
|
|
31
|
+
* All paths produced from this group's structure are resolved
|
|
32
|
+
* relative to this directory.
|
|
33
|
+
*/
|
|
34
|
+
root: string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Optional inline structure entries for this group.
|
|
38
|
+
* If present and non-empty, these take precedence over `structureFile`.
|
|
39
|
+
*/
|
|
40
|
+
structure?: StructureEntry[];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Name of the structure file inside the scaffold directory for this group.
|
|
44
|
+
*
|
|
45
|
+
* Example: "app.txt", "routes.txt".
|
|
46
|
+
*
|
|
47
|
+
* If omitted, the default is `<name>.txt` within the scaffold directory.
|
|
48
|
+
*/
|
|
49
|
+
structureFile?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Root configuration object for @timeax/scaffold.
|
|
54
|
+
*
|
|
55
|
+
* This is what you export from `scaffold/config.ts` in a consuming
|
|
56
|
+
* project, or from any programmatic usage of the library.
|
|
57
|
+
*/
|
|
58
|
+
export interface ScaffoldConfig {
|
|
59
|
+
/**
|
|
60
|
+
* Absolute or relative project root (where files are created).
|
|
61
|
+
*
|
|
62
|
+
* If omitted, the engine will treat `process.cwd()` as the root.
|
|
63
|
+
*/
|
|
64
|
+
root?: string;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Base directory where structures are applied and files/folders
|
|
68
|
+
* are actually created.
|
|
69
|
+
*
|
|
70
|
+
* This is resolved relative to `root` (not CWD).
|
|
71
|
+
*
|
|
72
|
+
* Default: same as `root`.
|
|
73
|
+
*
|
|
74
|
+
* Examples:
|
|
75
|
+
* - base: '.' with root: '.' → apply to <cwd>
|
|
76
|
+
* - base: 'src' with root: '.' → apply to <cwd>/src
|
|
77
|
+
* - base: '..' with root: 'tools' → apply to <cwd>/tools/..
|
|
78
|
+
*/
|
|
79
|
+
base?: string;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Path to the scaffold cache file, relative to `root`.
|
|
83
|
+
*
|
|
84
|
+
* Default: ".scaffold-cache.json"
|
|
85
|
+
*/
|
|
86
|
+
cacheFile?: string;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* File size threshold (in bytes) above which deletions become
|
|
90
|
+
* interactive (e.g. ask "are you sure?").
|
|
91
|
+
*
|
|
92
|
+
* Default is determined by the core engine (e.g. 128 KB).
|
|
93
|
+
*/
|
|
94
|
+
sizePromptThreshold?: number;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Optional single-root structure (legacy or simple mode).
|
|
98
|
+
*
|
|
99
|
+
* If `groups` is defined and non-empty, this is ignored.
|
|
100
|
+
* Paths are relative to `root` in this mode.
|
|
101
|
+
*/
|
|
102
|
+
structure?: StructureEntry[];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Name of the single structure file in the scaffold directory
|
|
106
|
+
* for legacy mode.
|
|
107
|
+
*
|
|
108
|
+
* If `groups` is empty and `structure` is not provided, this
|
|
109
|
+
* file name is used (default: "structure.txt").
|
|
110
|
+
*/
|
|
111
|
+
structureFile?: string;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Multiple structure groups (recommended).
|
|
115
|
+
*
|
|
116
|
+
* When provided and non-empty, the engine will iterate over each
|
|
117
|
+
* group and apply its structure relative to each group's `root`.
|
|
118
|
+
*/
|
|
119
|
+
groups?: StructureGroupConfig[];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Hook configuration for file lifecycle events.
|
|
123
|
+
*
|
|
124
|
+
* Each category (e.g. "preCreateFile") is an array of hook configs,
|
|
125
|
+
* each with its own `include` / `exclude` / `files` filters.
|
|
126
|
+
*/
|
|
127
|
+
hooks?: {
|
|
128
|
+
[K in RegularHookKind]?: RegularHookConfig[];
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Stub definitions keyed by stub name.
|
|
133
|
+
*
|
|
134
|
+
* These are referenced from structure entries by `stub: name`.
|
|
135
|
+
*/
|
|
136
|
+
stubs?: Record<string, StubConfig>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* When true, the CLI or consuming code may choose to start scaffold
|
|
140
|
+
* in watch mode by default (implementation-specific).
|
|
141
|
+
*
|
|
142
|
+
* This flag itself does not start watch mode; it is a hint to the
|
|
143
|
+
* runner / CLI.
|
|
144
|
+
*/
|
|
145
|
+
watch?: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Options when scanning an existing directory into a structure.txt tree.
|
|
151
|
+
*/
|
|
152
|
+
export interface ScanStructureOptions {
|
|
153
|
+
/**
|
|
154
|
+
* Glob patterns (relative to the scanned root) to ignore.
|
|
155
|
+
*/
|
|
156
|
+
ignore?: string[];
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Maximum depth to traverse (0 = only that dir).
|
|
160
|
+
* Default: Infinity (no limit).
|
|
161
|
+
*/
|
|
162
|
+
maxDepth?: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Options when scanning based on the scaffold config/groups.
|
|
167
|
+
*/
|
|
168
|
+
export interface ScanFromConfigOptions extends ScanStructureOptions {
|
|
169
|
+
/**
|
|
170
|
+
* If provided, only scan these group names (by `StructureGroupConfig.name`).
|
|
171
|
+
* If omitted, all groups are scanned (or single-root mode).
|
|
172
|
+
*/
|
|
173
|
+
groups?: string[];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Optional override for scaffold directory; normally you can let
|
|
177
|
+
* loadScaffoldConfig resolve this from "<cwd>/scaffold".
|
|
178
|
+
*/
|
|
179
|
+
scaffoldDir?: string;
|
|
180
|
+
}
|