@config-bound/cli 0.1.0
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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-format$colon$ci.log +6 -0
- package/.turbo/turbo-lint$colon$ci.log +4 -0
- package/.turbo/turbo-test.log +19 -0
- package/CHANGELOG.md +15 -0
- package/README.md +66 -0
- package/bin/configbound.js +3 -0
- package/dist/cli.module.d.ts +3 -0
- package/dist/cli.module.d.ts.map +1 -0
- package/dist/cli.module.js +29 -0
- package/dist/cli.module.js.map +1 -0
- package/dist/commands/export.command.d.ts +30 -0
- package/dist/commands/export.command.d.ts.map +1 -0
- package/dist/commands/export.command.js +226 -0
- package/dist/commands/export.command.js.map +1 -0
- package/dist/commands/list.command.d.ts +13 -0
- package/dist/commands/list.command.d.ts.map +1 -0
- package/dist/commands/list.command.js +93 -0
- package/dist/commands/list.command.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +17 -0
- package/dist/main.js.map +1 -0
- package/dist/services/config-discovery.service.d.ts +15 -0
- package/dist/services/config-discovery.service.d.ts.map +1 -0
- package/dist/services/config-discovery.service.js +191 -0
- package/dist/services/config-discovery.service.js.map +1 -0
- package/dist/services/config-discovery.service.spec.d.ts +2 -0
- package/dist/services/config-discovery.service.spec.d.ts.map +1 -0
- package/dist/services/config-discovery.service.spec.js +137 -0
- package/dist/services/config-discovery.service.spec.js.map +1 -0
- package/dist/services/config-loader.service.d.ts +13 -0
- package/dist/services/config-loader.service.d.ts.map +1 -0
- package/dist/services/config-loader.service.js +241 -0
- package/dist/services/config-loader.service.js.map +1 -0
- package/dist/services/file-writer.service.d.ts +6 -0
- package/dist/services/file-writer.service.d.ts.map +1 -0
- package/dist/services/file-writer.service.js +38 -0
- package/dist/services/file-writer.service.js.map +1 -0
- package/dist/services/file-writer.service.spec.d.ts +2 -0
- package/dist/services/file-writer.service.spec.d.ts.map +1 -0
- package/dist/services/file-writer.service.spec.js +98 -0
- package/dist/services/file-writer.service.spec.js.map +1 -0
- package/dist/services/schema-export.service.d.ts +14 -0
- package/dist/services/schema-export.service.d.ts.map +1 -0
- package/dist/services/schema-export.service.js +58 -0
- package/dist/services/schema-export.service.js.map +1 -0
- package/dist/services/schema-export.service.spec.d.ts +2 -0
- package/dist/services/schema-export.service.spec.d.ts.map +1 -0
- package/dist/services/schema-export.service.spec.js +69 -0
- package/dist/services/schema-export.service.spec.js.map +1 -0
- package/eslint.config.mjs +5 -0
- package/jest.config.js +39 -0
- package/package.json +62 -0
- package/src/cli.module.ts +21 -0
- package/src/commands/export.command.ts +253 -0
- package/src/commands/list.command.ts +108 -0
- package/src/main.ts +19 -0
- package/src/services/config-discovery.service.spec.ts +209 -0
- package/src/services/config-discovery.service.ts +237 -0
- package/src/services/config-loader.service.ts +379 -0
- package/src/services/file-writer.service.spec.ts +139 -0
- package/src/services/file-writer.service.ts +32 -0
- package/src/services/schema-export.service.spec.ts +124 -0
- package/src/services/schema-export.service.ts +85 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { Project, Node, SyntaxKind, SourceFile } from 'ts-morph';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import { ensureError } from '@config-bound/config-bound/utilities';
|
|
6
|
+
|
|
7
|
+
export interface DiscoveredConfig {
|
|
8
|
+
filePath: string;
|
|
9
|
+
exportName: string;
|
|
10
|
+
isDefault: boolean;
|
|
11
|
+
configName?: string;
|
|
12
|
+
lineNumber: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Injectable()
|
|
16
|
+
export class ConfigDiscoveryService {
|
|
17
|
+
async discoverConfigs(
|
|
18
|
+
searchPath: string,
|
|
19
|
+
recursive: boolean = true
|
|
20
|
+
): Promise<DiscoveredConfig[]> {
|
|
21
|
+
const tsFiles = this.findTypeScriptFiles(searchPath, recursive);
|
|
22
|
+
const discovered: DiscoveredConfig[] = [];
|
|
23
|
+
|
|
24
|
+
// Create a single project instance for all files
|
|
25
|
+
const project = new Project({
|
|
26
|
+
skipAddingFilesFromTsConfig: true,
|
|
27
|
+
compilerOptions: {
|
|
28
|
+
allowJs: false,
|
|
29
|
+
skipLibCheck: true
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Add all files at once
|
|
34
|
+
for (const filePath of tsFiles) {
|
|
35
|
+
try {
|
|
36
|
+
project.addSourceFileAtPath(filePath);
|
|
37
|
+
} catch (error: unknown) {
|
|
38
|
+
console.warn(`skipping ${filePath}: ${ensureError(error).message}`);
|
|
39
|
+
// Skip files with parse errors
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Now extract configs from all source files
|
|
45
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
46
|
+
try {
|
|
47
|
+
const configs = this.extractConfigsFromFile(sourceFile);
|
|
48
|
+
discovered.push(...configs);
|
|
49
|
+
} catch (error: unknown) {
|
|
50
|
+
console.warn(`skipping ${sourceFile}: ${ensureError(error).message}`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return discovered;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private extractConfigsFromFile(sourceFile: SourceFile): DiscoveredConfig[] {
|
|
59
|
+
const discovered: DiscoveredConfig[] = [];
|
|
60
|
+
const filePath = sourceFile.getFilePath();
|
|
61
|
+
|
|
62
|
+
// Find all variable declarations
|
|
63
|
+
const variableStatements = sourceFile.getVariableStatements();
|
|
64
|
+
|
|
65
|
+
for (const statement of variableStatements) {
|
|
66
|
+
const declarations = statement.getDeclarations();
|
|
67
|
+
|
|
68
|
+
for (const declaration of declarations) {
|
|
69
|
+
const initializer = declaration.getInitializer();
|
|
70
|
+
if (!initializer) continue;
|
|
71
|
+
|
|
72
|
+
// Check if it's a call to ConfigBound.createConfig()
|
|
73
|
+
if (this.isConfigBoundCreateConfig(initializer)) {
|
|
74
|
+
const isExported = statement
|
|
75
|
+
.getModifiers()
|
|
76
|
+
.some((mod: Node) => mod.getKind() === SyntaxKind.ExportKeyword);
|
|
77
|
+
|
|
78
|
+
if (isExported) {
|
|
79
|
+
const exportName = declaration.getName();
|
|
80
|
+
const configName = this.extractConfigName(initializer);
|
|
81
|
+
|
|
82
|
+
discovered.push({
|
|
83
|
+
filePath,
|
|
84
|
+
exportName,
|
|
85
|
+
isDefault: false,
|
|
86
|
+
configName,
|
|
87
|
+
lineNumber: declaration.getStartLineNumber()
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check for default exports
|
|
95
|
+
const defaultExport = sourceFile.getDefaultExportSymbol();
|
|
96
|
+
if (defaultExport) {
|
|
97
|
+
const declarations = defaultExport.getDeclarations();
|
|
98
|
+
for (const declaration of declarations) {
|
|
99
|
+
if (Node.isExportAssignment(declaration)) {
|
|
100
|
+
const expression = declaration.getExpression();
|
|
101
|
+
if (this.isConfigBoundCreateConfig(expression)) {
|
|
102
|
+
const configName = this.extractConfigName(expression);
|
|
103
|
+
discovered.push({
|
|
104
|
+
filePath,
|
|
105
|
+
exportName: 'default',
|
|
106
|
+
isDefault: true,
|
|
107
|
+
configName,
|
|
108
|
+
lineNumber: declaration.getStartLineNumber()
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return discovered;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private isConfigBoundCreateConfig(node: Node): boolean {
|
|
119
|
+
if (!Node.isCallExpression(node)) return false;
|
|
120
|
+
|
|
121
|
+
const expression = node.getExpression();
|
|
122
|
+
|
|
123
|
+
// Check for ConfigBound.createConfig()
|
|
124
|
+
if (Node.isPropertyAccessExpression(expression)) {
|
|
125
|
+
const obj = expression.getExpression();
|
|
126
|
+
const prop = expression.getName();
|
|
127
|
+
|
|
128
|
+
if (
|
|
129
|
+
Node.isIdentifier(obj) &&
|
|
130
|
+
obj.getText() === 'ConfigBound' &&
|
|
131
|
+
prop === 'createConfig'
|
|
132
|
+
) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check for createConfig() (named import)
|
|
138
|
+
if (Node.isIdentifier(expression)) {
|
|
139
|
+
if (expression.getText() === 'createConfig') {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private extractConfigName(node: Node): string | undefined {
|
|
148
|
+
if (!Node.isCallExpression(node)) return undefined;
|
|
149
|
+
|
|
150
|
+
const args = node.getArguments();
|
|
151
|
+
if (args.length === 0) return undefined;
|
|
152
|
+
|
|
153
|
+
const firstArg = args[0];
|
|
154
|
+
|
|
155
|
+
// Look for name property in object literal
|
|
156
|
+
if (Node.isObjectLiteralExpression(firstArg)) {
|
|
157
|
+
const nameProperty = firstArg
|
|
158
|
+
.getProperties()
|
|
159
|
+
.find(
|
|
160
|
+
(prop) => Node.isPropertyAssignment(prop) && prop.getName() === 'name'
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if (nameProperty && Node.isPropertyAssignment(nameProperty)) {
|
|
164
|
+
const initializer = nameProperty.getInitializer();
|
|
165
|
+
if (initializer && Node.isStringLiteral(initializer)) {
|
|
166
|
+
return initializer.getLiteralValue();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private findTypeScriptFiles(
|
|
175
|
+
searchPath: string,
|
|
176
|
+
recursive: boolean
|
|
177
|
+
): string[] {
|
|
178
|
+
const files: string[] = [];
|
|
179
|
+
const excludedDirs = new Set([
|
|
180
|
+
'node_modules',
|
|
181
|
+
'dist',
|
|
182
|
+
'build',
|
|
183
|
+
'coverage',
|
|
184
|
+
'.git',
|
|
185
|
+
'.next',
|
|
186
|
+
'.nuxt',
|
|
187
|
+
'out',
|
|
188
|
+
'public',
|
|
189
|
+
'static',
|
|
190
|
+
'__pycache__',
|
|
191
|
+
'venv',
|
|
192
|
+
'env'
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
function traverse(dir: string) {
|
|
196
|
+
let entries;
|
|
197
|
+
try {
|
|
198
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
199
|
+
} catch {
|
|
200
|
+
// Skip directories we can't read
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
const fullPath = path.join(dir, entry.name);
|
|
206
|
+
|
|
207
|
+
// Skip excluded directories and hidden directories
|
|
208
|
+
if (entry.isDirectory()) {
|
|
209
|
+
if (excludedDirs.has(entry.name) || entry.name.startsWith('.')) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (recursive) {
|
|
213
|
+
traverse(fullPath);
|
|
214
|
+
}
|
|
215
|
+
} else if (entry.isFile() && entry.name.endsWith('.ts')) {
|
|
216
|
+
// Skip test files and declaration files
|
|
217
|
+
if (
|
|
218
|
+
!entry.name.endsWith('.spec.ts') &&
|
|
219
|
+
!entry.name.endsWith('.test.ts') &&
|
|
220
|
+
!entry.name.endsWith('.d.ts')
|
|
221
|
+
) {
|
|
222
|
+
files.push(fullPath);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const stats = fs.statSync(searchPath);
|
|
229
|
+
if (stats.isDirectory()) {
|
|
230
|
+
traverse(searchPath);
|
|
231
|
+
} else if (stats.isFile() && searchPath.endsWith('.ts')) {
|
|
232
|
+
files.push(searchPath);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return files;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
import { Section } from '@config-bound/config-bound/section';
|
|
6
|
+
import {
|
|
7
|
+
ConfigBound,
|
|
8
|
+
TypedConfigBound,
|
|
9
|
+
ConfigSchema,
|
|
10
|
+
ConfigLoaderException,
|
|
11
|
+
ConfigFileNotFoundException,
|
|
12
|
+
ConfigFileIsDirectoryException,
|
|
13
|
+
ExportNotFoundException,
|
|
14
|
+
NoConfigBoundInstancesException,
|
|
15
|
+
MultipleConfigBoundInstancesException,
|
|
16
|
+
InvalidConfigBoundInstanceException,
|
|
17
|
+
ConfigFileParseException,
|
|
18
|
+
MissingDependencyException
|
|
19
|
+
} from '@config-bound/config-bound';
|
|
20
|
+
|
|
21
|
+
export interface LoadedConfig {
|
|
22
|
+
name: string;
|
|
23
|
+
sections: Section[];
|
|
24
|
+
instance: ConfigBound | TypedConfigBound<ConfigSchema>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Injectable()
|
|
28
|
+
export class ConfigLoaderService {
|
|
29
|
+
async loadConfig(
|
|
30
|
+
filePath: string,
|
|
31
|
+
exportName?: string
|
|
32
|
+
): Promise<LoadedConfig> {
|
|
33
|
+
const absolutePath = path.resolve(filePath);
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(absolutePath)) {
|
|
36
|
+
const parentDir = path.dirname(absolutePath);
|
|
37
|
+
const parentExists = fs.existsSync(parentDir);
|
|
38
|
+
let similarFiles: string[] | undefined;
|
|
39
|
+
if (parentExists) {
|
|
40
|
+
const fileName = path.basename(absolutePath);
|
|
41
|
+
const dirFiles = fs.readdirSync(parentDir);
|
|
42
|
+
similarFiles = dirFiles.filter((file) =>
|
|
43
|
+
file.toLowerCase().includes(fileName.toLowerCase().slice(0, 3))
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
throw new ConfigFileNotFoundException(
|
|
47
|
+
absolutePath,
|
|
48
|
+
parentDir,
|
|
49
|
+
parentExists,
|
|
50
|
+
similarFiles
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const stat = fs.statSync(absolutePath);
|
|
55
|
+
if (stat.isDirectory()) {
|
|
56
|
+
throw new ConfigFileIsDirectoryException(absolutePath);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If it's a TypeScript file, check if there's a compiled .js version
|
|
60
|
+
const isTypeScript =
|
|
61
|
+
absolutePath.endsWith('.ts') || absolutePath.endsWith('.tsx');
|
|
62
|
+
let fileToLoad = absolutePath;
|
|
63
|
+
|
|
64
|
+
const parseExceptionBaseMessage = `TypeScript file found but no compiled .js file exists. Please compile the file first (e.g., run 'tsc' or 'npm run build').`;
|
|
65
|
+
if (isTypeScript) {
|
|
66
|
+
// First, check for .js in the same directory
|
|
67
|
+
const jsPath = absolutePath.replace(/\.tsx?$/, '.js');
|
|
68
|
+
if (fs.existsSync(jsPath)) {
|
|
69
|
+
fileToLoad = jsPath;
|
|
70
|
+
} else {
|
|
71
|
+
// If in src/, check for corresponding file in dist/
|
|
72
|
+
const srcMatch = absolutePath.match(/^(.*[/\\])src([/\\].+)$/);
|
|
73
|
+
if (srcMatch) {
|
|
74
|
+
const distPath =
|
|
75
|
+
srcMatch[1] + 'dist' + srcMatch[2].replace(/\.tsx?$/, '.js');
|
|
76
|
+
if (fs.existsSync(distPath)) {
|
|
77
|
+
fileToLoad = distPath;
|
|
78
|
+
} else {
|
|
79
|
+
throw new ConfigFileParseException(
|
|
80
|
+
filePath,
|
|
81
|
+
`${parseExceptionBaseMessage} Checked: ${jsPath} and ${distPath}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
throw new ConfigFileParseException(
|
|
86
|
+
filePath,
|
|
87
|
+
`${parseExceptionBaseMessage} Expected compiled file at: ${jsPath}`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const fileURL = pathToFileURL(fileToLoad).href;
|
|
95
|
+
const module = await import(fileURL);
|
|
96
|
+
|
|
97
|
+
let configInstance:
|
|
98
|
+
| ConfigBound
|
|
99
|
+
| TypedConfigBound<ConfigSchema>
|
|
100
|
+
| undefined;
|
|
101
|
+
|
|
102
|
+
if (exportName && exportName !== 'default') {
|
|
103
|
+
configInstance = module[exportName];
|
|
104
|
+
if (!configInstance) {
|
|
105
|
+
const allExports = Object.keys(module).filter(
|
|
106
|
+
(key) => key !== '__esModule'
|
|
107
|
+
);
|
|
108
|
+
const namedExports = allExports.filter((key) => key !== 'default');
|
|
109
|
+
const hasDefault = allExports.includes('default');
|
|
110
|
+
const exportValue = module[exportName];
|
|
111
|
+
|
|
112
|
+
throw new ExportNotFoundException(
|
|
113
|
+
exportName,
|
|
114
|
+
filePath,
|
|
115
|
+
namedExports,
|
|
116
|
+
hasDefault,
|
|
117
|
+
exportValue
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
} else if (exportName === 'default' || !exportName) {
|
|
121
|
+
// Try default export first
|
|
122
|
+
const defaultExport = module.default;
|
|
123
|
+
|
|
124
|
+
// If default export is a valid ConfigBound instance, use it
|
|
125
|
+
if (defaultExport && this.isConfigBoundInstance(defaultExport)) {
|
|
126
|
+
configInstance = defaultExport;
|
|
127
|
+
} else if (!exportName) {
|
|
128
|
+
// If no specific export name and default is not a ConfigBound instance,
|
|
129
|
+
// try to find any ConfigBound instance in the file
|
|
130
|
+
const allExports = Object.entries(module).filter(
|
|
131
|
+
([key]) => key !== '__esModule'
|
|
132
|
+
);
|
|
133
|
+
const configExports = allExports
|
|
134
|
+
.filter(([key]) => key !== 'default')
|
|
135
|
+
.filter(([, value]) => this.isConfigBoundInstance(value));
|
|
136
|
+
|
|
137
|
+
if (configExports.length === 0) {
|
|
138
|
+
const nonConfigExports = allExports
|
|
139
|
+
.filter(([key]) => key !== 'default')
|
|
140
|
+
.filter(([, value]) => !this.isConfigBoundInstance(value))
|
|
141
|
+
.map(([key, value]) => [key, typeof value] as [string, string]);
|
|
142
|
+
const hasDefault = allExports.some(([key]) => key === 'default');
|
|
143
|
+
|
|
144
|
+
throw new NoConfigBoundInstancesException(
|
|
145
|
+
filePath,
|
|
146
|
+
nonConfigExports,
|
|
147
|
+
hasDefault
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (configExports.length > 1) {
|
|
152
|
+
const exportNames = configExports.map(([key]) => key);
|
|
153
|
+
throw new MultipleConfigBoundInstancesException(
|
|
154
|
+
filePath,
|
|
155
|
+
exportNames
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
configInstance = configExports[0][1] as
|
|
160
|
+
| ConfigBound
|
|
161
|
+
| TypedConfigBound<ConfigSchema>;
|
|
162
|
+
} else {
|
|
163
|
+
// exportName === 'default' was explicitly requested, so use it even if invalid
|
|
164
|
+
configInstance = defaultExport;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!configInstance) {
|
|
169
|
+
const targetExport = exportName || 'default';
|
|
170
|
+
const allExports = Object.keys(module).filter(
|
|
171
|
+
(key) => key !== '__esModule'
|
|
172
|
+
);
|
|
173
|
+
const namedExports = allExports.filter((key) => key !== 'default');
|
|
174
|
+
const hasDefault = allExports.includes('default');
|
|
175
|
+
|
|
176
|
+
throw new ExportNotFoundException(
|
|
177
|
+
targetExport,
|
|
178
|
+
filePath,
|
|
179
|
+
namedExports,
|
|
180
|
+
hasDefault,
|
|
181
|
+
module[targetExport]
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!this.isConfigBoundInstance(configInstance)) {
|
|
186
|
+
const targetExport = exportName || 'default';
|
|
187
|
+
const exportValue = module[targetExport];
|
|
188
|
+
const valueType = typeof exportValue;
|
|
189
|
+
const valueDetails =
|
|
190
|
+
exportValue === null
|
|
191
|
+
? 'null'
|
|
192
|
+
: exportValue === undefined
|
|
193
|
+
? 'undefined'
|
|
194
|
+
: valueType === 'object'
|
|
195
|
+
? `${valueType} (missing required properties: name, sections)`
|
|
196
|
+
: valueType;
|
|
197
|
+
|
|
198
|
+
throw new InvalidConfigBoundInstanceException(
|
|
199
|
+
targetExport,
|
|
200
|
+
filePath,
|
|
201
|
+
valueDetails
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
name: configInstance.name,
|
|
207
|
+
sections: configInstance.sections,
|
|
208
|
+
instance: configInstance
|
|
209
|
+
};
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error instanceof ConfigLoaderException) {
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
if (error instanceof Error) {
|
|
215
|
+
if (error.message.includes('Cannot find module')) {
|
|
216
|
+
const moduleMatch = error.message.match(
|
|
217
|
+
/Cannot find module ['"]([^'"]+)['"]/
|
|
218
|
+
);
|
|
219
|
+
const missingModule = moduleMatch ? moduleMatch[1] : 'unknown';
|
|
220
|
+
throw new MissingDependencyException(
|
|
221
|
+
filePath,
|
|
222
|
+
missingModule,
|
|
223
|
+
error.message
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
error.message.includes('SyntaxError') ||
|
|
228
|
+
error.message.includes('parse') ||
|
|
229
|
+
error.message.includes('Unexpected token')
|
|
230
|
+
) {
|
|
231
|
+
throw new ConfigFileParseException(filePath, error.message);
|
|
232
|
+
}
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Failed to load config file: ${filePath}. Unexpected error: ${String(error)}`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async loadConfigs(filePath: string): Promise<Map<string, LoadedConfig>> {
|
|
242
|
+
const absolutePath = path.resolve(filePath);
|
|
243
|
+
|
|
244
|
+
if (!fs.existsSync(absolutePath)) {
|
|
245
|
+
const parentDir = path.dirname(absolutePath);
|
|
246
|
+
const parentExists = fs.existsSync(parentDir);
|
|
247
|
+
let similarFiles: string[] | undefined;
|
|
248
|
+
if (parentExists) {
|
|
249
|
+
const fileName = path.basename(absolutePath);
|
|
250
|
+
const dirFiles = fs.readdirSync(parentDir);
|
|
251
|
+
similarFiles = dirFiles.filter((file) =>
|
|
252
|
+
file.toLowerCase().includes(fileName.toLowerCase().slice(0, 3))
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
throw new ConfigFileNotFoundException(
|
|
256
|
+
absolutePath,
|
|
257
|
+
parentDir,
|
|
258
|
+
parentExists,
|
|
259
|
+
similarFiles
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const stat = fs.statSync(absolutePath);
|
|
264
|
+
if (stat.isDirectory()) {
|
|
265
|
+
throw new ConfigFileIsDirectoryException(absolutePath);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// If it's a TypeScript file, check if there's a compiled .js version
|
|
269
|
+
const isTypeScript =
|
|
270
|
+
absolutePath.endsWith('.ts') || absolutePath.endsWith('.tsx');
|
|
271
|
+
let fileToLoad = absolutePath;
|
|
272
|
+
|
|
273
|
+
if (isTypeScript) {
|
|
274
|
+
// First, check for .js in the same directory
|
|
275
|
+
const jsPath = absolutePath.replace(/\.tsx?$/, '.js');
|
|
276
|
+
if (fs.existsSync(jsPath)) {
|
|
277
|
+
fileToLoad = jsPath;
|
|
278
|
+
} else {
|
|
279
|
+
// If in src/, check for corresponding file in dist/
|
|
280
|
+
const srcMatch = absolutePath.match(/^(.*[/\\])src([/\\].+)$/);
|
|
281
|
+
if (srcMatch) {
|
|
282
|
+
const distPath =
|
|
283
|
+
srcMatch[1] + 'dist' + srcMatch[2].replace(/\.tsx?$/, '.js');
|
|
284
|
+
if (fs.existsSync(distPath)) {
|
|
285
|
+
fileToLoad = distPath;
|
|
286
|
+
} else {
|
|
287
|
+
throw new ConfigFileParseException(
|
|
288
|
+
filePath,
|
|
289
|
+
`TypeScript file found but no compiled .js file exists. Please compile the file first (e.g., run 'tsc' or 'npm run build'). Checked: ${jsPath} and ${distPath}`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
throw new ConfigFileParseException(
|
|
294
|
+
filePath,
|
|
295
|
+
`TypeScript file found but no compiled .js file exists. Please compile the file first (e.g., run 'tsc' or 'npm run build'). Expected compiled file at: ${jsPath}`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const fileURL = pathToFileURL(fileToLoad).href;
|
|
303
|
+
const module = await import(fileURL);
|
|
304
|
+
|
|
305
|
+
const configs = new Map<string, LoadedConfig>();
|
|
306
|
+
|
|
307
|
+
// Check default export
|
|
308
|
+
if (module.default && this.isConfigBoundInstance(module.default)) {
|
|
309
|
+
configs.set('default', {
|
|
310
|
+
name: module.default.name,
|
|
311
|
+
sections: module.default.sections,
|
|
312
|
+
instance: module.default
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Check named exports
|
|
317
|
+
for (const [exportName, exportValue] of Object.entries(module)) {
|
|
318
|
+
if (
|
|
319
|
+
exportName !== '__esModule' &&
|
|
320
|
+
exportName !== 'default' &&
|
|
321
|
+
this.isConfigBoundInstance(exportValue)
|
|
322
|
+
) {
|
|
323
|
+
const typedValue = exportValue as
|
|
324
|
+
| ConfigBound
|
|
325
|
+
| TypedConfigBound<ConfigSchema>;
|
|
326
|
+
configs.set(exportName, {
|
|
327
|
+
name: typedValue.name,
|
|
328
|
+
sections: typedValue.sections,
|
|
329
|
+
instance: typedValue
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return configs;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error instanceof ConfigLoaderException) {
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
if (error instanceof Error) {
|
|
340
|
+
if (error.message.includes('Cannot find module')) {
|
|
341
|
+
const moduleMatch = error.message.match(
|
|
342
|
+
/Cannot find module ['"]([^'"]+)['"]/
|
|
343
|
+
);
|
|
344
|
+
const missingModule = moduleMatch ? moduleMatch[1] : 'unknown';
|
|
345
|
+
throw new MissingDependencyException(
|
|
346
|
+
filePath,
|
|
347
|
+
missingModule,
|
|
348
|
+
error.message
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (
|
|
352
|
+
error.message.includes('SyntaxError') ||
|
|
353
|
+
error.message.includes('parse') ||
|
|
354
|
+
error.message.includes('Unexpected token')
|
|
355
|
+
) {
|
|
356
|
+
throw new ConfigFileParseException(filePath, error.message);
|
|
357
|
+
}
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
throw new Error(
|
|
361
|
+
`Failed to load config file: ${filePath}. Unexpected error: ${String(error)}`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private isConfigBoundInstance(
|
|
367
|
+
value: unknown
|
|
368
|
+
): value is ConfigBound | TypedConfigBound<ConfigSchema> {
|
|
369
|
+
return (
|
|
370
|
+
value !== null &&
|
|
371
|
+
value !== undefined &&
|
|
372
|
+
typeof value === 'object' &&
|
|
373
|
+
'name' in value &&
|
|
374
|
+
'sections' in value &&
|
|
375
|
+
typeof (value as { name: unknown }).name === 'string' &&
|
|
376
|
+
typeof (value as { sections: unknown }).sections === 'object'
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|