@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.
@@ -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,5 @@
1
+ // src/index.ts
2
+
3
+ export * from './schema';
4
+ export * from './core/runner';
5
+ export * from './core/scan-structure';
@@ -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
+ }