@pikku/inspector 0.12.42 → 0.12.44

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 (126) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/dist/add/add-addon-bans.js +1 -0
  3. package/dist/add/add-ai-agent.d.ts +3 -1
  4. package/dist/add/add-ai-agent.js +82 -0
  5. package/dist/add/add-credential.js +5 -1
  6. package/dist/add/add-functions.js +10 -9
  7. package/dist/add/add-gateway.js +3 -0
  8. package/dist/add/add-http-route.js +2 -2
  9. package/dist/add/add-keyed-wiring.js +2 -0
  10. package/dist/add/add-mcp-prompt.js +0 -1
  11. package/dist/add/add-mcp-resource.js +0 -1
  12. package/dist/add/add-permission.d.ts +1 -1
  13. package/dist/add/add-permission.js +3 -177
  14. package/dist/add/add-queue-worker.js +3 -0
  15. package/dist/add/add-schedule.js +3 -0
  16. package/dist/add/add-scope.d.ts +2 -0
  17. package/dist/add/add-scope.js +146 -0
  18. package/dist/add/add-trigger.js +3 -0
  19. package/dist/add/add-wire-remote-addon.d.ts +10 -0
  20. package/dist/add/add-wire-remote-addon.js +66 -0
  21. package/dist/add/add-workflow-graph.js +32 -3
  22. package/dist/add/add-workflow.js +9 -4
  23. package/dist/error-codes.d.ts +3 -0
  24. package/dist/error-codes.js +4 -0
  25. package/dist/inspector.js +11 -5
  26. package/dist/types.d.ts +19 -13
  27. package/dist/utils/custom-types-generator.js +2 -1
  28. package/dist/utils/ensure-function-metadata.d.ts +15 -0
  29. package/dist/utils/ensure-function-metadata.js +24 -0
  30. package/dist/utils/filter-inspector-state.js +9 -0
  31. package/dist/utils/get-property-value.d.ts +7 -2
  32. package/dist/utils/get-property-value.js +48 -1
  33. package/dist/utils/load-addon-functions-meta.js +81 -6
  34. package/dist/utils/permissions.d.ts +1 -21
  35. package/dist/utils/permissions.js +17 -108
  36. package/dist/utils/post-process.d.ts +30 -1
  37. package/dist/utils/post-process.js +150 -72
  38. package/dist/utils/schema-generator.js +19 -4
  39. package/dist/utils/serialize-inspector-state.d.ts +13 -8
  40. package/dist/utils/serialize-inspector-state.js +12 -6
  41. package/dist/utils/serialize-permissions-groups-meta.d.ts +0 -2
  42. package/dist/utils/serialize-permissions-groups-meta.js +0 -26
  43. package/dist/utils/workflow/derive-workflow-plan.js +9 -3
  44. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +373 -91
  45. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +274 -100
  46. package/dist/utils/workflow/dsl/patterns.d.ts +4 -0
  47. package/dist/utils/workflow/dsl/patterns.js +11 -0
  48. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +76 -5
  49. package/dist/utils/workflow/graph/finalize-workflows.d.ts +7 -0
  50. package/dist/utils/workflow/graph/finalize-workflows.js +11 -2
  51. package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +2 -0
  52. package/dist/utils/workflow/graph/serialize-workflow-graph.js +4 -0
  53. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +8 -4
  54. package/dist/visit.js +4 -0
  55. package/package.json +3 -3
  56. package/run-tests.sh +1 -1
  57. package/src/add/add-addon-bans.ts +1 -0
  58. package/src/add/add-ai-agent.test.ts +87 -0
  59. package/src/add/add-ai-agent.ts +122 -0
  60. package/src/add/add-credential.ts +8 -0
  61. package/src/add/add-functions.ts +10 -12
  62. package/src/add/add-gateway.ts +11 -0
  63. package/src/add/add-http-route.ts +2 -8
  64. package/src/add/add-keyed-wiring.ts +2 -0
  65. package/src/add/add-mcp-prompt.ts +0 -1
  66. package/src/add/add-mcp-resource.ts +0 -1
  67. package/src/add/add-permission.ts +4 -242
  68. package/src/add/add-queue-worker.ts +11 -0
  69. package/src/add/add-schedule.ts +11 -0
  70. package/src/add/add-scope.test.ts +346 -0
  71. package/src/add/add-scope.ts +225 -0
  72. package/src/add/add-trigger.ts +11 -0
  73. package/src/add/add-wire-remote-addon.ts +77 -0
  74. package/src/add/add-workflow-edge-cases.test.ts +201 -0
  75. package/src/add/add-workflow-fanout.test.ts +60 -0
  76. package/src/add/add-workflow-graph-input.test.ts +94 -0
  77. package/src/add/add-workflow-graph-notes.test.ts +74 -0
  78. package/src/add/add-workflow-graph.ts +35 -3
  79. package/src/add/add-workflow-on-error.test.ts +118 -0
  80. package/src/add/add-workflow-return-switch.test.ts +121 -0
  81. package/src/add/add-workflow-silent-drops.test.ts +163 -0
  82. package/src/add/add-workflow-sleep-expression.test.ts +97 -0
  83. package/src/add/add-workflow.ts +9 -4
  84. package/src/add/config-value-metadata.test.ts +169 -0
  85. package/src/add/inline-wiring-function.test.ts +104 -0
  86. package/src/add/validate-workflow-graph-addons.test.ts +102 -0
  87. package/src/error-codes.ts +5 -0
  88. package/src/inspector.ts +14 -4
  89. package/src/types.ts +16 -17
  90. package/src/utils/custom-types-generator.test.ts +26 -1
  91. package/src/utils/custom-types-generator.ts +2 -1
  92. package/src/utils/ensure-function-metadata.ts +42 -0
  93. package/src/utils/filter-inspector-state.test.ts +0 -2
  94. package/src/utils/filter-inspector-state.ts +9 -0
  95. package/src/utils/get-property-value.test.ts +141 -0
  96. package/src/utils/get-property-value.ts +62 -1
  97. package/src/utils/load-addon-functions-meta.test.ts +157 -0
  98. package/src/utils/load-addon-functions-meta.ts +94 -6
  99. package/src/utils/load-addon-scopes.test.ts +138 -0
  100. package/src/utils/permissions.test.ts +7 -232
  101. package/src/utils/permissions.ts +20 -160
  102. package/src/utils/post-process.test.ts +269 -4
  103. package/src/utils/post-process.ts +216 -95
  104. package/src/utils/schema-generator.test.ts +164 -0
  105. package/src/utils/schema-generator.ts +26 -3
  106. package/src/utils/serialize-inspector-state.ts +22 -25
  107. package/src/utils/serialize-permissions-groups-meta.ts +0 -28
  108. package/src/utils/workflow/derive-workflow-plan.test.ts +41 -3
  109. package/src/utils/workflow/derive-workflow-plan.ts +11 -4
  110. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
  111. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
  112. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
  113. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
  114. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
  115. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
  116. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
  117. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +311 -105
  118. package/src/utils/workflow/dsl/patterns.ts +18 -0
  119. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +82 -10
  120. package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
  121. package/src/utils/workflow/graph/finalize-workflows.test.ts +50 -0
  122. package/src/utils/workflow/graph/finalize-workflows.ts +13 -2
  123. package/src/utils/workflow/graph/serialize-workflow-graph.ts +6 -0
  124. package/src/utils/workflow/graph/workflow-graph.types.ts +11 -2
  125. package/src/visit.ts +4 -0
  126. package/tsconfig.tsbuildinfo +1 -1
