@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,244 @@
1
+ import readline from 'readline';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { Command } from 'commander';
5
+ import { runOnce, RunOptions } from '../core/runner';
6
+ import { watchScaffold } from '../core/watcher';
7
+ import {
8
+ scanDirectoryToStructureText,
9
+ writeScannedStructuresFromConfig,
10
+ } from '../core/scan-structure';
11
+ import { initScaffold } from '../core/init-scaffold';
12
+ import {
13
+ defaultLogger,
14
+ Logger,
15
+ type LogLevel,
16
+ } from '../util/logger';
17
+ import { ensureDirSync } from '../util/fs-utils';
18
+
19
+ interface BaseCliOptions {
20
+ config?: string;
21
+ dir?: string;
22
+ watch?: boolean;
23
+ quiet?: boolean;
24
+ debug?: boolean;
25
+ }
26
+
27
+ interface ScanCliOptions {
28
+ root?: string;
29
+ out?: string;
30
+ ignore?: string[];
31
+ fromConfig?: boolean;
32
+ groups?: string[];
33
+ }
34
+
35
+ interface InitCliOptions {
36
+ force?: boolean;
37
+ }
38
+
39
+ /**
40
+ * Create a logger with the appropriate level from CLI flags.
41
+ */
42
+ function createCliLogger(opts: { quiet?: boolean; debug?: boolean }): Logger {
43
+ if (opts.quiet) {
44
+ defaultLogger.setLevel('silent');
45
+ } else if (opts.debug) {
46
+ defaultLogger.setLevel('debug');
47
+ }
48
+ return defaultLogger.child('[cli]');
49
+ }
50
+
51
+ function askYesNo(question: string): Promise<'delete' | 'keep'> {
52
+ const rl = readline.createInterface({
53
+ input: process.stdin,
54
+ output: process.stdout,
55
+ });
56
+
57
+ return new Promise((resolve) => {
58
+ rl.question(`${question} [y/N] `, (answer) => {
59
+ rl.close();
60
+ const val = answer.trim().toLowerCase();
61
+ if (val === 'y' || val === 'yes') {
62
+ resolve('delete');
63
+ } else {
64
+ resolve('keep');
65
+ }
66
+ });
67
+ });
68
+ }
69
+
70
+ async function handleRunCommand(cwd: string, baseOpts: BaseCliOptions) {
71
+ const logger = createCliLogger(baseOpts);
72
+
73
+ const configPath = baseOpts.config
74
+ ? path.resolve(cwd, baseOpts.config)
75
+ : undefined;
76
+ const scaffoldDir = baseOpts.dir
77
+ ? path.resolve(cwd, baseOpts.dir)
78
+ : undefined;
79
+
80
+ logger.debug(
81
+ `Starting scaffold (cwd=${cwd}, config=${configPath ?? 'auto'}, dir=${scaffoldDir ?? 'scaffold/'
82
+ }, watch=${baseOpts.watch ? 'yes' : 'no'})`,
83
+ );
84
+
85
+ const runnerOptions: RunOptions = {
86
+ configPath,
87
+ scaffoldDir,
88
+ logger,
89
+ interactiveDelete: async ({
90
+ relativePath,
91
+ size,
92
+ createdByStub,
93
+ groupName,
94
+ }) => {
95
+ const sizeKb = (size / 1024).toFixed(1);
96
+ const stubInfo = createdByStub ? ` (stub: ${createdByStub})` : '';
97
+ const groupInfo = groupName ? ` [group: ${groupName}]` : '';
98
+ const question =
99
+ `File "${relativePath}"${groupInfo} is ~${sizeKb}KB and no longer in structure${stubInfo}. Delete it?`;
100
+
101
+ return askYesNo(question);
102
+ },
103
+ };
104
+
105
+ if (baseOpts.watch) {
106
+ // Watch mode – this will not return
107
+ watchScaffold(cwd, runnerOptions);
108
+ } else {
109
+ await runOnce(cwd, runnerOptions);
110
+ }
111
+ }
112
+
113
+ async function handleScanCommand(
114
+ cwd: string,
115
+ scanOpts: ScanCliOptions,
116
+ baseOpts: BaseCliOptions,
117
+ ) {
118
+ const logger = createCliLogger(baseOpts);
119
+
120
+ const useConfigMode =
121
+ scanOpts.fromConfig || (!scanOpts.root && !scanOpts.out);
122
+
123
+ if (useConfigMode) {
124
+ logger.info('Scanning project using scaffold config/groups...');
125
+ await writeScannedStructuresFromConfig(cwd, {
126
+ ignore: scanOpts.ignore,
127
+ groups: scanOpts.groups,
128
+ });
129
+ return;
130
+ }
131
+
132
+ // Manual single-root mode
133
+ const rootDir = path.resolve(cwd, scanOpts.root ?? '.');
134
+ const ignore = scanOpts.ignore ?? [];
135
+
136
+ logger.info(`Scanning directory for structure: ${rootDir}`);
137
+ const text = scanDirectoryToStructureText(rootDir, {
138
+ ignore,
139
+ });
140
+
141
+ if (scanOpts.out) {
142
+ const outPath = path.resolve(cwd, scanOpts.out);
143
+ const dir = path.dirname(outPath);
144
+ ensureDirSync(dir);
145
+ fs.writeFileSync(outPath, text, 'utf8');
146
+ logger.info(`Wrote structure to ${outPath}`);
147
+ } else {
148
+ process.stdout.write(text + '\n');
149
+ }
150
+ }
151
+
152
+ async function handleInitCommand(
153
+ cwd: string,
154
+ initOpts: InitCliOptions,
155
+ baseOpts: BaseCliOptions,
156
+ ) {
157
+ const logger = createCliLogger(baseOpts);
158
+
159
+ const scaffoldDirRel = baseOpts.dir ?? 'scaffold';
160
+
161
+ logger.info(`Initializing scaffold directory at "${scaffoldDirRel}"...`);
162
+
163
+ const result = await initScaffold(cwd, {
164
+ scaffoldDir: scaffoldDirRel,
165
+ force: initOpts.force,
166
+ });
167
+
168
+ logger.info(
169
+ `Done. Config: ${result.configPath}, Structure: ${result.structurePath}`,
170
+ );
171
+ }
172
+
173
+ async function main() {
174
+ const cwd = process.cwd();
175
+
176
+ const program = new Command();
177
+
178
+ program
179
+ .name('scaffold')
180
+ .description('@timeax/scaffold – structure-based project scaffolding')
181
+ // global-ish options used by base + scan + init
182
+ .option('-c, --config <path>', 'Path to scaffold config file')
183
+ .option('-d, --dir <path>', 'Path to scaffold directory (default: ./scaffold)')
184
+ .option('-w, --watch', 'Watch scaffold directory for changes')
185
+ .option('--quiet', 'Silence logs')
186
+ .option('--debug', 'Enable debug logging');
187
+
188
+ // scan subcommand
189
+ program
190
+ .command('scan')
191
+ .description(
192
+ 'Generate structure.txt-style output (config-aware by default, or manual root/out)',
193
+ )
194
+ .option(
195
+ '--from-config',
196
+ 'Scan based on scaffold config/groups and write structure files into scaffold/ (default if no root/out specified)',
197
+ )
198
+ .option(
199
+ '-r, --root <path>',
200
+ 'Root directory to scan (manual mode)',
201
+ )
202
+ .option(
203
+ '-o, --out <path>',
204
+ 'Output file path (manual mode)',
205
+ )
206
+ .option(
207
+ '--ignore <patterns...>',
208
+ 'Additional glob patterns to ignore (relative to root)',
209
+ )
210
+ .option(
211
+ '--groups <names...>',
212
+ 'Limit config-based scanning to specific groups (by name)',
213
+ )
214
+ .action(async (scanOpts: ScanCliOptions, cmd: Command) => {
215
+ const baseOpts = cmd.parent?.opts<BaseCliOptions>() ?? {};
216
+ await handleScanCommand(cwd, scanOpts, baseOpts);
217
+ });
218
+
219
+ // init subcommand
220
+ program
221
+ .command('init')
222
+ .description('Initialize scaffold folder and config/structure files')
223
+ .option(
224
+ '--force',
225
+ 'Overwrite existing config/structure files if they already exist',
226
+ )
227
+ .action(async (initOpts: InitCliOptions, cmd: Command) => {
228
+ const baseOpts = cmd.parent?.opts<BaseCliOptions>() ?? {};
229
+ await handleInitCommand(cwd, initOpts, baseOpts);
230
+ });
231
+
232
+ // Base command: run scaffold once or in watch mode
233
+ program.action(async (opts: BaseCliOptions) => {
234
+ await handleRunCommand(cwd, opts);
235
+ });
236
+
237
+ await program.parseAsync(process.argv);
238
+ }
239
+
240
+ // Run and handle errors
241
+ main().catch((err) => {
242
+ defaultLogger.error(err);
243
+ process.exit(1);
244
+ });
@@ -0,0 +1,255 @@
1
+ // src/core/apply-structure.ts
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import type {
6
+ ScaffoldConfig,
7
+ StructureEntry,
8
+ FileEntry,
9
+ DirEntry,
10
+ HookContext,
11
+ } from '../schema';
12
+ import { CacheManager } from './cache-manager';
13
+ import { HookRunner } from './hook-runner';
14
+ import {
15
+ ensureDirSync,
16
+ statSafeSync,
17
+ toProjectRelativePath,
18
+ toPosixPath,
19
+ } from '../util/fs-utils';
20
+ import type { Logger } from '../util/logger';
21
+ import { defaultLogger } from '../util/logger';
22
+
23
+ export interface InteractiveDeleteParams {
24
+ absolutePath: string;
25
+ relativePath: string; // project-root relative, POSIX
26
+ size: number;
27
+ createdByStub?: string;
28
+ groupName?: string;
29
+ }
30
+
31
+ export interface ApplyOptions {
32
+ config: ScaffoldConfig;
33
+
34
+ /**
35
+ * Global project root for this run.
36
+ */
37
+ projectRoot: string;
38
+
39
+ /**
40
+ * Absolute directory where this structure group should be applied.
41
+ * For grouped mode, this is projectRoot + group.root.
42
+ * For single mode, this will simply be projectRoot.
43
+ */
44
+ baseDir: string;
45
+
46
+ /**
47
+ * Which structure entries to apply (already resolved from txt or inline).
48
+ */
49
+ structure: StructureEntry[];
50
+
51
+ cache: CacheManager;
52
+ hooks: HookRunner;
53
+
54
+ /**
55
+ * Optional group metadata (only set for groups).
56
+ */
57
+ groupName?: string;
58
+ groupRoot?: string;
59
+
60
+ /**
61
+ * Optional override for deletion threshold.
62
+ * Falls back to config.sizePromptThreshold or internal default.
63
+ */
64
+ sizePromptThreshold?: number;
65
+
66
+ /**
67
+ * Optional interactive delete callback.
68
+ * Should ask the user and return 'delete' or 'keep'.
69
+ */
70
+ interactiveDelete?: (
71
+ params: InteractiveDeleteParams,
72
+ ) => Promise<'delete' | 'keep'>;
73
+
74
+ /**
75
+ * Optional logger; defaults to defaultLogger.child('[apply]').
76
+ */
77
+ logger?: Logger;
78
+ }
79
+
80
+ export async function applyStructure(opts: ApplyOptions): Promise<void> {
81
+ const {
82
+ config,
83
+ projectRoot,
84
+ baseDir,
85
+ structure,
86
+ cache,
87
+ hooks,
88
+ groupName,
89
+ groupRoot,
90
+ sizePromptThreshold,
91
+ interactiveDelete,
92
+ } = opts;
93
+
94
+ const logger =
95
+ opts.logger ?? defaultLogger.child(groupName ? `[apply:${groupName}]` : '[apply]');
96
+
97
+ const desiredPaths = new Set<string>(); // project-root relative, POSIX
98
+
99
+ const threshold = sizePromptThreshold ?? config.sizePromptThreshold ?? 128 * 1024;
100
+
101
+ async function walk(entry: StructureEntry, inheritedStub?: string): Promise<void> {
102
+ const effectiveStub = entry.stub ?? inheritedStub;
103
+ if (entry.type === 'dir') {
104
+ await handleDir(entry as DirEntry, effectiveStub);
105
+ } else {
106
+ await handleFile(entry as FileEntry, effectiveStub);
107
+ }
108
+ }
109
+
110
+ async function handleDir(entry: DirEntry, inheritedStub?: string): Promise<void> {
111
+ const relFromBase = entry.path.replace(/^[./]+/, '');
112
+ const absDir = path.resolve(baseDir, relFromBase);
113
+ const relFromRoot = toPosixPath(
114
+ toProjectRelativePath(projectRoot, absDir),
115
+ );
116
+
117
+ desiredPaths.add(relFromRoot);
118
+
119
+ ensureDirSync(absDir);
120
+
121
+ const nextStub = entry.stub ?? inheritedStub;
122
+
123
+ if (entry.children) {
124
+ for (const child of entry.children) {
125
+ // eslint-disable-next-line no-await-in-loop
126
+ await walk(child, nextStub);
127
+ }
128
+ }
129
+ }
130
+
131
+ async function handleFile(entry: FileEntry, inheritedStub?: string): Promise<void> {
132
+ const relFromBase = entry.path.replace(/^[./]+/, '');
133
+ const absFile = path.resolve(baseDir, relFromBase);
134
+ const relFromRoot = toPosixPath(
135
+ toProjectRelativePath(projectRoot, absFile),
136
+ );
137
+
138
+ desiredPaths.add(relFromRoot);
139
+
140
+ const stubName = entry.stub ?? inheritedStub;
141
+
142
+ const ctx: HookContext = {
143
+ projectRoot,
144
+ targetPath: relFromRoot,
145
+ absolutePath: absFile,
146
+ isDirectory: false,
147
+ stubName,
148
+ };
149
+
150
+ // If file already exists, do not overwrite; just ensure hooks
151
+ if (fs.existsSync(absFile)) {
152
+ return;
153
+ }
154
+
155
+ await hooks.runRegular('preCreateFile', ctx);
156
+
157
+ const dir = path.dirname(absFile);
158
+ ensureDirSync(dir);
159
+
160
+ if (stubName) {
161
+ await hooks.runStub('preStub', ctx);
162
+ }
163
+
164
+ let content = '';
165
+ const stubContent = await hooks.renderStubContent(ctx);
166
+ if (typeof stubContent === 'string') {
167
+ content = stubContent;
168
+ }
169
+
170
+ fs.writeFileSync(absFile, content, 'utf8');
171
+ const stats = fs.statSync(absFile);
172
+
173
+ cache.set({
174
+ path: relFromRoot,
175
+ createdAt: new Date().toISOString(),
176
+ sizeAtCreate: stats.size,
177
+ createdByStub: stubName,
178
+ groupName,
179
+ groupRoot,
180
+ });
181
+
182
+ logger.info(`created ${relFromRoot}`);
183
+
184
+ if (stubName) {
185
+ await hooks.runStub('postStub', ctx);
186
+ }
187
+
188
+ await hooks.runRegular('postCreateFile', ctx);
189
+ }
190
+
191
+ // 1) Create/update from structure
192
+ for (const entry of structure) {
193
+ // eslint-disable-next-line no-await-in-loop
194
+ await walk(entry);
195
+ }
196
+
197
+ // 2) Handle deletions: any cached path not in desiredPaths
198
+ for (const cachedPath of cache.allPaths()) {
199
+ if (desiredPaths.has(cachedPath)) continue;
200
+
201
+ const abs = path.resolve(projectRoot, cachedPath);
202
+ const stats = statSafeSync(abs);
203
+
204
+ if (!stats) {
205
+ cache.delete(cachedPath);
206
+ continue;
207
+ }
208
+
209
+ // Only handle files here; dirs are not tracked in cache.
210
+ if (!stats.isFile()) {
211
+ cache.delete(cachedPath);
212
+ continue;
213
+ }
214
+
215
+ const entry = cache.get(cachedPath);
216
+ const ctx: HookContext = {
217
+ projectRoot,
218
+ targetPath: cachedPath,
219
+ absolutePath: abs,
220
+ isDirectory: false,
221
+ stubName: entry?.createdByStub,
222
+ };
223
+
224
+ await hooks.runRegular('preDeleteFile', ctx);
225
+
226
+ let shouldDelete = true;
227
+ if (stats.size > threshold && interactiveDelete) {
228
+ const res = await interactiveDelete({
229
+ absolutePath: abs,
230
+ relativePath: cachedPath,
231
+ size: stats.size,
232
+ createdByStub: entry?.createdByStub,
233
+ groupName: entry?.groupName,
234
+ });
235
+
236
+ if (res === 'keep') {
237
+ shouldDelete = false;
238
+ cache.delete(cachedPath); // user takes ownership
239
+ logger.info(`keeping ${cachedPath} (removed from cache)`);
240
+ }
241
+ }
242
+
243
+ if (shouldDelete) {
244
+ try {
245
+ fs.unlinkSync(abs);
246
+ logger.info(`deleted ${cachedPath}`);
247
+ } catch (err) {
248
+ logger.warn(`failed to delete ${cachedPath}`, err);
249
+ }
250
+
251
+ cache.delete(cachedPath);
252
+ await hooks.runRegular('postDeleteFile', ctx);
253
+ }
254
+ }
255
+ }
@@ -0,0 +1,99 @@
1
+ // src/core/cache-manager.ts
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { ensureDirSync, toPosixPath } from '../util/fs-utils';
6
+ import { defaultLogger } from '../util/logger';
7
+
8
+ const logger = defaultLogger.child('[cache]');
9
+
10
+ export interface CacheEntry {
11
+ /**
12
+ * Path relative to the *project root* (global root), POSIX style.
13
+ */
14
+ path: string;
15
+
16
+ createdAt: string;
17
+ sizeAtCreate: number;
18
+ createdByStub?: string;
19
+ groupName?: string;
20
+ groupRoot?: string;
21
+ }
22
+
23
+ export interface CacheFile {
24
+ version: 1;
25
+ entries: Record<string, CacheEntry>;
26
+ }
27
+
28
+ const DEFAULT_CACHE: CacheFile = {
29
+ version: 1,
30
+ entries: {},
31
+ };
32
+
33
+ export class CacheManager {
34
+ private cache: CacheFile = DEFAULT_CACHE;
35
+
36
+ constructor(
37
+ private readonly projectRoot: string,
38
+ private readonly cacheFileRelPath: string,
39
+ ) { }
40
+
41
+ private get cachePathAbs(): string {
42
+ return path.resolve(this.projectRoot, this.cacheFileRelPath);
43
+ }
44
+
45
+ load(): void {
46
+ const cachePath = this.cachePathAbs;
47
+ if (!fs.existsSync(cachePath)) {
48
+ this.cache = { ...DEFAULT_CACHE, entries: {} };
49
+ return;
50
+ }
51
+
52
+ try {
53
+ const raw = fs.readFileSync(cachePath, 'utf8');
54
+ const parsed = JSON.parse(raw) as CacheFile;
55
+ if (parsed.version === 1 && parsed.entries) {
56
+ this.cache = parsed;
57
+ } else {
58
+ logger.warn('Cache file version mismatch or invalid, resetting cache.');
59
+ this.cache = { ...DEFAULT_CACHE, entries: {} };
60
+ }
61
+ } catch (err) {
62
+ logger.warn('Failed to read cache file, resetting cache.', err);
63
+ this.cache = { ...DEFAULT_CACHE, entries: {} };
64
+ }
65
+ }
66
+
67
+ save(): void {
68
+ const cachePath = this.cachePathAbs;
69
+ const dir = path.dirname(cachePath);
70
+ ensureDirSync(dir);
71
+ fs.writeFileSync(cachePath, JSON.stringify(this.cache, null, 2), 'utf8');
72
+ }
73
+
74
+ get(relPath: string): CacheEntry | undefined {
75
+ const key = toPosixPath(relPath);
76
+ return this.cache.entries[key];
77
+ }
78
+
79
+ set(entry: CacheEntry): void {
80
+ const key = toPosixPath(entry.path);
81
+ this.cache.entries[key] = {
82
+ ...entry,
83
+ path: key,
84
+ };
85
+ }
86
+
87
+ delete(relPath: string): void {
88
+ const key = toPosixPath(relPath);
89
+ delete this.cache.entries[key];
90
+ }
91
+
92
+ allPaths(): string[] {
93
+ return Object.keys(this.cache.entries);
94
+ }
95
+
96
+ allEntries(): CacheEntry[] {
97
+ return Object.values(this.cache.entries);
98
+ }
99
+ }