@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.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-format$colon$ci.log +6 -0
  3. package/.turbo/turbo-lint$colon$ci.log +4 -0
  4. package/.turbo/turbo-test.log +19 -0
  5. package/CHANGELOG.md +15 -0
  6. package/README.md +66 -0
  7. package/bin/configbound.js +3 -0
  8. package/dist/cli.module.d.ts +3 -0
  9. package/dist/cli.module.d.ts.map +1 -0
  10. package/dist/cli.module.js +29 -0
  11. package/dist/cli.module.js.map +1 -0
  12. package/dist/commands/export.command.d.ts +30 -0
  13. package/dist/commands/export.command.d.ts.map +1 -0
  14. package/dist/commands/export.command.js +226 -0
  15. package/dist/commands/export.command.js.map +1 -0
  16. package/dist/commands/list.command.d.ts +13 -0
  17. package/dist/commands/list.command.d.ts.map +1 -0
  18. package/dist/commands/list.command.js +93 -0
  19. package/dist/commands/list.command.js.map +1 -0
  20. package/dist/main.d.ts +3 -0
  21. package/dist/main.d.ts.map +1 -0
  22. package/dist/main.js +17 -0
  23. package/dist/main.js.map +1 -0
  24. package/dist/services/config-discovery.service.d.ts +15 -0
  25. package/dist/services/config-discovery.service.d.ts.map +1 -0
  26. package/dist/services/config-discovery.service.js +191 -0
  27. package/dist/services/config-discovery.service.js.map +1 -0
  28. package/dist/services/config-discovery.service.spec.d.ts +2 -0
  29. package/dist/services/config-discovery.service.spec.d.ts.map +1 -0
  30. package/dist/services/config-discovery.service.spec.js +137 -0
  31. package/dist/services/config-discovery.service.spec.js.map +1 -0
  32. package/dist/services/config-loader.service.d.ts +13 -0
  33. package/dist/services/config-loader.service.d.ts.map +1 -0
  34. package/dist/services/config-loader.service.js +241 -0
  35. package/dist/services/config-loader.service.js.map +1 -0
  36. package/dist/services/file-writer.service.d.ts +6 -0
  37. package/dist/services/file-writer.service.d.ts.map +1 -0
  38. package/dist/services/file-writer.service.js +38 -0
  39. package/dist/services/file-writer.service.js.map +1 -0
  40. package/dist/services/file-writer.service.spec.d.ts +2 -0
  41. package/dist/services/file-writer.service.spec.d.ts.map +1 -0
  42. package/dist/services/file-writer.service.spec.js +98 -0
  43. package/dist/services/file-writer.service.spec.js.map +1 -0
  44. package/dist/services/schema-export.service.d.ts +14 -0
  45. package/dist/services/schema-export.service.d.ts.map +1 -0
  46. package/dist/services/schema-export.service.js +58 -0
  47. package/dist/services/schema-export.service.js.map +1 -0
  48. package/dist/services/schema-export.service.spec.d.ts +2 -0
  49. package/dist/services/schema-export.service.spec.d.ts.map +1 -0
  50. package/dist/services/schema-export.service.spec.js +69 -0
  51. package/dist/services/schema-export.service.spec.js.map +1 -0
  52. package/eslint.config.mjs +5 -0
  53. package/jest.config.js +39 -0
  54. package/package.json +62 -0
  55. package/src/cli.module.ts +21 -0
  56. package/src/commands/export.command.ts +253 -0
  57. package/src/commands/list.command.ts +108 -0
  58. package/src/main.ts +19 -0
  59. package/src/services/config-discovery.service.spec.ts +209 -0
  60. package/src/services/config-discovery.service.ts +237 -0
  61. package/src/services/config-loader.service.ts +379 -0
  62. package/src/services/file-writer.service.spec.ts +139 -0
  63. package/src/services/file-writer.service.ts +32 -0
  64. package/src/services/schema-export.service.spec.ts +124 -0
  65. package/src/services/schema-export.service.ts +85 -0
  66. package/tsconfig.json +10 -0
