@pikku/inspector 0.12.22 → 0.12.24

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 (45) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/add/add-addon-bans.d.ts +7 -0
  3. package/dist/add/add-addon-bans.js +65 -0
  4. package/dist/add/add-auth.js +43 -0
  5. package/dist/add/add-channel.js +47 -6
  6. package/dist/add/add-cli.js +17 -0
  7. package/dist/add/add-http-route.d.ts +11 -1
  8. package/dist/add/add-http-route.js +37 -0
  9. package/dist/add/add-http-routes.d.ts +0 -3
  10. package/dist/add/add-http-routes.js +179 -36
  11. package/dist/error-codes.d.ts +3 -1
  12. package/dist/error-codes.js +3 -0
  13. package/dist/inspector.js +17 -5
  14. package/dist/types.d.ts +48 -1
  15. package/dist/utils/get-exported-variable-name.d.ts +2 -0
  16. package/dist/utils/get-exported-variable-name.js +34 -0
  17. package/dist/utils/load-addon-functions-meta.js +98 -0
  18. package/dist/utils/post-process.js +16 -3
  19. package/dist/utils/resolve-addon-package.js +3 -1
  20. package/dist/utils/resolve-ref-contract.d.ts +21 -0
  21. package/dist/utils/resolve-ref-contract.js +46 -0
  22. package/dist/utils/serialize-inspector-state.d.ts +1 -0
  23. package/dist/utils/serialize-inspector-state.js +9 -0
  24. package/dist/visit.js +24 -19
  25. package/package.json +1 -1
  26. package/src/add/add-addon-bans.ts +84 -0
  27. package/src/add/add-auth.test.ts +94 -0
  28. package/src/add/add-auth.ts +46 -0
  29. package/src/add/add-channel.ts +66 -7
  30. package/src/add/add-cli.ts +30 -0
  31. package/src/add/add-http-route.ts +75 -1
  32. package/src/add/add-http-routes.ts +283 -41
  33. package/src/add/addon-bans.test.ts +121 -0
  34. package/src/add/addon-contracts.test.ts +221 -0
  35. package/src/error-codes.ts +4 -0
  36. package/src/inspector.ts +17 -5
  37. package/src/types.ts +70 -1
  38. package/src/utils/get-exported-variable-name.ts +48 -0
  39. package/src/utils/load-addon-functions-meta.ts +164 -0
  40. package/src/utils/post-process.ts +17 -3
  41. package/src/utils/resolve-addon-package.ts +6 -1
  42. package/src/utils/resolve-ref-contract.ts +71 -0
  43. package/src/utils/serialize-inspector-state.ts +10 -0
  44. package/src/visit.ts +26 -19
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,21 @@
1
+ import * as ts from 'typescript';
2
+ export interface RefContractResolution<T> {
3
+ contract: T;
4
+ /**
5
+ * Optional basePath override supplied by the consumer via the second
6
+ * argument, e.g. refHTTP('ns:routes', { basePath: '/ext' }). When undefined
7
+ * the addon contract's own basePath is preserved.
8
+ */
9
+ basePath?: string;
10
+ }
11
+ /**
12
+ * Resolve a refHTTP / refChannel / refCLI call expression against the addon
13
+ * contracts already loaded (and namespaced) by loadAddonFunctionsMeta.
14
+ *
15
+ * The first string argument has the form 'namespace:contractName', mirroring
16
+ * how ref('namespace:fn') references an addon function. Detection is purely
17
+ * syntactic — no import resolution is required because the namespace and
18
+ * contract name are carried in the string literal. An optional second object
19
+ * argument may override mount details such as basePath.
20
+ */
21
+ export declare const resolveRefContract: <T>(node: ts.Node, helperName: "refHTTP" | "refChannel" | "refCLI", addonContracts: Record<string, Record<string, T>>) => RefContractResolution<T> | null;
@@ -0,0 +1,46 @@
1
+ import * as ts from 'typescript';
2
+ const getStringProperty = (obj, name) => {
3
+ for (const prop of obj.properties) {
4
+ if (ts.isPropertyAssignment(prop) &&
5
+ (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) &&
6
+ prop.name.text === name &&
7
+ ts.isStringLiteral(prop.initializer)) {
8
+ return prop.initializer.text;
9
+ }
10
+ }
11
+ return undefined;
12
+ };
13
+ /**
14
+ * Resolve a refHTTP / refChannel / refCLI call expression against the addon
15
+ * contracts already loaded (and namespaced) by loadAddonFunctionsMeta.
16
+ *
17
+ * The first string argument has the form 'namespace:contractName', mirroring
18
+ * how ref('namespace:fn') references an addon function. Detection is purely
19
+ * syntactic — no import resolution is required because the namespace and
20
+ * contract name are carried in the string literal. An optional second object
21
+ * argument may override mount details such as basePath.
22
+ */
23
+ export const resolveRefContract = (node, helperName, addonContracts) => {
24
+ if (!ts.isCallExpression(node))
25
+ return null;
26
+ if (!ts.isIdentifier(node.expression) ||
27
+ node.expression.text !== helperName) {
28
+ return null;
29
+ }
30
+ const [arg, optionsArg] = node.arguments;
31
+ if (!arg || !ts.isStringLiteral(arg))
32
+ return null;
33
+ const separator = arg.text.indexOf(':');
34
+ if (separator === -1)
35
+ return null;
36
+ const namespace = arg.text.slice(0, separator);
37
+ const contractName = arg.text.slice(separator + 1);
38
+ const contract = addonContracts[namespace]?.[contractName];
39
+ if (contract === undefined)
40
+ return null;
41
+ let basePath;
42
+ if (optionsArg && ts.isObjectLiteralExpression(optionsArg)) {
43
+ basePath = getStringProperty(optionsArg, 'basePath');
44
+ }
45
+ return { contract, basePath };
46
+ };
@@ -267,6 +267,7 @@ export interface SerializableInspectorState {
267
267
  openAPISpec: Record<string, any> | null;
268
268
  diagnostics: InspectorDiagnostic[];
269
269
  addonFunctions: InspectorState['addonFunctions'];
270
+ exportedContracts: InspectorState['exportedContracts'];
270
271
  }
