@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,221 @@
1
+ import { strict as assert } from 'node:assert'
2
+ import { describe, test } from 'node:test'
3
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { inspect } from '../inspector.js'
7
+ import type { InspectorLogger } from '../types.js'
8
+
9
+ const logger: InspectorLogger = {
10
+ debug: () => {},
11
+ info: () => {},
12
+ warn: () => {},
13
+ error: () => {},
14
+ critical: () => {},
15
+ hasCriticalErrors: () => false,
16
+ }
17
+
18
+ describe('addon contract metadata', () => {
19
+ test('collects exported local define contracts', async () => {
20
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-exported-contracts-'))
21
+ const file = join(rootDir, 'contracts.ts')
22
+
23
+ await writeFile(
24
+ join(rootDir, 'package.json'),
25
+ JSON.stringify({ name: 'test-app', type: 'module' }, null, 2)
26
+ )
27
+
28
+ await writeFile(
29
+ file,
30
+ [
31
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
32
+ "import { defineHTTPRoutes } from '@pikku/core/http'",
33
+ "import { defineChannelRoutes } from '@pikku/core/channel'",
34
+ "import { defineCLICommands } from '@pikku/core/cli'",
35
+ 'export const listTodos = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
36
+ 'export const subscribeTodo = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
37
+ 'export const runSync = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
38
+ "export const httpRoutes = defineHTTPRoutes({ basePath: '/todos', routes: { list: { method: 'get', route: '/', func: listTodos } } })",
39
+ 'export const channelRoutes = defineChannelRoutes({ subscribe: { func: subscribeTodo } })',
40
+ 'export const cliCommands = defineCLICommands({ sync: { func: runSync, options: {} } })',
41
+ ].join('\n')
42
+ )
43
+
44
+ try {
45
+ const state = await inspect(logger, [file], { rootDir })
46
+ assert.equal(
47
+ state.exportedContracts.http.httpRoutes?.routes.list?.func.pikkuFuncId,
48
+ 'listTodos'
49
+ )
50
+ assert.equal(
51
+ state.exportedContracts.channel.channelRoutes?.subscribe?.pikkuFuncId,
52
+ 'subscribeTodo'
53
+ )
54
+ assert.equal(
55
+ state.exportedContracts.cli.cliCommands?.sync?.pikkuFuncId,
56
+ 'runSync'
57
+ )
58
+ } finally {
59
+ await rm(rootDir, { recursive: true, force: true })
60
+ }
61
+ })
62
+
63
+ test('expands addon contracts referenced via refHTTP/refChannel/refCLI', async () => {
64
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-addon-refs-app-'))
65
+ const nodeModulesDir = join(rootDir, 'node_modules', '@test', 'addon')
66
+ const appFile = join(rootDir, 'app.ts')
67
+
68
+ await mkdir(join(nodeModulesDir, '.pikku', 'function'), { recursive: true })
69
+ await mkdir(join(nodeModulesDir, '.pikku', 'http'), { recursive: true })
70
+ await mkdir(join(nodeModulesDir, '.pikku', 'cli'), { recursive: true })
71
+ await mkdir(join(nodeModulesDir, '.pikku', 'channel'), { recursive: true })
72
+
73
+ await writeFile(
74
+ join(rootDir, 'package.json'),
75
+ JSON.stringify({ name: 'test-app', type: 'module' }, null, 2)
76
+ )
77
+ await writeFile(
78
+ join(nodeModulesDir, 'package.json'),
79
+ JSON.stringify({ name: '@test/addon', type: 'module' }, null, 2)
80
+ )
81
+ await writeFile(
82
+ join(
83
+ nodeModulesDir,
84
+ '.pikku',
85
+ 'function',
86
+ 'pikku-functions-meta.gen.json'
87
+ ),
88
+ JSON.stringify(
89
+ {
90
+ listTodos: {
91
+ pikkuFuncId: 'listTodos',
92
+ inputSchemaName: null,
93
+ outputSchemaName: null,
94
+ sessionless: true,
95
+ },
96
+ runSync: {
97
+ pikkuFuncId: 'runSync',
98
+ inputSchemaName: null,
99
+ outputSchemaName: null,
100
+ sessionless: true,
101
+ },
102
+ subscribeTodo: {
103
+ pikkuFuncId: 'subscribeTodo',
104
+ inputSchemaName: null,
105
+ outputSchemaName: null,
106
+ sessionless: true,
107
+ },
108
+ },
109
+ null,
110
+ 2
111
+ )
112
+ )
113
+ await writeFile(
114
+ join(
115
+ nodeModulesDir,
116
+ '.pikku',
117
+ 'http',
118
+ 'pikku-http-contracts-meta.gen.json'
119
+ ),
120
+ JSON.stringify(
121
+ {
122
+ httpRoutes: {
123
+ basePath: '/addon',
124
+ routes: {
125
+ list: {
126
+ method: 'get',
127
+ route: '/todos',
128
+ func: { pikkuFuncId: 'listTodos' },
129
+ },
130
+ },
131
+ },
132
+ },
133
+ null,
134
+ 2
135
+ )
136
+ )
137
+ await writeFile(
138
+ join(
139
+ nodeModulesDir,
140
+ '.pikku',
141
+ 'cli',
142
+ 'pikku-cli-contracts-meta.gen.json'
143
+ ),
144
+ JSON.stringify(
145
+ {
146
+ cliCommands: {
147
+ sync: {
148
+ pikkuFuncId: 'runSync',
149
+ positionals: [],
150
+ options: {},
151
+ },
152
+ },
153
+ },
154
+ null,
155
+ 2
156
+ )
157
+ )
158
+ await writeFile(
159
+ join(
160
+ nodeModulesDir,
161
+ '.pikku',
162
+ 'channel',
163
+ 'pikku-channel-contracts-meta.gen.json'
164
+ ),
165
+ JSON.stringify(
166
+ {
167
+ channelRoutes: {
168
+ subscribe: {
169
+ pikkuFuncId: 'subscribeTodo',
170
+ },
171
+ },
172
+ },
173
+ null,
174
+ 2
175
+ )
176
+ )
177
+
178
+ await writeFile(
179
+ appFile,
180
+ [
181
+ "import { wireAddon } from '@pikku/core/rpc'",
182
+ "import { wireHTTPRoutes } from '@pikku/core/http'",
183
+ "import { wireChannel } from '@pikku/core/channel'",
184
+ "import { wireCLI } from '@pikku/core/cli'",
185
+ 'declare const refHTTP: (name: string) => any',
186
+ 'declare const refChannel: (name: string) => any',
187
+ 'declare const refCLI: (name: string) => any',
188
+ "wireAddon({ name: 'addon', package: '@test/addon' })",
189
+ "wireHTTPRoutes({ basePath: '/api', routes: { keep: refHTTP('addon:httpRoutes'), moved: refHTTP('addon:httpRoutes', { basePath: '/ext' }) } })",
190
+ "wireChannel({ name: 'live', route: '/live', auth: false, onMessageWiring: { action: refChannel('addon:channelRoutes') } })",
191
+ "wireCLI({ program: 'app', commands: { ...refCLI('addon:cliCommands') } })",
192
+ ].join('\n')
193
+ )
194
+
195
+ try {
196
+ const state = await inspect(logger, [appFile], { rootDir })
197
+
198
+ // Default: the addon contract's own basePath ('/addon') is preserved —
199
+ // the cosmetic slot key ('keep') does not affect the path.
200
+ const httpEntry = state.http.meta.get['/api/addon/todos']
201
+ assert.equal(httpEntry?.packageName, '@test/addon')
202
+ assert.equal(httpEntry?.pikkuFuncId, 'addon:listTodos')
203
+
204
+ // Override: the second-arg basePath ('/ext') replaces the addon's own.
205
+ const overridden = state.http.meta.get['/api/ext/todos']
206
+ assert.equal(overridden?.packageName, '@test/addon')
207
+ assert.equal(overridden?.pikkuFuncId, 'addon:listTodos')
208
+
209
+ const channelEntry =
210
+ state.channels.meta.live?.messageWirings.action?.subscribe
211
+ assert.equal(channelEntry?.packageName, '@test/addon')
212
+ assert.equal(channelEntry?.pikkuFuncId, 'addon:subscribeTodo')
213
+
214
+ const cliEntry = state.cli.meta.programs.app?.commands.sync
215
+ assert.equal(cliEntry?.packageName, '@test/addon')
216
+ assert.equal(cliEntry?.pikkuFuncId, 'addon:runSync')
217
+ } finally {
218
+ await rm(rootDir, { recursive: true, force: true })
219
+ }
220
+ })
221
+ })
@@ -84,6 +84,10 @@ export enum ErrorCode {
84
84
 
85
85
  // Data classification errors
86
86
  PII_IN_OUTPUT = 'PKU910',
87
+
88
+ // Addon authoring errors
89
+ ADDON_WIRING_NOT_ALLOWED = 'PKU920',
90
+ ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = 'PKU921',
87
91
  }
