@facetlayer/prism-framework 0.4.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 (42) hide show
  1. package/.claude/settings.local.json +20 -0
  2. package/CHANGELOG +28 -0
  3. package/CLAUDE.md +44 -0
  4. package/README.md +47 -0
  5. package/build.mts +8 -0
  6. package/dist/call-command.d.ts +13 -0
  7. package/dist/call-command.d.ts.map +1 -0
  8. package/dist/cli.d.ts +3 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +475 -0
  11. package/dist/config/ConfigFile.d.ts +7 -0
  12. package/dist/config/ConfigFile.d.ts.map +1 -0
  13. package/dist/config/index.d.ts +4 -0
  14. package/dist/config/index.d.ts.map +1 -0
  15. package/dist/config/loadConfig.d.ts +11 -0
  16. package/dist/config/loadConfig.d.ts.map +1 -0
  17. package/dist/generate-api-clients.d.ts +6 -0
  18. package/dist/generate-api-clients.d.ts.map +1 -0
  19. package/dist/getPorts.d.ts +10 -0
  20. package/dist/getPorts.d.ts.map +1 -0
  21. package/dist/list-endpoints-command.d.ts +5 -0
  22. package/dist/list-endpoints-command.d.ts.map +1 -0
  23. package/dist/loadEnv.d.ts +12 -0
  24. package/dist/loadEnv.d.ts.map +1 -0
  25. package/docs/endpoint-tools.md +116 -0
  26. package/docs/env-files.md +64 -0
  27. package/docs/generate-api-clients-config.md +84 -0
  28. package/docs/getting-started.md +86 -0
  29. package/package.json +43 -0
  30. package/src/call-command.ts +147 -0
  31. package/src/cli.ts +163 -0
  32. package/src/config/ConfigFile.ts +7 -0
  33. package/src/config/index.ts +3 -0
  34. package/src/config/loadConfig.ts +58 -0
  35. package/src/generate-api-clients.ts +203 -0
  36. package/src/getPorts.ts +39 -0
  37. package/src/list-endpoints-command.ts +34 -0
  38. package/src/loadEnv.ts +59 -0
  39. package/test/call-command.test.ts +96 -0
  40. package/test/generate-api-clients.test.ts +33 -0
  41. package/test/generate-api-clients.test.ts.disabled +75 -0
  42. package/tsconfig.json +21 -0