271
272
  /**
272
273
  * Serializes InspectorState to a JSON-compatible format
@@ -150,6 +150,7 @@ export function serializeInspectorState(state) {
150
150
  openAPISpec: state.openAPISpec,
151
151
  diagnostics: state.diagnostics,
152
152
  addonFunctions: state.addonFunctions,
153
+ exportedContracts: state.exportedContracts,
153
154
  };
154
155
  }
155
156
  /**
@@ -314,6 +315,14 @@ export function deserializeInspectorState(data) {
314
315
  openAPISpec: data.openAPISpec || null,
315
316
  diagnostics: data.diagnostics || [],
316
317
  addonFunctions: data.addonFunctions || {},
318
+ exportedContracts: data.exportedContracts || {
319
+ http: {},
320
+ cli: {},
321
+ channel: {},
322
+ addonHttp: {},
323
+ addonCli: {},
324
+ addonChannel: {},
325
+ },
317
326
  program: null,
318
327
  };
319
328
  }
package/dist/visit.js CHANGED
@@ -3,6 +3,7 @@ import { addFileWithFactory } from './add/add-file-with-factory.js';
3
3
  import { addFileExtendsCoreType } from './add/add-file-extends-core-type.js';
4
4
  import { addHTTPRoute } from './add/add-http-route.js';
5
5
  import { addHTTPRoutes } from './add/add-http-routes.js';
6
+ import { checkAddonBans } from './add/add-addon-bans.js';
6
7
  import { addSchedule } from './add/add-schedule.js';
7
8
  import { addTrigger } from './add/add-trigger.js';
8
9
  import { addQueueWorker } from './add/add-queue-worker.js';
@@ -41,23 +42,27 @@ export const visitSetup = (logger, checker, node, state, options) => {
41
42
  ts.forEachChild(node, (child) => visitSetup(logger, checker, child, state, options));
42
43
  };
43
44
  export const visitRoutes = (logger, checker, node, state, options) => {
44
- addFunctions(logger, node, checker, state, options);
45
- addAuth(logger, node, checker, state, options);
46
- addSecret(logger, node, checker, state, options);
47
- addCredential(logger, node, checker, state, options);
48
- addVariable(logger, node, checker, state, options);
49
- addHTTPRoute(logger, node, checker, state, options);
50
- addHTTPRoutes(logger, node, checker, state, options);
51
- addSchedule(logger, node, checker, state, options);
52
- addTrigger(logger, node, checker, state, options);
53
- addQueueWorker(logger, node, checker, state, options);
54
- addChannel(logger, node, checker, state, options);
55
- addGateway(logger, node, checker, state, options);
56
- addCLI(logger, node, checker, state, options);
57
- addCLIRenderers(logger, node, checker, state, options);
58
- addMCPResource(logger, node, checker, state, options);
59
- addMCPPrompt(logger, node, checker, state, options);
60
- addWorkflowGraph(logger, node, checker, state, options);
61
- addAIAgent(logger, node, checker, state, options);
62
- ts.forEachChild(node, (child) => visitRoutes(logger, checker, child, state, options));
45
+ const nextOptions = ts.isSourceFile(node)
46
+ ? { ...options, sourceFile: node }
47
+ : options;
48
+ checkAddonBans(logger, node, checker, state, nextOptions);
49
+ addFunctions(logger, node, checker, state, nextOptions);
50
+ addAuth(logger, node, checker, state, nextOptions);
51
+ addSecret(logger, node, checker, state, nextOptions);
52
+ addCredential(logger, node, checker, state, nextOptions);
53
+ addVariable(logger, node, checker, state, nextOptions);
54
+ addHTTPRoute(logger, node, checker, state, nextOptions);
55
+ addHTTPRoutes(logger, node, checker, state, nextOptions);
56
+ addSchedule(logger, node, checker, state, nextOptions);
57
+ addTrigger(logger, node, checker, state, nextOptions);
58
+ addQueueWorker(logger, node, checker, state, nextOptions);
59
+ addChannel(logger, node, checker, state, nextOptions);
60
+ addGateway(logger, node, checker, state, nextOptions);
61
+ addCLI(logger, node, checker, state, nextOptions);
62
+ addCLIRenderers(logger, node, checker, state, nextOptions);
63
+ addMCPResource(logger, node, checker, state, nextOptions);
64
+ addMCPPrompt(logger, node, checker, state, nextOptions);
65
+ addWorkflowGraph(logger, node, checker, state, nextOptions);
66
+ addAIAgent(logger, node, checker, state, nextOptions);
67
+ ts.forEachChild(node, (child) => visitRoutes(logger, checker, child, state, nextOptions));
63
68
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.22",
3
+ "version": "0.12.24",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",
@@ -0,0 +1,84 @@
1
+ import * as ts from 'typescript'
2
+ import type { AddWiring } from '../types.js'
3
+ import { ErrorCode } from '../error-codes.js'
4
+
5
+ /**
6
+ * Wiring helpers an addon must not call. Addons declare contracts with the
7
+ * define* helpers and export functions; the consuming app does the wiring via
8
+ * refHTTP / refChannel / refCLI. Service declarations remain allowed.
9
+ */
10
+ const BANNED_WIRINGS = new Set([
11
+ 'wireAddon',
12
+ 'wireChannel',
13
+ 'wireCLI',
14
+ 'wireGateway',
15
+ 'wireHTTP',
16
+ 'wireHTTPRoutes',
17
+ 'wireMCPPrompt',
18
+ 'wireMCPResource',
19
+ 'wireQueueWorker',
20
+ 'wireScheduler',
21
+ 'wireTrigger',
22
+ 'wireTriggerSource',
23
+ ])
24
+
25
+ const CONTRACT_DEFINERS = new Set([
26
+ 'defineHTTPRoutes',
27
+ 'defineChannelRoutes',
28
+ 'defineCLICommands',
29
+ ])
30
+
31
+ const hasHandlerProperty = (node: ts.Node): boolean => {
32
+ let found = false
33
+ const visit = (current: ts.Node) => {
34
+ if (found) return
35
+ if (
36
+ ts.isPropertyAssignment(current) &&
37
+ (ts.isIdentifier(current.name) || ts.isStringLiteral(current.name)) &&
38
+ (current.name.text === 'middleware' ||
39
+ current.name.text === 'permissions')
40
+ ) {
41
+ found = true
42
+ return
43
+ }
44
+ ts.forEachChild(current, visit)
45
+ }
46
+ visit(node)
47
+ return found
48
+ }
49
+
50
+ /**
51
+ * Enforce addon authoring rules. Only runs when inspecting an addon package
52
+ * (options.isAddon). Addons cannot wire transports, and their contracts cannot
53
+ * carry middleware or permissions — those are the consuming app's concern.
54
+ */
55
+ export const checkAddonBans: AddWiring = (
56
+ logger,
57
+ node,
58
+ _checker,
59
+ _state,
60
+ options
61
+ ) => {
62
+ if (!options.isAddon) return
63
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) return
64
+
65
+ const name = node.expression.text
66
+
67
+ if (BANNED_WIRINGS.has(name)) {
68
+ logger.critical(
69
+ ErrorCode.ADDON_WIRING_NOT_ALLOWED,
70
+ `Addons must not call '${name}'. Declare contracts with define* and export functions; the consuming app wires them via refHTTP / refChannel / refCLI.`
71
+ )
72
+ return
73
+ }
74
+
75
+ if (CONTRACT_DEFINERS.has(name)) {
76
+ const [arg] = node.arguments
77
+ if (arg && hasHandlerProperty(arg)) {
78
+ logger.critical(
79
+ ErrorCode.ADDON_CONTRACT_HANDLERS_NOT_ALLOWED,
80
+ `Addon contract '${name}' must not declare middleware or permissions — these are applied by the consuming app, not the addon.`
81
+ )
82
+ }
83
+ }
84
+ }
@@ -522,4 +522,98 @@ describe('addAuth inspector', () => {
522
522
  await rm(rootDir, { recursive: true, force: true })
523
523
  }
524
524
  })
