@pikku/inspector 0.9.5 → 0.10.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 (133) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/add/add-channel.d.ts +17 -0
  3. package/dist/{add-channel.js → add/add-channel.js} +60 -34
  4. package/dist/add/add-cli.d.ts +9 -0
  5. package/dist/add/add-cli.js +566 -0
  6. package/dist/{add-file-extends-core-type.d.ts → add/add-file-extends-core-type.d.ts} +2 -2
  7. package/dist/{add-file-extends-core-type.js → add/add-file-extends-core-type.js} +17 -4
  8. package/dist/{add-file-with-config.d.ts → add/add-file-with-config.d.ts} +1 -1
  9. package/dist/{add-file-with-config.js → add/add-file-with-config.js} +1 -1
  10. package/dist/{add-file-with-factory.d.ts → add/add-file-with-factory.d.ts} +2 -2
  11. package/dist/{add-file-with-factory.js → add/add-file-with-factory.js} +38 -5
  12. package/dist/add/add-functions.d.ts +6 -0
  13. package/dist/{add-functions.js → add/add-functions.js} +77 -10
  14. package/dist/{add-http-route.d.ts → add/add-http-route.d.ts} +2 -3
  15. package/dist/{add-http-route.js → add/add-http-route.js} +26 -13
  16. package/dist/add/add-mcp-prompt.d.ts +2 -0
  17. package/dist/add/add-mcp-prompt.js +74 -0
  18. package/dist/add/add-mcp-resource.d.ts +2 -0
  19. package/dist/add/add-mcp-resource.js +84 -0
  20. package/dist/add/add-mcp-tool.d.ts +2 -0
  21. package/dist/add/add-mcp-tool.js +80 -0
  22. package/dist/add/add-middleware.d.ts +5 -0
  23. package/dist/add/add-middleware.js +290 -0
  24. package/dist/add/add-permission.d.ts +5 -0
  25. package/dist/add/add-permission.js +292 -0
  26. package/dist/add/add-queue-worker.d.ts +2 -0
  27. package/dist/add/add-queue-worker.js +52 -0
  28. package/dist/{add-rpc-invocations.d.ts → add/add-rpc-invocations.d.ts} +1 -1
  29. package/dist/add/add-schedule.d.ts +2 -0
  30. package/dist/{add-schedule.js → add/add-schedule.js} +16 -11
  31. package/dist/error-codes.d.ts +35 -0
  32. package/dist/error-codes.js +40 -0
  33. package/dist/index.d.ts +6 -0
  34. package/dist/index.js +4 -0
  35. package/dist/inspector.d.ts +2 -3
  36. package/dist/inspector.js +38 -8
  37. package/dist/types.d.ts +108 -1
  38. package/dist/utils/ensure-function-metadata.d.ts +6 -0
  39. package/dist/utils/ensure-function-metadata.js +18 -0
  40. package/dist/utils/extract-function-name.d.ts +31 -0
  41. package/dist/{utils.js → utils/extract-function-name.js} +35 -149
  42. package/dist/utils/extract-services.d.ts +6 -0
  43. package/dist/utils/extract-services.js +29 -0
  44. package/dist/utils/filter-inspector-state.d.ts +6 -0
  45. package/dist/utils/filter-inspector-state.js +382 -0
  46. package/dist/utils/filter-utils.d.ts +19 -0
  47. package/dist/utils/filter-utils.js +109 -0
  48. package/dist/utils/find-root-dir.d.ts +23 -0
  49. package/dist/utils/find-root-dir.js +55 -0
  50. package/dist/utils/get-files-and-methods.d.ts +22 -0
  51. package/dist/utils/get-files-and-methods.js +61 -0
  52. package/dist/utils/get-property-value.d.ts +12 -0
  53. package/dist/{get-property-value.js → utils/get-property-value.js} +20 -0
  54. package/dist/utils/middleware.d.ts +39 -0
  55. package/dist/utils/middleware.js +157 -0
  56. package/dist/utils/permissions.d.ts +43 -0
  57. package/dist/utils/permissions.js +178 -0
  58. package/dist/utils/post-process.d.ts +16 -0
  59. package/dist/utils/post-process.js +132 -0
  60. package/dist/utils/serialize-inspector-state.d.ts +179 -0
  61. package/dist/utils/serialize-inspector-state.js +170 -0
  62. package/dist/utils/type-utils.d.ts +3 -0
  63. package/dist/utils/type-utils.js +50 -0
  64. package/dist/visit.d.ts +3 -3
  65. package/dist/visit.js +35 -31
  66. package/package.json +5 -6
  67. package/src/{add-channel.ts → add/add-channel.ts} +108 -56
  68. package/src/add/add-cli.ts +822 -0
  69. package/src/{add-file-extends-core-type.ts → add/add-file-extends-core-type.ts} +23 -5
  70. package/src/{add-file-with-config.ts → add/add-file-with-config.ts} +2 -2
  71. package/src/{add-file-with-factory.ts → add/add-file-with-factory.ts} +49 -6
  72. package/src/{add-functions.ts → add/add-functions.ts} +89 -19
  73. package/src/{add-http-route.ts → add/add-http-route.ts} +66 -32
  74. package/src/add/add-mcp-prompt.ts +128 -0
  75. package/src/add/add-mcp-prompt.ts.tmp +0 -0
  76. package/src/add/add-mcp-resource.ts +145 -0
  77. package/src/add/add-mcp-resource.ts.tmp +0 -0
  78. package/src/add/add-mcp-tool.ts +137 -0
  79. package/src/add/add-middleware.ts +385 -0
  80. package/src/add/add-permission.ts +391 -0
  81. package/src/add/add-queue-worker.ts +92 -0
  82. package/src/{add-rpc-invocations.ts → add/add-rpc-invocations.ts} +1 -1
  83. package/src/{add-schedule.ts → add/add-schedule.ts} +30 -28
  84. package/src/error-codes.ts +43 -0
  85. package/src/index.ts +12 -0
  86. package/src/inspector.ts +41 -17
  87. package/src/types.ts +128 -1
  88. package/src/utils/ensure-function-metadata.ts +24 -0
  89. package/src/{utils.ts → utils/extract-function-name.ts} +44 -206
  90. package/src/utils/extract-services.ts +35 -0
  91. package/src/utils/filter-inspector-state.test.ts +1433 -0
  92. package/src/utils/filter-inspector-state.ts +526 -0
  93. package/src/{utils.test.ts → utils/filter-utils.test.ts} +351 -2
  94. package/src/utils/filter-utils.ts +152 -0
  95. package/src/utils/find-root-dir.ts +68 -0
  96. package/src/utils/get-files-and-methods.ts +151 -0
  97. package/src/{get-property-value.ts → utils/get-property-value.ts} +27 -0
  98. package/src/utils/middleware.ts +241 -0
  99. package/src/utils/permissions.test.ts +327 -0
  100. package/src/utils/permissions.ts +262 -0
  101. package/src/utils/post-process.ts +178 -0
  102. package/src/utils/serialize-inspector-state.ts +375 -0
  103. package/src/utils/test-data/inspector-state.json +1680 -0
  104. package/src/utils/type-utils.ts +74 -0
  105. package/src/visit.ts +50 -34
  106. package/tsconfig.tsbuildinfo +1 -1
  107. package/dist/add-channel.d.ts +0 -13
  108. package/dist/add-functions.d.ts +0 -7
  109. package/dist/add-mcp-prompt.d.ts +0 -3
  110. package/dist/add-mcp-prompt.js +0 -61
  111. package/dist/add-mcp-resource.d.ts +0 -3
  112. package/dist/add-mcp-resource.js +0 -68
  113. package/dist/add-mcp-tool.d.ts +0 -3
  114. package/dist/add-mcp-tool.js +0 -64
  115. package/dist/add-middleware.d.ts +0 -7
  116. package/dist/add-middleware.js +0 -35
  117. package/dist/add-permission.d.ts +0 -7
  118. package/dist/add-permission.js +0 -35
  119. package/dist/add-queue-worker.d.ts +0 -3
  120. package/dist/add-queue-worker.js +0 -48
  121. package/dist/add-schedule.d.ts +0 -3
  122. package/dist/get-property-value.d.ts +0 -3
  123. package/dist/utils.d.ts +0 -39
  124. package/src/add-mcp-prompt.ts +0 -104
  125. package/src/add-mcp-resource.ts +0 -116
  126. package/src/add-mcp-tool.ts +0 -107
  127. package/src/add-middleware.ts +0 -51
  128. package/src/add-permission.ts +0 -53
  129. package/src/add-queue-worker.ts +0 -92
  130. /package/dist/{add-rpc-invocations.js → add/add-rpc-invocations.js} +0 -0
  131. /package/dist/{does-type-extend-core-type.d.ts → utils/does-type-extend-core-type.d.ts} +0 -0
  132. /package/dist/{does-type-extend-core-type.js → utils/does-type-extend-core-type.js} +0 -0
  133. /package/src/{does-type-extend-core-type.ts → utils/does-type-extend-core-type.ts} +0 -0