88
92
 
89
93
  /**
package/src/inspector.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as ts from 'typescript'
2
2
  import { performance } from 'perf_hooks'
3
+ import { resolve } from 'path'
3
4
  import { visitSetup, visitRoutes } from './visit.js'
4
5
  import { TypesMap } from './types-map.js'
5
6
  import type {
@@ -216,6 +217,14 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
216
217
  openAPISpec: null,
217
218
  diagnostics: [],
218
219
  addonFunctions: {},
220
+ exportedContracts: {
221
+ http: {},
222
+ cli: {},
223
+ channel: {},
224
+ addonHttp: {},
225
+ addonCli: {},
226
+ addonChannel: {},
227
+ },
219
228
  program: null,
220
229
  }
221
230
  }
@@ -225,6 +234,7 @@ export const inspect = async (
225
234
  routeFiles: string[],
226
235
  options: InspectorOptions = {}
227
236
  ): Promise<InspectorState> => {
237
+ const normalizedRouteFiles = routeFiles.map((file) => resolve(file))
228
238
  const compilerOptions: ts.CompilerOptions = {
229
239
  target: ts.ScriptTarget.ESNext,
230
240
  module: ts.ModuleKind.Node16,
@@ -237,13 +247,13 @@ export const inspect = async (
237
247
  }
238
248
  const startProgram = performance.now()
239
249
  const program = ts.createProgram(
240
- routeFiles,
250
+ normalizedRouteFiles,
241
251
  compilerOptions,
242
252
  undefined, // host
243
253
  options.oldProgram
244
254
  )
245
255
  logger.debug(
246
- `Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${routeFiles.length} files${options.oldProgram ? ', incremental' : ''})`
256
+ `Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${normalizedRouteFiles.length} files${options.oldProgram ? ', incremental' : ''})`
247
257
  )
248
258
 
249
259
  const startChecker = performance.now()
@@ -253,7 +263,7 @@ export const inspect = async (
253
263
  )
254
264
 
255
265
  // Use provided rootDir or infer from source files
256
- const rootDir = options.rootDir || findCommonAncestor(routeFiles)
266
+ const rootDir = options.rootDir || findCommonAncestor(normalizedRouteFiles)
257
267
 
258
268
  const startSourceFiles = performance.now()
259
269
  // node_modules under rootDir (e.g. a locally-installed addon) is a
@@ -275,8 +285,9 @@ export const inspect = async (
275
285
  // First sweep: add all functions
276
286
  const startSetup = performance.now()
277
287
  for (const sourceFile of sourceFiles) {
288
+ const sourceOptions = { ...options, sourceFile }
278
289
  ts.forEachChild(sourceFile, (child) =>
279
- visitSetup(logger, checker, child, state, options)
290
+ visitSetup(logger, checker, child, state, sourceOptions)
280
291
  )
281
292
  }
282
293
  logger.debug(
@@ -290,8 +301,9 @@ export const inspect = async (
290
301
  // Second sweep: add all transports
291
302
  const startRoutes = performance.now()
292
303
  for (const sourceFile of sourceFiles) {
304
+ const sourceOptions = { ...options, sourceFile }
293
305
  ts.forEachChild(sourceFile, (child) =>
294
- visitRoutes(logger, checker, child, state, options)
306
+ visitRoutes(logger, checker, child, state, sourceOptions)
295
307
  )
296
308
  }
297
309
  logger.debug(
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type * as ts from 'typescript'
2
- import type { ChannelsMeta } from '@pikku/core/channel'
2
+ import type { ChannelMessageMeta, ChannelsMeta } from '@pikku/core/channel'
3
3
  import type { GatewaysMeta } from '@pikku/core/gateway'
4
4
  import type { HTTPWiringsMeta } from '@pikku/core/http'
5
5
  import type { ScheduledTasksMeta } from '@pikku/core/scheduler'
@@ -13,6 +13,7 @@ import type {
13
13
  } from '@pikku/core/mcp'
14
14
  import type { AIAgentMeta } from '@pikku/core/ai-agent'
15
15
  import type { CLIMeta } from '@pikku/core/cli'
16
+ import type { CLICommandMeta } from '@pikku/core/cli'
16
17
  import type { NodesMeta } from '@pikku/core/node'
17
18
  import type { SecretDefinitions } from '@pikku/core/secret'
18
19
  import type { CredentialDefinitions } from '@pikku/core/credential'
@@ -107,6 +108,67 @@ export interface InspectorChannelState {
107
108
  files: Set<string>
108
109
  }
109
110
 
111
+ export interface ExportedHTTPRouteFunctionMeta {
112
+ pikkuFuncId: string
113
+ packageName?: string
114
+ }
115
+
116
+ export interface ExportedHTTPRouteConfigMeta {
117
+ method: string
118
+ route: string
119
+ func: ExportedHTTPRouteFunctionMeta
120
+ auth?: boolean
121
+ tags?: string[]
122
+ sse?: boolean
123
+ contentType?: string
124
+ timeout?: number
125
+ headers?: Record<string, string>
126
+ }
127
+
128
+ export interface ExportedHTTPRoutesGroupMeta {
129
+ basePath?: string
130
+ tags?: string[]
131
+ auth?: boolean
132
+ routes: ExportedHTTPRouteMapMeta
133
+ }
134
+
135
+ export type ExportedHTTPRouteEntryMeta =
136
+ | ExportedHTTPRouteConfigMeta
137
+ | ExportedHTTPRoutesGroupMeta
138
+ | ExportedHTTPRouteMapMeta
139
+
140
+ export interface ExportedHTTPRouteMapMeta {
141
+ [key: string]: ExportedHTTPRouteEntryMeta
142
+ }
143
+
144
+ export type ExportedHTTPContractsMeta = Record<
145
+ string,
146
+ ExportedHTTPRoutesGroupMeta
147
+ >
148
+
149
+ export interface ExportedChannelRouteMeta extends ChannelMessageMeta {
150
+ auth?: boolean
151
+ }
152
+
153
+ export type ExportedChannelContractsMeta = Record<
154
+ string,
155
+ Record<string, ExportedChannelRouteMeta>
156
+ >
157
+
158
+ export type ExportedCLIContractsMeta = Record<
159
+ string,
160
+ Record<string, CLICommandMeta>
161
+ >
162
+
163
+ export interface InspectorExportedContractsState {
164
+ http: ExportedHTTPContractsMeta
165
+ cli: ExportedCLIContractsMeta
166
+ channel: ExportedChannelContractsMeta
167
+ addonHttp: Record<string, ExportedHTTPContractsMeta>
168
+ addonCli: Record<string, ExportedCLIContractsMeta>
169
+ addonChannel: Record<string, ExportedChannelContractsMeta>
170
+ }
171
+
110
172
  export interface InspectorMiddlewareDefinition {
111
173
  services: FunctionServicesMeta
112
174
  wires?: FunctionWiresMeta
@@ -214,6 +276,7 @@ export type InspectorOptions = Partial<{
214
276
  setupOnly: boolean
215
277
  rootDir: string
216
278
  isAddon: boolean
279
+ sourceFile: ts.SourceFile
217
280
  types: Partial<{
218
281
  configFileType: string
219
282
  userSessionType: string
@@ -434,6 +497,11 @@ export interface InspectorState {
434
497
  * codebase, if any. The CLI generates the `/auth/*` HTTP wiring from it.
435
498
  * More than one `pikkuBetterAuth` is a critical error. */