525
+
526
+ test('user-registered betterAuthStatelessSession sets userStatelessSession', async () => {
527
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-stateless-'))
528
+ const file = join(rootDir, 'middleware.ts')
529
+ await writeFile(
530
+ file,
531
+ [
532
+ "import { addHTTPMiddleware } from '#pikku'",
533
+ "import { betterAuthStatelessSession } from '@pikku/better-auth'",
534
+ "addHTTPMiddleware('*', [",
535
+ ' betterAuthStatelessSession({',
536
+ ' mapSession: (r: any) => ({ userId: r.user.id, role: r.user.role }),',
537
+ ' }),',
538
+ '])',
539
+ ].join('\n')
540
+ )
541
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
542
+ try {
543
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
544
+ assert.equal(state.auth.userStatelessSession, true)
545
+ } finally {
546
+ await rm(rootDir, { recursive: true, force: true })
547
+ }
548
+ })
549
+
550
+ test('a standalone betterAuthStatelessSession() call (not a registration) does NOT set the flag', async () => {
551
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-standalone-'))
552
+ const file = join(rootDir, 'start.ts')
553
+ await writeFile(
554
+ file,
555
+ [
556
+ "import { betterAuthStatelessSession } from '@pikku/better-auth'",
557
+ '// harness use, not a global registration',
558
+ 'const mw = betterAuthStatelessSession()',
559
+ 'void mw',
560
+ ].join('\n')
561
+ )
562
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
563
+ try {
564
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
565
+ assert.ok(
566
+ !state.auth.userStatelessSession,
567
+ 'a bare call must not count as a registration'
568
+ )
569
+ } finally {
570
+ await rm(rootDir, { recursive: true, force: true })
571
+ }
572
+ })
573
+
574
+ test('a route-scoped addHTTPMiddleware registration does NOT set the flag', async () => {
575
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-scoped-'))
576
+ const file = join(rootDir, 'middleware.ts')
577
+ await writeFile(
578
+ file,
579
+ [
580
+ "import { addHTTPMiddleware } from '#pikku'",
581
+ "import { betterAuthStatelessSession } from '@pikku/better-auth'",
582
+ "addHTTPMiddleware('/api/admin/*', [betterAuthStatelessSession()])",
583
+ ].join('\n')
584
+ )
585
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
586
+ try {
587
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
588
+ assert.ok(
589
+ !state.auth.userStatelessSession,
590
+ 'a route-scoped registration must not suppress the global generated middleware'
591
+ )
592
+ } finally {
593
+ await rm(rootDir, { recursive: true, force: true })
594
+ }
595
+ })
596
+
597
+ test('betterAuthStatelessSession in a .gen.ts file does NOT set the flag', async () => {
598
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-genonly-'))
599
+ const file = join(rootDir, 'auth-middleware.gen.ts')
600
+ await writeFile(
601
+ file,
602
+ [
603
+ "import { addHTTPMiddleware } from '#pikku'",
604
+ "import { betterAuthStatelessSession } from '@pikku/better-auth'",
605
+ "addHTTPMiddleware('*', [betterAuthStatelessSession()])",
606
+ ].join('\n')
607
+ )
608
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
609
+ try {
610
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
611
+ assert.ok(
612
+ !state.auth.userStatelessSession,
613
+ 'generated file must not self-trigger the skip'
614
+ )
615
+ } finally {
616
+ await rm(rootDir, { recursive: true, force: true })
617
+ }
618
+ })
525
619
  })
