@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,184 @@
1
+ // src/core/config-loader.ts
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import crypto from 'crypto';
7
+ import { pathToFileURL } from 'url';
8
+ import { transform } from 'esbuild';
9
+
10
+ import type { ScaffoldConfig } from '../schema';
11
+ import { defaultLogger } from '../util/logger';
12
+ import { ensureDirSync } from '../util/fs-utils';
13
+
14
+ const logger = defaultLogger.child('[config]');
15
+
16
+ export interface LoadScaffoldConfigOptions {
17
+ /**
18
+ * Optional explicit scaffold directory path (absolute or relative to cwd).
19
+ * If provided, this overrides config.root for locating the scaffold folder.
20
+ */
21
+ scaffoldDir?: string;
22
+
23
+ /**
24
+ * Optional explicit config file path (absolute or relative to cwd).
25
+ * If not provided, we look for config.* inside the scaffoldDir.
26
+ */
27
+ configPath?: string;
28
+ }
29
+
30
+ export interface LoadScaffoldConfigResult {
31
+ /**
32
+ * Parsed scaffold configuration.
33
+ */
34
+ config: ScaffoldConfig;
35
+
36
+ /**
37
+ * Absolute path to the scaffold directory (where config & *.txt live).
38
+ */
39
+ scaffoldDir: string;
40
+
41
+ /**
42
+ * Effective project root BASE where structures are applied.
43
+ * This is derived from config.root + config.base.
44
+ */
45
+ projectRoot: string;
46
+ }
47
+
48
+ /**
49
+ * Load scaffold configuration based on CWD + optional overrides + config.root/base.
50
+ *
51
+ * Resolution rules:
52
+ * - configRoot:
53
+ * - Start from cwd.
54
+ * - Apply config.root (if defined) as a path relative to cwd.
55
+ * - scaffoldDir:
56
+ * - If options.scaffoldDir is provided → use it as-is (resolved from cwd).
57
+ * - Else → <configRoot>/scaffold.
58
+ * - projectRoot (base):
59
+ * - If config.base is defined → resolve relative to configRoot.
60
+ * - Else → configRoot.
61
+ */
62
+ export async function loadScaffoldConfig(
63
+ cwd: string,
64
+ options: LoadScaffoldConfigOptions = {},
65
+ ): Promise<LoadScaffoldConfigResult> {
66
+ const absCwd = path.resolve(cwd);
67
+
68
+ // First pass: figure out an initial scaffold dir just to locate config.*
69
+ const initialScaffoldDir = options.scaffoldDir
70
+ ? path.resolve(absCwd, options.scaffoldDir)
71
+ : path.join(absCwd, 'scaffold');
72
+
73
+ const configPath =
74
+ options.configPath ?? resolveConfigPath(initialScaffoldDir);
75
+
76
+ // Import config (supports .ts/.tsx via esbuild)
77
+ const config = await importConfig(configPath);
78
+
79
+ // Now compute configRoot (where scaffold/ lives by default)
80
+ let configRoot = absCwd;
81
+ if (config.root) {
82
+ configRoot = path.resolve(absCwd, config.root);
83
+ }
84
+
85
+ // Final scaffoldDir (can still be overridden by CLI)
86
+ const scaffoldDir = options.scaffoldDir
87
+ ? path.resolve(absCwd, options.scaffoldDir)
88
+ : path.join(configRoot, 'scaffold');
89
+
90
+ // projectRoot (base) is relative to configRoot
91
+ const baseRoot = config.base
92
+ ? path.resolve(configRoot, config.base)
93
+ : configRoot;
94
+
95
+ logger.debug(
96
+ `Loaded config: configRoot=${configRoot}, baseRoot=${baseRoot}, scaffoldDir=${scaffoldDir}`,
97
+ );
98
+
99
+ return {
100
+ config,
101
+ scaffoldDir,
102
+ projectRoot: baseRoot,
103
+ };
104
+ }
105
+
106
+ function resolveConfigPath(scaffoldDir: string): string {
107
+ const candidates = [
108
+ 'config.ts',
109
+ 'config.mts',
110
+ 'config.mjs',
111
+ 'config.js',
112
+ 'config.cjs',
113
+ ];
114
+
115
+ for (const file of candidates) {
116
+ const full = path.join(scaffoldDir, file);
117
+ if (fs.existsSync(full)) {
118
+ return full;
119
+ }
120
+ }
121
+
122
+ throw new Error(
123
+ `Could not find scaffold config in ${scaffoldDir}. Looked for: ${candidates.join(
124
+ ', ',
125
+ )}`,
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Import a ScaffoldConfig from the given path.
131
+ * - For .ts/.tsx we transpile with esbuild to ESM and load from a temp file.
132
+ * - For .js/.mjs/.cjs we import directly.
133
+ */
134
+ async function importConfig(configPath: string): Promise<ScaffoldConfig> {
135
+ const ext = path.extname(configPath).toLowerCase();
136
+
137
+ if (ext === '.ts' || ext === '.tsx') {
138
+ return importTsConfig(configPath);
139
+ }
140
+
141
+ const url = pathToFileURL(configPath).href;
142
+ const mod = await import(url);
143
+ return (mod.default ?? mod) as ScaffoldConfig;
144
+ }
145
+
146
+ /**
147
+ * Transpile a TS config file to ESM with esbuild and import the compiled file.
148
+ * We cache based on (path + mtime) so changes invalidate the temp.
149
+ */
150
+ async function importTsConfig(configPath: string): Promise<ScaffoldConfig> {
151
+ const source = fs.readFileSync(configPath, 'utf8');
152
+ const stat = fs.statSync(configPath);
153
+
154
+ const hash = crypto
155
+ .createHash('sha1')
156
+ .update(configPath)
157
+ .update(String(stat.mtimeMs))
158
+ .digest('hex');
159
+
160
+ const tmpDir = path.join(os.tmpdir(), 'timeax-scaffold-config');
161
+ ensureDirSync(tmpDir);
162
+
163
+ const tmpFile = path.join(tmpDir, `${hash}.mjs`);
164
+
165
+ if (!fs.existsSync(tmpFile)) {
166
+ const result = await transform(source, {
167
+ loader: 'ts',
168
+ format: 'esm',
169
+ sourcemap: 'inline',
170
+ target: 'ESNext',
171
+ tsconfigRaw: {
172
+ compilerOptions: {
173
+
174
+ },
175
+ },
176
+ });
177
+
178
+ fs.writeFileSync(tmpFile, result.code, 'utf8');
179
+ }
180
+
181
+ const url = pathToFileURL(tmpFile).href;
182
+ const mod = await import(url);
183
+ return (mod.default ?? mod) as ScaffoldConfig;
184
+ }
@@ -0,0 +1,73 @@
1
+ // src/core/hook-runner.ts
2
+
3
+ import { minimatch } from 'minimatch';
4
+ import type {
5
+ ScaffoldConfig,
6
+ HookContext,
7
+ RegularHookKind,
8
+ StubHookKind,
9
+ StubConfig,
10
+ RegularHookConfig,
11
+ StubHookConfig,
12
+ } from '../schema';
13
+
14
+ function matchesFilter(
15
+ pathRel: string,
16
+ cfg: { include?: string[]; exclude?: string[]; files?: string[] },
17
+ ): boolean {
18
+ const { include, exclude, files } = cfg;
19
+
20
+ const patterns: string[] = [];
21
+ if (include?.length) patterns.push(...include);
22
+ if (files?.length) patterns.push(...files);
23
+
24
+ if (patterns.length) {
25
+ const ok = patterns.some((p) => minimatch(pathRel, p));
26
+ if (!ok) return false;
27
+ }
28
+
29
+ if (exclude?.length) {
30
+ const blocked = exclude.some((p) => minimatch(pathRel, p));
31
+ if (blocked) return false;
32
+ }
33
+
34
+ return true;
35
+ }
36
+
37
+ export class HookRunner {
38
+ constructor(private readonly config: ScaffoldConfig) { }
39
+
40
+ async runRegular(kind: RegularHookKind, ctx: HookContext): Promise<void> {
41
+ const configs: RegularHookConfig[] = this.config.hooks?.[kind] ?? [];
42
+ for (const cfg of configs) {
43
+ if (!matchesFilter(ctx.targetPath, cfg)) continue;
44
+ await cfg.fn(ctx);
45
+ }
46
+ }
47
+
48
+ private getStubConfig(stubName?: string): StubConfig | undefined {
49
+ if (!stubName) return undefined;
50
+ return this.config.stubs?.[stubName];
51
+ }
52
+
53
+ async runStub(kind: StubHookKind, ctx: HookContext): Promise<void> {
54
+ const stub = this.getStubConfig(ctx.stubName);
55
+ if (!stub?.hooks) return;
56
+
57
+ const configs: StubHookConfig[] =
58
+ kind === 'preStub'
59
+ ? stub.hooks.preStub ?? []
60
+ : stub.hooks.postStub ?? [];
61
+
62
+ for (const cfg of configs) {
63
+ if (!matchesFilter(ctx.targetPath, cfg)) continue;
64
+ await cfg.fn(ctx);
65
+ }
66
+ }
67
+
68
+ async renderStubContent(ctx: HookContext): Promise<string | undefined> {
69
+ const stub = this.getStubConfig(ctx.stubName);
70
+ if (!stub?.getContent) return undefined;
71
+ return stub.getContent(ctx);
72
+ }
73
+ }
@@ -0,0 +1,162 @@
1
+ // src/core/init-scaffold.ts
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { ensureDirSync } from '../util/fs-utils';
6
+ import { defaultLogger } from '../util/logger';
7
+
8
+ const logger = defaultLogger.child('[init]');
9
+
10
+ export interface InitScaffoldOptions {
11
+ /**
12
+ * Path to the scaffold directory (relative to cwd).
13
+ * Default: "scaffold"
14
+ */
15
+ scaffoldDir?: string;
16
+
17
+ /**
18
+ * Overwrite existing config/structure files if they already exist.
19
+ */
20
+ force?: boolean;
21
+
22
+ /**
23
+ * Name of the config file inside the scaffold directory.
24
+ * Default: "config.ts"
25
+ */
26
+ configFileName?: string;
27
+
28
+ /**
29
+ * Name of the default structure file inside the scaffold directory
30
+ * for single-root mode.
31
+ * Default: "structure.txt"
32
+ */
33
+ structureFileName?: string;
34
+ }
35
+
36
+ // src/core/init-scaffold.ts
37
+
38
+ const DEFAULT_CONFIG_TS = `import type { ScaffoldConfig } from '@timeax/scaffold';
39
+
40
+ const config: ScaffoldConfig = {
41
+ // Root for resolving the scaffold/ folder & this config file.
42
+ // By default, this is the directory where you run \`scaffold\`.
43
+ // Example:
44
+ // root: '.', // scaffold/ at <cwd>/scaffold
45
+ // root: 'tools', // scaffold/ at <cwd>/tools/scaffold
46
+ // root: '.',
47
+
48
+ // Base directory where structures are applied and files/folders are created.
49
+ // This is resolved relative to \`root\` above. Defaults to the same as root.
50
+ // Example:
51
+ // base: '.', // apply to <root>
52
+ // base: 'src', // apply to <root>/src
53
+ // base: '..', // apply to parent of <root>
54
+ // base: '.',
55
+
56
+ // Cache file path, relative to base.
57
+ // cacheFile: '.scaffold-cache.json',
58
+
59
+ // --- Single-structure mode (simple) ---
60
+ // structureFile: 'structure.txt',
61
+
62
+ // --- Grouped mode (uncomment and adjust) ---
63
+ // groups: [
64
+ // { name: 'app', root: 'app', structureFile: 'app.txt' },
65
+ // { name: 'frontend', root: 'resources/js', structureFile: 'frontend.txt' },
66
+ // ],
67
+
68
+ hooks: {
69
+ // preCreateFile: [],
70
+ // postCreateFile: [],
71
+ // preDeleteFile: [],
72
+ // postDeleteFile: [],
73
+ },
74
+
75
+ stubs: {
76
+ // Example:
77
+ // page: {
78
+ // name: 'page',
79
+ // getContent: (ctx) =>
80
+ // \`export default function Page() { return <div>\${ctx.targetPath}</div>; }\`,
81
+ // },
82
+ },
83
+ };
84
+
85
+ export default config;
86
+ `;
87
+
88
+ const DEFAULT_STRUCTURE_TXT = `# scaffold/structure.txt
89
+ # Example structure definition.
90
+ # - Indent with 2 spaces per level
91
+ # - Directories must end with "/"
92
+ # - Files do not
93
+ # - Lines starting with "#" are comments and ignored by parser
94
+
95
+ # Example:
96
+ # src/
97
+ # index.ts
98
+ `;
99
+
100
+ /**
101
+ * Initialize the scaffold directory and basic config/structure files.
102
+ *
103
+ * - Creates the scaffold directory if it doesn't exist.
104
+ * - Writes a default config.ts if missing (or if force = true).
105
+ * - Writes a default structure.txt if missing (or if force = true).
106
+ */
107
+ export async function initScaffold(
108
+ cwd: string,
109
+ options: InitScaffoldOptions = {},
110
+ ): Promise<{
111
+ scaffoldDir: string;
112
+ configPath: string;
113
+ structurePath: string;
114
+ created: { config: boolean; structure: boolean };
115
+ }> {
116
+ const scaffoldDirRel = options.scaffoldDir ?? 'scaffold';
117
+ const scaffoldDirAbs = path.resolve(cwd, scaffoldDirRel);
118
+ const configFileName = options.configFileName ?? 'config.ts';
119
+ const structureFileName = options.structureFileName ?? 'structure.txt';
120
+
121
+ ensureDirSync(scaffoldDirAbs);
122
+
123
+ const configPath = path.join(scaffoldDirAbs, configFileName);
124
+ const structurePath = path.join(scaffoldDirAbs, structureFileName);
125
+
126
+ let createdConfig = false;
127
+ let createdStructure = false;
128
+
129
+ // config.ts
130
+ if (fs.existsSync(configPath) && !options.force) {
131
+ logger.info(`Config already exists at ${configPath} (use --force to overwrite).`);
132
+ } else {
133
+ fs.writeFileSync(configPath, DEFAULT_CONFIG_TS, 'utf8');
134
+ createdConfig = true;
135
+ logger.info(
136
+ `${fs.existsSync(configPath) ? 'Overwrote' : 'Created'} config at ${configPath}`,
137
+ );
138
+ }
139
+
140
+ // structure.txt
141
+ if (fs.existsSync(structurePath) && !options.force) {
142
+ logger.info(
143
+ `Structure file already exists at ${structurePath} (use --force to overwrite).`,
144
+ );
145
+ } else {
146
+ fs.writeFileSync(structurePath, DEFAULT_STRUCTURE_TXT, 'utf8');
147
+ createdStructure = true;
148
+ logger.info(
149
+ `${fs.existsSync(structurePath) ? 'Overwrote' : 'Created'} structure file at ${structurePath}`,
150
+ );
151
+ }
152
+
153
+ return {
154
+ scaffoldDir: scaffoldDirAbs,
155
+ configPath,
156
+ structurePath,
157
+ created: {
158
+ config: createdConfig,
159
+ structure: createdStructure,
160
+ },
161
+ };
162
+ }
@@ -0,0 +1,64 @@
1
+ // src/core/resolve-structure.ts
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import type {
6
+ ScaffoldConfig,
7
+ StructureEntry,
8
+ StructureGroupConfig,
9
+ } from '../schema';
10
+ import { parseStructureText } from './structure-txt';
11
+ import { defaultLogger } from '../util/logger';
12
+
13
+ const logger = defaultLogger.child('[structure]');
14
+
15
+ export function resolveGroupStructure(
16
+ scaffoldDir: string,
17
+ group: StructureGroupConfig,
18
+ ): StructureEntry[] {
19
+ if (group.structure && group.structure.length) {
20
+ logger.debug(`Using inline structure for group "${group.name}"`);
21
+ return group.structure;
22
+ }
23
+
24
+ const fileName = group.structureFile ?? `${group.name}.txt`;
25
+ const filePath = path.join(scaffoldDir, fileName);
26
+
27
+ if (!fs.existsSync(filePath)) {
28
+ throw new Error(
29
+ `@timeax/scaffold: Group "${group.name}" has no structure. ` +
30
+ `Expected file "${fileName}" in "${scaffoldDir}".`,
31
+ );
32
+ }
33
+
34
+ logger.debug(`Reading structure for group "${group.name}" from ${filePath}`);
35
+ const raw = fs.readFileSync(filePath, 'utf8');
36
+ return parseStructureText(raw);
37
+ }
38
+
39
+ /**
40
+ * Legacy single-structure mode (no groups defined).
41
+ */
42
+ export function resolveSingleStructure(
43
+ scaffoldDir: string,
44
+ config: ScaffoldConfig,
45
+ ): StructureEntry[] {
46
+ if (config.structure && config.structure.length) {
47
+ logger.debug('Using inline single structure (no groups)');
48
+ return config.structure;
49
+ }
50
+
51
+ const fileName = config.structureFile ?? 'structure.txt';
52
+ const filePath = path.join(scaffoldDir, fileName);
53
+
54
+ if (!fs.existsSync(filePath)) {
55
+ throw new Error(
56
+ `@timeax/scaffold: No structure defined. ` +
57
+ `Expected "${fileName}" in "${scaffoldDir}".`,
58
+ );
59
+ }
60
+
61
+ logger.debug(`Reading single structure from ${filePath}`);
62
+ const raw = fs.readFileSync(filePath, 'utf8');
63
+ return parseStructureText(raw);
64
+ }
@@ -0,0 +1,94 @@
1
+ // src/core/runner.ts
2
+
3
+ import path from 'path';
4
+ import { loadScaffoldConfig } from './config-loader';
5
+ import {
6
+ resolveGroupStructure,
7
+ resolveSingleStructure,
8
+ } from './resolve-structure';
9
+ import { CacheManager } from './cache-manager';
10
+ import { HookRunner } from './hook-runner';
11
+ import { applyStructure, type InteractiveDeleteParams } from './apply-structure';
12
+ import type { Logger } from '../util/logger';
13
+ import { defaultLogger } from '../util/logger';
14
+
15
+ export interface RunOptions {
16
+ /**
17
+ * Optional interactive delete callback; if omitted, deletions
18
+ * above the size threshold will be skipped (kept + removed from cache).
19
+ */
20
+ interactiveDelete?: (
21
+ params: InteractiveDeleteParams,
22
+ ) => Promise<'delete' | 'keep'>;
23
+
24
+ /**
25
+ * Optional logger override.
26
+ */
27
+ logger?: Logger;
28
+
29
+ /**
30
+ * Optional overrides (e.g. allow CLI to point at a different scaffold dir).
31
+ */
32
+ scaffoldDir?: string;
33
+ configPath?: string;
34
+ }
35
+
36
+ /**
37
+ * Run scaffold once for the current working directory.
38
+ */
39
+ export async function runOnce(cwd: string, options: RunOptions = {}): Promise<void> {
40
+ const logger = options.logger ?? defaultLogger.child('[runner]');
41
+ const { config, scaffoldDir, projectRoot } = await loadScaffoldConfig(cwd, {
42
+ scaffoldDir: options.scaffoldDir,
43
+ configPath: options.configPath,
44
+ });
45
+
46
+ const cachePath = config.cacheFile ?? '.scaffold-cache.json';
47
+ const cache = new CacheManager(projectRoot, cachePath);
48
+ cache.load();
49
+
50
+ const hooks = new HookRunner(config);
51
+
52
+ // Grouped mode
53
+ if (config.groups && config.groups.length > 0) {
54
+ for (const group of config.groups) {
55
+ const groupRootAbs = path.resolve(projectRoot, group.root);
56
+ const structure = resolveGroupStructure(scaffoldDir, group);
57
+
58
+ const groupLogger = logger.child(`[group:${group.name}]`);
59
+
60
+ // eslint-disable-next-line no-await-in-loop
61
+ await applyStructure({
62
+ config,
63
+ projectRoot,
64
+ baseDir: groupRootAbs,
65
+ structure,
66
+ cache,
67
+ hooks,
68
+ groupName: group.name,
69
+ groupRoot: group.root,
70
+ interactiveDelete: options.interactiveDelete,
71
+ logger: groupLogger,
72
+ });
73
+ }
74
+ } else {
75
+ // Single-root mode
76
+ const structure = resolveSingleStructure(scaffoldDir, config);
77
+ const baseLogger = logger.child('[group:default]');
78
+
79
+ await applyStructure({
80
+ config,
81
+ projectRoot,
82
+ baseDir: projectRoot,
83
+ structure,
84
+ cache,
85
+ hooks,
86
+ groupName: 'default',
87
+ groupRoot: '.',
88
+ interactiveDelete: options.interactiveDelete,
89
+ logger: baseLogger,
90
+ });
91
+ }
92
+
93
+ cache.save();
94
+ }