@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.
- package/CHANGELOG.md +98 -0
- package/dist/add/add-addon-bans.js +1 -0
- package/dist/add/add-ai-agent.d.ts +3 -1
- package/dist/add/add-ai-agent.js +82 -0
- package/dist/add/add-credential.js +3 -1
- package/dist/add/add-functions.js +10 -9
- package/dist/add/add-gateway.js +3 -0
- package/dist/add/add-http-route.js +2 -2
- package/dist/add/add-mcp-prompt.js +0 -1
- package/dist/add/add-mcp-resource.js +0 -1
- package/dist/add/add-permission.d.ts +1 -1
- package/dist/add/add-permission.js +3 -177
- package/dist/add/add-queue-worker.js +3 -0
- package/dist/add/add-schedule.js +3 -0
- package/dist/add/add-scope.d.ts +2 -0
- package/dist/add/add-scope.js +146 -0
- package/dist/add/add-trigger.js +3 -0
- package/dist/add/add-wire-remote-addon.d.ts +10 -0
- package/dist/add/add-wire-remote-addon.js +66 -0
- package/dist/add/add-workflow-graph.js +32 -3
- package/dist/error-codes.d.ts +3 -0
- package/dist/error-codes.js +4 -0
- package/dist/inspector.js +11 -5
- package/dist/types.d.ts +19 -13
- package/dist/utils/custom-types-generator.js +2 -1
- package/dist/utils/ensure-function-metadata.d.ts +15 -0
- package/dist/utils/ensure-function-metadata.js +24 -0
- package/dist/utils/filter-inspector-state.js +9 -0
- package/dist/utils/get-property-value.d.ts +7 -2
- package/dist/utils/get-property-value.js +48 -1
- package/dist/utils/load-addon-functions-meta.js +81 -6
- package/dist/utils/permissions.d.ts +1 -21
- package/dist/utils/permissions.js +17 -108
- package/dist/utils/post-process.d.ts +30 -1
- package/dist/utils/post-process.js +150 -72
- package/dist/utils/serialize-inspector-state.d.ts +13 -8
- package/dist/utils/serialize-inspector-state.js +12 -6
- package/dist/utils/serialize-permissions-groups-meta.d.ts +0 -2
- package/dist/utils/serialize-permissions-groups-meta.js +0 -26
- package/dist/utils/workflow/derive-workflow-plan.js +9 -3
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +56 -1
- package/dist/utils/workflow/dsl/patterns.d.ts +4 -0
- package/dist/utils/workflow/dsl/patterns.js +11 -0
- package/dist/utils/workflow/graph/convert-dsl-to-graph.js +14 -0
- package/dist/utils/workflow/graph/finalize-workflows.d.ts +7 -0
- package/dist/utils/workflow/graph/finalize-workflows.js +11 -2
- package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +2 -0
- package/dist/utils/workflow/graph/serialize-workflow-graph.js +4 -0
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +6 -2
- package/dist/visit.js +4 -0
- package/package.json +3 -3
- package/src/add/add-addon-bans.ts +1 -0
- package/src/add/add-ai-agent.test.ts +87 -0
- package/src/add/add-ai-agent.ts +122 -0
- package/src/add/add-credential.ts +6 -0
- package/src/add/add-functions.ts +10 -12
- package/src/add/add-gateway.ts +11 -0
- package/src/add/add-http-route.ts +2 -8
- package/src/add/add-mcp-prompt.ts +0 -1
- package/src/add/add-mcp-resource.ts +0 -1
- package/src/add/add-permission.ts +4 -242
- package/src/add/add-queue-worker.ts +11 -0
- package/src/add/add-schedule.ts +11 -0
- package/src/add/add-scope.test.ts +346 -0
- package/src/add/add-scope.ts +225 -0
- package/src/add/add-trigger.ts +11 -0
- package/src/add/add-wire-remote-addon.ts +77 -0
- package/src/add/add-workflow-graph-input.test.ts +94 -0
- package/src/add/add-workflow-graph-notes.test.ts +74 -0
- package/src/add/add-workflow-graph.ts +35 -3
- package/src/add/inline-wiring-function.test.ts +104 -0
- package/src/add/validate-workflow-graph-addons.test.ts +102 -0
- package/src/error-codes.ts +5 -0
- package/src/inspector.ts +14 -4
- package/src/types.ts +16 -17
- package/src/utils/custom-types-generator.test.ts +26 -1
- package/src/utils/custom-types-generator.ts +2 -1
- package/src/utils/ensure-function-metadata.ts +42 -0
- package/src/utils/filter-inspector-state.test.ts +0 -2
- package/src/utils/filter-inspector-state.ts +9 -0
- package/src/utils/get-property-value.test.ts +141 -0
- package/src/utils/get-property-value.ts +62 -1
- package/src/utils/load-addon-functions-meta.test.ts +157 -0
- package/src/utils/load-addon-functions-meta.ts +94 -6
- package/src/utils/load-addon-scopes.test.ts +138 -0
- package/src/utils/permissions.test.ts +7 -232
- package/src/utils/permissions.ts +20 -160
- package/src/utils/post-process.test.ts +269 -4
- package/src/utils/post-process.ts +216 -95
- package/src/utils/serialize-inspector-state.ts +22 -25
- package/src/utils/serialize-permissions-groups-meta.ts +0 -28
- package/src/utils/workflow/derive-workflow-plan.test.ts +41 -3
- package/src/utils/workflow/derive-workflow-plan.ts +11 -4
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +66 -0
- package/src/utils/workflow/dsl/patterns.ts +18 -0
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +15 -0
- package/src/utils/workflow/graph/finalize-workflows.test.ts +50 -0
- package/src/utils/workflow/graph/finalize-workflows.ts +13 -2
- package/src/utils/workflow/graph/serialize-workflow-graph.ts +6 -0
- package/src/utils/workflow/graph/workflow-graph.types.ts +8 -0
- package/src/visit.ts +4 -0
- 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 ===
|
|
186
|
+
(d: any) => (d.secretId ?? d.name) === resolvedSecretId
|
|
173
187
|
)
|
|
174
188
|
if (!existing) {
|
|
175
|
-
state.secrets.definitions.push(
|
|
176
|
-
|
|
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 ===
|
|
240
|
+
(d: any) => (d.variableId ?? d.name) === resolvedVariableId
|
|
193
241
|
)
|
|
194
242
|
if (!existing) {
|
|
195
|
-
state.variables.definitions.push(
|
|
196
|
-
|
|
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
|
+
})
|