@@ -99,6 +99,35 @@ const readPluginId = (el: ts.Expression): string | undefined => {
99
99
  return undefined
100
100
  }
101
101
 
102
+ /**
103
+ * True when `node` sits inside a GLOBAL middleware registration — i.e. an actual
104
+ * global registration, not a bare standalone call or a route-scoped one.
105
+ *
106
+ * `addGlobalMiddleware(...)` is always global. `addHTTPMiddleware` is global only
107
+ * in its array form (`addHTTPMiddleware([...])`) or with the `'*'` wildcard
108
+ * pattern; a specific route pattern (`addHTTPMiddleware('/api/admin/*', [...])`)
109
+ * scopes the middleware to that route and must NOT count as a global stateless
110
+ * registration (#754).
111
+ */
112
+ const isInsideGlobalMiddlewareRegistration = (node: ts.Node): boolean => {
113
+ let parent: ts.Node | undefined = node.parent
114
+ while (parent) {
115
+ if (ts.isCallExpression(parent) && ts.isIdentifier(parent.expression)) {
116
+ const fn = parent.expression.text
117
+ if (fn === 'addGlobalMiddleware') return true
118
+ if (fn === 'addHTTPMiddleware') {
119
+ const first = parent.arguments[0]
120
+ if (!first) return false
121
+ // String first arg → route pattern (global only when '*'); otherwise
122
+ // the array form, which is global.
123
+ return ts.isStringLiteral(first) ? first.text === '*' : true
124
+ }
125
+ }
126
+ parent = parent.parent
127
+ }
128
+ return false
129
+ }
130
+
102
131
  /**
103
132
  * Detects `pikkuBetterAuth((services) => betterAuth({...}))` calls.
104
133
  *
@@ -122,6 +151,23 @@ export const addAuth: AddWiring = (logger, node, _checker, state) => {
122
151
  if (!ts.isCallExpression(node)) return
123
152
 
124
153
  const expression = node.expression
154
+
155
+ // A user-registered stateless session middleware (custom mapSession) means the
156
+ // CLI must NOT auto-generate its own default-map one — the generated one runs
157
+ // first and pre-empts the user's via the `if (session) next()` short-circuit
158
+ // (pikkujs/pikku#754). Only a GLOBAL registration counts (inside
159
+ // addHTTPMiddleware/addGlobalMiddleware) — a bare betterAuthStatelessSession()
160
+ // call (e.g. a test harness) is not a registration. Ignore generated files so
161
+ // the emitted middleware can't self-trigger the skip.
162
+ if (
163
+ ts.isIdentifier(expression) &&
164
+ expression.text === 'betterAuthStatelessSession' &&
165
+ !node.getSourceFile().fileName.endsWith('.gen.ts') &&
166
+ isInsideGlobalMiddlewareRegistration(node)
167
+ ) {
168
+ state.auth.userStatelessSession = true
169
+ }
170
+
125
171
  if (!ts.isIdentifier(expression) || expression.text !== 'pikkuBetterAuth')
126
172
  return
127
173
 
@@ -21,6 +21,8 @@ import { resolveIdentifier } from '../utils/resolve-identifier.js'
21
21
  import { resolveFunctionMeta } from '../utils/resolve-function-meta.js'
22
22
  import { resolveAddonName } from '../utils/resolve-addon-package.js'
23
23
  import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js'
24
+ import { getExportedVariableName } from '../utils/get-exported-variable-name.js'
25
+ import { resolveRefContract } from '../utils/resolve-ref-contract.js'
24
26
 
25
27
  /**
26
28
  * Safely get the "initializer" expression of a property-like AST node:
@@ -40,6 +42,16 @@ function getInitializerOf(
40
42
  return undefined
41
43
  }
42
44
 
45
+ function getObjectPropertyName(
46
+ name: ts.PropertyName | undefined
47
+ ): string | null {
48
+ if (!name) return null
49
+ if (ts.isIdentifier(name)) return name.text
50
+ if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text
51
+ if (ts.isComputedPropertyName(name)) return null
52
+ return name.getText()
53
+ }
54
+
43
55
  /**
44
56
  * Resolve a handler expression (Identifier, CallExpression, or { func })
45
57
  * into its underlying function name.
@@ -116,6 +128,27 @@ function getHandlerNameFromExpression(
116
128
  return null
117
129
  }
118
130
 
131
+ function extractExportedChannelRoutes(
132
+ logger: {
133
+ error: (msg: string) => void
134
+ critical: (code: ErrorCode, msg: string) => void
135
+ },
136
+ routes: ts.ObjectLiteralExpression,
137
+ state: InspectorState,
138
+ checker: ts.TypeChecker
139
+ ): Record<string, ChannelMessageMeta> {
140
+ const wrapper = ts.factory.createObjectLiteralExpression([
141
+ ts.factory.createPropertyAssignment(
142
+ 'onMessageWiring',
143
+ ts.factory.createObjectLiteralExpression([
144
+ ts.factory.createPropertyAssignment('contract', routes),
145
+ ])
146
+ ),
147
+ ])
148
+
149
+ return addMessagesRoutes(logger, wrapper, state, checker).contract ?? {}
150
+ }
151
+
119
152
  /**
120
153
  * Build out the nested message-routes by looking up each handler
121
154
  * in state.functions.meta instead of re-inferring it here.
@@ -155,9 +188,24 @@ export function addMessagesRoutes(
155
188
  }
156
189
  }
157
190
 
191
+ const refContract = resolveRefContract(
192
+ chanInit,
193
+ 'refChannel',
194
+ state.exportedContracts.addonChannel
195
+ )
196
+ if (refContract) {
197
+ const refChannelKey = getObjectPropertyName(chanElem.name)
198
+ if (!refChannelKey) continue
199
+ result[refChannelKey] = {
200
+ ...refContract.contract,
201
+ }
202
+ continue
203
+ }
204
+
158
205
  if (!ts.isObjectLiteralExpression(chanInit)) continue
159
206
 
160
- const channelKey = chanElem.name!.getText()
207
+ const channelKey = getObjectPropertyName(chanElem.name)
208
+ if (!channelKey) continue
161
209
  result[channelKey] = {}
162
210
 
163
211
  for (const routeElem of chanInit.properties) {
@@ -168,11 +216,8 @@ export function addMessagesRoutes(
168
216
  const routeName = routeElem.name
169
217
  if (!routeName) continue
170
218
 
171
- let routeKey = routeName.getText()
172
- // For string literals like 'greet' or "greet", strip the quotes
173
- if (ts.isStringLiteral(routeName)) {
174
- routeKey = routeName.text
175
- }
219
+ const routeKey = getObjectPropertyName(routeName)
220
+ if (!routeKey) continue
176
221
 
177
222
  // For shorthand properties, we need to resolve the identifier to its declaration
178
223
  if (ts.isShorthandPropertyAssignment(routeElem)) {
@@ -529,6 +574,18 @@ export const addChannel: AddWiring = (
529
574
  options
530
575
  ) => {
531
576
  if (!ts.isCallExpression(node)) return
577
+ if (
578
+ ts.isIdentifier(node.expression) &&
579
+ node.expression.text === 'defineChannelRoutes'
580
+ ) {
581
+ const exportName = getExportedVariableName(node, options.sourceFile)
582
+ const [firstArg] = node.arguments
583
+ if (exportName && firstArg && ts.isObjectLiteralExpression(firstArg)) {
584
+ state.exportedContracts.channel[exportName] =
585
+ extractExportedChannelRoutes(logger, firstArg, state, checker)
586
+ }
587
+ return
588
+ }
532
589
  const { expression, arguments: args } = node
533
590
  if (!ts.isIdentifier(expression) || expression.text !== 'wireChannel') return
534
591
  const first = args[0]
@@ -664,7 +721,9 @@ export const addChannel: AddWiring = (
664
721
  state.serviceAggregation.usedFunctions.add(message.pikkuFuncId)
665
722
  }
666
723
 
667
- for (const channelHandlers of Object.values(messageWirings)) {
724
+ for (const channelHandlers of Object.values(
725
+ messageWirings as Record<string, Record<string, ChannelMessageMeta>>
726
+ )) {
668
727
  for (const handler of Object.values(channelHandlers)) {
669
728
  state.serviceAggregation.usedFunctions.add(handler.pikkuFuncId)
670
729
  }
@@ -19,6 +19,8 @@ import { resolveIdentifier } from '../utils/resolve-identifier.js'
19
19
  import { resolveAddonName } from '../utils/resolve-addon-package.js'
20
20
  import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js'
21
21
  import { extractServicesFromFunction } from '../utils/extract-services.js'
22
+ import { getExportedVariableName } from '../utils/get-exported-variable-name.js'
23
+ import { resolveRefContract } from '../utils/resolve-ref-contract.js'
22
24
 
23
25
  // Track if we've warned about missing Config type to avoid duplicate warnings
24
26
  const configTypeWarningShown = new Set<string>()
@@ -34,6 +36,25 @@ export const addCLI: AddWiring = (
34
36
  options
35
37
  ) => {
36
38
  if (!ts.isCallExpression(node)) return
39
+ if (
40
+ ts.isIdentifier(node.expression) &&
41
+ node.expression.text === 'defineCLICommands'
42
+ ) {
43
+ const exportName = getExportedVariableName(node, options.sourceFile)
44
+ const [firstArg] = node.arguments
45
+ if (exportName && firstArg && ts.isObjectLiteralExpression(firstArg)) {
46
+ inspectorState.exportedContracts.cli[exportName] = processCommands(
47
+ logger,
48
+ firstArg,
49
+ node.getSourceFile(),
50
+ typeChecker,
51
+ exportName,
52
+ inspectorState,
53
+ options
54
+ )
55
+ }
56
+ return
57
+ }
37
58
  // Check if this is a wireCLI call
38
59
  if (!node || !node.expression) {
39
60
  return
@@ -214,6 +235,15 @@ function processCommands(
214
235
  programTags
215
236
  )
216
237
  Object.assign(commands, spreadCommands)
238
+ } else {
239
+ const refCommands = resolveRefContract(
240
+ prop.expression,
241
+ 'refCLI',
242
+ inspectorState.exportedContracts.addonCli
243
+ )
244
+ if (refCommands) {
245
+ Object.assign(commands, refCommands.contract)
246
+ }
217
247
  }
218
248
  continue
219
249
  }
@@ -13,7 +13,11 @@ import {
13
13
  getPropertyAssignmentInitializer,
14
14
  extractTypeKeys,
15
15
  } from '../utils/type-utils.js'
16
- import type { AddWiring, InspectorState } from '../types.js'
16
+ import type {
17
+ AddWiring,
18
+ ExportedHTTPRouteConfigMeta,
19
+ InspectorState,
20
+ } from '../types.js'
17
21
  import { resolveHTTPMiddlewareFromObject } from '../utils/middleware.js'
18
22
  import { resolveHTTPPermissionsFromObject } from '../utils/permissions.js'
19
23
  import { extractWireNames } from '../utils/post-process.js'
@@ -40,6 +44,16 @@ export interface RegisterHTTPRouteParams {
40
44
  inheritedAuth?: boolean
41
45
  }
42
46
 
47
+ export interface RegisterHTTPRouteMetaParams {
48
+ route: ExportedHTTPRouteConfigMeta
49
+ state: InspectorState
50
+ logger: InspectorLogger
51
+ sourceFile: ts.SourceFile
52
+ basePath?: string
53
+ inheritedTags?: string[]
54
+ inheritedAuth?: boolean
55
+ }
56
+
43
57
  /**
44
58
  * Extract header schema reference from headers property
45
59
  */
