@pikku/inspector 0.12.42 → 0.12.43

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 (102) hide show
  1. package/CHANGELOG.md +98 -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 +3 -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-mcp-prompt.js +0 -1
  10. package/dist/add/add-mcp-resource.js +0 -1
  11. package/dist/add/add-permission.d.ts +1 -1
  12. package/dist/add/add-permission.js +3 -177
  13. package/dist/add/add-queue-worker.js +3 -0
  14. package/dist/add/add-schedule.js +3 -0
  15. package/dist/add/add-scope.d.ts +2 -0
  16. package/dist/add/add-scope.js +146 -0
  17. package/dist/add/add-trigger.js +3 -0
  18. package/dist/add/add-wire-remote-addon.d.ts +10 -0
  19. package/dist/add/add-wire-remote-addon.js +66 -0
  20. package/dist/add/add-workflow-graph.js +32 -3
  21. package/dist/error-codes.d.ts +3 -0
  22. package/dist/error-codes.js +4 -0
  23. package/dist/inspector.js +11 -5
  24. package/dist/types.d.ts +19 -13
  25. package/dist/utils/custom-types-generator.js +2 -1
  26. package/dist/utils/ensure-function-metadata.d.ts +15 -0
  27. package/dist/utils/ensure-function-metadata.js +24 -0
  28. package/dist/utils/filter-inspector-state.js +9 -0
  29. package/dist/utils/get-property-value.d.ts +7 -2
  30. package/dist/utils/get-property-value.js +48 -1
  31. package/dist/utils/load-addon-functions-meta.js +81 -6
  32. package/dist/utils/permissions.d.ts +1 -21
  33. package/dist/utils/permissions.js +17 -108
  34. package/dist/utils/post-process.d.ts +30 -1
  35. package/dist/utils/post-process.js +150 -72
  36. package/dist/utils/serialize-inspector-state.d.ts +13 -8
  37. package/dist/utils/serialize-inspector-state.js +12 -6
  38. package/dist/utils/serialize-permissions-groups-meta.d.ts +0 -2
  39. package/dist/utils/serialize-permissions-groups-meta.js +0 -26
  40. package/dist/utils/workflow/derive-workflow-plan.js +9 -3
  41. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +56 -1
  42. package/dist/utils/workflow/dsl/patterns.d.ts +4 -0
  43. package/dist/utils/workflow/dsl/patterns.js +11 -0
  44. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +14 -0
  45. package/dist/utils/workflow/graph/finalize-workflows.d.ts +7 -0
  46. package/dist/utils/workflow/graph/finalize-workflows.js +11 -2
  47. package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +2 -0
  48. package/dist/utils/workflow/graph/serialize-workflow-graph.js +4 -0
  49. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +6 -2
  50. package/dist/visit.js +4 -0
  51. package/package.json +3 -3
  52. package/src/add/add-addon-bans.ts +1 -0
  53. package/src/add/add-ai-agent.test.ts +87 -0
  54. package/src/add/add-ai-agent.ts +122 -0
  55. package/src/add/add-credential.ts +6 -0
  56. package/src/add/add-functions.ts +10 -12
  57. package/src/add/add-gateway.ts +11 -0
  58. package/src/add/add-http-route.ts +2 -8
  59. package/src/add/add-mcp-prompt.ts +0 -1
  60. package/src/add/add-mcp-resource.ts +0 -1
  61. package/src/add/add-permission.ts +4 -242
  62. package/src/add/add-queue-worker.ts +11 -0
  63. package/src/add/add-schedule.ts +11 -0
  64. package/src/add/add-scope.test.ts +346 -0
  65. package/src/add/add-scope.ts +225 -0
  66. package/src/add/add-trigger.ts +11 -0
  67. package/src/add/add-wire-remote-addon.ts +77 -0
  68. package/src/add/add-workflow-graph-input.test.ts +94 -0
  69. package/src/add/add-workflow-graph-notes.test.ts +74 -0
  70. package/src/add/add-workflow-graph.ts +35 -3
  71. package/src/add/inline-wiring-function.test.ts +104 -0
  72. package/src/add/validate-workflow-graph-addons.test.ts +102 -0
  73. package/src/error-codes.ts +5 -0
  74. package/src/inspector.ts +14 -4
  75. package/src/types.ts +16 -17
  76. package/src/utils/custom-types-generator.test.ts +26 -1
  77. package/src/utils/custom-types-generator.ts +2 -1
  78. package/src/utils/ensure-function-metadata.ts +42 -0
  79. package/src/utils/filter-inspector-state.test.ts +0 -2
  80. package/src/utils/filter-inspector-state.ts +9 -0
  81. package/src/utils/get-property-value.test.ts +141 -0
  82. package/src/utils/get-property-value.ts +62 -1
  83. package/src/utils/load-addon-functions-meta.test.ts +157 -0
  84. package/src/utils/load-addon-functions-meta.ts +94 -6
  85. package/src/utils/load-addon-scopes.test.ts +138 -0
  86. package/src/utils/permissions.test.ts +7 -232
  87. package/src/utils/permissions.ts +20 -160
  88. package/src/utils/post-process.test.ts +269 -4
  89. package/src/utils/post-process.ts +216 -95
  90. package/src/utils/serialize-inspector-state.ts +22 -25
  91. package/src/utils/serialize-permissions-groups-meta.ts +0 -28
  92. package/src/utils/workflow/derive-workflow-plan.test.ts +41 -3
  93. package/src/utils/workflow/derive-workflow-plan.ts +11 -4
  94. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +66 -0
  95. package/src/utils/workflow/dsl/patterns.ts +18 -0
  96. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +15 -0
  97. package/src/utils/workflow/graph/finalize-workflows.test.ts +50 -0
  98. package/src/utils/workflow/graph/finalize-workflows.ts +13 -2
  99. package/src/utils/workflow/graph/serialize-workflow-graph.ts +6 -0
  100. package/src/utils/workflow/graph/workflow-graph.types.ts +8 -0
  101. package/src/visit.ts +4 -0
  102. package/tsconfig.tsbuildinfo +1 -1
