@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,139 @@
1
+ // src/schema/hooks.ts
2
+
3
+ /**
4
+ * Lifecycle stages for non-stub (regular) hooks.
5
+ *
6
+ * These hooks are called around file operations (create/delete).
7
+ */
8
+ export type RegularHookKind =
9
+ | 'preCreateFile'
10
+ | 'postCreateFile'
11
+ | 'preDeleteFile'
12
+ | 'postDeleteFile';
13
+
14
+ /**
15
+ * Lifecycle stages for stub-related hooks.
16
+ *
17
+ * These hooks are called around stub resolution for file content.
18
+ */
19
+ export type StubHookKind = 'preStub' | 'postStub';
20
+
21
+ /**
22
+ * Context object passed to all hooks (both regular and stub).
23
+ */
24
+ export interface HookContext {
25
+ /**
26
+ * Absolute path to the group root / project root that this
27
+ * scaffold run is targeting.
28
+ */
29
+ projectRoot: string;
30
+
31
+ /**
32
+ * Path of the file or directory relative to the project root
33
+ * used for this run (or group root, if grouped).
34
+ *
35
+ * Example: "src/index.ts", "Http/Controllers/Controller.php".
36
+ */
37
+ targetPath: string;
38
+
39
+ /**
40
+ * Absolute path to the file or directory on disk.
41
+ */
42
+ absolutePath: string;
43
+
44
+ /**
45
+ * Whether the target is a directory.
46
+ * (For now, most hooks will be for files, but this is future-proofing.)
47
+ */
48
+ isDirectory: boolean;
49
+
50
+ /**
51
+ * The stub name associated with the file (if any).
52
+ *
53
+ * For regular hooks, this can be used to detect which stub
54
+ * produced a given file.
55
+ */
56
+ stubName?: string;
57
+ }
58
+
59
+ /**
60
+ * Common filter options used by both regular and stub hooks.
61
+ *
62
+ * Filters are evaluated against the `targetPath`.
63
+ */
64
+ export interface HookFilter {
65
+ /**
66
+ * Glob patterns which must match for the hook to run.
67
+ * If provided, at least one pattern must match.
68
+ */
69
+ include?: string[];
70
+
71
+ /**
72
+ * Glob patterns which, if any match, will prevent the hook
73
+ * from running.
74
+ */
75
+ exclude?: string[];
76
+
77
+ /**
78
+ * Additional patterns or explicit file paths, treated similarly
79
+ * to `include` — mainly a convenience alias.
80
+ */
81
+ files?: string[];
82
+ }
83
+
84
+ /**
85
+ * Function signature for regular hooks.
86
+ */
87
+ export type RegularHookFn = (ctx: HookContext) => void | Promise<void>;
88
+
89
+ /**
90
+ * Function signature for stub hooks.
91
+ */
92
+ export type StubHookFn = (ctx: HookContext) => void | Promise<void>;
93
+
94
+ /**
95
+ * Configuration for a regular hook instance.
96
+ *
97
+ * Each hook category (e.g. `preCreateFile`) can have an array
98
+ * of these, each with its own filter.
99
+ */
100
+ export interface RegularHookConfig extends HookFilter {
101
+ fn: RegularHookFn;
102
+ }
103
+
104
+ /**
105
+ * Configuration for a stub hook instance.
106
+ *
107
+ * Each stub can have its own `preStub` / `postStub` hook arrays,
108
+ * each with independent filters.
109
+ */
110
+ export interface StubHookConfig extends HookFilter {
111
+ fn: StubHookFn;
112
+ }
113
+
114
+ /**
115
+ * Stub configuration, defining how file content is generated
116
+ * and which stub-specific hooks apply.
117
+ */
118
+ export interface StubConfig {
119
+ /**
120
+ * Unique name of this stub within the config.
121
+ * This is referenced from structure entries via `stub: name`.
122
+ */
123
+ name: string;
124
+
125
+ /**
126
+ * Content generator for files that use this stub.
127
+ *
128
+ * If omitted, the scaffold engine may default to an empty file.
129
+ */
130
+ getContent?: (ctx: HookContext) => string | Promise<string>;
131
+
132
+ /**
133
+ * Stub-specific hooks called for files that reference this stub.
134
+ */
135
+ hooks?: {
136
+ preStub?: StubHookConfig[];
137
+ postStub?: StubHookConfig[];
138
+ };
139
+ }
@@ -0,0 +1,4 @@
1
+ // src/schema/index.ts
2
+ export * from './structure';
3
+ export * from './hooks';
4
+ export * from './config';
@@ -0,0 +1,77 @@
1
+ // src/schema/structure.ts
2
+
3
+ /**
4
+ * Common options that can be applied to both files and directories
5
+ * in the scaffold structure.
6
+ *
7
+ * These options are *declarative* — they do not enforce behavior by
8
+ * themselves; they are consumed by the core engine.
9
+ */
10
+ export interface BaseEntryOptions {
11
+ /**
12
+ * Glob patterns relative to the group root or project root
13
+ * (depending on how the engine is called).
14
+ *
15
+ * If provided, at least one pattern must match the entry path
16
+ * for the entry to be considered.
17
+ */
18
+ include?: string[];
19
+
20
+ /**
21
+ * Glob patterns relative to the group root or project root.
22
+ *
23
+ * If any pattern matches the entry path, the entry will be ignored.
24
+ */
25
+ exclude?: string[];
26
+
27
+ /**
28
+ * Name of the stub to use when creating this file or directory’s
29
+ * content. For directories, this can act as an “inherited” stub
30
+ * for child files if the engine chooses to support that behavior.
31
+ */
32
+ stub?: string;
33
+ }
34
+
35
+ /**
36
+ * A single file entry in the structure tree.
37
+ *
38
+ * Paths are always stored as POSIX-style forward-slash paths
39
+ * relative to the group root / project root.
40
+ */
41
+ export interface FileEntry extends BaseEntryOptions {
42
+ type: 'file';
43
+
44
+ /**
45
+ * File path (e.g. "src/index.ts", "Models/User.php").
46
+ * Paths should never end with a trailing slash.
47
+ */
48
+ path: string;
49
+ }
50
+
51
+ /**
52
+ * A directory entry in the structure tree.
53
+ *
54
+ * Paths should *logically* represent directories and may end
55
+ * with a trailing slash for readability (the engine can normalize).
56
+ */
57
+ export interface DirEntry extends BaseEntryOptions {
58
+ type: 'dir';
59
+
60
+ /**
61
+ * Directory path (e.g. "src/", "src/schema/", "Models/").
62
+ * It is recommended (but not strictly required) that directory
63
+ * paths end with a trailing slash.
64
+ */
65
+ path: string;
66
+
67
+ /**
68
+ * Nested structure entries for files and subdirectories.
69
+ */
70
+ children?: StructureEntry[];
71
+ }
72
+
73
+ /**
74
+ * A single node in the structure tree:
75
+ * either a file or a directory.
76
+ */
77
+ export type StructureEntry = FileEntry | DirEntry;
@@ -0,0 +1,126 @@
1
+ // src/util/fs-utils.ts
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ /**
7
+ * Convert any path to a POSIX-style path with forward slashes.
8
+ */
9
+ export function toPosixPath(p: string): string {
10
+ return p.replace(/\\/g, '/');
11
+ }
12
+
13
+ /**
14
+ * Ensure a directory exists (like mkdir -p).
15
+ * Returns the absolute path of the directory.
16
+ */
17
+ export function ensureDirSync(dirPath: string): string {
18
+ if (!fs.existsSync(dirPath)) {
19
+ fs.mkdirSync(dirPath, { recursive: true });
20
+ }
21
+ return dirPath;
22
+ }
23
+
24
+ /**
25
+ * Synchronous check for file or directory existence.
26
+ */
27
+ export function existsSync(targetPath: string): boolean {
28
+ return fs.existsSync(targetPath);
29
+ }
30
+
31
+ /**
32
+ * Read a file as UTF-8, returning null if it doesn't exist
33
+ * or if an error occurs (no exceptions thrown).
34
+ */
35
+ export function readFileSafeSync(filePath: string): string | null {
36
+ try {
37
+ return fs.readFileSync(filePath, 'utf8');
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Write a UTF-8 file, creating parent directories if needed.
45
+ */
46
+ export function writeFileSafeSync(filePath: string, contents: string): void {
47
+ const dir = path.dirname(filePath);
48
+ ensureDirSync(dir);
49
+ fs.writeFileSync(filePath, contents, 'utf8');
50
+ }
51
+
52
+ /**
53
+ * Remove a file if it exists. Does nothing on error.
54
+ */
55
+ export function removeFileSafeSync(filePath: string): void {
56
+ try {
57
+ if (fs.existsSync(filePath)) {
58
+ fs.unlinkSync(filePath);
59
+ }
60
+ } catch {
61
+ // ignore
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Get file stats if they exist, otherwise null.
67
+ */
68
+ export function statSafeSync(targetPath: string): fs.Stats | null {
69
+ try {
70
+ return fs.statSync(targetPath);
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Resolve an absolute path from projectRoot + relative path,
78
+ * and assert it stays within the project root.
79
+ *
80
+ * Throws if the resolved path escapes the project root.
81
+ */
82
+ export function resolveProjectPath(projectRoot: string, relPath: string): string {
83
+ const absRoot = path.resolve(projectRoot);
84
+ const absTarget = path.resolve(absRoot, relPath);
85
+
86
+ // Normalise for safety check
87
+ const rootWithSep = absRoot.endsWith(path.sep) ? absRoot : absRoot + path.sep;
88
+ if (!absTarget.startsWith(rootWithSep) && absTarget !== absRoot) {
89
+ throw new Error(
90
+ `Attempted to resolve path outside project root: ` +
91
+ `root="${absRoot}", target="${absTarget}"`,
92
+ );
93
+ }
94
+
95
+ return absTarget;
96
+ }
97
+
98
+ /**
99
+ * Convert an absolute path back to a project-relative path.
100
+ * Throws if the path is not under projectRoot.
101
+ */
102
+ export function toProjectRelativePath(projectRoot: string, absolutePath: string): string {
103
+ const absRoot = path.resolve(projectRoot);
104
+ const absTarget = path.resolve(absolutePath);
105
+
106
+ const rootWithSep = absRoot.endsWith(path.sep) ? absRoot : absRoot + path.sep;
107
+ if (!absTarget.startsWith(rootWithSep) && absTarget !== absRoot) {
108
+ throw new Error(
109
+ `Path "${absTarget}" is not inside project root "${absRoot}".`,
110
+ );
111
+ }
112
+
113
+ const rel = path.relative(absRoot, absTarget);
114
+ return toPosixPath(rel);
115
+ }
116
+
117
+ /**
118
+ * Check if `target` is inside (or equal to) `base` directory.
119
+ */
120
+ export function isSubPath(base: string, target: string): boolean {
121
+ const absBase = path.resolve(base);
122
+ const absTarget = path.resolve(target);
123
+
124
+ const baseWithSep = absBase.endsWith(path.sep) ? absBase : absBase + path.sep;
125
+ return absTarget === absBase || absTarget.startsWith(baseWithSep);
126
+ }
@@ -0,0 +1,144 @@
1
+ // src/util/logger.ts
2
+
3
+ export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
4
+
5
+ export interface LoggerOptions {
6
+ level?: LogLevel;
7
+ /**
8
+ * Optional prefix string (e.g. "[scaffold]" or "[group:app]").
9
+ */
10
+ prefix?: string;
11
+ }
12
+
13
+ /**
14
+ * Minimal ANSI color helpers (no external deps).
15
+ */
16
+ const supportsColor =
17
+ typeof process !== 'undefined' &&
18
+ process.stdout &&
19
+ process.stdout.isTTY &&
20
+ process.env.NO_COLOR !== '1';
21
+
22
+ type ColorFn = (text: string) => string;
23
+
24
+ function wrap(code: number): ColorFn {
25
+ const open = `\u001b[${code}m`;
26
+ const close = `\u001b[0m`;
27
+ return (text: string) => (supportsColor ? `${open}${text}${close}` : text);
28
+ }
29
+
30
+ const color = {
31
+ red: wrap(31),
32
+ yellow: wrap(33),
33
+ green: wrap(32),
34
+ cyan: wrap(36),
35
+ magenta: wrap(35),
36
+ dim: wrap(2),
37
+ bold: wrap(1),
38
+ gray: wrap(90),
39
+ };
40
+
41
+ function colorForLevel(level: LogLevel): ColorFn {
42
+ switch (level) {
43
+ case 'error':
44
+ return color.red;
45
+ case 'warn':
46
+ return color.yellow;
47
+ case 'info':
48
+ return color.cyan;
49
+ case 'debug':
50
+ return color.gray;
51
+ default:
52
+ return (s) => s;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Minimal logger for @timeax/scaffold with colored output.
58
+ */
59
+ export class Logger {
60
+ private level: LogLevel;
61
+ private prefix: string | undefined;
62
+
63
+ constructor(options: LoggerOptions = {}) {
64
+ this.level = options.level ?? 'info';
65
+ this.prefix = options.prefix;
66
+ }
67
+
68
+ setLevel(level: LogLevel) {
69
+ this.level = level;
70
+ }
71
+
72
+ getLevel(): LogLevel {
73
+ return this.level;
74
+ }
75
+
76
+ /**
77
+ * Create a child logger with an additional prefix.
78
+ */
79
+ child(prefix: string): Logger {
80
+ const combined = this.prefix ? `${this.prefix}${prefix}` : prefix;
81
+ return new Logger({ level: this.level, prefix: combined });
82
+ }
83
+
84
+ private formatMessage(msg: unknown, lvl: LogLevel): string {
85
+ const text =
86
+ typeof msg === 'string'
87
+ ? msg
88
+ : msg instanceof Error
89
+ ? msg.message
90
+ : String(msg);
91
+
92
+ const levelColor = colorForLevel(lvl);
93
+ const prefixColored = this.prefix
94
+ ? color.magenta(this.prefix)
95
+ : undefined;
96
+
97
+ const textColored =
98
+ lvl === 'debug' ? color.dim(text) : levelColor(text);
99
+
100
+ if (prefixColored) {
101
+ return `${prefixColored} ${textColored}`;
102
+ }
103
+
104
+ return textColored;
105
+ }
106
+
107
+ private shouldLog(targetLevel: LogLevel): boolean {
108
+ const order: LogLevel[] = ['silent', 'error', 'warn', 'info', 'debug'];
109
+ const currentIdx = order.indexOf(this.level);
110
+ const targetIdx = order.indexOf(targetLevel);
111
+ if (currentIdx === -1 || targetIdx === -1) return true;
112
+ if (this.level === 'silent') return false;
113
+ return targetIdx <= currentIdx || targetLevel === 'error';
114
+ }
115
+
116
+ error(msg: unknown, ...rest: unknown[]) {
117
+ if (!this.shouldLog('error')) return;
118
+ console.error(this.formatMessage(msg, 'error'), ...rest);
119
+ }
120
+
121
+ warn(msg: unknown, ...rest: unknown[]) {
122
+ if (!this.shouldLog('warn')) return;
123
+ console.warn(this.formatMessage(msg, 'warn'), ...rest);
124
+ }
125
+
126
+ info(msg: unknown, ...rest: unknown[]) {
127
+ if (!this.shouldLog('info')) return;
128
+ console.log(this.formatMessage(msg, 'info'), ...rest);
129
+ }
130
+
131
+ debug(msg: unknown, ...rest: unknown[]) {
132
+ if (!this.shouldLog('debug')) return;
133
+ console.debug(this.formatMessage(msg, 'debug'), ...rest);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Default process-wide logger used by CLI and core.
139
+ * Level can be controlled via SCAFFOLD_LOG_LEVEL env.
140
+ */
141
+ export const defaultLogger = new Logger({
142
+ level: (process.env.SCAFFOLD_LOG_LEVEL as LogLevel | undefined) ?? 'info',
143
+ prefix: '[scaffold]',
144
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "allowSyntheticDefaultImports": true,
8
+ "strict": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "rootDir": "src",
13
+ "outDir": "dist",
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true,
17
+ "types": [
18
+ "node"
19
+ ]
20
+ },
21
+ "include": [
22
+ "src"
23
+ ]
24
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,48 @@
1
+ // tsup.config.ts
2
+ import { defineConfig } from 'tsup';
3
+
4
+ export default defineConfig([
5
+ // Library build (@timeax/scaffold import)
6
+ {
7
+ entry: ['src/index.ts'],
8
+ outDir: 'dist',
9
+ format: ['esm', 'cjs'],
10
+ dts: true,
11
+ sourcemap: true,
12
+ clean: true,
13
+ target: 'node18',
14
+ platform: 'node',
15
+ treeshake: true,
16
+ splitting: false, // small lib, keep it simple
17
+ outExtension({ format }) {
18
+ return {
19
+ // ESM → .mjs, CJS → .cjs
20
+ js: format === 'esm' ? '.mjs' : '.cjs',
21
+ };
22
+ },
23
+ },
24
+
25
+ // CLI build (scaffold command)
26
+ {
27
+ entry: {
28
+ cli: 'src/cli/main.ts',
29
+ },
30
+ outDir: 'dist',
31
+ format: ['esm', 'cjs'],
32
+ dts: false,
33
+ sourcemap: true,
34
+ clean: false, // don't blow away the lib build
35
+ target: 'node18',
36
+ platform: 'node',
37
+ treeshake: true,
38
+ splitting: false,
39
+ outExtension({ format }) {
40
+ return {
41
+ js: format === 'esm' ? '.mjs' : '.cjs',
42
+ };
43
+ },
44
+ banner: {
45
+ js: '#!/usr/bin/env node',
46
+ },
47
+ },
48
+ ]);