@@ -421,6 +435,66 @@ export function registerHTTPRoute({
421
435
  }
422
436
  }
423
437
 
438
+ export function registerHTTPRouteMeta({
439
+ route,
440
+ state,
441
+ logger,
442
+ sourceFile,
443
+ basePath = '',
444
+ inheritedTags = [],
445
+ inheritedAuth,
446
+ }: RegisterHTTPRouteMetaParams): void {
447
+ const method = route.method.toLowerCase()
448
+ const fullRoute = basePath + route.route
449
+ const tags = [...inheritedTags, ...(route.tags || [])]
450
+ const funcName = route.func.pikkuFuncId
451
+ const fnMeta = resolveFunctionMeta(state, funcName)
452
+
453
+ if (!fnMeta) {
454
+ logger.critical(
455
+ ErrorCode.FUNCTION_METADATA_NOT_FOUND,
456
+ `No function metadata found for '${funcName}'.`
457
+ )
458
+ return
459
+ }
460
+
461
+ let params: string[] = []
462
+ try {
463
+ const keys = pathToRegexp(fullRoute).keys
464
+ params = keys.filter((k) => k.type === 'param').map((k) => k.name)
465
+ } catch (error) {
466
+ logger.error(
467
+ `Failed to parse route '${fullRoute}': ${error instanceof Error ? error.message : error}`
468
+ )
469
+ return
470
+ }
471
+
472
+ if (!route.func.packageName) {
473
+ computeInputTypes(
474
+ state.http.metaInputTypes,
475
+ method,
476
+ fnMeta.inputs?.[0] || null,
477
+ [],
478
+ params
479
+ )
480
+ }
481
+
482
+ state.serviceAggregation.usedFunctions.add(funcName)
483
+ state.http.files.add(sourceFile.fileName)
484
+ state.http.meta[method][fullRoute] = {
485
+ pikkuFuncId: funcName,
486
+ ...(route.func.packageName && { packageName: route.func.packageName }),
487
+ route: fullRoute,
488
+ sourceFile: sourceFile.fileName,
489
+ method: method as HTTPMethod,
490
+ params: params.length > 0 ? params : undefined,
491
+ inputTypes: undefined,
492
+ tags: tags.length > 0 ? tags : undefined,
493
+ sse: route.sse ? true : undefined,
494
+ groupBasePath: basePath || undefined,
495
+ }
496
+ }
497
+
424
498
  /**
425
499
  * Process wireHTTP calls
426
500
  */