@@ -1,6 +1,9 @@
1
1
  import { test, describe } from 'node:test'
2
2
  import { strict as assert } from 'node:assert'
3
- import { generateCustomTypes } from './custom-types-generator.js'
3
+ import {
4
+ generateCustomTypes,
5
+ sanitizeTypeName,
6
+ } from './custom-types-generator.js'
4
7
  import { TypesMap } from '../types-map.js'
5
8
 
6
9
  function makeTypesMap(
@@ -97,3 +100,25 @@ describe('generateCustomTypes — classification wrapper stripping', () => {
97
100
  )
98
101
  })
99
102
  })
103
+
104
+ describe('sanitizeTypeName — valid JS identifier', () => {
105
+ test('replaces disallowed characters with underscores', () => {
106
+ assert.equal(
107
+ sanitizeTypeName('Add Beehiiv subscribers'),
108
+ 'Add_Beehiiv_subscribers'
109
+ )
110
+ })
111
+
112
+ test('prefixes an underscore when the name starts with a digit', () => {
113
+ // A name like "2. Add Beehiiv newsletter subscribers" otherwise produces
114
+ // `2__Add_...`, an invalid JS identifier that breaks the generated
115
+ // `import <ident> from ...` in pikku-workflow-wirings-meta.gen.ts.
116
+ const id = sanitizeTypeName('2. Add Beehiiv newsletter subscribers')
117
+ assert.equal(id, '_2__Add_Beehiiv_newsletter_subscribers')
118
+ assert.match(id, /^[A-Za-z_$]/, 'must start with a valid identifier char')
119
+ })
120
+
121
+ test('leaves an already-valid identifier unchanged', () => {
122
+ assert.equal(sanitizeTypeName('UserEmail'), 'UserEmail')
123
+ })
124
+ })
@@ -8,7 +8,8 @@ import type { TypesMap } from '../types-map.js'
8
8
  */