package/src/cli.ts ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+
3
+ import yargs from 'yargs';
4
+ import { hideBin } from 'yargs/helpers';
5
+ import { readFileSync } from 'fs';
6
+ import { join, dirname } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import { loadEnv } from './loadEnv.ts';
9
+ import { callEndpoint } from './call-command.ts';
10
+ import { listEndpoints } from './list-endpoints-command.ts';
11
+ import { generateApiClients } from './generate-api-clients.ts';
12
+ import { loadConfig } from './config/index.ts';
13
+ import { DocFilesHelper } from '@facetlayer/doc-files-helper';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const __packageRoot = join(__dirname, '..');
18
+ const packageJson = JSON.parse(
19
+ readFileSync(join(__packageRoot, 'package.json'), 'utf-8')
20
+ );
21
+ const docFiles = new DocFilesHelper({
22
+ dirs: [join( __packageRoot, 'docs')],
23
+ files: [join(__packageRoot, 'README.md')],
24
+ });
25
+
26
+ async function main() {
27
+
28
+ const args = yargs(hideBin(process.argv))
29
+ .command(
30
+ 'list-endpoints',
31
+ 'List all available endpoints',
32
+ {},
33
+ async () => {
34
+ try {
35
+ const cwd = process.cwd();
36
+ const config = loadEnv(cwd);
37
+ console.log(`Using API server at: ${config.baseUrl}\n`);
38
+ await listEndpoints(config.baseUrl);
39
+ } catch (error) {
40
+ if (error instanceof Error && error.stack) {
41
+ console.error(error.stack);
42
+ } else {
43
+ console.error('Error:', error instanceof Error ? error.message : String(error));
44
+ }
45
+ process.exit(1);
46
+ }
47
+ }
48
+ )
49
+ .command(
50
+ 'call <positionals...>',
51
+ 'Call an endpoint. Named args starting with { } or [ ] are parsed as JSON.',
52
+ (yargs) => {
53
+ return yargs
54
+ },
55
+ async (argv) => {
56
+ try {
57
+ const cwd = process.cwd();
58
+ const config = loadEnv(cwd);
59
+
60
+ // Collect all other arguments as request body data
61
+ const requestData: Record<string, any> = {};
62
+ for (const [key, value] of Object.entries(argv)) {
63
+
64
+ // Skip known options
65
+ if (['positionals', '_', '$0'].includes(key)) {
66
+ continue;
67
+ }
68
+ requestData[key] = value;
69
+ }
70
+
71
+ try {
72
+ const result = await callEndpoint({
73
+ baseUrl: config.baseUrl,
74
+ positionalArgs: argv.positionals as string[],
75
+ namedArgs: requestData,
76
+ });
77
+ } catch (error) {
78
+ console.error('Error calling endpoint:');
79
+ if (error instanceof Error) {
80
+ console.error(error.message);
81
+ if (error.stack) {
82
+ console.error('\nStack trace:');
83
+ console.error(error.stack);
84
+ }
85
+ } else {
86
+ console.error(String(error));
87
+ }
88
+ process.exit(1);
89
+ }
90
+ } catch (error) {
91
+ if (error instanceof Error && error.stack) {
92
+ console.error(error.stack);
93
+ } else {
94
+ console.error('Error:', error instanceof Error ? error.message : String(error));
95
+ }
96
+ process.exit(1);
97
+ }
98
+ }
99
+ )
100
+ .command(
101
+ 'generate-api-clients',
102
+ 'Generate TypeScript API client types from OpenAPI schema',
103
+ (yargs) => {
104
+ return yargs.option('out', {
105
+ type: 'string',
106
+ array: true,
107
+ describe: 'Output file path(s) to write generated types to',
108
+ });
109
+ },
110
+ async (argv) => {
111
+ try {
112
+ const cwd = process.cwd();
113
+ const envConfig = loadEnv(cwd);
114
+ console.log(`Using API server at: ${envConfig.baseUrl}\n`);
115
+
116
+ let outputFiles: string[] = [];
117
+
118
+ if (argv.out && argv.out.length > 0) {
119
+ outputFiles = argv.out;
120
+ } else {
121
+ const result = loadConfig(cwd);
122
+ if (!result || result.config.generateApiClientTargets.length === 0) {
123
+ console.error('Error: No output files specified.');
124
+ console.error('Either use --out to specify output files, or configure targets in .prism.qc:');
125
+ console.error(' generate-api-client-target out=./src/api-types.ts');
126
+ process.exit(1);
127
+ }
128
+ outputFiles = result.config.generateApiClientTargets.map(t => t.outputFile);
129
+ }
130
+
131
+ await generateApiClients(envConfig.baseUrl, outputFiles);
132
+ } catch (error) {
133
+ if (error instanceof Error && error.stack) {
134
+ console.error(error.stack);
135
+ } else {
136
+ console.error('Error:', error instanceof Error ? error.message : String(error));
137
+ }
138
+ process.exit(1);
139
+ }
140
+ }
141
+ );
142
+
143
+ docFiles.yargsSetup(args);
144
+
145
+ args
146
+ .strictCommands()
147
+ .demandCommand(1, 'You must specify a command')
148
+ .help()
149
+ .alias('help', 'h')
150
+ .version(packageJson.version)
151
+ .alias('version', 'v')
152
+ .example([
153
+ ['$0 list-endpoints', 'List all available endpoints'],
154
+ ['$0 call /api/users', 'Call GET /api/users'],
155
+ ['$0 call POST /api/users --name "John" --email "john@example.com"', 'call POST with data'],
156
+ ['$0 call POST /api/users --config \'{"timeout":30}\'', 'pass JSON objects as args'],
157
+ ['$0 generate-api-clients --out ./api-types.ts', 'Generate API client types to a file'],
158
+ ['$0 generate-api-clients --out ./types.ts --out ./backup/types.ts', 'Write to multiple files'],
159
+ ])
160
+ .parse();
161
+ }
162
+
163
+ main();
@@ -0,0 +1,7 @@
1
+ export interface GenerateApiClientTarget {
2
+ outputFile: string;
3
+ }
4
+
5
+ export interface ConfigFile {
6
+ generateApiClientTargets: GenerateApiClientTarget[];
7
+ }
@@ -0,0 +1,3 @@
1
+ export type { ConfigFile, GenerateApiClientTarget } from './ConfigFile.ts';
2
+ export type { LoadConfigResult } from './loadConfig.ts';
3
+ export { loadConfig } from './loadConfig.ts';
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { dirname, join, resolve } from 'path';
3
+ import { parseFile } from '@facetlayer/qc';
4
+ import type { ConfigFile, GenerateApiClientTarget } from './ConfigFile.ts';
5
+
6
+ const CONFIG_FILENAME = '.prism.qc';
7
+
8
+ export interface LoadConfigResult {
9
+ config: ConfigFile;
10
+ configDir: string;
11
+ }
12
+
13
+ /**
14
+ * Find and load the .prism.qc config file.
15
+ * Searches in the provided directory and parent directories until found or root is reached.
16
+ */
17
+ export function loadConfig(cwd: string): LoadConfigResult | null {
18
+ let currentDir = resolve(cwd);
19
+
20
+ while (true) {
21
+ const configPath = join(currentDir, CONFIG_FILENAME);
22
+
23
+ if (existsSync(configPath)) {
24
+ const config = parseConfigFile(configPath);
25
+ return { config, configDir: currentDir };
26
+ }
27
+
28
+ const parentDir = dirname(currentDir);
29
+ if (parentDir === currentDir) {
30
+ // Reached root directory
31
+ return null;
32
+ }
33
+ currentDir = parentDir;
34
+ }
35
+ }
36
+
37
+ function parseConfigFile(configPath: string): ConfigFile {
38
+ const content = readFileSync(configPath, 'utf-8');
39
+ const queries = parseFile(content);
40
+
41
+ const generateApiClientTargets: GenerateApiClientTarget[] = [];
42
+
43
+ for (const query of queries) {
44
+ switch (query.command) {
45
+ case 'generate-api-client': {
46
+ const outputFile = query.getStringValue('output-file');
47
+ generateApiClientTargets.push({ outputFile });
48
+ break;
49
+ }
50
+ default:
51
+ throw new Error(`Unknown command "${query.command}" in ${CONFIG_FILENAME}`);
52
+ }
53
+ }
54
+
55
+ return {
56
+ generateApiClientTargets,
57
+ };
58
+ }
@@ -0,0 +1,203 @@
1
+ interface OpenAPISchema {
2
+ paths: Record<string, Record<string, Operation>>;
3
+ components?: {
4
+ schemas?: Record<string, any>;
5
+ };
6
+ }
7
+
8
+ interface Operation {
9
+ operationId?: string;
10
+ requestBody?: {
11
+ content?: {
12
+ 'application/json'?: {
13
+ schema?: any;
14
+ };
15
+ };
16
+ };
17
+ responses?: {
18
+ '200'?: {
19
+ content?: {
20
+ 'application/json'?: {
21
+ schema?: any;
22
+ };
23
+ };
24
+ };
25
+ };
26
+ }
27
+
28
+ interface EndpointMapping {
29
+ method: string;
30
+ path: string;
31
+ operationId: string;
32
+ }
33
+
34
+ function capitalizeFirst(str: string): string {
35
+ return str.charAt(0).toUpperCase() + str.slice(1);
36
+ }
37
+
38
+ /**
39
+ * Convert OpenAPI path parameter format {param} to Express format :param
40
+ */
41
+ export function convertToExpressPath(openApiPath: string): string {
42
+ return openApiPath.replace(/\{([^}]+)\}/g, ':$1');
43
+ }
44
+
45
+ function schemaToTypeScript(schema: any, components?: Record<string, any>, indent = 0): string {
46
+ if (!schema) {
47
+ return 'unknown';
48
+ }
49
+
50
+ const indentStr = ' '.repeat(indent);
51
+
52
+ // Handle $ref
53
+ if (schema.$ref) {
54
+ const refName = schema.$ref.split('/').pop();
55
+ return refName;
56
+ }
57
+
58
+ // Handle array
59
+ if (schema.type === 'array') {
60
+ const itemType = schemaToTypeScript(schema.items, components, indent);
61
+ return `Array<${itemType}>`;
62
+ }
63
+
64
+ // Handle object
65
+ if (schema.type === 'object' || schema.properties) {
66
+ const properties = schema.properties || {};
67
+ const required = schema.required || [];
68
+
69
+ if (Object.keys(properties).length === 0) {
70
+ return 'Record<string, unknown>';
71
+ }
72
+
73
+ const props = Object.entries(properties).map(([key, value]: [string, any]) => {
74
+ const isRequired = required.includes(key);
75
+ const propType = schemaToTypeScript(value, components, indent + 1);
76
+ const optional = isRequired ? '' : '?';
77
+ return `${indentStr} ${key}${optional}: ${propType};`;
78
+ });
79
+
80
+ return `{\n${props.join('\n')}\n${indentStr}}`;
81
+ }
82
+
83
+ // Handle primitives
84
+ switch (schema.type) {
85
+ case 'string':
86
+ return 'string';
87
+ case 'number':
88
+ case 'integer':
89
+ return 'number';
90
+ case 'boolean':
91
+ return 'boolean';
92
+ case 'null':
93
+ return 'null';
94
+ default:
95
+ return 'unknown';
96
+ }
97
+ }
98
+
99
+ import { mkdirSync, writeFileSync } from 'fs';
100
+ import { dirname, resolve } from 'path';
101
+
102
+ export async function generateApiClients(baseUrl: string, outputFiles: string[]): Promise<void> {
103
+ if (outputFiles.length === 0) {
104
+ throw new Error('At least one --out file must be specified');
105
+ }
106
+
107
+ try {
108
+ // Fetch the OpenAPI schema from the server
109
+ const response = await fetch(`${baseUrl}/openapi.json`);
110
+
111
+ if (!response.ok) {
112
+ throw new Error(
113
+ `Failed to fetch OpenAPI schema: ${response.status} ${response.statusText}\n` +
114
+ `Make sure the Prism API server is running at ${baseUrl}`
115
+ );
116
+ }
117
+
118
+ const schema = await response.json() as OpenAPISchema;
119
+
120
+ const lines: string[] = [];
121
+ const endpointMap: EndpointMapping[] = [];
122
+
123
+ // Generate component schemas first
124
+ if (schema.components?.schemas) {
125
+ lines.push('// Component Schemas');
126
+ for (const [name, componentSchema] of Object.entries(schema.components.schemas)) {
127
+ const typeStr = schemaToTypeScript(componentSchema, schema.components.schemas);
128
+ lines.push(`export type ${name} = ${typeStr};\n`);
129
+ }
130
+ lines.push('');
131
+ }
132
+
133
+ // Process each endpoint
134
+ lines.push('// Endpoint Types');
135
+ for (const [pathStr, pathItem] of Object.entries(schema.paths)) {
136
+ for (const [method, operation] of Object.entries(pathItem)) {
137
+ if (!operation.operationId) continue;
138
+
139
+ const typeName = capitalizeFirst(operation.operationId);
140
+ const expressPath = convertToExpressPath(pathStr);
141
+ endpointMap.push({ method: method.toLowerCase(), path: expressPath, operationId: operation.operationId });
142
+
143
+ // Generate Request type
144
+ const requestSchema = operation.requestBody?.content?.['application/json']?.schema;
145
+ const requestType = requestSchema
146
+ ? schemaToTypeScript(requestSchema, schema.components?.schemas)
147
+ : 'void';
148
+ lines.push(`export type ${typeName}Request = ${requestType};\n`);
149
+
150
+ // Generate Response type
151
+ const responseSchema = operation.responses?.['200']?.content?.['application/json']?.schema;
152
+ const responseType = responseSchema
153
+ ? schemaToTypeScript(responseSchema, schema.components?.schemas)
154
+ : 'void';
155
+ lines.push(`export type ${typeName}Response = ${responseType};\n`);
156
+ }
157
+ }
158
+
159
+ // Generate ApiEndpoint union type
160
+ lines.push('// Union type of all valid endpoints');
161
+ lines.push('export type ApiEndpoint =');
162
+ const endpointStrings = endpointMap.map(({ method, path }) => ` | "${method} ${path}"`);
163
+ lines.push(endpointStrings.join('\n'));
164
+ lines.push(';\n');
165
+
166
+ // Generate generic RequestType and ResponseType
167
+ lines.push('// Generic Request/Response Types by Endpoint');
168
+ lines.push('export type RequestType<T extends ApiEndpoint> =');
169
+ const requestCases = endpointMap.map(({ method, path, operationId }) =>
170
+ ` T extends "${method} ${path}" ? ${capitalizeFirst(operationId)}Request :`
171
+ );
172
+ lines.push(requestCases.join('\n'));
173
+ lines.push(' never;\n');
174
+
175
+ lines.push('export type ResponseType<T extends ApiEndpoint> =');
176
+ const responseCases = endpointMap.map(({ method, path, operationId }) =>
177
+ ` T extends "${method} ${path}" ? ${capitalizeFirst(operationId)}Response :`
178
+ );
179
+ lines.push(responseCases.join('\n'));
180
+ lines.push(' never;\n');
181
+
182
+ const output = lines.join('\n');
183
+
184
+ // Add header comment
185
+ const header = `// Generated API client types
186
+ // Auto-generated from OpenAPI schema - do not edit manually
187
+
188
+ `;
189
+
190
+ const content = header + output;
191
+
192
+ // Write to each output file
193
+ for (const outputFile of outputFiles) {
194
+ const resolvedPath = resolve(outputFile);
195
+ mkdirSync(dirname(resolvedPath), { recursive: true });
196
+ writeFileSync(resolvedPath, content, 'utf-8');
197
+ console.log(`Written: ${resolvedPath}`);
198
+ }
199
+ } catch (error) {
200
+ console.error('❌ Error generating client:', error);
201
+ throw error;
202
+ }
203
+ }
@@ -0,0 +1,39 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as dotenv from 'dotenv';
4
+
5
+ /**
6
+ * Finds and reads the .env file for a directory
7
+ * Searches in the directory itself, then checks parent directories
8
+ * @param dir - Directory to search from
9
+ * @returns Parsed environment variables
10
+ */
11
+ function loadEnvFile(dir: string): Record<string, string> {
12
+ // First, check if there's a .env file directly in the provided directory
13
+ let envPath = path.join(dir, '.env');
14
+
15
+ if (fs.existsSync(envPath)) {
16
+ const envConfig = dotenv.parse(fs.readFileSync(envPath));
17
+ return envConfig;
18
+ }
19
+
20
+ throw new Error(`Environment file not found. Searched: ${dir}/.env`);
21
+ }
22
+
23
+ /**
24
+ * Gets the port from a directory's .env file
25
+ * @param options - Configuration options
26
+ * @param options.dir - Directory to search for .env file (defaults to current working directory)
27
+ * @returns The port number
28
+ */
29
+ export function getPort(options?: { dir?: string }): number {
30
+ const dir = options?.dir || process.cwd();
31
+ const env = loadEnvFile(dir);
32
+
33
+ if (env.PORT) {
34
+ return parseInt(env.PORT, 10);
35
+ }
36
+
37
+ throw new Error('Unable to determine port from .env file');
38
+ }
39
+
@@ -0,0 +1,34 @@
1
+ import { callEndpoint } from './call-command.ts';
2
+
3
+ /**
4
+ * List all available endpoints by calling /endpoints.json
5
+ */
6
+ export async function listEndpoints(baseUrl: string): Promise<void> {
7
+ try {
8
+ const response = await callEndpoint({
9
+ baseUrl,
10
+ positionalArgs: ['GET', '/endpoints.json'],
11
+ namedArgs: {},
12
+ quiet: true,
13
+ });
14
+
15
+ const endpoints = response.endpoints;
16
+
17
+ console.log('Available endpoints:\n');
18
+ if (Array.isArray(endpoints)) {
19
+ for (const endpoint of endpoints) {
20
+ const fullPath = `${endpoint.method} ${endpoint.path}`;
21
+ console.log(` ${fullPath}`);
22
+ if (endpoint.description) {
23
+ console.log(` ${endpoint.description}`);
24
+ }
25
+ }
26
+ } else {
27
+ console.log(JSON.stringify(endpoints, null, 2));
28
+ }
29
+ } catch (error) {
30
+ console.error('Could not list endpoints. The server may not support the /api/endpoints introspection endpoint.');
31
+ console.error(error instanceof Error ? error.message : String(error));
32
+ process.exit(1);
33
+ }
34
+ }
package/src/loadEnv.ts ADDED
@@ -0,0 +1,59 @@
1
+ import * as path from 'path';
2
+ import * as fs from 'fs';
3
+ import { config } from 'dotenv';
4
+
5
+ export interface EnvConfig {
6
+ port: number;
7
+ baseUrl: string;
8
+ }
9
+
10
+ /**
11
+ * Load and parse the .env file from the project directory
12
+ * @param cwd - Current working directory to search from
13
+ * @returns Configuration object with port and baseUrl
14
+ * @throws Error if .env file is missing or PRISM_API_PORT is not defined
15
+ */
16
+ export function loadEnv(cwd: string): EnvConfig {
17
+ const envPath = path.resolve(cwd, '.env');
18
+
19
+ if (!fs.existsSync(envPath)) {
20
+ throw new Error(
21
+ `No .env file found at ${envPath}\n\n` +
22
+ 'Please create a .env file with PRISM_API_PORT defined.\n' +
23
+ 'Example:\n' +
24
+ ' PRISM_API_PORT=3000'
25
+ );
26
+ }
27
+
28
+ // Load the .env file
29
+ const result = config({ path: envPath });
30
+
31
+ if (result.error) {
32
+ throw new Error(`Failed to load .env file: ${result.error.message}`);
33
+ }
34
+
35
+ const port = process.env.PRISM_API_PORT;
36
+
37
+ if (!port) {
38
+ throw new Error(
39
+ 'PRISM_API_PORT is not defined in .env file\n\n' +
40
+ 'Please add PRISM_API_PORT to your .env file.\n' +
41
+ 'Example:\n' +
42
+ ' PRISM_API_PORT=3000'
43
+ );
44
+ }
45
+
46
+ const portNumber = parseInt(port, 10);
47
+
48
+ if (isNaN(portNumber) || portNumber <= 0 || portNumber > 65535) {
49
+ throw new Error(
50
+ `Invalid PRISM_API_PORT value: ${port}\n\n` +
51
+ 'Port must be a number between 1 and 65535'
52
+ );
53
+ }
54
+
55
+ return {
56
+ port: portNumber,
57
+ baseUrl: `http://localhost:${portNumber}`,
58
+ };
59
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseNamedArgs } from '../src/call-command';
3
+
4
+ describe('parseNamedArgs', () => {
5
+ describe('basic values', () => {
6
+ it('should pass through simple string values', () => {
7
+ const result = parseNamedArgs({ name: 'John', email: 'john@example.com' });
8
+ expect(result).toEqual({ name: 'John', email: 'john@example.com' });
9
+ });
10
+
11
+ it('should pass through non-string values', () => {
12
+ const result = parseNamedArgs({ count: 42, active: true });
13
+ expect(result).toEqual({ count: 42, active: true });
14
+ });
15
+ });
16
+
17
+ describe('JSON parsing', () => {
18
+ it('should parse JSON object strings', () => {
19
+ const result = parseNamedArgs({ config: '{"timeout": 30}' });
20
+ expect(result).toEqual({ config: { timeout: 30 } });
21
+ });
22
+
23
+ it('should parse JSON array strings', () => {
24
+ const result = parseNamedArgs({ items: '["a", "b", "c"]' });
25
+ expect(result).toEqual({ items: ['a', 'b', 'c'] });
26
+ });
27
+
28
+ it('should handle whitespace around JSON', () => {
29
+ const result = parseNamedArgs({ data: ' {"key": "value"} ' });
30
+ expect(result).toEqual({ data: { key: 'value' } });
31
+ });
32
+
33
+ it('should keep invalid JSON as string', () => {
34
+ const result = parseNamedArgs({ bad: '{not valid json}' });
35
+ expect(result).toEqual({ bad: '{not valid json}' });
36
+ });
37
+
38
+ it('should not parse strings that only start with { or [', () => {
39
+ const result = parseNamedArgs({ text: '{hello world' });
40
+ expect(result).toEqual({ text: '{hello world' });
41
+ });
42
+ });
43
+
44
+ describe('nested objects from yargs', () => {
45
+ it('should parse JSON strings inside nested objects', () => {
46
+ // Yargs creates nested objects from dot notation before we see them
47
+ const result = parseNamedArgs({
48
+ schema: {
49
+ name: 'test-schema',
50
+ statements: '["CREATE TABLE test (id INT)"]'
51
+ }
52
+ });
53
+ expect(result).toEqual({
54
+ schema: {
55
+ name: 'test-schema',
56
+ statements: ['CREATE TABLE test (id INT)']
57
+ }
58
+ });
59
+ });
60
+
61
+ it('should handle deeply nested objects with JSON strings', () => {
62
+ const result = parseNamedArgs({
63
+ config: {
64
+ database: {
65
+ options: '{"timeout": 30, "retries": 3}'
66
+ }
67
+ }
68
+ });
69
+ expect(result).toEqual({
70
+ config: {
71
+ database: {
72
+ options: { timeout: 30, retries: 3 }
73
+ }
74
+ }
75
+ });
76
+ });
77
+ });
78
+
79
+ describe('real-world example from test.sh', () => {
80
+ it('should handle the migration command args', () => {
81
+ // Yargs parses --schema.name and --schema.statements into nested object
82
+ const result = parseNamedArgs({
83
+ schema: {
84
+ name: 'test-schema-v2',
85
+ statements: '["CREATE TABLE test_products (id INTEGER PRIMARY KEY, name TEXT, price REAL)"]'
86
+ }
87
+ });
88
+ expect(result).toEqual({
89
+ schema: {
90
+ name: 'test-schema-v2',
91
+ statements: ['CREATE TABLE test_products (id INTEGER PRIMARY KEY, name TEXT, price REAL)']
92
+ }
93
+ });
94
+ });
95
+ });
96
+ });