@@ -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`
@@ -0,0 +1,138 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, test, beforeEach, afterEach } from 'node:test'
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { loadAddonFunctionsMeta } from './load-addon-functions-meta.js'
7
+
8
+ const PACKAGE = '@test/addon'
9
+
10
+ let rootDir: string
11
+ let warnings: string[]
12
+
13
+ const logger = {
14
+ debug: () => {},
15
+ info: () => {},
16
+ warn: (msg: string) => warnings.push(msg),
17
+ error: () => {},
18
+ } as any
19
+
20
+ /**
21
+ * Lays out an installed addon the way the host resolves one: a real package in
22
+ * node_modules whose built `.pikku` carries its generated metadata.
23
+ */
24
+ const installAddon = (scopesMeta?: unknown) => {
25
+ const pkgDir = join(rootDir, 'node_modules', '@test', 'addon')
26
+ const pikkuDir = join(pkgDir, '.pikku')
27
+
28
+ mkdirSync(join(pikkuDir, 'function'), { recursive: true })
29
+ writeFileSync(
30
+ join(pkgDir, 'package.json'),
31
+ JSON.stringify({ name: PACKAGE, version: '1.0.0' }),
32
+ 'utf8'
33
+ )
34
+ writeFileSync(
35
+ join(pikkuDir, 'function', 'pikku-functions-meta.gen.json'),
36
+ JSON.stringify({ someFunc: { pikkuFuncId: 'someFunc' } }),
37
+ 'utf8'
38
+ )
39
+
40
+ if (scopesMeta !== undefined) {
41
+ mkdirSync(join(pikkuDir, 'scopes'), { recursive: true })
42
+ writeFileSync(
43
+ join(pikkuDir, 'scopes', 'pikku-scopes-meta.gen.json'),
44
+ JSON.stringify(scopesMeta),
45
+ 'utf8'
46
+ )
47
+ }
48
+ }
49
+
50
+ const makeState = (definitions: any[] = []) =>
51
+ ({
52
+ rootDir,
53
+ addonFunctions: {},
54
+ addonServerlessIncompatible: new Map(),
55
+ addonRequiredParentServices: [],
56
+ mcpEndpoints: { toolsMeta: {} },
57
+ exportedContracts: { addonHttp: {}, addonCli: {}, addonChannel: {} },
58
+ secrets: { definitions: [] },
59
+ variables: { definitions: [] },
60
+ scopes: { definitions, files: new Set() },
61
+ schemas: {},
62
+ rpc: {
63
+ wireAddonDeclarations: new Map([['test', { package: PACKAGE }]]),
64
+ },
65
+ }) as any
66
+
67
+ beforeEach(() => {
68
+ rootDir = mkdtempSync(join(tmpdir(), 'pikku-addon-scopes-'))
69
+ warnings = []
70
+ writeFileSync(
71
+ join(rootDir, 'package.json'),
72
+ JSON.stringify({ name: 'host' }),
73
+ 'utf8'
74
+ )
75
+ })
76
+
77
+ afterEach(() => {
78
+ rmSync(rootDir, { recursive: true, force: true })
79
+ })
80
+
81
+ describe('loadAddonFunctionsMeta — addon scopes', () => {
82
+ // Without this, an addon's own scopes never reach the host's ScopeId union or
83
+ // its declared set, so the pikku_scopes FK would reject granting one.
84
+ test("merges an addon's declared scopes into the host state", async () => {
85
+ installAddon({
86
+ pikku: {
87
+ name: 'pikku',
88
+ displayName: 'Pikku',
89
+ scopes: { scopes: { scopes: { manage: {} } } },
90
+ },
91
+ })
92
+ const state = makeState()
93
+
94
+ await loadAddonFunctionsMeta(logger, state)
95
+
96
+ assert.deepEqual(
97
+ state.scopes.definitions.map((d: any) => d.name),
98
+ ['pikku']
99
+ )
100
+ assert.deepEqual(state.scopes.definitions[0].scopes, {
101
+ scopes: { scopes: { manage: {} } },
102
+ })
103
+ })
104
+
105
+ test("keeps the host's own scopes alongside the addon's", async () => {
106
+ installAddon({ pikku: { name: 'pikku' } })
107
+ const state = makeState([{ name: 'admin' }])
108
+
109
+ await loadAddonFunctionsMeta(logger, state)
110
+
111
+ assert.deepEqual(state.scopes.definitions.map((d: any) => d.name).sort(), [
112
+ 'admin',
113
+ 'pikku',
114
+ ])
115
+ })
116
+
117
+ // A host that already declares the name owns it — mirrors how addon secrets
118
+ // and variables resolve the same collision.
119
+ test('does not let an addon redeclare a scope the host already owns', async () => {
120
+ installAddon({ admin: { name: 'admin', description: 'from the addon' } })
121
+ const state = makeState([{ name: 'admin', description: 'from the host' }])
122
+
123
+ await loadAddonFunctionsMeta(logger, state)
124
+
125
+ assert.equal(state.scopes.definitions.length, 1)
126
+ assert.equal(state.scopes.definitions[0].description, 'from the host')
127
+ })
128
+
129
+ test('is a no-op for an addon that declares no scopes', async () => {
130
+ installAddon()
131
+ const state = makeState()
132
+
133
+ await loadAddonFunctionsMeta(logger, state)
134
+
135
+ assert.deepEqual(state.scopes.definitions, [])
136
+ assert.deepEqual(warnings, [])
137
+ })
138
+ })