9
9
 
10
10
  export function sanitizeTypeName(name: string): string {
11
- return name.replace(/[^a-zA-Z0-9_$]/g, '_')
11
+ const sanitized = name.replace(/[^a-zA-Z0-9_$]/g, '_')
12
+ return /^[0-9]/.test(sanitized) ? `_${sanitized}` : sanitized
12
13
  }
13
14
 
14
15
  const CLASSIFICATION_WRAPPERS = new Set(['Private', 'Pii', 'Secret'])
@@ -309,6 +309,48 @@ export function ensureFunctionMetadata(
309
309
  }
310
310
  }
311
311
 
312
+ /**
313
+ * Register metadata for a `func:` inlined into a wiring (queue/scheduler/
314
+ * trigger/gateway/…).
315
+ *
316
+ * An inline function has no exported name, so `addFunctions` skips it (it bails
317
+ * on `__temp_` ids) and only the wiring's context-based id exists. Without this
318
+ * the wiring points at a function that was never registered and the worker
319
+ * resolves to nothing at runtime ("Missing generated metadata for ...").
320
+ *
321
+ * The stub also carries no session info, so the runtime would treat it as
322
+ * session-required and every invocation would fail with "Authentication
323
+ * required". Derive `sessionless` from the helper the function was built with —
324
+ * the same rule `addFunctions` applies to named ones (`!== 'pikkuFunc'`).
325
+ */
326
+ export function ensureInlineWiringFunction(
327
+ state: InspectorState,
328
+ pikkuFuncId: string,
329
+ fallbackName: string | undefined,
330
+ funcInitializer: ts.Node,
331
+ checker: ts.TypeChecker,
332
+ isHelper?: boolean
333
+ ): void {
334
+ ensureFunctionMetadata(
335
+ state,
336
+ pikkuFuncId,
337
+ fallbackName,
338
+ funcInitializer,
339
+ checker,
340
+ isHelper
341
+ )
342
+
343
+ const meta = state.functions.meta[pikkuFuncId]
344
+ if (
345
+ meta &&
346
+ meta.sessionless === undefined &&
347
+ ts.isCallExpression(funcInitializer) &&
348
+ ts.isIdentifier(funcInitializer.expression)
349
+ ) {
350
+ meta.sessionless = funcInitializer.expression.text !== 'pikkuFunc'
351
+ }
352
+ }
353
+
312
354
  function populateTypesLookup(
313
355
  state: InspectorState,
314
356
  pikkuFuncId: string,
@@ -82,7 +82,6 @@ function createMockInspectorState(): Omit<InspectorState, 'typesLookup'> {
82
82
  },
83
83
  files: new Set(['/test/project/src/api/users.ts']),
84
84
  routeMiddleware: new Map(),
85
- routePermissions: new Map(),
86
85
  },