@@ -0,0 +1,253 @@
1
+ import { Command, CommandRunner, Option } from 'nest-commander';
2
+ import { ConfigLoaderService } from '../services/config-loader.service.js';
3
+ import { ConfigDiscoveryService } from '../services/config-discovery.service.js';
4
+ import {
5
+ SchemaExportService,
6
+ type ExportFormat
7
+ } from '../services/schema-export.service.js';
8
+ import { FileWriterService } from '../services/file-writer.service.js';
9
+ import chalk from 'chalk';
10
+ import * as readline from 'readline';
11
+
12
+ interface ExportCommandOptions {
13
+ config?: string;
14
+ format: ExportFormat;
15
+ output?: string;
16
+ includeOmitted?: boolean;
17
+ pretty?: boolean;
18
+ name?: string;
19
+ }
20
+
21
+ @Command({
22
+ name: 'export',
23
+ description: 'Export ConfigBound schema in various formats'
24
+ })
25
+ export class ExportCommand extends CommandRunner {
26
+ constructor(
27
+ private readonly loaderService: ConfigLoaderService,
28
+ private readonly discoveryService: ConfigDiscoveryService,
29
+ private readonly exportService: SchemaExportService,
30
+ private readonly writerService: FileWriterService
31
+ ) {
32
+ super();
33
+ }
34
+
35
+ async run(
36
+ _passedParams: string[],
37
+ options?: ExportCommandOptions
38
+ ): Promise<void> {
39
+ try {
40
+ let configPath: string;
41
+ let exportName: string | undefined;
42
+
43
+ // If no explicit --config, auto-discover
44
+ if (!options?.config) {
45
+ const discovered = await this.discoverAndSelect();
46
+ if (!discovered) {
47
+ console.log(chalk.yellow('Export cancelled.'));
48
+ return;
49
+ }
50
+ configPath = discovered.filePath;
51
+ exportName =
52
+ discovered.exportName === 'default'
53
+ ? undefined
54
+ : discovered.exportName;
55
+ } else {
56
+ // Explicit config path provided
57
+ configPath = options.config;
58
+ exportName = options.name;
59
+ }
60
+
61
+ const format = options?.format || 'json';
62
+ const includeOmitted = options?.includeOmitted || false;
63
+ const pretty = options?.pretty !== false;
64
+ const output = options?.output;
65
+
66
+ console.log(chalk.blue('Loading configuration...'));
67
+ const config = await this.loaderService.loadConfig(
68
+ configPath,
69
+ exportName
70
+ );
71
+
72
+ console.log(chalk.green(`Loaded config: ${chalk.bold(config.name)}`));
73
+
74
+ const exportedSchema = this.exportService.exportToString(
75
+ config.name,
76
+ config.sections,
77
+ config.instance,
78
+ {
79
+ format,
80
+ includeOmitted,
81
+ pretty
82
+ }
83
+ );
84
+
85
+ if (output) {
86
+ this.writerService.writeToFile(output, exportedSchema);
87
+ console.log(chalk.green(`Schema exported to: ${output}`));
88
+ } else {
89
+ console.log(chalk.blue('\nExported schema:\n'));
90
+ this.writerService.writeToStdout(exportedSchema);
91
+ }
92
+ } catch (error) {
93
+ if (error instanceof Error) {
94
+ console.error(chalk.red(`Error: ${error.message}`));
95
+ } else {
96
+ console.error(chalk.red(`Error: ${String(error)}`));
97
+ }
98
+ process.exit(1);
99
+ }
100
+ }
101
+
102
+ private async discoverAndSelect(): Promise<{
103
+ filePath: string;
104
+ exportName: string;
105
+ } | null> {
106
+ const startTime = Date.now();
107
+ console.log(chalk.blue('Searching for ConfigBound configurations...'));
108
+
109
+ const configs = await this.discoveryService.discoverConfigs(
110
+ process.cwd(),
111
+ true
112
+ );
113
+
114
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
115
+ console.log(chalk.gray(`Search completed in ${elapsed}s`));
116
+
117
+ if (configs.length === 0) {
118
+ console.log(
119
+ chalk.yellow('\nNo ConfigBound configurations found in project.')
120
+ );
121
+ console.log(
122
+ chalk.gray(
123
+ 'Tip: Use --config <path> to specify a config file explicitly.'
124
+ )
125
+ );
126
+ return null;
127
+ }
128
+
129
+ // Auto-select if only one config found
130
+ if (configs.length === 1) {
131
+ const selected = configs[0];
132
+ const exportType = selected.isDefault ? 'default' : 'named';
133
+ const relativePath = selected.filePath.replace(process.cwd() + '/', '');
134
+ console.log(
135
+ chalk.green(
136
+ `\nFound 1 configuration - auto-selected: ${chalk.bold(selected.configName || 'unnamed')} (${exportType} export: ${selected.exportName})`
137
+ )
138
+ );
139
+ console.log(chalk.gray(` from ${relativePath}\n`));
140
+
141
+ return {
142
+ filePath: selected.filePath,
143
+ exportName: selected.exportName
144
+ };
145
+ }
146
+
147
+ // Multiple configs - show menu
148
+ console.log(chalk.green(`\nFound ${configs.length} configurations:\n`));
149
+
150
+ configs.forEach((config: (typeof configs)[0], index: number) => {
151
+ const exportType = config.isDefault ? '(default)' : '(named)';
152
+ const configNamePart = config.configName
153
+ ? chalk.bold(config.configName)
154
+ : chalk.gray('unnamed');
155
+ const relativePath = config.filePath.replace(process.cwd() + '/', '');
156
+ console.log(
157
+ `${chalk.cyan((index + 1).toString().padStart(2))}. ${configNamePart} ${chalk.gray(exportType + ' - ' + config.exportName)}`
158
+ );
159
+ console.log(` ${chalk.gray(relativePath)}`);
160
+ });
161
+
162
+ const rl = readline.createInterface({
163
+ input: process.stdin,
164
+ output: process.stdout
165
+ });
166
+
167
+ const answer = await new Promise<string>((resolve) => {
168
+ rl.question(
169
+ chalk.yellow(
170
+ `\nSelect configuration (1-${configs.length}) or Ctrl+C to cancel: `
171
+ ),
172
+ (answer) => {
173
+ rl.close();
174
+ resolve(answer);
175
+ }
176
+ );
177
+ });
178
+
179
+ const selectedIndex = parseInt(answer, 10) - 1;
180
+
181
+ if (
182
+ isNaN(selectedIndex) ||
183
+ selectedIndex < 0 ||
184
+ selectedIndex >= configs.length
185
+ ) {
186
+ console.log(chalk.red('Invalid selection.'));
187
+ return null;
188
+ }
189
+
190
+ const selected = configs[selectedIndex];
191
+ console.log();
192
+ return {
193
+ filePath: selected.filePath,
194
+ exportName: selected.exportName
195
+ };
196
+ }
197
+
198
+ @Option({
199
+ flags: '-c, --config <path>',
200
+ description: 'Path to config file'
201
+ })
202
+ parseConfig(val: string): string {
203
+ return val;
204
+ }
205
+
206
+ @Option({
207
+ flags: '-f, --format <format>',
208
+ description: 'Output format: json, yaml, env (default: json)',
209
+ defaultValue: 'json'
210
+ })
211
+ parseFormat(val: string): ExportFormat {
212
+ const validFormats: ExportFormat[] = ['json', 'yaml', 'env'];
213
+ if (!validFormats.includes(val as ExportFormat)) {
214
+ throw new Error(
215
+ `Invalid format: ${val}. Valid formats: ${validFormats.join(', ')}`
216
+ );
217
+ }
218
+ return val as ExportFormat;
219
+ }
220
+
221
+ @Option({
222
+ flags: '-o, --output <path>',
223
+ description: 'Output file path (default: stdout)'
224
+ })
225
+ parseOutput(val: string): string {
226
+ return val;
227
+ }
228
+
229
+ @Option({
230
+ flags: '--include-omitted',
231
+ description: 'Include elements marked with omitFromSchema: true'
232
+ })
233
+ parseIncludeOmitted(): boolean {
234
+ return true;
235
+ }
236
+
237
+ @Option({
238
+ flags: '--pretty [boolean]',
239
+ description: 'Pretty-print JSON output (default: true)',
240
+ defaultValue: true
241
+ })
242
+ parsePretty(val: string): boolean {
243
+ return val !== 'false';
244
+ }
245
+
246
+ @Option({
247
+ flags: '--name <name>',
248
+ description: 'Export named variable when file has multiple exports'
249
+ })
250
+ parseName(val: string): string {
251
+ return val;
252
+ }
253
+ }
@@ -0,0 +1,108 @@
1
+ import { Command, CommandRunner, Option } from 'nest-commander';
2
+ import { ConfigDiscoveryService } from '../services/config-discovery.service.js';
3
+ import chalk from 'chalk';
4
+ import * as path from 'path';
5
+
6
+ interface ListCommandOptions {
7
+ recursive?: boolean;
8
+ }
9
+
10
+ @Command({
11
+ name: 'list',
12
+ description: 'List all discovered ConfigBound configurations',
13
+ arguments: '[path]',
14
+ argsDescription: {
15
+ path: 'Directory to search (default: current directory)'
16
+ }
17
+ })
18
+ export class ListCommand extends CommandRunner {
19
+ constructor(private readonly discoveryService: ConfigDiscoveryService) {
20
+ super();
21
+ }
22
+
23
+ async run(
24
+ passedParams: string[],
25
+ options?: ListCommandOptions
26
+ ): Promise<void> {
27
+ const searchPath = passedParams[0] || process.cwd();
28
+ const recursive = options?.recursive !== false;
29
+
30
+ try {
31
+ console.log(
32
+ chalk.blue(`Searching for ConfigBound configurations in: ${searchPath}`)
33
+ );
34
+ console.log(chalk.gray(`Recursive: ${recursive ? 'yes' : 'no'}`));
35
+ console.log();
36
+
37
+ const configs = await this.discoveryService.discoverConfigs(
38
+ searchPath,
39
+ recursive
40
+ );
41
+
42
+ if (configs.length === 0) {
43
+ console.log(chalk.yellow('No ConfigBound configurations found.'));
44
+ return;
45
+ }
46
+
47
+ console.log(
48
+ chalk.green(
49
+ `Found ${configs.length} ConfigBound configuration${configs.length === 1 ? '' : 's'}:`
50
+ )
51
+ );
52
+ console.log();
53
+
54
+ // Group by file
55
+ const groupedByFile = configs.reduce(
56
+ (acc: Record<string, typeof configs>, config: (typeof configs)[0]) => {
57
+ if (!acc[config.filePath]) {
58
+ acc[config.filePath] = [];
59
+ }
60
+ acc[config.filePath].push(config);
61
+ return acc;
62
+ },
63
+ {} as Record<string, typeof configs>
64
+ );
65
+
66
+ for (const [filePath, fileConfigs] of Object.entries(groupedByFile)) {
67
+ const relativePath = path.relative(process.cwd(), filePath);
68
+ console.log(chalk.cyan(`📄 ${relativePath}`));
69
+
70
+ for (const config of fileConfigs as typeof configs) {
71
+ const exportType = config.isDefault
72
+ ? chalk.gray('(default)')
73
+ : chalk.gray('(named)');
74
+ const configNamePart = config.configName
75
+ ? chalk.magenta(` - ${config.configName}`)
76
+ : '';
77
+ const linePart = chalk.gray(`:${config.lineNumber}`);
78
+
79
+ console.log(
80
+ ` ${chalk.white(config.exportName)} ${exportType}${configNamePart}${linePart}`
81
+ );
82
+ }
83
+ console.log();
84
+ }
85
+
86
+ console.log(
87
+ chalk.gray(
88
+ 'Tip: Use `configbound export --config <file> --name <export>` to export a specific configuration.'
89
+ )
90
+ );
91
+ } catch (error) {
92
+ if (error instanceof Error) {
93
+ console.error(chalk.red(`Error: ${error.message}`));
94
+ } else {
95
+ console.error(chalk.red(`Error: ${String(error)}`));
96
+ }
97
+ process.exit(1);
98
+ }
99
+ }
100
+
101
+ @Option({
102
+ flags: '-r, --recursive [boolean]',
103
+ description: 'Search subdirectories (default: true)'
104
+ })
105
+ parseRecursive(val: string): boolean {
106
+ return val !== 'false';
107
+ }
108
+ }
package/src/main.ts ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { CommandFactory } from 'nest-commander';
4
+ import { CliModule } from './cli.module.js';
5
+
6
+ async function bootstrap() {
7
+ await CommandFactory.run(CliModule, {
8
+ logger: false,
9
+ errorHandler: (err) => {
10
+ if (err && typeof err === 'object' && 'code' in err && err.code === 'commander.help') {
11
+ process.exit(0);
12
+ }
13
+ console.error('Unexpected error:', err);
14
+ process.exit(1);
15
+ }
16
+ });
17
+ }
18
+
19
+ bootstrap();
@@ -0,0 +1,209 @@
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { ConfigDiscoveryService } from './config-discovery.service.js';
3
+ import { describe, beforeEach, afterEach, it, expect } from '@jest/globals';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import * as os from 'os';
7
+
8
+ describe('ConfigDiscoveryService', () => {
9
+ let service: ConfigDiscoveryService;
10
+ let tempDir: string;
11
+
12
+ beforeEach(async () => {
13
+ const module: TestingModule = await Test.createTestingModule({
14
+ providers: [ConfigDiscoveryService]
15
+ }).compile();
16
+
17
+ service = module.get<ConfigDiscoveryService>(ConfigDiscoveryService);
18
+
19
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'configbound-test-'));
20
+ });
21
+
22
+ afterEach(() => {
23
+ if (fs.existsSync(tempDir)) {
24
+ fs.rmSync(tempDir, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ function createTempFile(relativePath: string, content: string): string {
29
+ const filePath = path.join(tempDir, relativePath);
30
+ const dir = path.dirname(filePath);
31
+
32
+ if (!fs.existsSync(dir)) {
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ }
35
+
36
+ fs.writeFileSync(filePath, content, 'utf8');
37
+ return filePath;
38
+ }
39
+
40
+ it('should be defined', () => {
41
+ expect(service).toBeDefined();
42
+ });
43
+
44
+ it('should discover named export with ConfigBound.createConfig', async () => {
45
+ createTempFile(
46
+ 'config.ts',
47
+ `
48
+ import { ConfigBound } from '@config-bound/config-bound';
49
+
50
+ export const appConfig = ConfigBound.createConfig({
51
+ name: 'MyApp',
52
+ sections: {}
53
+ });
54
+ `
55
+ );
56
+
57
+ const configs = await service.discoverConfigs(tempDir, false);
58
+
59
+ expect(configs).toHaveLength(1);
60
+ expect(configs[0].exportName).toBe('appConfig');
61
+ expect(configs[0].configName).toBe('MyApp');
62
+ expect(configs[0].isDefault).toBe(false);
63
+ });
64
+
65
+ it('should discover named export with createConfig from named import', async () => {
66
+ createTempFile(
67
+ 'config.ts',
68
+ `
69
+ import { createConfig } from '@config-bound/config-bound';
70
+
71
+ export const dbConfig = createConfig({
72
+ name: 'Database',
73
+ sections: {}
74
+ });
75
+ `
76
+ );
77
+
78
+ const configs = await service.discoverConfigs(tempDir, false);
79
+
80
+ expect(configs).toHaveLength(1);
81
+ expect(configs[0].exportName).toBe('dbConfig');
82
+ expect(configs[0].configName).toBe('Database');
83
+ });
84
+
85
+ it('should discover default export', async () => {
86
+ createTempFile(
87
+ 'config.ts',
88
+ `
89
+ import { ConfigBound } from '@config-bound/config-bound';
90
+
91
+ export default ConfigBound.createConfig({
92
+ name: 'DefaultConfig',
93
+ sections: {}
94
+ });
95
+ `
96
+ );
97
+
98
+ const configs = await service.discoverConfigs(tempDir, false);
99
+
100
+ expect(configs).toHaveLength(1);
101
+ expect(configs[0].exportName).toBe('default');
102
+ expect(configs[0].isDefault).toBe(true);
103
+ expect(configs[0].configName).toBe('DefaultConfig');
104
+ });
105
+
106
+ it('should discover multiple exports in same file', async () => {
107
+ createTempFile(
108
+ 'configs.ts',
109
+ `
110
+ import { createConfig } from '@config-bound/config-bound';
111
+
112
+ export const config1 = createConfig({ name: 'Config1', sections: {} });
113
+ export const config2 = createConfig({ name: 'Config2', sections: {} });
114
+ `
115
+ );
116
+
117
+ const configs = await service.discoverConfigs(tempDir, false);
118
+
119
+ expect(configs).toHaveLength(2);
120
+ expect(
121
+ configs.map((c: (typeof configs)[0]) => c.exportName).sort()
122
+ ).toEqual(['config1', 'config2']);
123
+ });
124
+
125
+ it('should discover configs recursively', async () => {
126
+ createTempFile(
127
+ 'config1.ts',
128
+ `
129
+ import { createConfig } from '@config-bound/config-bound';
130
+ export const config1 = createConfig({ name: 'Config1', sections: {} });
131
+ `
132
+ );
133
+
134
+ createTempFile(
135
+ 'subdir/config2.ts',
136
+ `
137
+ import { createConfig } from '@config-bound/config-bound';
138
+ export const config2 = createConfig({ name: 'Config2', sections: {} });
139
+ `
140
+ );
141
+
142
+ const configs = await service.discoverConfigs(tempDir, true);
143
+
144
+ expect(configs).toHaveLength(2);
145
+ });
146
+
147
+ it('should not discover configs when recursive is false', async () => {
148
+ createTempFile(
149
+ 'config1.ts',
150
+ `
151
+ import { createConfig } from '@config-bound/config-bound';
152
+ export const config1 = createConfig({ name: 'Config1', sections: {} });
153
+ `
154
+ );
155
+
156
+ createTempFile(
157
+ 'subdir/config2.ts',
158
+ `
159
+ import { createConfig } from '@config-bound/config-bound';
160
+ export const config2 = createConfig({ name: 'Config2', sections: {} });
161
+ `
162
+ );
163
+
164
+ const configs = await service.discoverConfigs(tempDir, false);
165
+
166
+ expect(configs).toHaveLength(1);
167
+ });
168
+
169
+ it('should skip non-exported configs', async () => {
170
+ createTempFile(
171
+ 'config.ts',
172
+ `
173
+ import { createConfig } from '@config-bound/config-bound';
174
+ const privateConfig = createConfig({ name: 'Private', sections: {} });
175
+ `
176
+ );
177
+
178
+ const configs = await service.discoverConfigs(tempDir, false);
179
+
180
+ expect(configs).toHaveLength(0);
181
+ });
182
+
183
+ it('should skip test files', async () => {
184
+ createTempFile(
185
+ 'config.spec.ts',
186
+ `
187
+ import { createConfig } from '@config-bound/config-bound';
188
+ export const testConfig = createConfig({ name: 'Test', sections: {} });
189
+ `
190
+ );
191
+
192
+ const configs = await service.discoverConfigs(tempDir, false);
193
+
194
+ expect(configs).toHaveLength(0);
195
+ });
196
+
197
+ it('should handle files without configs', async () => {
198
+ createTempFile(
199
+ 'other.ts',
200
+ `
201
+ export const someValue = 42;
202
+ `
203
+ );
204
+
205
+ const configs = await service.discoverConfigs(tempDir, false);
206
+
207
+ expect(configs).toHaveLength(0);
208
+ });
209
+ });