@objectql/cli 1.8.2 → 1.8.4

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 (48) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +229 -0
  3. package/USAGE_EXAMPLES.md +147 -0
  4. package/__tests__/commands.test.ts +274 -1
  5. package/dist/commands/ai.js +4 -3
  6. package/dist/commands/ai.js.map +1 -1
  7. package/dist/commands/build.d.ts +12 -0
  8. package/dist/commands/build.js +119 -0
  9. package/dist/commands/build.js.map +1 -0
  10. package/dist/commands/dev.d.ts +9 -0
  11. package/dist/commands/dev.js +23 -0
  12. package/dist/commands/dev.js.map +1 -0
  13. package/dist/commands/format.d.ts +9 -0
  14. package/dist/commands/format.js +137 -0
  15. package/dist/commands/format.js.map +1 -0
  16. package/dist/commands/lint.d.ts +9 -0
  17. package/dist/commands/lint.js +120 -0
  18. package/dist/commands/lint.js.map +1 -0
  19. package/dist/commands/new.js +5 -52
  20. package/dist/commands/new.js.map +1 -1
  21. package/dist/commands/start.d.ts +11 -0
  22. package/dist/commands/start.js +119 -0
  23. package/dist/commands/start.js.map +1 -0
  24. package/dist/commands/sync.d.ts +14 -0
  25. package/dist/commands/sync.js +314 -0
  26. package/dist/commands/sync.js.map +1 -0
  27. package/dist/commands/test.d.ts +10 -0
  28. package/dist/commands/test.js +120 -0
  29. package/dist/commands/test.js.map +1 -0
  30. package/dist/index.js +109 -14
  31. package/dist/index.js.map +1 -1
  32. package/jest.config.js +19 -0
  33. package/package.json +7 -7
  34. package/src/commands/ai.ts +4 -3
  35. package/src/commands/build.ts +98 -0
  36. package/src/commands/dev.ts +23 -0
  37. package/src/commands/format.ts +110 -0
  38. package/src/commands/lint.ts +98 -0
  39. package/src/commands/new.ts +5 -52
  40. package/src/commands/start.ts +100 -0
  41. package/src/commands/sync.ts +328 -0
  42. package/src/commands/test.ts +98 -0
  43. package/src/index.ts +114 -14
  44. package/tsconfig.tsbuildinfo +1 -1
  45. package/dist/commands/studio.d.ts +0 -5
  46. package/dist/commands/studio.js +0 -364
  47. package/dist/commands/studio.js.map +0 -1
  48. package/src/commands/studio.ts +0 -354