87
86
  functions: {
88
87
  typesMap: new Map(),
@@ -332,7 +331,6 @@ function createMockInspectorState(): Omit<InspectorState, 'typesLookup'> {
332
331
  permissions: {
333
332
  definitions: {},
334
333
  instances: {},
335
- tagPermissions: new Map(),
336
334
  },
337
335
  serviceAggregation: {
338
336
  requiredServices: new Set(),
@@ -425,6 +425,8 @@ export function filterInspectorState(
425
425
  internalFiles: new Map(state.rpc.internalFiles),
426
426
  exposedMeta: { ...state.rpc.exposedMeta },
427
427
  exposedFiles: new Map(state.rpc.exposedFiles),
428
+ remoteMeta: { ...state.rpc.remoteMeta },
429
+ remoteFiles: new Map(state.rpc.remoteFiles),
428
430
  invokedFunctions: new Set(state.rpc.invokedFunctions),
429
431
  },
430
432
  cli: {
@@ -1052,6 +1054,13 @@ export function filterInspectorState(
1052
1054
  filteredState.rpc.exposedFiles.delete(key)
1053
1055
  }
1054
1056
  }
1057
+ for (const key of Object.keys(filteredState.rpc.remoteMeta)) {
1058
+ const targetFuncId = filteredState.rpc.remoteMeta[key]
1059
+ if (!survivingFuncIds.has(targetFuncId) && !survivingFuncIds.has(key)) {
1060
+ delete filteredState.rpc.remoteMeta[key]
1061
+ filteredState.rpc.remoteFiles.delete(key)
1062
+ }
1063
+ }
1055
1064
  // Prune invokedFunctions to match surviving functions
1056
1065
  for (const funcId of filteredState.rpc.invokedFunctions) {
1057
1066
  if (!survivingFuncIds.has(funcId)) {
@@ -0,0 +1,141 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import * as ts from 'typescript'
4
+ import {
5
+ getArrayPropertyValue,
6
+ getRecordPropertyValue,
7
+ } from './get-property-value.js'
8
+
9
+ /**
10
+ * Parses `const x = { ... }` and hands back the object literal.
11
+ */
12
+ const objectLiteral = (source: string): ts.ObjectLiteralExpression => {
13
+ const file = ts.createSourceFile(
14
+ 'test.ts',
15
+ `const x = ${source}`,
16
+ ts.ScriptTarget.Latest,
17
+ true
18
+ )
19
+ const statement = file.statements[0] as ts.VariableStatement
20
+ const initializer = statement.declarationList.declarations[0]!.initializer
21
+ if (!initializer || !ts.isObjectLiteralExpression(initializer)) {
22
+ throw new Error('expected an object literal')
23
+ }
24
+ return initializer
25
+ }
26
+
27
+ const firstObjectLiteral = (src: string): ts.ObjectLiteralExpression => {
28
+ const sourceFile = ts.createSourceFile(
29
+ 'test.ts',
30
+ src,
31
+ ts.ScriptTarget.Latest,
32
+ true
33
+ )
34
+ let found: ts.ObjectLiteralExpression | undefined
35
+ const walk = (node: ts.Node) => {
36
+ if (!found && ts.isObjectLiteralExpression(node)) {
37
+ found = node
38
+ }
39
+ ts.forEachChild(node, walk)
40
+ }
41
+ walk(sourceFile)
42
+ if (!found) {
43
+ throw new Error('no object literal in source')
44
+ }
45
+ return found
46
+ }
47
+
48
+ describe('getArrayPropertyValue', () => {
49
+ test('reads a plain array literal', () => {
50
+ const obj = objectLiteral(`{ tags: ['a', 'b'] }`)
51
+ assert.deepEqual(getArrayPropertyValue(obj, 'tags'), ['a', 'b'])
52
+ })
53
+
54
+ test('returns null when the property is absent', () => {
55
+ const obj = objectLiteral(`{ other: ['a'] }`)
56
+ assert.equal(getArrayPropertyValue(obj, 'tags'), null)
57
+ })
58
+
59
+ test('reads an array widened with `as const`', () => {
60
+ const obj = objectLiteral(`{ tags: ['a', 'b'] as const }`)
61
+ assert.deepEqual(
62
+ getArrayPropertyValue(obj, 'tags'),
63
+ ['a', 'b'],
64
+ '`as const` on an array is idiomatic and must not drop the value'
65
+ )
66
+ })
67
+
68
+ test('reads an array cast with `as any`', () => {
69
+ const obj = objectLiteral(`{ tags: ['a'] as any }`)
70
+ assert.deepEqual(
71
+ getArrayPropertyValue(obj, 'tags'),
72
+ ['a'],
73
+ 'a cast must not let a value escape static validation'
74
+ )
75
+ })
76
+
77
+ test('reads an array through `satisfies`', () => {
78
+ const obj = objectLiteral(`{ tags: ['a'] satisfies string[] }`)
79
+ assert.deepEqual(getArrayPropertyValue(obj, 'tags'), ['a'])
80
+ })
81
+
82
+ test('reads an array through nested casts', () => {
83
+ const obj = objectLiteral(`{ tags: ['a'] as const as any }`)
84
+ assert.deepEqual(getArrayPropertyValue(obj, 'tags'), ['a'])
85
+ })
86
+
87
+ test('ignores non-string elements', () => {
88
+ const obj = objectLiteral(`{ tags: ['a', 1, foo] }`)
89
+ assert.deepEqual(getArrayPropertyValue(obj, 'tags'), ['a'])
90
+ })
91
+
92
+ test('returns null for a non-array initializer', () => {
93
+ const obj = objectLiteral(`{ tags: 'a' }`)
94
+ assert.equal(getArrayPropertyValue(obj, 'tags'), null)
95
+ })
96
+ })
97
+
98
+ describe('getRecordPropertyValue', () => {
99
+ test('extracts a string record', () => {
100
+ const obj = firstObjectLiteral(
101
+ `const x = { additionalParams: { access_type: 'offline', prompt: 'consent' } }`
102
+ )
103
+ assert.deepStrictEqual(getRecordPropertyValue(obj, 'additionalParams'), {
104
+ access_type: 'offline',
105
+ prompt: 'consent',
106
+ })
107
+ })
108
+
109
+ test('handles quoted keys', () => {
110
+ const obj = firstObjectLiteral(
111
+ `const x = { additionalParams: { 'token_access_type': 'offline' } }`
112
+ )
113
+ assert.deepStrictEqual(getRecordPropertyValue(obj, 'additionalParams'), {
114
+ token_access_type: 'offline',
115
+ })
116
+ })
117
+
118
+ test('returns null when the property is absent', () => {
119
+ const obj = firstObjectLiteral(`const x = { scopes: ['a'] }`)
120
+ assert.strictEqual(getRecordPropertyValue(obj, 'additionalParams'), null)
121
+ })
122
+
123
+ test('returns null for a non-literal initializer', () => {
124
+ const obj = firstObjectLiteral(`const x = { additionalParams: SOME_CONST }`)
125
+ assert.strictEqual(getRecordPropertyValue(obj, 'additionalParams'), null)
126
+ })
127
+
128
+ test('skips non-string values rather than coercing them', () => {
129
+ const obj = firstObjectLiteral(
130
+ `const x = { additionalParams: { a: 'keep', b: 42, c: true } }`
131
+ )
132
+ assert.deepStrictEqual(getRecordPropertyValue(obj, 'additionalParams'), {
133
+ a: 'keep',
134
+ })
135
+ })
136
+
137
+ test('returns null for an empty record', () => {
138
+ const obj = firstObjectLiteral(`const x = { additionalParams: {} }`)
139
+ assert.strictEqual(getRecordPropertyValue(obj, 'additionalParams'), null)
140
+ })
141
+ })
@@ -5,6 +5,17 @@ import { extractStringLiteral } from './extract-node-value.js'
5
5
  /**
6
6
  * Extracts an array of strings from an object property.
7
7
  */
8
+ /**
9
+ * Unwraps `x as const` / `x as any` / `x satisfies T` down to the underlying
10
+ * expression. Without this a cast makes the value invisible to the inspector,
11
+ * so it is silently dropped from meta rather than validated — and `as const` on
12
+ * an array is idiomatic enough that this is easy to hit by accident.
13
+ */
14
+ const unwrapAs = (node: ts.Expression): ts.Expression =>
15
+ ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
16
+ ? unwrapAs(node.expression)
17
+ : node
18
+
8
19
  export const getArrayPropertyValue = (
9
20
  obj: ts.ObjectLiteralExpression,
10
21
  propertyName: string
@@ -17,7 +28,7 @@ export const getArrayPropertyValue = (
17
28
  )
18
29
 
19
30
  if (property && ts.isPropertyAssignment(property)) {
20
- const initializer = property.initializer
31
+ const initializer = unwrapAs(property.initializer)
21
32
  if (ts.isArrayLiteralExpression(initializer)) {
22
33
  return initializer.elements
23
34
  .filter(ts.isStringLiteral)
@@ -28,6 +39,56 @@ export const getArrayPropertyValue = (
28
39
  return null
29
40
  }
30
41
 
42
+ /**
43
+ * Extracts a string->string record from an object property.
44
+ *
45
+ * `getPropertyValue` falls through to `getText()` for an object literal, which
46
+ * returns the raw source text rather than the record — so callers that need the
47
+ * entries (e.g. oauth2 `additionalParams`) must use this instead.
48
+ */
49
+ export const getRecordPropertyValue = (
50
+ obj: ts.ObjectLiteralExpression,
51
+ propertyName: string
52
+ ): Record<string, string> | null => {
53
+ const property = obj.properties.find(
54
+ (p) =>
55
+ ts.isPropertyAssignment(p) &&
56
+ ts.isIdentifier(p.name) &&
57
+ p.name.text === propertyName
58
+ )
59
+
60
+ if (!property || !ts.isPropertyAssignment(property)) {
61
+ return null
62
+ }
63
+ const initializer = property.initializer
64
+ if (!ts.isObjectLiteralExpression(initializer)) {
65
+ return null
66
+ }
67
+
68
+ const record: Record<string, string> = {}
69
+ for (const prop of initializer.properties) {
70
+ if (!ts.isPropertyAssignment(prop)) {
71
+ continue
72
+ }
73
+ const key = ts.isIdentifier(prop.name)
74
+ ? prop.name.text
75
+ : ts.isStringLiteral(prop.name)
76
+ ? prop.name.text
77
+ : null
78
+ if (key === null) {
79
+ continue
80
+ }
81
+ if (
82
+ ts.isStringLiteral(prop.initializer) ||
83
+ ts.isNoSubstitutionTemplateLiteral(prop.initializer)
84
+ ) {
85
+ record[key] = prop.initializer.text
86
+ }
87
+ }
88
+
89
+ return Object.keys(record).length > 0 ? record : null
90
+ }
91
+
31
92
  /**
32
93
  * Wiring identity fields (`name`, `secretId`, `variableId`, …) are read
33
94
  * STATICALLY from source — a const or variable reference is keyed by its
@@ -0,0 +1,157 @@
1
+ import { strict as assert } from 'assert'
2
+ import { describe, test, before, after } from 'node:test'
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
4
+ import { tmpdir } from 'os'
5
+ import { join } from 'path'
6
+ import { loadAddonFunctionsMeta } from './load-addon-functions-meta.js'
7
+ import type { InspectorState, InspectorLogger } from '../types.js'
8
+
9
+ const ADDON = '@addon/slack'
10
+
11
+ const writeAddonFixture = (rootDir: string) => {
12
+ writeFileSync(
13
+ join(rootDir, 'package.json'),
14
+ JSON.stringify({ name: 'consumer' })
15
+ )
16
+ const addonDir = join(rootDir, 'node_modules', ADDON)
17
+ const pikku = join(addonDir, '.pikku')
18
+ mkdirSync(join(pikku, 'function'), { recursive: true })
19
+ mkdirSync(join(pikku, 'secrets'), { recursive: true })
20
+ mkdirSync(join(pikku, 'variables'), { recursive: true })
21
+ mkdirSync(join(pikku, 'credentials'), { recursive: true })
22
+ writeFileSync(join(addonDir, 'package.json'), JSON.stringify({ name: ADDON }))
23
+ writeFileSync(
24
+ join(pikku, 'function', 'pikku-functions-meta.gen.json'),
25
+ JSON.stringify({})
26
+ )
27
+ // Real addon secrets/variables have a logical name that DIFFERS from the id
28
+ // the addon reads by (secretId/variableId) — overrides key on the id.
29
+ writeFileSync(
30
+ join(pikku, 'secrets', 'pikku-secrets-meta.gen.json'),
31
+ JSON.stringify({ slack: { name: 'slack', secretId: 'SLACK_TOKEN' } })
32
+ )
33
+ writeFileSync(
34
+ join(pikku, 'variables', 'pikku-variables-meta.gen.json'),
35
+ JSON.stringify({ region: { name: 'region', variableId: 'REGION' } })
36
+ )
37
+ writeFileSync(
38
+ join(pikku, 'credentials', 'pikku-credentials-meta.gen.json'),
39
+ JSON.stringify({
40
+ slackOAuth: { name: 'slackOAuth', type: 'singleton' },
41
+ })
42
+ )
43
+ }
44
+
45
+ const makeState = (
46
+ rootDir: string,
47
+ wireAddonDeclarations: Map<string, any>
48
+ ): InspectorState =>
49
+ ({
50
+ rootDir,
51
+ rpc: { wireAddonDeclarations },
52
+ addonFunctions: {},
53
+ secrets: { definitions: [] },
54
+ variables: { definitions: [] },
55
+ credentials: { definitions: [] },
56
+ mcpEndpoints: { toolsMeta: {} },
57
+ addonServerlessIncompatible: new Map(),
58
+ addonRequiredParentServices: [],
59
+ exportedContracts: { addonHttp: {}, addonCli: {}, addonChannel: {} },
60
+ }) as unknown as InspectorState
61
+
62
+ const logger = {
63
+ debug: () => {},
64
+ info: () => {},
65
+ warn: () => {},
66
+ error: () => {},
67
+ critical: () => {},
68
+ diagnostic: () => {},
69
+ } as unknown as InspectorLogger
70
+
71
+ describe('loadAddonFunctionsMeta — per-instance secret/variable overrides', () => {
72
+ let rootDir: string
73
+
74
+ before(() => {
75
+ rootDir = mkdtempSync(join(tmpdir(), 'pikku-addon-meta-'))
76
+ writeAddonFixture(rootDir)
77
+ })
78
+
79
+ after(() => {
80
+ rmSync(rootDir, { recursive: true, force: true })
81
+ })
82
+
83
+ test('registers the override target names, one per instance, not the shared logical name', async () => {
84
+ const state = makeState(
85
+ rootDir,
86
+ new Map<string, any>([
87
+ [
88
+ 'slack-marketing',
89
+ {
90
+ package: ADDON,
91
+ // Overrides key on the secretId/variableId, not the logical name.
92
+ secretOverrides: { SLACK_TOKEN: 'SLACK_MARKETING_TOKEN' },
93
+ variableOverrides: { REGION: 'MARKETING_REGION' },
94
+ credentialOverrides: { slackOAuth: 'slack_marketing_oauth' },
95
+ },
96
+ ],
97
+ [
98
+ 'slack-support',
99
+ {
100
+ package: ADDON,
101
+ secretOverrides: { SLACK_TOKEN: 'SLACK_SUPPORT_TOKEN' },
102
+ credentialOverrides: { slackOAuth: 'slack_support_oauth' },
103
+ },
104
+ ],
105
+ ])
106
+ )
107
+
108
+ await loadAddonFunctionsMeta(logger, state)
109
+
110
+ const secretIds = state.secrets.definitions
111
+ .map((d: any) => d.secretId)
112
+ .sort()
113
+ assert.deepEqual(secretIds, [
114
+ 'SLACK_MARKETING_TOKEN',
115
+ 'SLACK_SUPPORT_TOKEN',
116
+ ])
117
+
118
+ // slack-marketing overrides region; slack-support has no override, so it
119
+ // falls back to the addon's default variableId.
120
+ const variableIds = state.variables.definitions
121
+ .map((d: any) => d.variableId)
122
+ .sort()
123
+ assert.deepEqual(variableIds, ['MARKETING_REGION', 'REGION'])
124
+
125
+ // Each instance's credentialOverride surfaces a distinct credential name
126
+ // (which doubles as the better-auth providerId) — no shared account pool.
127
+ const credentialNames = state.credentials.definitions
128
+ .map((d: any) => d.name)
129
+ .sort()
130
+ assert.deepEqual(credentialNames, [
131
+ 'slack_marketing_oauth',
132
+ 'slack_support_oauth',
133
+ ])
134
+ })
135
+
136
+ test('falls back to the addon logical name when no override is provided', async () => {
137
+ const state = makeState(
138
+ rootDir,
139
+ new Map<string, any>([['slack-plain', { package: ADDON }]])
140
+ )
141
+
142
+ await loadAddonFunctionsMeta(logger, state)
143
+
144
+ assert.deepEqual(
145
+ state.secrets.definitions.map((d: any) => d.secretId),
146
+ ['SLACK_TOKEN']
147
+ )
148
+ assert.deepEqual(
149
+ state.variables.definitions.map((d: any) => d.variableId),
150
+ ['REGION']
151
+ )
152
+ assert.deepEqual(
153
+ state.credentials.definitions.map((d: any) => d.name),
154
+ ['slackOAuth']
155
+ )
156
+ })
157
+ })
@@ -133,6 +133,10 @@ export async function loadAddonFunctionsMeta(
133
133
  const require = createRequire(join(state.rootDir, 'package.json'))
134
134
 
135
135
  for (const [namespace, decl] of wireAddonDeclarations) {
136
+ // Remote addons (wireRemoteAddon) ship as a devDependency: types only.
137
+ // Their functions, secrets, variables, schemas and services live on the
138
+ // HOST that runs them — never load or require any of that here.
139
+ if (decl.remote) continue
136
140
  try {
137
141
  const metaPath = require.resolve(
138
142
  `${decl.package}/.pikku/function/pikku-functions-meta.gen.json`
@@ -168,18 +172,55 @@ export async function loadAddonFunctionsMeta(
168
172
  const secretsRaw = await readFile(secretsMetaPath, 'utf-8')
169
173
  const secretsMeta = JSON.parse(secretsRaw)
170
174
  for (const [key, def] of Object.entries<any>(secretsMeta)) {
175
+ // secretOverrides key on the SECRET ID (the string the addon passes to
176
+ // getSecret — its typed map is keyed by secretId, e.g.
177
+ // `getSecret('MAILGUN_CREDENTIALS')`), NOT the logical meta key, so the
178
+ // runtime aliaser (which also keys on secretId) and this merge agree.
179
+ // The resolved id is the real project secret; the addon's secretId is
180
+ // the default when no override is given. Two instances with different
181
+ // overrides therefore surface two distinct project secrets. `key` is
182
+ // the fallback for older meta that predates the secretId field.
183
+ const secretId = def.secretId ?? key
184
+ const resolvedSecretId = decl.secretOverrides?.[secretId] ?? secretId
171
185
  const existing = state.secrets.definitions.find(
172
- (d: any) => d.name === key
186
+ (d: any) => (d.secretId ?? d.name) === resolvedSecretId
173
187
  )
174
188
  if (!existing) {
175
- state.secrets.definitions.push(def)
176
- logger.debug(`Loaded addon secret '${key}' from ${decl.package}`)
189
+ state.secrets.definitions.push({
190
+ ...def,
191
+ secretId: resolvedSecretId,
192
+ })
193
+ logger.debug(
194
+ `Loaded addon secret '${resolvedSecretId}' from ${decl.package}`
195
+ )
177
196
  }
178
197
  }
179
198
  } catch {
180
199
  // No secrets meta — that's fine
181
200
  }
182
201
 
202
+ // Load addon scopes meta. Without this an addon's own scopes never reach
203
+ // the host's ScopeId union or its declared set, so the pikku_scopes FK
204
+ // would refuse to grant one.
205
+ try {
206
+ const scopesMetaPath = require.resolve(
207
+ `${decl.package}/.pikku/scopes/pikku-scopes-meta.gen.json`
208
+ )
209
+ const scopesRaw = await readFile(scopesMetaPath, 'utf-8')
210
+ const scopesMeta = JSON.parse(scopesRaw)
211
+ for (const [key, def] of Object.entries<any>(scopesMeta)) {
212
+ const existing = state.scopes.definitions.find(
213
+ (d: any) => d.name === key
214
+ )
215
+ if (!existing) {
216
+ state.scopes.definitions.push(def)
217
+ logger.debug(`Loaded addon scope '${key}' from ${decl.package}`)
218
+ }
219
+ }
220
+ } catch {
221
+ // No scopes meta — that's fine
222
+ }
223
+
183
224
  // Load addon variables meta
184
225
  try {
185
226
  const variablesMetaPath = require.resolve(
@@ -188,17 +229,62 @@ export async function loadAddonFunctionsMeta(
188
229
  const variablesRaw = await readFile(variablesMetaPath, 'utf-8')
189
230
  const variablesMeta = JSON.parse(variablesRaw)
190
231
  for (const [key, def] of Object.entries<any>(variablesMeta)) {
232
+ // variableOverrides key on the VARIABLE ID (the string the addon reads
233
+ // via its typed map, keyed by variableId), same as secretOverrides key
234
+ // on secretId — so the runtime aliaser and this merge agree. `key` is
235
+ // the fallback for older meta without a variableId field.
236
+ const variableId = def.variableId ?? key
237
+ const resolvedVariableId =
238
+ decl.variableOverrides?.[variableId] ?? variableId
191
239
  const existing = state.variables.definitions.find(
192
- (d: any) => d.name === key
240
+ (d: any) => (d.variableId ?? d.name) === resolvedVariableId
193
241
  )
194
242
  if (!existing) {
195
- state.variables.definitions.push(def)
196
- logger.debug(`Loaded addon variable '${key}' from ${decl.package}`)
243
+ state.variables.definitions.push({
244
+ ...def,
245
+ variableId: resolvedVariableId,
246
+ })
247
+ logger.debug(
248
+ `Loaded addon variable '${resolvedVariableId}' from ${decl.package}`
249
+ )
197
250
  }
198
251
  }
199
252
  } catch {
200
253
  // No variables meta — that's fine
201
254
  }
255
+
256
+ // Load addon credentials meta. Without this an addon's OAuth2/wire
257
+ // credentials never reach the consuming app's CREDENTIAL_OAUTH2_CONFIGS,
258
+ // so the credential-oauth provider and BetterAuthCredentialService can't
259
+ // resolve them — the addon's Connect flow and status silently no-op.
260
+ try {
261
+ const credentialsMetaPath = require.resolve(
262
+ `${decl.package}/.pikku/credentials/pikku-credentials-meta.gen.json`
263
+ )
264
+ const credentialsRaw = await readFile(credentialsMetaPath, 'utf-8')
265
+ const credentialsMeta = JSON.parse(credentialsRaw)
266
+ for (const [key, def] of Object.entries<any>(credentialsMeta)) {
267
+ // Each instance's credentialOverrides remap the addon's logical
268
+ // credential name to a project credential — same mechanic as secrets
269
+ // and variables above. The credential name doubles as the better-auth
270
+ // providerId (accounts keyed by providerId+userId), so two instances
271
+ // with different overrides surface two distinct OAuth providers rather
272
+ // than sharing one account pool. The logical name is the default when
273
+ // no override is given.
274
+ const resolvedName = decl.credentialOverrides?.[key] ?? key
275
+ const existing = state.credentials.definitions.find(
276
+ (d: any) => d.name === resolvedName
277
+ )
278
+ if (!existing) {
279
+ state.credentials.definitions.push({ ...def, name: resolvedName })
280
+ logger.debug(
281
+ `Loaded addon credential '${resolvedName}' from ${decl.package}`
282
+ )
283
+ }
284
+ }
285
+ } catch {
286
+ // No credentials meta — that's fine
287
+ }
202
288
  // Load addon serverlessIncompatible service names from pikku-addon-meta.gen.json
203
289
  let loadedParentServices = false
204
290
  try {
@@ -323,6 +409,8 @@ export async function loadAddonSchemas(
323
409
  const require = createRequire(join(state.rootDir, 'package.json'))
324
410
 
325
411
  for (const [namespace, decl] of wireAddonDeclarations) {
412
+ // Remote addons carry no local schemas — their funcs run on the host.
413
+ if (decl.remote) continue
326
414
  try {
327
415
  const metaPath = require.resolve(
328
416
  `${decl.package}/.pikku/function/pikku-functions-meta.gen.json`