@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.
- package/CHANGELOG.md +266 -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 +5 -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-keyed-wiring.js +2 -0
- 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/add/add-workflow.js +9 -4
- 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/schema-generator.js +19 -4
- 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/deserialize-dsl-workflow.js +373 -91
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +274 -100
- 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 +76 -5
- 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 +8 -4
- package/dist/visit.js +4 -0
- package/package.json +3 -3
- package/run-tests.sh +1 -1
- 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 +8 -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-keyed-wiring.ts +2 -0
- 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-edge-cases.test.ts +201 -0
- package/src/add/add-workflow-fanout.test.ts +60 -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/add-workflow-on-error.test.ts +118 -0
- package/src/add/add-workflow-return-switch.test.ts +121 -0
- package/src/add/add-workflow-silent-drops.test.ts +163 -0
- package/src/add/add-workflow-sleep-expression.test.ts +97 -0
- package/src/add/add-workflow.ts +9 -4
- package/src/add/config-value-metadata.test.ts +169 -0
- 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/schema-generator.test.ts +164 -0
- package/src/utils/schema-generator.ts +26 -3
- 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/deserialize-dsl-workflow.ts +480 -119
- package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
- package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
- package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
- package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
- package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
- package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +311 -105
- package/src/utils/workflow/dsl/patterns.ts +18 -0
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +82 -10
- package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -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 +11 -2
- package/src/visit.ts +4 -0
- 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 {
|
|
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
|
-
|
|
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 ===
|
|
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`
|