@navios/commander 0.5.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 (72) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +242 -0
  3. package/dist/src/commander.application.d.mts +29 -0
  4. package/dist/src/commander.application.d.mts.map +1 -0
  5. package/dist/src/commander.factory.d.mts +8 -0
  6. package/dist/src/commander.factory.d.mts.map +1 -0
  7. package/dist/src/decorators/cli-module.decorator.d.mts +7 -0
  8. package/dist/src/decorators/cli-module.decorator.d.mts.map +1 -0
  9. package/dist/src/decorators/command.decorator.d.mts +8 -0
  10. package/dist/src/decorators/command.decorator.d.mts.map +1 -0
  11. package/dist/src/decorators/index.d.mts +3 -0
  12. package/dist/src/decorators/index.d.mts.map +1 -0
  13. package/dist/src/index.d.mts +8 -0
  14. package/dist/src/index.d.mts.map +1 -0
  15. package/dist/src/interfaces/cli-module.interface.d.mts +5 -0
  16. package/dist/src/interfaces/cli-module.interface.d.mts.map +1 -0
  17. package/dist/src/interfaces/command-handler.interface.d.mts +4 -0
  18. package/dist/src/interfaces/command-handler.interface.d.mts.map +1 -0
  19. package/dist/src/interfaces/index.d.mts +3 -0
  20. package/dist/src/interfaces/index.d.mts.map +1 -0
  21. package/dist/src/interfaces/module.interface.d.mts +5 -0
  22. package/dist/src/interfaces/module.interface.d.mts.map +1 -0
  23. package/dist/src/metadata/cli-module.metadata.d.mts +11 -0
  24. package/dist/src/metadata/cli-module.metadata.d.mts.map +1 -0
  25. package/dist/src/metadata/command.metadata.d.mts +12 -0
  26. package/dist/src/metadata/command.metadata.d.mts.map +1 -0
  27. package/dist/src/metadata/index.d.mts +3 -0
  28. package/dist/src/metadata/index.d.mts.map +1 -0
  29. package/dist/src/services/cli-parser.service.d.mts +44 -0
  30. package/dist/src/services/cli-parser.service.d.mts.map +1 -0
  31. package/dist/src/services/index.d.mts +3 -0
  32. package/dist/src/services/index.d.mts.map +1 -0
  33. package/dist/src/services/module-loader.service.d.mts +33 -0
  34. package/dist/src/services/module-loader.service.d.mts.map +1 -0
  35. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  36. package/dist/tsconfig.tsbuildinfo +1 -0
  37. package/dist/tsup.config.d.mts +3 -0
  38. package/dist/tsup.config.d.mts.map +1 -0
  39. package/dist/vitest.config.d.mts +3 -0
  40. package/dist/vitest.config.d.mts.map +1 -0
  41. package/lib/_tsup-dts-rollup.d.mts +456 -0
  42. package/lib/_tsup-dts-rollup.d.ts +456 -0
  43. package/lib/index.d.mts +98 -0
  44. package/lib/index.d.ts +98 -0
  45. package/lib/index.js +541 -0
  46. package/lib/index.js.map +1 -0
  47. package/lib/index.mjs +524 -0
  48. package/lib/index.mjs.map +1 -0
  49. package/package.json +40 -0
  50. package/project.json +66 -0
  51. package/src/__tests__/commander.factory.e2e.spec.mts +965 -0
  52. package/src/commander.application.mts +159 -0
  53. package/src/commander.factory.mts +20 -0
  54. package/src/decorators/cli-module.decorator.mts +39 -0
  55. package/src/decorators/command.decorator.mts +29 -0
  56. package/src/decorators/index.mts +2 -0
  57. package/src/index.mts +7 -0
  58. package/src/interfaces/command-handler.interface.mts +3 -0
  59. package/src/interfaces/index.mts +2 -0
  60. package/src/interfaces/module.interface.mts +4 -0
  61. package/src/metadata/cli-module.metadata.mts +54 -0
  62. package/src/metadata/command.metadata.mts +54 -0
  63. package/src/metadata/index.mts +2 -0
  64. package/src/services/__tests__/cli-parser.service.spec.mts +404 -0
  65. package/src/services/cli-parser.service.mts +231 -0
  66. package/src/services/index.mts +2 -0
  67. package/src/services/module-loader.service.mts +120 -0
  68. package/tsconfig.json +18 -0
  69. package/tsconfig.lib.json +8 -0
  70. package/tsconfig.spec.json +13 -0
  71. package/tsup.config.mts +12 -0
  72. package/vitest.config.mts +9 -0