@@ -0,0 +1,822 @@
1
+ import ts, { TypeChecker } from 'typescript'
2
+ import {
3
+ AddWiring,
4
+ InspectorLogger,
5
+ InspectorOptions,
6
+ InspectorState,
7
+ } from '../types.js'
8
+ import { CLIProgramMeta, CLICommandMeta } from '@pikku/core/cli'
9
+ import { extractFunctionName } from '../utils/extract-function-name.js'
10
+ import { resolveMiddleware } from '../utils/middleware.js'
11
+ import { extractWireNames } from '../utils/post-process.js'
12
+ import { getPropertyValue } from '../utils/get-property-value.js'
13
+
14
+ // Track if we've warned about missing Config type to avoid duplicate warnings
15
+ const configTypeWarningShown = new Set<string>()
16
+
17
+ /**
18
+ * Adds CLI command metadata to the inspector state
19
+ */
20
+ export const addCLI: AddWiring = (
21
+ logger,
22
+ node,
23
+ typeChecker,
24
+ inspectorState,
25
+ options
26
+ ) => {
27
+ if (!ts.isCallExpression(node)) return
28
+ // Check if this is a wireCLI call
29
+ if (!node || !node.expression) {
30
+ return
31
+ }
32
+ const expression = node.expression
33
+ if (!ts.isIdentifier(expression) || expression.text !== 'wireCLI') {
34
+ return
35
+ }
36
+
37
+ // Get the argument (should be an object literal)
38
+ if (node.arguments.length !== 1) {
39
+ return
40
+ }
41
+
42
+ const arg = node.arguments[0]
43
+ if (!ts.isObjectLiteralExpression(arg)) {
44
+ return
45
+ }
46
+
47
+ const sourceFile = node.getSourceFile()
48
+
49
+ // Add to files set
50
+ inspectorState.cli.files.add(sourceFile.fileName)
51
+
52
+ // Process the CLI configuration
53
+ const cliConfig = processCLIConfig(
54
+ logger,
55
+ arg,
56
+ sourceFile,
57
+ typeChecker,
58
+ inspectorState,
59
+ options
60
+ )
61
+
62
+ if (!cliConfig) {
63
+ return
64
+ }
65
+
66
+ // Add this program to the CLI metadata
67
+ inspectorState.cli.meta.programs[cliConfig.programName] =
68
+ cliConfig.programMeta
69
+ }
70
+
71
+ /**
72
+ * Processes a CLI configuration object
73
+ */
74
+ function processCLIConfig(
75
+ logger: InspectorLogger,
76
+ node: ts.ObjectLiteralExpression,
77
+ sourceFile: ts.SourceFile,
78
+ typeChecker: TypeChecker,
79
+ inspectorState: InspectorState,
80
+ options: InspectorOptions
81
+ ): { programName: string; programMeta: CLIProgramMeta } | null {
82
+ let programName = ''
83
+ let programTags: string[] | undefined
84
+ const programMeta: CLIProgramMeta = {
85
+ program: '',
86
+ commands: {},
87
+ options: {},
88
+ }
89
+
90
+ // First pass: extract program name and tags
91
+ for (const prop of node.properties) {
92
+ if (!ts.isPropertyAssignment(prop)) continue
93
+ if (!ts.isIdentifier(prop.name)) continue
94
+
95
+ const propName = prop.name.text
96
+
97
+ if (propName === 'program' && ts.isStringLiteral(prop.initializer)) {
98
+ programName = prop.initializer.text
99
+ programMeta.program = programName
100
+ } else if (propName === 'tags') {
101
+ programTags = (getPropertyValue(node, 'tags') as string[]) || undefined
102
+ }
103
+ }
104
+
105
+ if (!programName) {
106
+ return null
107
+ }
108
+
109
+ // Second pass: process other properties with program tags available
110
+ for (const prop of node.properties) {
111
+ if (!ts.isPropertyAssignment(prop)) continue
112
+ if (!ts.isIdentifier(prop.name)) continue
113
+
114
+ const propName = prop.name.text
115
+
116
+ switch (propName) {
117
+ case 'program':
118
+ case 'tags':
119
+ // Already handled in first pass
120
+ break
121
+
122
+ case 'commands':
123
+ if (ts.isObjectLiteralExpression(prop.initializer)) {
124
+ programMeta.commands = processCommands(
125
+ logger,
126
+ prop.initializer,
127
+ sourceFile,
128
+ typeChecker,
129
+ programName,
130
+ inspectorState,
131
+ options,
132
+ programTags
133
+ )
134
+ }
135
+ break
136
+
137
+ case 'options':
138
+ if (ts.isObjectLiteralExpression(prop.initializer)) {
139
+ programMeta.options = processOptions(
140
+ logger,
141
+ prop.initializer,
142
+ typeChecker,
143
+ inspectorState,
144
+ options
145
+ )
146
+ }
147
+ break
148
+
149
+ case 'render':
150
+ // Extract the actual renderer function name
151
+ programMeta.defaultRenderName = extractFunctionName(
152
+ prop.initializer,
153
+ typeChecker,
154
+ inspectorState.rootDir
155
+ ).pikkuFuncName
156
+ break
157
+ }
158
+ }
159
+
160
+ return { programName, programMeta }
161
+ }
162
+
163
+ /**
164
+ * Processes the commands object
165
+ */
166
+ function processCommands(
167
+ logger: InspectorLogger,
168
+ node: ts.ObjectLiteralExpression,
169
+ sourceFile: ts.SourceFile,
170
+ typeChecker: TypeChecker,
171
+ programName: string,
172
+ inspectorState: InspectorState,
173
+ options: InspectorOptions,
174
+ programTags?: string[]
175
+ ): Record<string, CLICommandMeta> {
176
+ const commands: Record<string, CLICommandMeta> = {}
177
+ let defaultCommandName: string | null = null
178
+
179
+ for (const prop of node.properties) {
180
+ if (!ts.isPropertyAssignment(prop)) continue
181
+
182
+ const commandName = getPropertyName(prop)
183
+ if (!commandName) continue
184
+
185
+ const commandMeta = processCommand(
186
+ logger,
187
+ inspectorState,
188
+ options,
189
+ commandName,
190
+ prop.initializer,
191
+ sourceFile,
192
+ typeChecker,
193
+ programName,
194
+ [],
195
+ programTags
196
+ )
197
+
198
+ if (commandMeta) {
199
+ commands[commandName] = commandMeta
200
+
201
+ // Validate only one default command
202
+ if (commandMeta.isDefault) {
203
+ if (defaultCommandName !== null) {
204
+ const position = prop.getStart(sourceFile)
205
+ const { line, character } =
206
+ sourceFile.getLineAndCharacterOfPosition(position)
207
+
208
+ throw new Error(
209
+ `Multiple default commands found in CLI program "${programName}" at ${sourceFile.fileName}:${line + 1}:${character + 1}.\n` +
210
+ `Commands "${defaultCommandName}" and "${commandName}" are both marked as default.\n` +
211
+ `Only one command can be marked as default per program.`
212
+ )
213
+ }
214
+ defaultCommandName = commandName
215
+ }
216
+ }
217
+ }
218
+
219
+ return commands
220
+ }
221
+
222
+ /**
223
+ * Processes a single command
224
+ */
225
+ function processCommand(
226
+ logger: InspectorLogger,
227
+ inspectorState: InspectorState,
228
+ options: InspectorOptions,
229
+ name: string,
230
+ node: ts.Expression,
231
+ sourceFile: ts.SourceFile,
232
+ typeChecker: TypeChecker,
233
+ programName: string,
234
+ parentPath: string[] = [],
235
+ programTags?: string[]
236
+ ): CLICommandMeta | null {
237
+ const fullPath = [...parentPath, name]
238
+
239
+ // Handle shorthand (just a function)
240
+ if (
241
+ ts.isIdentifier(node) ||
242
+ ts.isArrowFunction(node) ||
243
+ ts.isFunctionExpression(node)
244
+ ) {
245
+ return {
246
+ pikkuFuncName: extractFunctionName(
247
+ node,
248
+ typeChecker,
249
+ inspectorState.rootDir
250
+ ).pikkuFuncName,
251
+ positionals: [],
252
+ options: {},
253
+ }
254
+ }
255
+
256
+ // Handle pikkuCLICommand calls
257
+ if (ts.isCallExpression(node)) {
258
+ // Check if it's a pikkuCLICommand call
259
+ if (
260
+ ts.isIdentifier(node.expression) &&
261
+ node.expression.text === 'pikkuCLICommand' &&
262
+ node.arguments.length > 0 &&
263
+ ts.isObjectLiteralExpression(node.arguments[0])
264
+ ) {
265
+ // Process the object literal argument
266
+ return processCommand(
267
+ logger,
268
+ inspectorState,
269
+ options,
270
+ name,
271
+ node.arguments[0],
272
+ sourceFile,
273
+ typeChecker,
274
+ programName,
275
+ parentPath,
276
+ programTags
277
+ )
278
+ }
279
+ return null
280
+ }
281
+
282
+ // Handle full command object
283
+ if (!ts.isObjectLiteralExpression(node)) {
284
+ return null
285
+ }
286
+
287
+ const meta: CLICommandMeta = {
288
+ pikkuFuncName: '',
289
+ positionals: [],
290
+ options: {},
291
+ }
292
+
293
+ // First pass: extract pikkuFuncName and tags so we can use them when processing options/middleware
294
+ let pikkuFuncName: string | undefined
295
+ let optionsNode: ts.ObjectLiteralExpression | undefined
296
+ let tags: string[] | undefined
297
+
298
+ for (const prop of node.properties) {
299
+ if (!ts.isPropertyAssignment(prop)) continue
300
+ if (!ts.isIdentifier(prop.name)) continue
301
+
302
+ const propName = prop.name.text
303
+
304
+ if (propName === 'func') {
305
+ pikkuFuncName = extractFunctionName(
306
+ prop.initializer,
307
+ typeChecker,
308
+ inspectorState.rootDir
309
+ ).pikkuFuncName
310
+ meta.pikkuFuncName = pikkuFuncName
311
+ } else if (
312
+ propName === 'options' &&
313
+ ts.isObjectLiteralExpression(prop.initializer)
314
+ ) {
315
+ optionsNode = prop.initializer
316
+ } else if (propName === 'tags') {
317
+ tags = (getPropertyValue(node, 'tags') as string[]) || undefined
318
+ }
319
+ }
320
+
321
+ // Merge program-level tags with command-level tags
322
+ const allTags = [...(programTags || []), ...(tags || [])]
323
+
324
+ // Resolve middleware
325
+ const middleware = resolveMiddleware(
326
+ inspectorState,
327
+ node,
328
+ allTags.length > 0 ? allTags : undefined,
329
+ typeChecker
330
+ )
331
+ if (middleware) {
332
+ meta.middleware = middleware
333
+ }
334
+
335
+ // Add merged tags to metadata
336
+ if (allTags.length > 0) {
337
+ meta.tags = allTags
338
+ }
339
+
340
+ // Second pass: process all properties
341
+ for (const prop of node.properties) {
342
+ if (!ts.isPropertyAssignment(prop)) continue
343
+ if (!ts.isIdentifier(prop.name)) continue
344
+
345
+ const propName = prop.name.text
346
+
347
+ switch (propName) {
348
+ case 'parameters':
349
+ if (ts.isStringLiteral(prop.initializer)) {
350
+ meta.parameters = prop.initializer.text
351
+ meta.positionals = parseCommandPattern(prop.initializer.text)
352
+ }
353
+ break
354
+
355
+ case 'description':
356
+ if (ts.isStringLiteral(prop.initializer)) {
357
+ meta.description = prop.initializer.text
358
+ }
359
+ break
360
+
361
+ case 'func':
362
+ // Already handled in first pass
363
+ break
364
+
365
+ case 'render':
366
+ meta.renderName = extractFunctionName(
367
+ prop.initializer,
368
+ typeChecker,
369
+ inspectorState.rootDir
370
+ ).pikkuFuncName
371
+ break
372
+
373
+ case 'options':
374
+ // Process with pikkuFuncName from first pass
375
+ if (optionsNode) {
376
+ meta.options = processOptions(
377
+ logger,
378
+ optionsNode,
379
+ typeChecker,
380
+ inspectorState,
381
+ options,
382
+ pikkuFuncName
383
+ )
384
+ }
385
+ break
386
+
387
+ case 'subcommands':
388
+ if (ts.isObjectLiteralExpression(prop.initializer)) {
389
+ meta.subcommands = {}
390
+ for (const subProp of prop.initializer.properties) {
391
+ if (!ts.isPropertyAssignment(subProp)) continue
392
+
393
+ const subName = getPropertyName(subProp)
394
+ if (!subName) continue
395
+
396
+ const subCommand = processCommand(
397
+ logger,
398
+ inspectorState,
399
+ options,
400
+ subName,
401
+ subProp.initializer,
402
+ sourceFile,
403
+ typeChecker,
404
+ programName,
405
+ fullPath,
406
+ programTags
407
+ )
408
+
409
+ if (subCommand) {
410
+ meta.subcommands[subName] = subCommand
411
+ }
412
+ }
413
+ }
414
+ break
415
+
416
+ case 'isDefault':
417
+ if (
418
+ prop.initializer.kind === ts.SyntaxKind.TrueKeyword ||
419
+ prop.initializer.kind === ts.SyntaxKind.FalseKeyword
420
+ ) {
421
+ meta.isDefault = prop.initializer.kind === ts.SyntaxKind.TrueKeyword
422
+ }
423
+ break
424
+ }
425
+ }
426
+
427
+ // --- track used functions/middleware for service aggregation ---
428
+ inspectorState.serviceAggregation.usedFunctions.add(meta.pikkuFuncName)
429
+ extractWireNames(meta.middleware).forEach((name) =>
430
+ inspectorState.serviceAggregation.usedMiddleware.add(name)
431
+ )
432
+ // Note: subcommands are tracked recursively when they're processed
433
+
434
+ return meta
435
+ }
436
+
437
+ /**
438
+ * Processes CLI options and extracts enum values from function input types
439
+ */
440
+ function processOptions(
441
+ logger: InspectorLogger,
442
+ node: ts.ObjectLiteralExpression,
443
+ typeChecker: TypeChecker,
444
+ inspectorState: InspectorState,
445
+ inspectorOptions: InspectorOptions,
446
+ pikkuFuncName?: string
447
+ ): Record<string, any> {
448
+ const options: Record<string, any> = {}
449
+
450
+ for (const prop of node.properties) {
451
+ if (!ts.isPropertyAssignment(prop)) continue
452
+
453
+ const optionName = getPropertyName(prop)
454
+ if (!optionName) continue
455
+
456
+ if (ts.isObjectLiteralExpression(prop.initializer)) {
457
+ const option: any = {}
458
+ let manualChoices: string[] | undefined
459
+
460
+ for (const optProp of prop.initializer.properties) {
461
+ if (!ts.isPropertyAssignment(optProp)) continue
462
+ if (!ts.isIdentifier(optProp.name)) continue
463
+
464
+ const optPropName = optProp.name.text
465
+
466
+ switch (optPropName) {
467
+ case 'description':
468
+ if (ts.isStringLiteral(optProp.initializer)) {
469
+ option.description = optProp.initializer.text
470
+ }
471
+ break
472
+
473
+ case 'short':
474
+ if (ts.isStringLiteral(optProp.initializer)) {
475
+ option.short = optProp.initializer.text
476
+ }
477
+ break
478
+
479
+ case 'default':
480
+ // Extract default value from expression
481
+ if (ts.isStringLiteral(optProp.initializer)) {
482
+ option.default = optProp.initializer.text
483
+ } else if (ts.isNumericLiteral(optProp.initializer)) {
484
+ option.default = parseFloat(optProp.initializer.text)
485
+ } else if (optProp.initializer.kind === ts.SyntaxKind.TrueKeyword) {
486
+ option.default = true
487
+ } else if (
488
+ optProp.initializer.kind === ts.SyntaxKind.FalseKeyword
489
+ ) {
490
+ option.default = false
491
+ }
492
+ break
493
+
494
+ case 'choices':
495
+ // Extract manually specified choices
496
+ if (ts.isArrayLiteralExpression(optProp.initializer)) {
497
+ manualChoices = []
498
+ for (const element of optProp.initializer.elements) {
499
+ if (ts.isStringLiteral(element)) {
500
+ manualChoices.push(element.text)
501
+ }
502
+ }
503
+ }
504
+ break
505
+ }
506
+ }
507
+
508
+ // Extract enum values from the function input type if available
509
+ // Get the input type if we have a pikkuFuncName
510
+ let inputTypes: ts.Type[] | undefined
511
+ if (pikkuFuncName) {
512
+ inputTypes = inspectorState.typesLookup.get(pikkuFuncName)
513
+ }
514
+
515
+ let derivedChoices: string[] | null = null
516
+
517
+ if (inputTypes && inputTypes.length > 0) {
518
+ derivedChoices = extractEnumFromPropertyType(
519
+ inputTypes[0]!,
520
+ optionName,
521
+ typeChecker
522
+ )
523
+ } else {
524
+ // Fallback: try to extract from Config type
525
+ derivedChoices = extractEnumFromConfigType(
526
+ logger,
527
+ optionName,
528
+ typeChecker,
529
+ inspectorState,
530
+ inspectorOptions
531
+ )
532
+ }
533
+
534
+ // Validate and set choices
535
+ if (manualChoices && derivedChoices) {
536
+ // Both manual and derived choices exist - validate manual is subset of derived
537
+ const invalidChoices = manualChoices.filter(
538
+ (choice) => !derivedChoices!.includes(choice)
539
+ )
540
+
541
+ if (invalidChoices.length > 0) {
542
+ const sourceFile = node.getSourceFile()
543
+ const position = prop.getStart(sourceFile)
544
+ const { line, character } =
545
+ sourceFile.getLineAndCharacterOfPosition(position)
546
+
547
+ throw new Error(
548
+ `Invalid choices for option "${optionName}" at ${sourceFile.fileName}:${line + 1}:${character + 1}.\n` +
549
+ `The following choices are not valid according to the type: ${invalidChoices.join(', ')}.\n` +
550
+ `Valid choices from type: ${derivedChoices.join(', ')}.`
551
+ )
552
+ }
553
+
554
+ // Manual choices are valid - use them
555
+ option.choices = manualChoices
556
+ } else if (manualChoices) {
557
+ // Only manual choices - use them
558
+ option.choices = manualChoices
559
+ } else if (derivedChoices) {
560
+ // Only derived choices - use them
561
+ option.choices = derivedChoices
562
+ }
563
+
564
+ options[optionName] = option
565
+ }
566
+ }
567
+
568
+ return options
569
+ }
570
+
571
+ /**
572
+ * Extracts enum values from a property of a type
573
+ * Handles both union types ('a' | 'b') and TypeScript enums
574
+ */
575
+ function extractEnumFromPropertyType(
576
+ type: ts.Type,
577
+ propertyName: string,
578
+ typeChecker: TypeChecker
579
+ ): string[] | null {
580
+ // Get the property from the type
581
+ const property = type.getProperty(propertyName)
582
+ if (!property) {
583
+ return null
584
+ }
585
+
586
+ // Get the type of the property
587
+ const propertyType = typeChecker.getTypeOfSymbolAtLocation(
588
+ property,
589
+ property.valueDeclaration!
590
+ )
591
+
592
+ const enumValues: string[] = []
593
+
594
+ // Check if it's a union type (e.g., 'debug' | 'info' | 'warn')
595
+ if (propertyType.isUnion()) {
596
+ for (const unionType of propertyType.types) {
597
+ // Check if it's a string literal type
598
+ if (unionType.flags & ts.TypeFlags.StringLiteral) {
599
+ const literalType = unionType as ts.StringLiteralType
600
+ enumValues.push(literalType.value)
601
+ }
602
+ // Check if it's an enum member (could be string or number enum)
603
+ else if (unionType.flags & ts.TypeFlags.EnumLiteral) {
604
+ const enumLiteralType = unionType as ts.LiteralType
605
+ // For string enums, use the value directly
606
+ if (typeof enumLiteralType.value === 'string') {
607
+ enumValues.push(enumLiteralType.value)
608
+ }
609
+ // For numeric enums, get the symbol name (e.g., "Debug", "Info")
610
+ else {
611
+ const symbol = (unionType as any).symbol
612
+ if (symbol && symbol.name) {
613
+ enumValues.push(symbol.name)
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+ // Check if it's an enum type directly
620
+ else if (propertyType.flags & ts.TypeFlags.Enum) {
621
+ const symbol = propertyType.getSymbol()
622
+ if (symbol && symbol.exports) {
623
+ symbol.exports.forEach((member) => {
624
+ const memberType = typeChecker.getTypeOfSymbolAtLocation(
625
+ member,
626
+ member.valueDeclaration!
627
+ )
628
+ if (memberType.flags & ts.TypeFlags.StringLiteral) {
629
+ const literalType = memberType as ts.StringLiteralType
630
+ enumValues.push(literalType.value)
631
+ } else if (typeof (memberType as any).value === 'string') {
632
+ enumValues.push((memberType as any).value)
633
+ }
634
+ })
635
+ }
636
+ }
637
+ // Check if it's an enum literal type
638
+ else if (propertyType.flags & ts.TypeFlags.EnumLiteral) {
639
+ const enumLiteralType = propertyType as ts.LiteralType
640
+ if (typeof enumLiteralType.value === 'string') {
641
+ enumValues.push(enumLiteralType.value)
642
+ }
643
+ }
644
+
645
+ return enumValues.length > 0 ? enumValues : null
646
+ }
647
+
648
+ /**
649
+ * Extracts enum values from the Config type
650
+ */
651
+ function extractEnumFromConfigType(
652
+ logger: InspectorLogger,
653
+ propertyName: string,
654
+ typeChecker: TypeChecker,
655
+ inspectorState: InspectorState,
656
+ _inspectorOptions: InspectorOptions
657
+ ): string[] | null {
658
+ // Look for Config type in typesLookup
659
+ const configTypes = inspectorState.typesLookup.get('Config')
660
+ if (!configTypes || configTypes.length === 0) {
661
+ // Only warn once per CLI file to avoid spamming logs
662
+ if (!configTypeWarningShown.has('missing-config-type')) {
663
+ configTypeWarningShown.add('missing-config-type')
664
+ logger.warn(
665
+ `Could not find Config type in typesLookup. ` +
666
+ `Make sure you have a Config interface extending CoreConfig in your codebase.`
667
+ )
668
+ }
669
+ return null
670
+ }
671
+
672
+ // Use the first Config type (there should only be one)
673
+ const configType = configTypes[0]
674
+ if (!configType) {
675
+ if (!configTypeWarningShown.has('undefined-config-type')) {
676
+ configTypeWarningShown.add('undefined-config-type')
677
+ logger.warn(`Config type is undefined in typesLookup.`)
678
+ }
679
+ return null
680
+ }
681
+
682
+ // Extract enum from the property
683
+ return extractEnumFromPropertyType(configType, propertyName, typeChecker)
684
+ }
685
+
686
+ /**
687
+ * Gets the property name from a property assignment
688
+ */
689
+ function getPropertyName(prop: ts.PropertyAssignment): string | null {
690
+ if (ts.isIdentifier(prop.name)) {
691
+ return prop.name.text
692
+ }
693
+ if (ts.isStringLiteral(prop.name)) {
694
+ return prop.name.text
695
+ }
696
+ return null
697
+ }
698
+
699
+ /**
700
+ * Parses a parameters string to extract positional arguments
701
+ * Parameters format: "<env> [region] [files...]"
702
+ */
703
+ function parseCommandPattern(pattern: string): any[] {
704
+ const positionals: any[] = []
705
+
706
+ // Split by spaces to get all parameter definitions
707
+ const parts = pattern.split(' ').filter((p) => p.trim())
708
+
709
+ for (const part of parts) {
710
+ if (part.startsWith('<') && part.endsWith('>')) {
711
+ // Required positional
712
+ const name = part.slice(1, -1)
713
+ if (name.endsWith('...')) {
714
+ positionals.push({
715
+ name: name.slice(0, -3),
716
+ required: true,
717
+ variadic: true,
718
+ })
719
+ } else {
720
+ positionals.push({
721
+ name,
722
+ required: true,
723
+ })
724
+ }
725
+ } else if (part.startsWith('[') && part.endsWith(']')) {
726
+ // Optional positional
727
+ const name = part.slice(1, -1)
728
+ if (name.endsWith('...')) {
729
+ positionals.push({
730
+ name: name.slice(0, -3),
731
+ required: false,
732
+ variadic: true,
733
+ })
734
+ } else {
735
+ positionals.push({
736
+ name,
737
+ required: false,
738
+ })
739
+ }
740
+ } else if (part.trim()) {
741
+ // Found a literal word in the parameters pattern
742
+ throw new Error(
743
+ `Invalid parameters pattern '${pattern}': found literal word '${part}'. ` +
744
+ `Parameters should only contain <required> or [optional] arguments. ` +
745
+ `Example: "<env> [region]" or "<files...>"`
746
+ )
747
+ }
748
+ }
749
+
750
+ return positionals
751
+ }
752
+
753
+ /**
754
+ * Adds CLI renderer metadata to the inspector state
755
+ */
756
+ export const addCLIRenderers: AddWiring = (
757
+ logger,
758
+ node,
759
+ typeChecker,
760
+ inspectorState,
761
+ options
762
+ ) => {
763
+ if (!ts.isCallExpression(node)) return
764
+
765
+ const { expression, arguments: args, typeArguments } = node
766
+
767
+ // Only handle pikkuCLIRender calls
768
+ if (!ts.isIdentifier(expression) || expression.text !== 'pikkuCLIRender') {
769
+ return
770
+ }
771
+
772
+ if (args.length === 0) return
773
+
774
+ // Extract renderer name
775
+ const { pikkuFuncName, exportedName } = extractFunctionName(
776
+ node,
777
+ typeChecker,
778
+ inspectorState.rootDir
779
+ )
780
+
781
+ // Get the source file path
782
+ const sourceFile = node.getSourceFile()
783
+ const filePath = sourceFile.fileName
784
+
785
+ // Extract services from type parameters (second type param is Services)
786
+ const services: { optimized: boolean; services: string[] } = {
787
+ optimized: true,
788
+ services: [],
789
+ }
790
+
791
+ if (typeArguments && typeArguments.length >= 2) {
792
+ // Second type parameter is the Services type
793
+ const servicesTypeNode = typeArguments[1]
794
+ if (servicesTypeNode) {
795
+ const servicesType = typeChecker.getTypeFromTypeNode(servicesTypeNode)
796
+
797
+ // Extract property names from the Services type
798
+ const properties = servicesType.getProperties()
799
+ for (const prop of properties) {
800
+ services.services.push(prop.getName())
801
+ }
802
+
803
+ // If no specific services found, it might be using the full services object
804
+ if (properties.length === 0) {
805
+ services.optimized = false
806
+ }
807
+ }
808
+ }
809
+
810
+ // Store renderer metadata
811
+ inspectorState.cli.meta.renderers[pikkuFuncName] = {
812
+ name: pikkuFuncName,
813
+ exportedName: exportedName ?? undefined,
814
+ services,
815
+ filePath,
816
+ }
817
+
818
+ // Add to files map if exported
819
+ if (exportedName) {
820
+ inspectorState.cli.files.add(filePath)
821
+ }
822
+ }