436
499
  definition: AuthDefinition | null
500
+ /** True when a user (non-generated) file already registers
501
+ * `betterAuthStatelessSession(...)`. The CLI then skips auto-generating its
502
+ * own default-map stateless middleware, which would otherwise pre-empt the
503
+ * user's custom mapSession (pikkujs/pikku#754). */
504
+ userStatelessSession?: boolean
437
505
  }
438
506
  secrets: {
439
507
  definitions: SecretDefinitions
@@ -485,5 +553,6 @@ export interface InspectorState {
485
553
  openAPISpec: Record<string, any> | null
486
554
  diagnostics: InspectorDiagnostic[]
487
555
  addonFunctions: Record<string, FunctionsMeta> // namespace -> addon's function metadata
556
+ exportedContracts: InspectorExportedContractsState
488
557
  program: ts.Program | null // Retained for incremental re-inspection
489
558
  }
@@ -0,0 +1,48 @@
1
+ import * as ts from 'typescript'
2
+
3
+ const isExportedVariableStatement = (
4
+ statement: ts.Statement
5
+ ): statement is ts.VariableStatement =>
6
+ ts.isVariableStatement(statement) &&
7
+ (statement.modifiers?.some(
8
+ (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword
9
+ ) ??
10
+ false)
11
+
12
+ export const getExportedVariableName = (
13
+ node: ts.Node,
14
+ sourceFile: ts.SourceFile | undefined
15
+ ): string | null => {
16
+ if (!sourceFile) {
17
+ return null
18
+ }
19
+
20
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
21
+ for (const statement of sourceFile.statements) {
22
+ if (!isExportedVariableStatement(statement)) continue
23
+ for (const declaration of statement.declarationList.declarations) {
24
+ if (declaration === node) {
25
+ return node.name.text
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ if (!ts.isCallExpression(node)) {
32
+ return null
33
+ }
34
+
35
+ for (const statement of sourceFile.statements) {
36
+ if (!isExportedVariableStatement(statement)) continue
37
+ for (const declaration of statement.declarationList.declarations) {
38
+ if (
39
+ ts.isIdentifier(declaration.name) &&
40
+ declaration.initializer === node
41
+ ) {
42
+ return declaration.name.text
43
+ }
44
+ }
45
+ }
46
+
47
+ return null
48
+ }
@@ -2,6 +2,121 @@ import { readFile, readdir } from 'fs/promises'
2
2
  import { createRequire } from 'module'
3
3
  import { join, dirname } from 'path'
4
4
  import type { InspectorState, InspectorLogger } from '../types.js'
5
+ import type {
6
+ ExportedChannelContractsMeta,
7
+ ExportedHTTPRouteConfigMeta,
8
+ ExportedHTTPRouteEntryMeta,
9
+ ExportedHTTPRoutesGroupMeta,
10
+ ExportedCLIContractsMeta,
11
+ ExportedHTTPContractsMeta,
12
+ ExportedHTTPRouteMapMeta,
13
+ } from '../types.js'
14
+
15
+ const isHTTPRouteConfig = (
16
+ value: ExportedHTTPRouteEntryMeta
17
+ ): value is ExportedHTTPRouteConfigMeta =>
18
+ typeof value === 'object' &&
19
+ value !== null &&
20
+ 'method' in value &&
21
+ 'func' in value &&
22
+ 'route' in value
23
+
24
+ const isHTTPRouteGroup = (
25
+ value: ExportedHTTPRouteEntryMeta
26
+ ): value is ExportedHTTPRoutesGroupMeta =>
27
+ typeof value === 'object' &&
28
+ value !== null &&
29
+ 'routes' in value &&
30
+ !('method' in value)
31
+
32
+ const applyPackageToHTTPRouteMap = (
33
+ routes: ExportedHTTPRouteMapMeta,
34
+ packageName: string,
35
+ namespace?: string
36
+ ) => {
37
+ for (const value of Object.values(routes)) {
38
+ if (!value || typeof value !== 'object') continue
39
+ if (isHTTPRouteConfig(value)) {
40
+ if (!value.func.packageName) {
41
+ value.func.packageName = packageName
42
+ }
43
+ if (namespace && !value.func.pikkuFuncId.includes(':')) {
44
+ value.func.pikkuFuncId = `${namespace}:${value.func.pikkuFuncId}`
45
+ }
46
+ continue
47
+ }
48
+ if (isHTTPRouteGroup(value)) {
49
+ applyPackageToHTTPRouteMap(value.routes, packageName, namespace)
50
+ continue
51
+ }
52
+ applyPackageToHTTPRouteMap(
53
+ value as ExportedHTTPRouteMapMeta,
54
+ packageName,
55
+ namespace
56
+ )
57
+ }
58
+ }
59
+
60
+ const applyPackageToHTTPContracts = (
61
+ contracts: ExportedHTTPContractsMeta,
62
+ packageName: string,
63
+ namespace: string
64
+ ) => {
65
+ for (const contract of Object.values(contracts)) {
66
+ applyPackageToHTTPRouteMap(contract.routes, packageName, namespace)
67
+ }
68
+ }
69
+
70
+ const applyPackageToCLICommands = (
71
+ commands: Record<string, any>,
72
+ packageName: string,
73
+ namespace?: string
74
+ ) => {
75
+ for (const command of Object.values(commands)) {
76
+ if (command && typeof command === 'object') {
77
+ if (!command.packageName && command.pikkuFuncId) {
78
+ command.packageName = packageName
79
+ }
80
+ if (
81
+ namespace &&
82
+ typeof command.pikkuFuncId === 'string' &&
83
+ !command.pikkuFuncId.includes(':')
84
+ ) {
85
+ command.pikkuFuncId = `${namespace}:${command.pikkuFuncId}`
86
+ }
87
+ if (command.subcommands) {
88
+ applyPackageToCLICommands(command.subcommands, packageName, namespace)
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ const applyPackageToCLIContracts = (
95
+ contracts: ExportedCLIContractsMeta,
96
+ packageName: string,
97
+ namespace: string
98
+ ) => {
99
+ for (const commands of Object.values(contracts)) {
100
+ applyPackageToCLICommands(commands, packageName, namespace)
101
+ }
102
+ }
103
+
104
+ const applyPackageToChannelContracts = (
105
+ contracts: ExportedChannelContractsMeta,
106
+ packageName: string,
107
+ namespace: string
108
+ ) => {
109
+ for (const routes of Object.values(contracts)) {
110
+ for (const route of Object.values(routes)) {
111
+ if (!route.packageName) {
112
+ route.packageName = packageName
113
+ }
114
+ if (!route.pikkuFuncId.includes(':')) {
115
+ route.pikkuFuncId = `${namespace}:${route.pikkuFuncId}`
116
+ }
117
+ }
118
+ }
119
+ }
5
120
 
6
121
  /**
7
122
  * After the setup sweep discovers wireAddon() declarations, load each addon
@@ -104,6 +219,55 @@ export async function loadAddonFunctionsMeta(
104
219
  } catch {
105
220
  // No services gen — addon may not have requiredParentServices
106
221
  }
222
+
223
+ try {
224
+ const httpContractsPath = require.resolve(
225
+ `${decl.package}/.pikku/http/pikku-http-contracts-meta.gen.json`
226
+ )
227
+ const httpContractsRaw = await readFile(httpContractsPath, 'utf-8')
228
+ const httpContracts = JSON.parse(
229
+ httpContractsRaw
230
+ ) as ExportedHTTPContractsMeta
231
+ applyPackageToHTTPContracts(httpContracts, decl.package, namespace)
232
+ state.exportedContracts.addonHttp[namespace] = httpContracts
233
+ } catch {
234
+ // No addon HTTP contracts metadata
235
+ }
236
+
237
+ try {
238
+ const cliContractsPath = require.resolve(
239
+ `${decl.package}/.pikku/cli/pikku-cli-contracts-meta.gen.json`
240
+ )
241
+ const cliContractsRaw = await readFile(cliContractsPath, 'utf-8')
242
+ const cliContracts = JSON.parse(
243
+ cliContractsRaw
244
+ ) as ExportedCLIContractsMeta
245
+ applyPackageToCLIContracts(cliContracts, decl.package, namespace)
246
+ state.exportedContracts.addonCli[namespace] = cliContracts
247
+ } catch {
248
+ // No addon CLI contracts metadata
249
+ }
250
+
251
+ try {
252
+ const channelContractsPath = require.resolve(
253
+ `${decl.package}/.pikku/channel/pikku-channel-contracts-meta.gen.json`
254
+ )
255
+ const channelContractsRaw = await readFile(
256
+ channelContractsPath,
257
+ 'utf-8'
258
+ )
259
+ const channelContracts = JSON.parse(
260
+ channelContractsRaw
261
+ ) as ExportedChannelContractsMeta
262
+ applyPackageToChannelContracts(
263
+ channelContracts,
264
+ decl.package,
265
+ namespace
266
+ )
267
+ state.exportedContracts.addonChannel[namespace] = channelContracts
268
+ } catch {
269
+ // No addon channel contracts metadata
270
+ }
107
271
  } catch (error: any) {
108
272
  logger.warn(
109
273
  `Failed to load addon function metadata for '${namespace}' (${decl.package}): ${error.message}`
@@ -317,9 +317,23 @@ export function aggregateRequiredServices(
317
317
  requiredServices.add('eventHub')
318
318
  }
319
319
 
320
- // 7. Services that addons need from the parent project
321
- for (const service of state.addonRequiredParentServices ?? []) {
322
- requiredServices.add(service)
320
+ // 7. Services that consumed addons need from the parent project.
321
+ // These are required ONLY by units that actually deploy an addon function;
322
+ // a unit that merely calls the addon over RPC (or never touches it) must not
323
+ // carry them, or every per-unit bundle would over-include the addon's
324
+ // parent-service dependencies (e.g. aiAgentRunner, deploymentService) and
325
+ // defeat per-unit tree-shaking.
326
+ const addonFuncIds = new Set<string>()
327
+ for (const fns of Object.values(state.addonFunctions ?? {})) {
328
+ for (const id of Object.keys(fns)) addonFuncIds.add(id)
329
+ }
330
+ const unitDeploysAddonFn = [...usedFunctions].some((fn) =>
331
+ addonFuncIds.has(fn)
332
+ )
333
+ if (unitDeploysAddonFn) {
334
+ for (const service of state.addonRequiredParentServices ?? []) {
335
+ requiredServices.add(service)
336
+ }
323
337
  }
324
338
  }
325
339
 
@@ -80,7 +80,12 @@ export const resolveAddonName = (
80
80
  // Bare package import path
81
81
  if (candidatePackage && !candidatePackage.startsWith('.')) {
82
82
  for (const addonDecl of wireAddonDeclarations.values()) {
83
- if (addonDecl.package === candidatePackage) return addonDecl.package
83
+ if (
84
+ addonDecl.package === candidatePackage ||
85
+ candidatePackage.startsWith(`${addonDecl.package}/`)
86
+ ) {
87
+ return addonDecl.package
88
+ }
84
89
  }
85
90
  }
86
91