@@ -0,0 +1,404 @@
1
+ import { Container } from '@navios/di'
2
+
3
+ import { beforeEach, describe, expect, it } from 'vitest'
4
+ import { z } from 'zod'
5
+
6
+ import { CliParserService } from '../cli-parser.service.mjs'
7
+
8
+ describe('CliParserService', () => {
9
+ let parser: CliParserService
10
+ beforeEach(async () => {
11
+ const container = new Container()
12
+ parser = await container.get(CliParserService)
13
+ })
14
+
15
+ describe('basic parsing', () => {
16
+ it('should parse simple command', () => {
17
+ const result = parser.parse(['node', 'script.js', 'test'])
18
+ expect(result.command).toBe('test')
19
+ expect(result.options).toEqual({})
20
+ expect(result.positionals).toEqual([])
21
+ })
22
+
23
+ it('should parse multi-word command', () => {
24
+ const result = parser.parse(['node', 'script.js', 'db', 'migrate'])
25
+ expect(result.command).toBe('db migrate')
26
+ expect(result.options).toEqual({})
27
+ expect(result.positionals).toEqual([])
28
+ })
29
+
30
+ it('should parse command with long options', () => {
31
+ const result = parser.parse(['node', 'script.js', 'test', '--verbose'])
32
+ expect(result.command).toBe('test')
33
+ expect(result.options).toEqual({ verbose: true })
34
+ })
35
+
36
+ it('should parse command with long options and values', () => {
37
+ const result = parser.parse([
38
+ 'node',
39
+ 'script.js',
40
+ 'test',
41
+ '--config',
42
+ 'prod',
43
+ ])
44
+ expect(result.command).toBe('test')
45
+ expect(result.options).toEqual({ config: 'prod' })
46
+ })
47
+
48
+ it('should parse command with kebab-case options to camelCase', () => {
49
+ const result = parser.parse(['node', 'script.js', 'test', '--dry-run'])
50
+ expect(result.command).toBe('test')
51
+ expect(result.options).toEqual({ dryRun: true })
52
+ })
53
+
54
+ it('should parse command with short options', () => {
55
+ const result = parser.parse(['node', 'script.js', 'test', '-v'])
56
+ expect(result.command).toBe('test')
57
+ expect(result.options).toEqual({ v: true })
58
+ })
59
+
60
+ it('should parse command with short options and values', () => {
61
+ const result = parser.parse(['node', 'script.js', 'test', '-c', 'prod'])
62
+ expect(result.command).toBe('test')
63
+ expect(result.options).toEqual({ c: 'prod' })
64
+ })
65
+
66
+ it('should parse multiple short flags', () => {
67
+ const result = parser.parse(['node', 'script.js', 'test', '-abc'])
68
+ expect(result.command).toBe('test')
69
+ expect(result.options).toEqual({ a: true, b: true, c: true })
70
+ })
71
+
72
+ it('should parse mixed options and positionals', () => {
73
+ const result = parser.parse(
74
+ [
75
+ 'node',
76
+ 'script.js',
77
+ 'test',
78
+ '--verbose',
79
+ 'file1',
80
+ '-c',
81
+ 'prod',
82
+ 'file2',
83
+ ],
84
+ z.object({
85
+ verbose: z.boolean(),
86
+ c: z.string(),
87
+ }),
88
+ )
89
+ expect(result.command).toBe('test')
90
+ expect(result.options).toEqual({ verbose: true, c: 'prod' })
91
+ expect(result.positionals).toEqual(['file1', 'file2'])
92
+ })
93
+
94
+ it('should parse options with equal sign syntax', () => {
95
+ const result = parser.parse([
96
+ 'node',
97
+ 'script.js',
98
+ 'test',
99
+ '--config=prod',
100
+ '--port=3000',
101
+ ])
102
+ expect(result.command).toBe('test')
103
+ expect(result.options).toEqual({ config: 'prod', port: 3000 })
104
+ })
105
+
106
+ it('should throw error when no command provided', () => {
107
+ expect(() => parser.parse(['node', 'script.js'])).toThrow(
108
+ '[Navios Commander] No command provided',
109
+ )
110
+ })
111
+ })
112
+
113
+ describe('value type parsing', () => {
114
+ it('should parse boolean values', () => {
115
+ const result = parser.parse([
116
+ 'node',
117
+ 'script.js',
118
+ 'test',
119
+ '--flag',
120
+ 'true',
121
+ '--other',
122
+ 'false',
123
+ ])
124
+ expect(result.options).toEqual({ flag: true, other: false })
125
+ })
126
+
127
+ it('should parse integer values', () => {
128
+ const result = parser.parse([
129
+ 'node',
130
+ 'script.js',
131
+ 'test',
132
+ '--port',
133
+ '3000',
134
+ ])
135
+ expect(result.options).toEqual({ port: 3000 })
136
+ })
137
+
138
+ it('should parse negative integer values', () => {
139
+ const result = parser.parse([
140
+ 'node',
141
+ 'script.js',
142
+ 'test',
143
+ '--offset',
144
+ '"-10"',
145
+ ])
146
+ expect(result.options).toEqual({ offset: '"-10"' })
147
+ })
148
+
149
+ it('should parse float values', () => {
150
+ const result = parser.parse([
151
+ 'node',
152
+ 'script.js',
153
+ 'test',
154
+ '--ratio',
155
+ '3.14',
156
+ ])
157
+ expect(result.options).toEqual({ ratio: 3.14 })
158
+ })
159
+
160
+ it('should parse null value', () => {
161
+ const result = parser.parse([
162
+ 'node',
163
+ 'script.js',
164
+ 'test',
165
+ '--value',
166
+ 'null',
167
+ ])
168
+ expect(result.options).toEqual({ value: null })
169
+ })
170
+
171
+ it('should parse JSON object', () => {
172
+ const result = parser.parse([
173
+ 'node',
174
+ 'script.js',
175
+ 'test',
176
+ '--data',
177
+ '{"key":"value"}',
178
+ ])
179
+ expect(result.options).toEqual({ data: { key: 'value' } })
180
+ })
181
+
182
+ it('should parse JSON array', () => {
183
+ const result = parser.parse([
184
+ 'node',
185
+ 'script.js',
186
+ 'test',
187
+ '--items',
188
+ '[1,2,3]',
189
+ ])
190
+ expect(result.options).toEqual({ items: [1, 2, 3] })
191
+ })
192
+ })
193
+
194
+ describe('schema-aware parsing', () => {
195
+ it('should detect boolean flags from schema', () => {
196
+ const schema = z.object({
197
+ verbose: z.boolean(),
198
+ config: z.string(),
199
+ })
200
+
201
+ const result = parser.parse(
202
+ ['node', 'script.js', 'test', '--verbose', '--config', 'prod'],
203
+ schema,
204
+ )
205
+
206
+ expect(result.options).toEqual({
207
+ verbose: true,
208
+ config: 'prod',
209
+ })
210
+ })
211
+
212
+ it('should handle optional boolean flags', () => {
213
+ const schema = z.object({
214
+ verbose: z.boolean().optional(),
215
+ dryRun: z.boolean().optional(),
216
+ config: z.string(),
217
+ })
218
+
219
+ const result = parser.parse(
220
+ [
221
+ 'node',
222
+ 'script.js',
223
+ 'test',
224
+ '--verbose',
225
+ '--dry-run',
226
+ '--config',
227
+ 'prod',
228
+ ],
229
+ schema,
230
+ )
231
+
232
+ expect(result.options).toEqual({
233
+ verbose: true,
234
+ dryRun: true,
235
+ config: 'prod',
236
+ })
237
+ })
238
+
239
+ it('should handle default boolean flags', () => {
240
+ const schema = z.object({
241
+ verbose: z.boolean().default(false),
242
+ config: z.string(),
243
+ })
244
+
245
+ const result = parser.parse(
246
+ ['node', 'script.js', 'test', '--verbose', '--config', 'prod'],
247
+ schema,
248
+ )
249
+
250
+ expect(result.options).toEqual({
251
+ verbose: true,
252
+ config: 'prod',
253
+ })
254
+ })
255
+
256
+ it('should prevent boolean flag from consuming next option as value', () => {
257
+ const schema = z.object({
258
+ verbose: z.boolean(),
259
+ debug: z.boolean(),
260
+ config: z.string(),
261
+ })
262
+
263
+ // Without schema, --verbose would incorrectly consume '--debug' as its value
264
+ const result = parser.parse(
265
+ [
266
+ 'node',
267
+ 'script.js',
268
+ 'test',
269
+ '--verbose',
270
+ '--debug',
271
+ '--config',
272
+ 'prod',
273
+ ],
274
+ schema,
275
+ )
276
+
277
+ expect(result.options).toEqual({
278
+ verbose: true,
279
+ debug: true,
280
+ config: 'prod',
281
+ })
282
+ })
283
+
284
+ it('should handle mixed boolean and non-boolean options', () => {
285
+ const schema = z.object({
286
+ verbose: z.boolean(),
287
+ port: z.number(),
288
+ host: z.string(),
289
+ dryRun: z.boolean(),
290
+ })
291
+
292
+ const result = parser.parse(
293
+ [
294
+ 'node',
295
+ 'script.js',
296
+ 'test',
297
+ '--verbose',
298
+ '--port',
299
+ '3000',
300
+ '--host',
301
+ 'localhost',
302
+ '--dry-run',
303
+ ],
304
+ schema,
305
+ )
306
+
307
+ expect(result.options).toEqual({
308
+ verbose: true,
309
+ port: 3000,
310
+ host: 'localhost',
311
+ dryRun: true,
312
+ })
313
+ })
314
+
315
+ it('should work without schema (fallback behavior)', () => {
316
+ const result = parser.parse([
317
+ 'node',
318
+ 'script.js',
319
+ 'test',
320
+ '--verbose',
321
+ '--config',
322
+ 'prod',
323
+ ])
324
+
325
+ // Without schema, parser uses heuristics
326
+ expect(result.options).toEqual({
327
+ verbose: true,
328
+ config: 'prod',
329
+ })
330
+ })
331
+
332
+ it('should handle short boolean flags from schema', () => {
333
+ const schema = z.object({
334
+ v: z.boolean(),
335
+ c: z.string(),
336
+ })
337
+
338
+ const result = parser.parse(
339
+ ['node', 'script.js', 'test', '-v', '-c', 'prod'],
340
+ schema,
341
+ )
342
+
343
+ expect(result.options).toEqual({
344
+ v: true,
345
+ c: 'prod',
346
+ })
347
+ })
348
+
349
+ it('should handle complex schema with nested optional booleans', () => {
350
+ const schema = z.object({
351
+ verbose: z.boolean().optional(),
352
+ debug: z.boolean().default(false),
353
+ quiet: z.boolean(),
354
+ config: z.string(),
355
+ port: z.number().optional(),
356
+ })
357
+
358
+ const result = parser.parse(
359
+ [
360
+ 'node',
361
+ 'script.js',
362
+ 'test',
363
+ '--verbose',
364
+ '--debug',
365
+ '--quiet',
366
+ '--config',
367
+ 'production',
368
+ ],
369
+ schema,
370
+ )
371
+
372
+ expect(result.options).toEqual({
373
+ verbose: true,
374
+ debug: true,
375
+ quiet: true,
376
+ config: 'production',
377
+ })
378
+ })
379
+ })
380
+
381
+ describe('edge cases', () => {
382
+ it('should handle command followed immediately by options', () => {
383
+ const result = parser.parse(['node', 'script.js', 'test', '--flag'])
384
+ expect(result.command).toBe('test')
385
+ expect(result.options).toEqual({ flag: true })
386
+ })
387
+
388
+ it('should handle options with dashes in values', () => {
389
+ const result = parser.parse([
390
+ 'node',
391
+ 'script.js',
392
+ 'test',
393
+ '--branch',
394
+ 'feature-test',
395
+ ])
396
+ expect(result.options).toEqual({ branch: 'feature-test' })
397
+ })
398
+
399
+ it('should handle single dash as positional', () => {
400
+ const result = parser.parse(['node', 'script.js', 'test', '-'])
401
+ expect(result.positionals).toEqual(['-'])
402
+ })
403
+ })
404
+ })
@@ -0,0 +1,231 @@
1
+ import type { ZodObject, ZodType } from 'zod'
2
+
3
+ import { Injectable } from '@navios/di'
4
+
5
+ export interface ParsedCliArgs {
6
+ command: string
7
+ options: Record<string, any>
8
+ positionals: string[]
9
+ }
10
+
11
+ @Injectable()
12
+ export class CliParserService {
13
+ /**
14
+ * Parses command-line arguments from process.argv
15
+ * Commands can be multi-word (e.g., 'db migrate', 'cache clear')
16
+ * Expected format: node script.js command [subcommand...] --flag value --boolean-flag positional1 positional2
17
+ *
18
+ * @param argv - Array of command-line arguments (typically process.argv)
19
+ * @param optionsSchema - Optional Zod schema to determine boolean flags and option types
20
+ * @returns Parsed command (space-separated if multi-word), options, and positional arguments
21
+ */
22
+ parse(argv: string[], optionsSchema?: ZodObject): ParsedCliArgs {
23
+ // Skip first two args (node and script path)
24
+ const args = argv.slice(2)
25
+
26
+ if (args.length === 0) {
27
+ throw new Error('[Navios Commander] No command provided')
28
+ }
29
+
30
+ // Extract boolean field names from schema for accurate parsing
31
+ const booleanFields = optionsSchema
32
+ ? this.extractBooleanFields(optionsSchema)
33
+ : new Set<string>()
34
+
35
+ // Collect command words until we hit an argument that starts with '-' or '--'
36
+ const commandParts: string[] = []
37
+ let i = 0
38
+ while (i < args.length && !args[i].startsWith('-')) {
39
+ commandParts.push(args[i])
40
+ i++
41
+ }
42
+
43
+ if (commandParts.length === 0) {
44
+ throw new Error('[Navios Commander] No command provided')
45
+ }
46
+
47
+ const command = commandParts.join(' ')
48
+ const options: Record<string, any> = {}
49
+ const positionals: string[] = []
50
+ while (i < args.length) {
51
+ const arg = args[i]
52
+
53
+ if (arg.startsWith('--')) {
54
+ // Long option format: --key=value or --key value
55
+ const key = arg.slice(2)
56
+ const equalIndex = key.indexOf('=')
57
+
58
+ if (equalIndex !== -1) {
59
+ // Format: --key=value
60
+ const optionName = key.slice(0, equalIndex)
61
+ const optionValue = key.slice(equalIndex + 1)
62
+ options[this.camelCase(optionName)] = this.parseValue(optionValue)
63
+ i++
64
+ } else {
65
+ // Format: --key value or --boolean-flag
66
+ const camelCaseKey = this.camelCase(key)
67
+ const isBoolean =
68
+ booleanFields.has(camelCaseKey) || booleanFields.has(key)
69
+ const nextArg = args[i + 1]
70
+
71
+ if (isBoolean) {
72
+ // Known boolean flag from schema
73
+ options[camelCaseKey] = true
74
+ i++
75
+ } else if (nextArg && !nextArg.startsWith('-')) {
76
+ // Has a value
77
+ options[camelCaseKey] = this.parseValue(nextArg)
78
+ i += 2
79
+ } else {
80
+ // Assume boolean flag
81
+ options[camelCaseKey] = true
82
+ i++
83
+ }
84
+ }
85
+ } else if (arg.startsWith('-') && arg.length > 1 && arg !== '-') {
86
+ // Short option format: -k value or -abc (multiple flags)
87
+ const flags = arg.slice(1)
88
+
89
+ if (flags.length === 1) {
90
+ // Single short flag: -k value or -k
91
+ const isBoolean = booleanFields.has(flags)
92
+ const nextArg = args[i + 1]
93
+
94
+ if (isBoolean) {
95
+ // Known boolean flag from schema
96
+ options[flags] = true
97
+ i++
98
+ } else if (nextArg && !nextArg.startsWith('-')) {
99
+ options[flags] = this.parseValue(nextArg)
100
+ i += 2
101
+ } else {
102
+ options[flags] = true
103
+ i++
104
+ }
105
+ } else {
106
+ // Multiple short flags: -abc -> {a: true, b: true, c: true}
107
+ for (const flag of flags) {
108
+ options[flag] = true
109
+ }
110
+ i++
111
+ }
112
+ } else {
113
+ // Positional argument
114
+ positionals.push(arg)
115
+ i++
116
+ }
117
+ }
118
+
119
+ return {
120
+ command,
121
+ options,
122
+ positionals,
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Converts kebab-case to camelCase
128
+ */
129
+ private camelCase(str: string): string {
130
+ return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
131
+ }
132
+
133
+ /**
134
+ * Attempts to parse string values into appropriate types
135
+ */
136
+ private parseValue(value: string): any {
137
+ // Check for boolean
138
+ if (value === 'true') return true
139
+ if (value === 'false') return false
140
+
141
+ // Check for null/undefined
142
+ if (value === 'null') return null
143
+ if (value === 'undefined') return undefined
144
+
145
+ // Check for number
146
+ if (/^-?\d+$/.test(value)) {
147
+ return parseInt(value, 10)
148
+ }
149
+ if (/^-?\d+\.\d+$/.test(value)) {
150
+ return parseFloat(value)
151
+ }
152
+
153
+ // Check for JSON
154
+ if (
155
+ (value.startsWith('{') && value.endsWith('}')) ||
156
+ (value.startsWith('[') && value.endsWith(']'))
157
+ ) {
158
+ try {
159
+ return JSON.parse(value)
160
+ } catch {
161
+ // If parsing fails, return as string
162
+ return value
163
+ }
164
+ }
165
+
166
+ // Return as string
167
+ return value
168
+ }
169
+
170
+ /**
171
+ * Extracts boolean field names from a Zod schema
172
+ * Handles ZodObject, ZodOptional, and ZodDefault wrappers
173
+ */
174
+ private extractBooleanFields(schema: ZodObject): Set<string> {
175
+ const booleanFields = new Set<string>()
176
+
177
+ try {
178
+ // Check if schema has _def.typeName (Zod schema structure)
179
+ const typeName = schema.def.type
180
+
181
+ if (typeName === 'object') {
182
+ // Extract shape from ZodObject
183
+ const shape = schema.def.shape
184
+
185
+ if (shape && typeof shape === 'object') {
186
+ for (const [key, fieldSchema] of Object.entries(shape)) {
187
+ if (this.isSchemaBoolean(fieldSchema as any)) {
188
+ booleanFields.add(key)
189
+ }
190
+ }
191
+ }
192
+ }
193
+ } catch {
194
+ // Silently fail if schema introspection fails
195
+ }
196
+
197
+ return booleanFields
198
+ }
199
+
200
+ /**
201
+ * Checks if a Zod schema represents a boolean type
202
+ * Unwraps ZodOptional and ZodDefault
203
+ */
204
+ private isSchemaBoolean(schema: ZodType): boolean {
205
+ try {
206
+ let currentSchema = schema
207
+ const typeName = currentSchema.def.type
208
+
209
+ // Unwrap ZodOptional and ZodDefault
210
+ if (typeName === 'optional' || typeName === 'default') {
211
+ currentSchema = (currentSchema as any)?._def?.innerType || currentSchema
212
+ }
213
+
214
+ const innerTypeName = currentSchema.def.type
215
+ return innerTypeName === 'boolean'
216
+ } catch {
217
+ return false
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Formats help text for available commands
223
+ */
224
+ formatCommandList(commands: Array<{ path: string; class: any }>): string {
225
+ const lines = ['Available commands:', '']
226
+ for (const { path } of commands) {
227
+ lines.push(` ${path}`)
228
+ }
229
+ return lines.join('\n')
230
+ }
231
+ }
@@ -0,0 +1,2 @@
1
+ export * from './module-loader.service.mjs'
2
+ export * from './cli-parser.service.mjs'