@@ -0,0 +1,328 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import chalk from 'chalk';
4
+ import * as yaml from 'js-yaml';
5
+ import { IntrospectedSchema, IntrospectedTable, IntrospectedColumn, ObjectConfig, IObjectQL, FieldConfig, FieldType } from '@objectql/types';
6
+
7
+ interface SyncOptions {
8
+ config?: string;
9
+ output?: string;
10
+ tables?: string[];
11
+ force?: boolean;
12
+ app?: IObjectQL; // Allow passing app instance directly for testing
13
+ }
14
+
15
+ /**
16
+ * Sync database schema to ObjectQL .object.yml files
17
+ * Introspects existing SQL database and generates object definitions
18
+ */
19
+ export async function syncDatabase(options: SyncOptions) {
20
+ const outputDir = path.resolve(process.cwd(), options.output || './src/objects');
21
+
22
+ console.log(chalk.blue('🔄 Syncing database schema to ObjectQL...'));
23
+ console.log(chalk.gray(`Output directory: ${outputDir}\n`));
24
+
25
+ let app: IObjectQL | undefined = options.app;
26
+ const shouldClose = !options.app; // Only close if we loaded it ourselves
27
+
28
+ try {
29
+ // Load ObjectQL instance from config if not provided
30
+ if (!app) {
31
+ app = await loadObjectQLInstance(options.config);
32
+ }
33
+
34
+ // Check if driver supports introspection
35
+ const driver = app.datasource('default');
36
+ if (!driver || !driver.introspectSchema) {
37
+ const errorMsg = 'The configured driver does not support schema introspection. Only SQL drivers (PostgreSQL, MySQL, SQLite) support this feature.';
38
+ console.error(chalk.red(`❌ ${errorMsg}`));
39
+ throw new Error(errorMsg);
40
+ }
41
+
42
+ // Introspect database schema
43
+ console.log(chalk.blue('📊 Introspecting database schema...'));
44
+ const schema: IntrospectedSchema = await driver.introspectSchema();
45
+
46
+ const tableNames = Object.keys(schema.tables);
47
+ if (tableNames.length === 0) {
48
+ console.log(chalk.yellow('⚠ No tables found in database'));
49
+ return;
50
+ }
51
+
52
+ console.log(chalk.green(`✓ Found ${tableNames.length} table(s)\n`));
53
+
54
+ // Filter tables if specified
55
+ let tablesToSync = tableNames;
56
+ if (options.tables && options.tables.length > 0) {
57
+ tablesToSync = tableNames.filter(t => options.tables!.includes(t));
58
+ if (tablesToSync.length === 0) {
59
+ console.log(chalk.yellow('⚠ No matching tables found'));
60
+ return;
61
+ }
62
+ }
63
+
64
+ // Create output directory if it doesn't exist
65
+ if (!fs.existsSync(outputDir)) {
66
+ fs.mkdirSync(outputDir, { recursive: true });
67
+ console.log(chalk.gray(`Created directory: ${outputDir}\n`));
68
+ }
69
+
70
+ // Generate .object.yml files
71
+ let createdCount = 0;
72
+ let skippedCount = 0;
73
+
74
+ for (const tableName of tablesToSync) {
75
+ const table = schema.tables[tableName];
76
+ const filename = `${tableName}.object.yml`;
77
+ const filePath = path.join(outputDir, filename);
78
+
79
+ // Check if file already exists
80
+ if (fs.existsSync(filePath) && !options.force) {
81
+ console.log(chalk.yellow(`⊘ ${tableName} (file exists, use --force to overwrite)`));
82
+ skippedCount++;
83
+ continue;
84
+ }
85
+
86
+ // Generate object definition
87
+ const objectDef = generateObjectDefinition(table, schema);
88
+
89
+ // Write to file
90
+ const yamlContent = yaml.dump(objectDef, {
91
+ indent: 2,
92
+ lineWidth: -1,
93
+ noRefs: true,
94
+ sortKeys: false
95
+ });
96
+
97
+ fs.writeFileSync(filePath, yamlContent, 'utf-8');
98
+
99
+ console.log(chalk.green(`✓ ${tableName} → ${filename}`));
100
+ createdCount++;
101
+ }
102
+
103
+ console.log(chalk.blue('\n📊 Summary:'));
104
+ console.log(chalk.gray(`Total tables: ${tablesToSync.length}`));
105
+ console.log(chalk.gray(`Created: ${createdCount}`));
106
+ console.log(chalk.gray(`Skipped: ${skippedCount}`));
107
+
108
+ if (createdCount > 0) {
109
+ console.log(chalk.green(`\n✅ Successfully synced ${createdCount} table(s) to ${outputDir}`));
110
+ }
111
+
112
+ } catch (error: any) {
113
+ console.error(chalk.red(`❌ Sync failed: ${error.message}`));
114
+ if (error.stack) {
115
+ console.error(chalk.gray(error.stack));
116
+ }
117
+ throw error;
118
+ } finally {
119
+ // Ensure connection is closed if we opened it
120
+ if (shouldClose && app) {
121
+ if (app.close) {
122
+ await app.close();
123
+ } else {
124
+ // Fallback for older versions if close isn't available
125
+ const driver = app.datasource('default');
126
+ if (driver && (driver as any).disconnect) {
127
+ await (driver as any).disconnect();
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Generate ObjectQL object definition from introspected table
136
+ */
137
+ function generateObjectDefinition(table: IntrospectedTable, schema: IntrospectedSchema): ObjectConfig {
138
+ const obj: ObjectConfig = {
139
+ name: table.name,
140
+ label: formatLabel(table.name),
141
+ fields: {}
142
+ };
143
+
144
+ // Process each column
145
+ for (const column of table.columns) {
146
+ // Skip system fields (id, created_at, updated_at) - they're automatic
147
+ if (['id', 'created_at', 'updated_at'].includes(column.name)) {
148
+ continue;
149
+ }
150
+
151
+ const field: Partial<FieldConfig> = {};
152
+
153
+ // Check if this is a foreign key
154
+ const fk = table.foreignKeys.find(fk => fk.columnName === column.name);
155
+ if (fk) {
156
+ // This is a lookup/relationship field
157
+ field.type = 'lookup';
158
+ field.reference_to = fk.referencedTable;
159
+
160
+ // Add label
161
+ field.label = formatLabel(column.name);
162
+
163
+ // Add required constraint
164
+ if (!column.nullable) {
165
+ field.required = true;
166
+ }
167
+ } else {
168
+ // Regular field - map SQL type to ObjectQL type
169
+ const fieldType = mapSqlTypeToObjectQL(column.type, column);
170
+ field.type = fieldType;
171
+
172
+ // Add label
173
+ field.label = formatLabel(column.name);
174
+
175
+ // Add constraints
176
+ if (!column.nullable) {
177
+ field.required = true;
178
+ }
179
+
180
+ if (column.isUnique) {
181
+ field.unique = true;
182
+ }
183
+
184
+ // Add max_length for text-based fields
185
+ if (column.maxLength && (fieldType === 'text' || fieldType === 'textarea')) {
186
+ field.max_length = column.maxLength;
187
+ }
188
+
189
+ if (column.defaultValue !== undefined && column.defaultValue !== null) {
190
+ // Only include simple default values
191
+ if (typeof column.defaultValue === 'string' ||
192
+ typeof column.defaultValue === 'number' ||
193
+ typeof column.defaultValue === 'boolean') {
194
+ field.defaultValue = column.defaultValue;
195
+ }
196
+ }
197
+ }
198
+
199
+ obj.fields[column.name] = field as FieldConfig;
200
+ }
201
+
202
+ return obj;
203
+ }
204
+
205
+ /**
206
+ * Map SQL native type to ObjectQL field type
207
+ */
208
+ function mapSqlTypeToObjectQL(sqlType: string, column: IntrospectedColumn): FieldType {
209
+ const type = sqlType.toLowerCase();
210
+
211
+ // Integer types - map to 'number'
212
+ if (type.includes('int') || type.includes('serial') || type.includes('bigserial')) {
213
+ return 'number';
214
+ }
215
+
216
+ // Float/Decimal types
217
+ if (type.includes('float') || type.includes('double') ||
218
+ type.includes('decimal') || type.includes('numeric') || type.includes('real')) {
219
+ return 'number';
220
+ }
221
+
222
+ // Boolean
223
+ if (type.includes('bool') || type === 'bit') {
224
+ return 'boolean';
225
+ }
226
+
227
+ // Date/Time types
228
+ if (type.includes('timestamp') || type.includes('datetime')) {
229
+ return 'datetime';
230
+ }
231
+ if (type === 'date') {
232
+ return 'date';
233
+ }
234
+ if (type === 'time') {
235
+ return 'time';
236
+ }
237
+
238
+ // Text types
239
+ if (type.includes('text') || type.includes('clob') || type.includes('long')) {
240
+ return 'textarea';
241
+ }
242
+
243
+ // JSON types - map to 'object'
244
+ if (type.includes('json') || type.includes('jsonb')) {
245
+ return 'object';
246
+ }
247
+
248
+ // Binary/Blob types
249
+ if (type.includes('blob') || type.includes('binary') || type.includes('bytea')) {
250
+ return 'file';
251
+ }
252
+
253
+ // String types (varchar, char, etc.)
254
+ // Default to 'text' for general string fields
255
+ return 'text';
256
+ }
257
+
258
+ /**
259
+ * Format table/column name to human-readable label
260
+ * e.g., "user_profile" -> "User Profile"
261
+ */
262
+ function formatLabel(name: string): string {
263
+ return name
264
+ .split('_')
265
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
266
+ .join(' ');
267
+ }
268
+
269
+ /**
270
+ * Load ObjectQL instance from config file
271
+ */
272
+ async function loadObjectQLInstance(configPath?: string): Promise<IObjectQL> {
273
+ const cwd = process.cwd();
274
+
275
+ // Try to load from config file
276
+ let configFile = configPath;
277
+ if (!configFile) {
278
+ const potentialFiles = ['objectql.config.ts', 'objectql.config.js'];
279
+ for (const file of potentialFiles) {
280
+ if (fs.existsSync(path.join(cwd, file))) {
281
+ configFile = path.join(cwd, file);
282
+ break;
283
+ }
284
+ }
285
+ } else if (!path.isAbsolute(configFile)) {
286
+ // If configPath is provided but relative, make it absolute
287
+ configFile = path.join(cwd, configFile);
288
+ }
289
+
290
+ if (!configFile) {
291
+ throw new Error('No configuration file found (objectql.config.ts/js). Please create one with database connection.');
292
+ }
293
+
294
+ // Register ts-node for TypeScript support
295
+ if (configFile.endsWith('.ts')) {
296
+ try {
297
+ require('ts-node').register({
298
+ transpileOnly: true,
299
+ compilerOptions: {
300
+ module: 'commonjs'
301
+ }
302
+ });
303
+ } catch (err) {
304
+ throw new Error('TypeScript config file detected but ts-node is not installed. Please run: npm install --save-dev ts-node');
305
+ }
306
+ }
307
+
308
+ const configModule = require(configFile);
309
+
310
+ // Clear cache to support multiple runs in same process (e.g. tests)
311
+ try {
312
+ const resolvedPath = require.resolve(configFile);
313
+ delete require.cache[resolvedPath];
314
+ } catch (e) {
315
+ // Ignore resolution errors
316
+ }
317
+
318
+ // Support multiple export patterns: default, app, objectql, or db (in order of precedence)
319
+ const app = configModule.default || configModule.app || configModule.objectql || configModule.db;
320
+
321
+ if (!app) {
322
+ throw new Error('Config file must export an ObjectQL instance as default export or named export (app/objectql/db)');
323
+ }
324
+
325
+ // Initialize app (but don't sync schema - we're reading it)
326
+ await app.init();
327
+ return app;
328
+ }
@@ -0,0 +1,98 @@
1
+ import * as path from 'path';
2
+ import * as fs from 'fs';
3
+ import chalk from 'chalk';
4
+ import { spawn } from 'child_process';
5
+
6
+ interface TestOptions {
7
+ dir?: string;
8
+ watch?: boolean;
9
+ coverage?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Test command - runs tests for the ObjectQL project
14
+ */
15
+ export async function test(options: TestOptions) {
16
+ console.log(chalk.blue('🧪 Running tests...\n'));
17
+
18
+ const rootDir = path.resolve(process.cwd(), options.dir || '.');
19
+
20
+ // Look for package.json to determine test runner
21
+ const packageJsonPath = path.join(rootDir, 'package.json');
22
+ let testCommand = 'npm test';
23
+
24
+ try {
25
+ if (fs.existsSync(packageJsonPath)) {
26
+ try {
27
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
28
+
29
+ // Check if jest is configured
30
+ if (packageJson.devDependencies?.jest || packageJson.dependencies?.jest || packageJson.jest) {
31
+ const jestArgs = ['jest'];
32
+
33
+ if (options.watch) {
34
+ jestArgs.push('--watch');
35
+ }
36
+
37
+ if (options.coverage) {
38
+ jestArgs.push('--coverage');
39
+ }
40
+
41
+ console.log(chalk.cyan(`Running: ${jestArgs.join(' ')}\n`));
42
+
43
+ const jestProcess = spawn('npx', jestArgs, {
44
+ cwd: rootDir,
45
+ stdio: 'inherit',
46
+ shell: true
47
+ });
48
+
49
+ jestProcess.on('exit', (code) => {
50
+ if (code !== 0) {
51
+ console.error(chalk.red(`\n❌ Tests failed with exit code ${code}`));
52
+ process.exit(code || 1);
53
+ } else {
54
+ console.log(chalk.green('\n✅ All tests passed!'));
55
+ }
56
+ });
57
+
58
+ return;
59
+ }
60
+
61
+ // Fall back to package.json test script
62
+ if (packageJson.scripts?.test) {
63
+ console.log(chalk.cyan(`Running: npm test\n`));
64
+
65
+ const npmProcess = spawn('npm', ['test'], {
66
+ cwd: rootDir,
67
+ stdio: 'inherit',
68
+ shell: true
69
+ });
70
+
71
+ npmProcess.on('exit', (code) => {
72
+ if (code !== 0) {
73
+ console.error(chalk.red(`\n❌ Tests failed with exit code ${code}`));
74
+ process.exit(code || 1);
75
+ } else {
76
+ console.log(chalk.green('\n✅ All tests passed!'));
77
+ }
78
+ });
79
+
80
+ return;
81
+ }
82
+ } catch (parseError: any) {
83
+ console.error(chalk.yellow(`⚠️ Failed to parse package.json: ${parseError.message}`));
84
+ }
85
+ }
86
+
87
+ // No test configuration found
88
+ console.log(chalk.yellow('⚠️ No test configuration found'));
89
+ console.log(chalk.gray('To add tests to your project:'));
90
+ console.log(chalk.gray(' 1. Install jest: npm install --save-dev jest @types/jest ts-jest'));
91
+ console.log(chalk.gray(' 2. Create a jest.config.js file'));
92
+ console.log(chalk.gray(' 3. Add a test script to package.json'));
93
+
94
+ } catch (e: any) {
95
+ console.error(chalk.red('❌ Test execution failed:'), e.message);
96
+ process.exit(1);
97
+ }
98
+ }
package/src/index.ts CHANGED
@@ -2,12 +2,18 @@ import { Command } from 'commander';
2
2
  import { generateTypes } from './commands/generate';
3
3
  import { startRepl } from './commands/repl';
4
4
  import { serve } from './commands/serve';
5
- import { startStudio } from './commands/studio';
5
+ import { dev } from './commands/dev';
6
+ import { start } from './commands/start';
7
+ import { build } from './commands/build';
8
+ import { test } from './commands/test';
9
+ import { lint } from './commands/lint';
10
+ import { format } from './commands/format';
6
11
  import { initProject } from './commands/init';
7
12
  import { newMetadata } from './commands/new';
8
13
  import { i18nExtract, i18nInit, i18nValidate } from './commands/i18n';
9
14
  import { migrate, migrateCreate, migrateStatus } from './commands/migrate';
10
15
  import { aiGenerate, aiValidate, aiChat, aiConversational } from './commands/ai';
16
+ import { syncDatabase } from './commands/sync';
11
17
 
12
18
  const program = new Command();
13
19
 
@@ -153,6 +159,23 @@ migrateCmd
153
159
  }
154
160
  });
155
161
 
162
+ // Sync command - Introspect database and generate .object.yml files
163
+ program
164
+ .command('sync')
165
+ .description('Sync database schema to ObjectQL object definitions')
166
+ .option('-c, --config <path>', 'Path to objectql.config.ts/js')
167
+ .option('-o, --output <path>', 'Output directory for .object.yml files', './src/objects')
168
+ .option('-t, --tables <tables...>', 'Specific tables to sync (default: all)')
169
+ .option('-f, --force', 'Overwrite existing files')
170
+ .action(async (options) => {
171
+ try {
172
+ await syncDatabase(options);
173
+ } catch (error) {
174
+ console.error(error);
175
+ process.exit(1);
176
+ }
177
+ });
178
+
156
179
  // REPL command
157
180
  program
158
181
  .command('repl')
@@ -163,33 +186,110 @@ program
163
186
  await startRepl(options.config);
164
187
  });
165
188
 
166
- // Serve command
189
+ // Dev command - Start development server
167
190
  program
168
- .command('serve')
169
- .alias('s')
170
- .description('Start a development server')
191
+ .command('dev')
192
+ .alias('d')
193
+ .description('Start development server with hot reload')
171
194
  .option('-p, --port <number>', 'Port to listen on', '3000')
172
195
  .option('-d, --dir <path>', 'Directory containing schema', '.')
196
+ .option('--no-watch', 'Disable file watching')
173
197
  .action(async (options) => {
174
- await serve({ port: parseInt(options.port), dir: options.dir });
198
+ await dev({
199
+ port: parseInt(options.port),
200
+ dir: options.dir,
201
+ watch: options.watch
202
+ });
175
203
  });
176
204
 
177
- // Studio command
205
+ // Start command - Production server
178
206
  program
179
- .command('studio')
180
- .alias('ui')
181
- .description('Start the ObjectQL Studio')
182
- .option('-p, --port <number>', 'Port to listen on', '5555')
207
+ .command('start')
208
+ .description('Start production server')
209
+ .option('-p, --port <number>', 'Port to listen on', '3000')
183
210
  .option('-d, --dir <path>', 'Directory containing schema', '.')
184
- .option('--no-open', 'Do not open browser automatically')
211
+ .option('-c, --config <path>', 'Path to objectql.config.ts/js')
185
212
  .action(async (options) => {
186
- await startStudio({
213
+ await start({
187
214
  port: parseInt(options.port),
188
215
  dir: options.dir,
189
- open: options.open
216
+ config: options.config
217
+ });
218
+ });
219
+
220
+ // Build command - Build project for production
221
+ program
222
+ .command('build')
223
+ .alias('b')
224
+ .description('Build project and generate types')
225
+ .option('-d, --dir <path>', 'Source directory', '.')
226
+ .option('-o, --output <path>', 'Output directory', './dist')
227
+ .option('--no-types', 'Skip TypeScript type generation')
228
+ .option('--no-validate', 'Skip metadata validation')
229
+ .action(async (options) => {
230
+ await build({
231
+ dir: options.dir,
232
+ output: options.output,
233
+ types: options.types,
234
+ validate: options.validate
235
+ });
236
+ });
237
+
238
+ // Test command - Run tests
239
+ program
240
+ .command('test')
241
+ .alias('t')
242
+ .description('Run tests')
243
+ .option('-d, --dir <path>', 'Project directory', '.')
244
+ .option('-w, --watch', 'Watch mode')
245
+ .option('--coverage', 'Generate coverage report')
246
+ .action(async (options) => {
247
+ await test({
248
+ dir: options.dir,
249
+ watch: options.watch,
250
+ coverage: options.coverage
251
+ });
252
+ });
253
+
254
+ // Lint command - Validate metadata
255
+ program
256
+ .command('lint')
257
+ .alias('l')
258
+ .description('Validate metadata files')
259
+ .option('-d, --dir <path>', 'Directory to lint', '.')
260
+ .option('--fix', 'Automatically fix issues')
261
+ .action(async (options) => {
262
+ await lint({
263
+ dir: options.dir,
264
+ fix: options.fix
265
+ });
266
+ });
267
+
268
+ // Format command - Format metadata files
269
+ program
270
+ .command('format')
271
+ .alias('fmt')
272
+ .description('Format metadata files with Prettier')
273
+ .option('-d, --dir <path>', 'Directory to format', '.')
274
+ .option('--check', 'Check if files are formatted without modifying')
275
+ .action(async (options) => {
276
+ await format({
277
+ dir: options.dir,
278
+ check: options.check
190
279
  });
191
280
  });
192
281
 
282
+ // Serve command (kept for backwards compatibility)
283
+ program
284
+ .command('serve')
285
+ .alias('s')
286
+ .description('Start a development server (alias for dev)')
287
+ .option('-p, --port <number>', 'Port to listen on', '3000')
288
+ .option('-d, --dir <path>', 'Directory containing schema', '.')
289
+ .action(async (options) => {
290
+ await serve({ port: parseInt(options.port), dir: options.dir });
291
+ });
292
+
193
293
  // AI command - Interactive by default, with specific subcommands for other modes
194
294
  const aiCmd = program
195
295
  .command('ai')