@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
|
@@ -10,9 +10,12 @@ import type {
|
|
|
10
10
|
MiddlewareMetadata,
|
|
11
11
|
PermissionMetadata,
|
|
12
12
|
} from '@pikku/core'
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
13
15
|
import { extractTypeKeys } from './type-utils.js'
|
|
14
16
|
import { ErrorCode } from '../error-codes.js'
|
|
15
17
|
import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
|
|
18
|
+
import { flattenScopeDefinitions } from '@pikku/core/scope'
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Stamp the inspected authorize/callbacks service set onto the generated auth
|
|
@@ -46,7 +49,7 @@ export function stampAuthHandlerServices(
|
|
|
46
49
|
* Skips type:'http' and type:'tag' (reference groups, not individuals).
|
|
47
50
|
*/
|
|
48
51
|
export function extractWireNames(
|
|
49
|
-
list?: MiddlewareMetadata
|
|
52
|
+
list?: Array<MiddlewareMetadata | PermissionMetadata>
|
|
50
53
|
): string[] {
|
|
51
54
|
if (!list) return []
|
|
52
55
|
return list
|
|
@@ -57,34 +60,25 @@ export function extractWireNames(
|
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
|
-
* Helper to expand middleware
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
+
* Helper to expand middleware groups into individual names and add their
|
|
64
|
+
* services to the aggregation. Handles tag-based and HTTP-pattern-based
|
|
65
|
+
* middleware groups. Permissions are function-scoped only and carry no groups.
|
|
63
66
|
*/
|
|
64
67
|
function expandAndAddGroupServices(
|
|
65
|
-
list: MiddlewareMetadata[] |
|
|
68
|
+
list: MiddlewareMetadata[] | undefined,
|
|
66
69
|
state: InspectorState | Omit<InspectorState, 'typesLookup'>,
|
|
67
|
-
addServices: (services: FunctionServicesMeta | undefined) => void
|
|
68
|
-
isMiddleware: boolean
|
|
70
|
+
addServices: (services: FunctionServicesMeta | undefined) => void
|
|
69
71
|
): void {
|
|
70
72
|
if (!list) return
|
|
71
73
|
|
|
72
74
|
for (const item of list) {
|
|
73
75
|
if (item.type === 'tag') {
|
|
74
|
-
|
|
75
|
-
const groupMeta = isMiddleware
|
|
76
|
-
? state.middleware.tagMiddleware.get(item.tag)
|
|
77
|
-
: state.permissions.tagPermissions.get(item.tag)
|
|
78
|
-
|
|
76
|
+
const groupMeta = state.middleware.tagMiddleware.get(item.tag)
|
|
79
77
|
if (groupMeta?.services) {
|
|
80
78
|
addServices(groupMeta.services)
|
|
81
79
|
}
|
|
82
80
|
} else if (item.type === 'http' && 'route' in item) {
|
|
83
|
-
|
|
84
|
-
const groupMeta = isMiddleware
|
|
85
|
-
? state.http.routeMiddleware.get(item.route)
|
|
86
|
-
: state.http.routePermissions.get(item.route)
|
|
87
|
-
|
|
81
|
+
const groupMeta = state.http.routeMiddleware.get(item.route)
|
|
88
82
|
if (groupMeta?.services) {
|
|
89
83
|
addServices(groupMeta.services)
|
|
90
84
|
}
|
|
@@ -188,55 +182,33 @@ export function aggregateRequiredServices(
|
|
|
188
182
|
'options',
|
|
189
183
|
] as const) {
|
|
190
184
|
for (const routeMeta of Object.values(state.http.meta[method])) {
|
|
191
|
-
expandAndAddGroupServices(routeMeta.middleware, state, addServices
|
|
192
|
-
expandAndAddGroupServices(
|
|
193
|
-
routeMeta.permissions,
|
|
194
|
-
state,
|
|
195
|
-
addServices,
|
|
196
|
-
false
|
|
197
|
-
)
|
|
185
|
+
expandAndAddGroupServices(routeMeta.middleware, state, addServices)
|
|
198
186
|
}
|
|
199
187
|
}
|
|
200
188
|
|
|
201
189
|
// Also check other wiring types (channels, queues, schedulers, MCP)
|
|
202
190
|
for (const channelMeta of Object.values(state.channels.meta)) {
|
|
203
|
-
expandAndAddGroupServices(channelMeta.middleware, state, addServices
|
|
204
|
-
expandAndAddGroupServices(
|
|
205
|
-
channelMeta.permissions,
|
|
206
|
-
state,
|
|
207
|
-
addServices,
|
|
208
|
-
false
|
|
209
|
-
)
|
|
191
|
+
expandAndAddGroupServices(channelMeta.middleware, state, addServices)
|
|
210
192
|
}
|
|
211
193
|
|
|
212
194
|
for (const queueMeta of Object.values(state.queueWorkers.meta)) {
|
|
213
|
-
expandAndAddGroupServices(queueMeta.middleware, state, addServices
|
|
214
|
-
// expandAndAddGroupServices(queueMeta.permissions, state, addServices, false)
|
|
195
|
+
expandAndAddGroupServices(queueMeta.middleware, state, addServices)
|
|
215
196
|
}
|
|
216
197
|
|
|
217
198
|
for (const scheduleMeta of Object.values(state.scheduledTasks.meta)) {
|
|
218
|
-
expandAndAddGroupServices(scheduleMeta.middleware, state, addServices
|
|
219
|
-
// expandAndAddGroupServices(scheduleMeta.permissions, state, addServices, false)
|
|
199
|
+
expandAndAddGroupServices(scheduleMeta.middleware, state, addServices)
|
|
220
200
|
}
|
|
221
201
|
|
|
222
202
|
for (const toolMeta of Object.values(state.mcpEndpoints.toolsMeta)) {
|
|
223
|
-
expandAndAddGroupServices(toolMeta.middleware, state, addServices
|
|
224
|
-
expandAndAddGroupServices(toolMeta.permissions, state, addServices, false)
|
|
203
|
+
expandAndAddGroupServices(toolMeta.middleware, state, addServices)
|
|
225
204
|
}
|
|
226
205
|
|
|
227
206
|
for (const promptMeta of Object.values(state.mcpEndpoints.promptsMeta)) {
|
|
228
|
-
expandAndAddGroupServices(promptMeta.middleware, state, addServices
|
|
229
|
-
expandAndAddGroupServices(promptMeta.permissions, state, addServices, false)
|
|
207
|
+
expandAndAddGroupServices(promptMeta.middleware, state, addServices)
|
|
230
208
|
}
|
|
231
209
|
|
|
232
210
|
for (const resourceMeta of Object.values(state.mcpEndpoints.resourcesMeta)) {
|
|
233
|
-
expandAndAddGroupServices(resourceMeta.middleware, state, addServices
|
|
234
|
-
expandAndAddGroupServices(
|
|
235
|
-
resourceMeta.permissions,
|
|
236
|
-
state,
|
|
237
|
-
addServices,
|
|
238
|
-
false
|
|
239
|
-
)
|
|
211
|
+
expandAndAddGroupServices(resourceMeta.middleware, state, addServices)
|
|
240
212
|
}
|
|
241
213
|
|
|
242
214
|
// 5. Services from session service factories
|
|
@@ -378,17 +350,24 @@ export function validateSecretOverrides(
|
|
|
378
350
|
const { wireAddonDeclarations } = state.rpc
|
|
379
351
|
if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
|
|
380
352
|
|
|
381
|
-
|
|
353
|
+
// secretOverrides key on (and resolve to) SECRET IDs — the string the addon
|
|
354
|
+
// passes to getSecret — so validate against secretId, falling back to name for
|
|
355
|
+
// older meta without a secretId field.
|
|
356
|
+
const secretIds = new Set(
|
|
357
|
+
state.secrets.definitions.map((d: any) => d.secretId ?? d.name)
|
|
358
|
+
)
|
|
382
359
|
|
|
383
360
|
for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
|
|
384
361
|
if (!addonDecl.secretOverrides) continue
|
|
385
362
|
|
|
386
|
-
for (const
|
|
387
|
-
|
|
388
|
-
|
|
363
|
+
for (const [logicalName, resolvedName] of Object.entries(
|
|
364
|
+
addonDecl.secretOverrides
|
|
365
|
+
)) {
|
|
366
|
+
if (!secretIds.has(resolvedName)) {
|
|
367
|
+
const availableSecrets = Array.from(secretIds)
|
|
389
368
|
logger.critical(
|
|
390
369
|
ErrorCode.INVALID_VALUE,
|
|
391
|
-
`Secret override '${
|
|
370
|
+
`Secret override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a secret that does not exist. Available secrets: ${availableSecrets.join(', ') || 'none'}`
|
|
392
371
|
)
|
|
393
372
|
}
|
|
394
373
|
}
|
|
@@ -409,12 +388,14 @@ export function validateCredentialOverrides(
|
|
|
409
388
|
for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
|
|
410
389
|
if (!addonDecl.credentialOverrides) continue
|
|
411
390
|
|
|
412
|
-
for (const
|
|
413
|
-
|
|
391
|
+
for (const [logicalName, resolvedName] of Object.entries(
|
|
392
|
+
addonDecl.credentialOverrides
|
|
393
|
+
)) {
|
|
394
|
+
if (!credentialNames.has(resolvedName)) {
|
|
414
395
|
const availableCredentials = Array.from(credentialNames)
|
|
415
396
|
logger.critical(
|
|
416
397
|
ErrorCode.INVALID_VALUE,
|
|
417
|
-
`Credential override '${
|
|
398
|
+
`Credential override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a credential that does not exist. Available credentials: ${availableCredentials.join(', ') || 'none'}`
|
|
418
399
|
)
|
|
419
400
|
}
|
|
420
401
|
}
|
|
@@ -428,23 +409,120 @@ export function validateVariableOverrides(
|
|
|
428
409
|
const { wireAddonDeclarations } = state.rpc
|
|
429
410
|
if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
|
|
430
411
|
|
|
431
|
-
|
|
412
|
+
// variableOverrides key on (and resolve to) VARIABLE IDs, so validate against
|
|
413
|
+
// variableId, falling back to name for older meta without a variableId field.
|
|
414
|
+
const variableIds = new Set(
|
|
415
|
+
state.variables.definitions.map((d: any) => d.variableId ?? d.name)
|
|
416
|
+
)
|
|
432
417
|
|
|
433
418
|
for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
|
|
434
419
|
if (!addonDecl.variableOverrides) continue
|
|
435
420
|
|
|
436
|
-
for (const
|
|
437
|
-
|
|
438
|
-
|
|
421
|
+
for (const [logicalName, resolvedName] of Object.entries(
|
|
422
|
+
addonDecl.variableOverrides
|
|
423
|
+
)) {
|
|
424
|
+
if (!variableIds.has(resolvedName)) {
|
|
425
|
+
const availableVariables = Array.from(variableIds)
|
|
439
426
|
logger.critical(
|
|
440
427
|
ErrorCode.INVALID_VALUE,
|
|
441
|
-
`Variable override '${
|
|
428
|
+
`Variable override '${logicalName}' -> '${resolvedName}' in addon '${namespace}' (${addonDecl.package}) targets a variable that does not exist. Available variables: ${availableVariables.join(', ') || 'none'}`
|
|
442
429
|
)
|
|
443
430
|
}
|
|
444
431
|
}
|
|
445
432
|
}
|
|
446
433
|
}
|
|
447
434
|
|
|
435
|
+
/**
|
|
436
|
+
* A `wireRemoteAddon` package ships types only — its handlers run on the host —
|
|
437
|
+
* so it MUST be a devDependency, not a production dependency (a prod dep would
|
|
438
|
+
* drag in the runtime deps remote consumption exists to avoid). This is the
|
|
439
|
+
* mirror image of `wireAddon`, which requires a production dependency.
|
|
440
|
+
*/
|
|
441
|
+
export function validateRemoteAddonDependencies(
|
|
442
|
+
logger: InspectorLogger,
|
|
443
|
+
state: InspectorState | Omit<InspectorState, 'typesLookup'>
|
|
444
|
+
): void {
|
|
445
|
+
const { wireAddonDeclarations } = state.rpc
|
|
446
|
+
if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
|
|
447
|
+
|
|
448
|
+
const hasRemote = Array.from(wireAddonDeclarations.values()).some(
|
|
449
|
+
(d) => d.remote
|
|
450
|
+
)
|
|
451
|
+
if (!hasRemote) return
|
|
452
|
+
|
|
453
|
+
const pkgJsonPath = join(state.rootDir, 'package.json')
|
|
454
|
+
if (!existsSync(pkgJsonPath)) return // no manifest to check (e.g. some tests)
|
|
455
|
+
|
|
456
|
+
let pkgJson: {
|
|
457
|
+
dependencies?: Record<string, string>
|
|
458
|
+
devDependencies?: Record<string, string>
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))
|
|
462
|
+
} catch (e: any) {
|
|
463
|
+
logger.warn(
|
|
464
|
+
`Could not read ${pkgJsonPath} to verify remote addon dependencies: ${e?.message ?? e}`
|
|
465
|
+
)
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const prodDeps = pkgJson.dependencies ?? {}
|
|
470
|
+
const devDeps = pkgJson.devDependencies ?? {}
|
|
471
|
+
|
|
472
|
+
for (const [namespace, decl] of wireAddonDeclarations.entries()) {
|
|
473
|
+
if (!decl.remote) continue
|
|
474
|
+
if (decl.package in devDeps) continue // correct
|
|
475
|
+
|
|
476
|
+
if (decl.package in prodDeps) {
|
|
477
|
+
logger.critical(
|
|
478
|
+
ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY,
|
|
479
|
+
`Remote addon '${namespace}' ('${decl.package}') is a production dependency, but wireRemoteAddon consumes it for types only — its handlers run on the host. Move '${decl.package}' from "dependencies" to "devDependencies".`
|
|
480
|
+
)
|
|
481
|
+
} else {
|
|
482
|
+
logger.critical(
|
|
483
|
+
ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY,
|
|
484
|
+
`Remote addon '${namespace}' ('${decl.package}') is wired with wireRemoteAddon but is not in "devDependencies". Add '${decl.package}' to "devDependencies" (types only).`
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The auth a `wireRemoteAddon` consumer binds must reference a slot the consumer
|
|
492
|
+
* actually declares: a `credentialId` must be a wired credential, a `secretId`
|
|
493
|
+
* a wired secret. A custom `resolve()` and a public (omitted) surface are not
|
|
494
|
+
* statically checkable and are left to runtime.
|
|
495
|
+
*/
|
|
496
|
+
export function validateRemoteAddonAuth(
|
|
497
|
+
logger: InspectorLogger,
|
|
498
|
+
state: InspectorState | Omit<InspectorState, 'typesLookup'>
|
|
499
|
+
): void {
|
|
500
|
+
const { wireAddonDeclarations } = state.rpc
|
|
501
|
+
if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) return
|
|
502
|
+
|
|
503
|
+
const credentialNames = new Set(
|
|
504
|
+
state.credentials?.definitions.map((d) => d.name) ?? []
|
|
505
|
+
)
|
|
506
|
+
const secretNames = new Set(state.secrets.definitions.map((d) => d.name))
|
|
507
|
+
|
|
508
|
+
for (const [namespace, decl] of wireAddonDeclarations.entries()) {
|
|
509
|
+
if (!decl.remote) continue
|
|
510
|
+
|
|
511
|
+
if (decl.authCredentialId && !credentialNames.has(decl.authCredentialId)) {
|
|
512
|
+
logger.critical(
|
|
513
|
+
ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED,
|
|
514
|
+
`Remote addon '${namespace}' binds auth.credentialId '${decl.authCredentialId}', but no such credential is wired. Available credentials: ${Array.from(credentialNames).join(', ') || 'none'}`
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
if (decl.authSecretId && !secretNames.has(decl.authSecretId)) {
|
|
518
|
+
logger.critical(
|
|
519
|
+
ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED,
|
|
520
|
+
`Remote addon '${namespace}' binds auth.secretId '${decl.authSecretId}', but no such secret is wired. Available secrets: ${Array.from(secretNames).join(', ') || 'none'}`
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
448
526
|
export function computeResolvedIOTypes(state: InspectorState): void {
|
|
449
527
|
const { functions } = state
|
|
450
528
|
for (const [pikkuFuncId, meta] of Object.entries(functions.meta)) {
|
|
@@ -531,36 +609,8 @@ export function computeMiddlewareGroupsMeta(state: InspectorState): void {
|
|
|
531
609
|
}
|
|
532
610
|
|
|
533
611
|
export function computePermissionsGroupsMeta(state: InspectorState): void {
|
|
534
|
-
const httpGroups: Record<string, any> = {}
|
|
535
|
-
for (const [pattern, meta] of state.http.routePermissions.entries()) {
|
|
536
|
-
httpGroups[pattern] = {
|
|
537
|
-
exportName: meta.exportName,
|
|
538
|
-
sourceFile: meta.sourceFile,
|
|
539
|
-
position: meta.position,
|
|
540
|
-
services: meta.services,
|
|
541
|
-
count: meta.count,
|
|
542
|
-
instanceIds: meta.instanceIds,
|
|
543
|
-
isFactory: meta.isFactory,
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
const tagGroups: Record<string, any> = {}
|
|
548
|
-
for (const [tag, meta] of state.permissions.tagPermissions.entries()) {
|
|
549
|
-
tagGroups[tag] = {
|
|
550
|
-
exportName: meta.exportName,
|
|
551
|
-
sourceFile: meta.sourceFile,
|
|
552
|
-
position: meta.position,
|
|
553
|
-
services: meta.services,
|
|
554
|
-
count: meta.count,
|
|
555
|
-
instanceIds: meta.instanceIds,
|
|
556
|
-
isFactory: meta.isFactory,
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
612
|
state.permissionsGroupsMeta = {
|
|
561
613
|
definitions: state.permissions.definitions,
|
|
562
|
-
httpGroups,
|
|
563
|
-
tagGroups,
|
|
564
614
|
}
|
|
565
615
|
}
|
|
566
616
|
|
|
@@ -632,6 +682,44 @@ export function validateAgentModels(
|
|
|
632
682
|
}
|
|
633
683
|
}
|
|
634
684
|
|
|
685
|
+
/**
|
|
686
|
+
* A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
|
|
687
|
+
* `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
|
|
688
|
+
* registers and codegen fails deep in type-checking with an opaque error. Fail
|
|
689
|
+
* early with an actionable message pointing at `scaffold: { graph: true }`.
|
|
690
|
+
*/
|
|
691
|
+
export function validateWorkflowGraphAddons(
|
|
692
|
+
logger: InspectorLogger,
|
|
693
|
+
state: InspectorState
|
|
694
|
+
): void {
|
|
695
|
+
const addonGraphWired = Array.from(
|
|
696
|
+
state.rpc.wireAddonDeclarations.values()
|
|
697
|
+
).some((decl) => decl.package === '@pikku/addon-graph')
|
|
698
|
+
if (addonGraphWired) {
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
for (const [name, graph] of Object.entries(state.workflows.graphMeta)) {
|
|
703
|
+
for (const node of Object.values(graph.nodes)) {
|
|
704
|
+
if (!('rpcName' in node) || typeof node.rpcName !== 'string') {
|
|
705
|
+
continue
|
|
706
|
+
}
|
|
707
|
+
if (!node.rpcName.startsWith('graph:')) {
|
|
708
|
+
continue
|
|
709
|
+
}
|
|
710
|
+
if (state.functions.meta[node.rpcName]) {
|
|
711
|
+
continue
|
|
712
|
+
}
|
|
713
|
+
logger.critical(
|
|
714
|
+
ErrorCode.WORKFLOW_GRAPH_ADDON_NOT_WIRED,
|
|
715
|
+
`Workflow graph '${name}' references '${node.rpcName}' but @pikku/addon-graph is not wired. ` +
|
|
716
|
+
`Enable "scaffold": { "graph": true } in pikku.config.json (and install @pikku/addon-graph), ` +
|
|
717
|
+
`or wire it manually with wireAddon({ name: 'graph', package: '@pikku/addon-graph' }).`
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
635
723
|
/**
|
|
636
724
|
* Validates that Zod schemas and wiring side-effects (wireHTTPRoutes,
|
|
637
725
|
* addPermission, addHTTPMiddleware, etc.) do not coexist in the same file.
|
|
@@ -658,14 +746,6 @@ export function validateSchemaWiringSeparation(
|
|
|
658
746
|
wiringFiles.add(file)
|
|
659
747
|
}
|
|
660
748
|
|
|
661
|
-
// Permission wirings (addPermission calls)
|
|
662
|
-
for (const meta of state.permissions.tagPermissions.values()) {
|
|
663
|
-
wiringFiles.add(meta.sourceFile)
|
|
664
|
-
}
|
|
665
|
-
for (const meta of state.http.routePermissions.values()) {
|
|
666
|
-
wiringFiles.add(meta.sourceFile)
|
|
667
|
-
}
|
|
668
|
-
|
|
669
749
|
// Middleware wirings (addHTTPMiddleware calls)
|
|
670
750
|
for (const meta of state.http.routeMiddleware.values()) {
|
|
671
751
|
wiringFiles.add(meta.sourceFile)
|
|
@@ -769,3 +849,44 @@ export function computeDiagnostics(state: InspectorState): void {
|
|
|
769
849
|
|
|
770
850
|
state.diagnostics = diagnostics
|
|
771
851
|
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Validates that every scope referenced by a function is declared via
|
|
855
|
+
* `wireScope`. Runs after all visitors, so declaration order does not matter.
|
|
856
|
+
*
|
|
857
|
+
* A `*` suffix is a wildcard requirement: `admin:*` requires the `admin` scope
|
|
858
|
+
* to be declared, and grants its whole subtree.
|
|
859
|
+
*/
|
|
860
|
+
export function validateScopeReferences(
|
|
861
|
+
logger: InspectorLogger,
|
|
862
|
+
state: InspectorState | Omit<InspectorState, 'typesLookup'>
|
|
863
|
+
): void {
|
|
864
|
+
const declared = new Set(
|
|
865
|
+
flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id)
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
for (const [funcName, meta] of Object.entries(state.functions.meta)) {
|
|
869
|
+
if (!meta.scopes?.length) continue
|
|
870
|
+
|
|
871
|
+
for (const scope of meta.scopes) {
|
|
872
|
+
// A trailing wildcard grants a subtree; the node it hangs off must exist.
|
|
873
|
+
const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope
|
|
874
|
+
|
|
875
|
+
if (scope === '*') {
|
|
876
|
+
logger.critical(
|
|
877
|
+
ErrorCode.INVALID_VALUE,
|
|
878
|
+
`Function '${funcName}' requires the bare wildcard scope '*'. A function must require a specific scope — '*' is only meaningful as a grant.`
|
|
879
|
+
)
|
|
880
|
+
continue
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (!declared.has(declaredForm)) {
|
|
884
|
+
const available = Array.from(declared)
|
|
885
|
+
logger.critical(
|
|
886
|
+
ErrorCode.INVALID_VALUE,
|
|
887
|
+
`Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with wireScope. Available scopes: ${available.join(', ') || 'none'}`
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { test, describe, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
4
|
+
import { tmpdir } from 'os'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
|
|
7
|
+
import { getInitialInspectorState } from '../inspector.js'
|
|
8
|
+
import { generateAllSchemas } from './schema-generator.js'
|
|
9
|
+
|
|
10
|
+
const debugLines: string[] = []
|
|
11
|
+
const logger = {
|
|
12
|
+
debug(message: string) {
|
|
13
|
+
debugLines.push(message)
|
|
14
|
+
},
|
|
15
|
+
info() {},
|
|
16
|
+
warn() {},
|
|
17
|
+
error() {},
|
|
18
|
+
} as any
|
|
19
|
+
|
|
20
|
+
const UNRELATED_FILE_COUNT = 300
|
|
21
|
+
|
|
22
|
+
let projectDir: string
|
|
23
|
+
|
|
24
|
+
before(() => {
|
|
25
|
+
projectDir = mkdtempSync(join(tmpdir(), 'pikku-schema-gen-'))
|
|
26
|
+
mkdirSync(join(projectDir, 'src'))
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(projectDir, 'tsconfig.json'),
|
|
29
|
+
JSON.stringify({
|
|
30
|
+
compilerOptions: {
|
|
31
|
+
target: 'ESNext',
|
|
32
|
+
module: 'NodeNext',
|
|
33
|
+
moduleResolution: 'NodeNext',
|
|
34
|
+
strict: true,
|
|
35
|
+
skipLibCheck: true,
|
|
36
|
+
},
|
|
37
|
+
include: ['src/**/*.ts'],
|
|
38
|
+
})
|
|
39
|
+
)
|
|
40
|
+
writeFileSync(
|
|
41
|
+
join(projectDir, 'src', 'types.ts'),
|
|
42
|
+
'export type Thing = { id: string; count: number }\n'
|
|
43
|
+
)
|
|
44
|
+
// Files the tsconfig picks up but no generated custom type references. They
|
|
45
|
+
// stand in for the bulk of a real project, which the schema program has no
|
|
46
|
+
// reason to load.
|
|
47
|
+
for (let i = 0; i < UNRELATED_FILE_COUNT; i += 1) {
|
|
48
|
+
writeFileSync(
|
|
49
|
+
join(projectDir, 'src', `unrelated-${i}.ts`),
|
|
50
|
+
`export type Unrelated${i} = { a: string; b: number; c: boolean }\n`
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
after(() => {
|
|
56
|
+
rmSync(projectDir, { recursive: true, force: true })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('generateAllSchemas', () => {
|
|
60
|
+
// NOTE: this must be the first test in the file. The module-level caches are
|
|
61
|
+
// per-process, so only the first call actually builds a program — a later call
|
|
62
|
+
// reuses it and would measure a few MB no matter what.
|
|
63
|
+
//
|
|
64
|
+
// The schema generator builds a second ts.Program over the whole tsconfig —
|
|
65
|
+
// on a real tree that is ~2.5k files and ~600MB, and it used to be pinned at
|
|
66
|
+
// module scope for the life of the process. Combined with the main inspector
|
|
67
|
+
// program it pushed peak heap past 2GB and OOM'd `pikku all` (#982).
|
|
68
|
+
//
|
|
69
|
+
// Only the program is released; cachedTSSchemas (the small result cache that
|
|
70
|
+
// powers the same-process fast path) is deliberately kept.
|
|
71
|
+
test('does not retain the schema ts.Program after returning', async (t) => {
|
|
72
|
+
if (typeof global.gc !== 'function') {
|
|
73
|
+
t.skip('requires --expose-gc')
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const state = getInitialInspectorState(projectDir) as any
|
|
78
|
+
// A distinct type keeps this off the cachedTSSchemas fast path, so the
|
|
79
|
+
// program is genuinely rebuilt (and therefore genuinely retainable) here.
|
|
80
|
+
state.functions.typesMap.addCustomType(
|
|
81
|
+
'RetentionProbeInput',
|
|
82
|
+
'{ probe: string; n: number }',
|
|
83
|
+
[]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
global.gc!()
|
|
87
|
+
const before = process.memoryUsage().heapUsed
|
|
88
|
+
|
|
89
|
+
await generateAllSchemas(
|
|
90
|
+
logger,
|
|
91
|
+
{ tsconfig: join(projectDir, 'tsconfig.json') },
|
|
92
|
+
state
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
global.gc!()
|
|
96
|
+
const retainedMB = (process.memoryUsage().heapUsed - before) / 1024 / 1024
|
|
97
|
+
|
|
98
|
+
// A retained program (with its SourceFiles and TypeChecker) measures tens of
|
|
99
|
+
// MB even for this one-file fixture; the schemas themselves are a few KB.
|
|
100
|
+
assert.ok(
|
|
101
|
+
retainedMB < 10,
|
|
102
|
+
`expected the schema program to be released, but ${retainedMB.toFixed(1)}MB is still retained after GC`
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// The virtual file explicitly imports every type it needs, so it is a complete
|
|
107
|
+
// root on its own. Rooting the program at the whole tsconfig instead made it
|
|
108
|
+
// load the entire project (2572 files on a real tree vs 870), for no gain in
|
|
109
|
+
// what could be resolved (#982).
|
|
110
|
+
test('scopes the schema program to the virtual file import closure', async () => {
|
|
111
|
+
debugLines.length = 0
|
|
112
|
+
|
|
113
|
+
const state = getInitialInspectorState(projectDir) as any
|
|
114
|
+
state.functions.typesMap.addCustomType(
|
|
115
|
+
'ScopingProbeInput',
|
|
116
|
+
'{ scope: string }',
|
|
117
|
+
[]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
await generateAllSchemas(
|
|
121
|
+
logger,
|
|
122
|
+
{ tsconfig: join(projectDir, 'tsconfig.json') },
|
|
123
|
+
state
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const line = debugLines.find((l) => l.includes('Created schema program'))
|
|
127
|
+
assert.ok(
|
|
128
|
+
line,
|
|
129
|
+
`expected a "Created schema program" debug line, got: ${debugLines.join(' | ')}`
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const fileCount = Number(line!.match(/(\d+) files/)?.[1])
|
|
133
|
+
assert.ok(
|
|
134
|
+
Number.isFinite(fileCount),
|
|
135
|
+
`could not parse a file count out of: ${line}`
|
|
136
|
+
)
|
|
137
|
+
// The virtual file plus types.ts plus the default lib. Rooting at the whole
|
|
138
|
+
// tsconfig would pull in all UNRELATED_FILE_COUNT files on top of that.
|
|
139
|
+
assert.ok(
|
|
140
|
+
fileCount < UNRELATED_FILE_COUNT,
|
|
141
|
+
`expected the schema program to skip the ${UNRELATED_FILE_COUNT} unreferenced files, but it loaded ${fileCount}`
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('produces a schema for a custom type', async () => {
|
|
146
|
+
const state = getInitialInspectorState(projectDir) as any
|
|
147
|
+
state.functions.typesMap.addCustomType(
|
|
148
|
+
'ThingInput',
|
|
149
|
+
'{ id: string; count: number }',
|
|
150
|
+
[]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const schemas = await generateAllSchemas(
|
|
154
|
+
logger,
|
|
155
|
+
{ tsconfig: join(projectDir, 'tsconfig.json') },
|
|
156
|
+
state
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
assert.ok(
|
|
160
|
+
Object.keys(schemas).length > 0,
|
|
161
|
+
'expected at least one generated schema'
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -87,7 +87,9 @@ const inspectorVersion: string = (() => {
|
|
|
87
87
|
try {
|
|
88
88
|
const pkgUrl = new URL('../../package.json', import.meta.url)
|
|
89
89
|
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf-8'))
|
|
90
|
-
return typeof pkg.version === 'string'
|
|
90
|
+
return typeof pkg.version === 'string'
|
|
91
|
+
? pkg.version
|
|
92
|
+
: `v${SCHEMA_CACHE_VERSION}`
|
|
91
93
|
} catch {
|
|
92
94
|
return `v${SCHEMA_CACHE_VERSION}`
|
|
93
95
|
}
|
|
@@ -98,7 +100,10 @@ const inspectorVersion: string = (() => {
|
|
|
98
100
|
// inspector version (schema-format changes ship with a version bump).
|
|
99
101
|
function tsSchemaCacheKey(
|
|
100
102
|
customTypesContent: string,
|
|
101
|
-
config: {
|
|
103
|
+
config: {
|
|
104
|
+
schemasFromTypes?: string[]
|
|
105
|
+
schema?: { additionalProperties?: boolean }
|
|
106
|
+
}
|
|
102
107
|
): string {
|
|
103
108
|
return createHash('sha1')
|
|
104
109
|
.update(`v${SCHEMA_CACHE_VERSION}\0`)
|
|
@@ -145,6 +150,7 @@ function writeDiskTSSchemas(
|
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
function createProgramWithVirtualFile(
|
|
153
|
+
logger: InspectorLogger,
|
|
148
154
|
tsconfig: string,
|
|
149
155
|
virtualFilePath: string,
|
|
150
156
|
virtualFileContent: string
|
|
@@ -165,7 +171,11 @@ function createProgramWithVirtualFile(
|
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
const resolvedVirtualPath = resolve(virtualFilePath)
|
|
168
|
-
|
|
174
|
+
// The virtual file imports every type it references, so it is a complete root
|
|
175
|
+
// on its own and TypeScript pulls in exactly the transitive closure it needs.
|
|
176
|
+
// Rooting at cachedParsedConfig.fileNames instead loaded the whole project
|
|
177
|
+
// (2572 files vs 870 on a real tree) without widening what could be resolved.
|
|
178
|
+
const fileNames = [resolvedVirtualPath]
|
|
169
179
|
|
|
170
180
|
const defaultHost = ts.createCompilerHost(cachedParsedConfig.options)
|
|
171
181
|
const customHost: ts.CompilerHost = {
|
|
@@ -200,6 +210,7 @@ function createProgramWithVirtualFile(
|
|
|
200
210
|
},
|
|
201
211
|
}
|
|
202
212
|
|
|
213
|
+
const startProgram = performance.now()
|
|
203
214
|
const program = ts.createProgram(
|
|
204
215
|
fileNames,
|
|
205
216
|
cachedParsedConfig.options,
|
|
@@ -207,6 +218,9 @@ function createProgramWithVirtualFile(
|
|
|
207
218
|
cachedSchemaProgram // reuse previous program for incremental compilation
|
|
208
219
|
)
|
|
209
220
|
cachedSchemaProgram = program
|
|
221
|
+
logger.debug(
|
|
222
|
+
`Created schema program in ${(performance.now() - startProgram).toFixed(0)}ms (${program.getSourceFiles().length} files)`
|
|
223
|
+
)
|
|
210
224
|
return program
|
|
211
225
|
}
|
|
212
226
|
|
|
@@ -274,6 +288,7 @@ function generateTSSchemas(
|
|
|
274
288
|
'__pikku_virtual_types__.ts'
|
|
275
289
|
)
|
|
276
290
|
const program = createProgramWithVirtualFile(
|
|
291
|
+
logger,
|
|
277
292
|
tsconfig,
|
|
278
293
|
virtualFilePath,
|
|
279
294
|
customTypesContent
|
|
@@ -614,6 +629,14 @@ export async function generateAllSchemas(
|
|
|
614
629
|
zodSchemas
|
|
615
630
|
)
|
|
616
631
|
|
|
632
|
+
// Release the program once the schemas are out of it. It spans the whole
|
|
633
|
+
// tsconfig (~2.5k files, ~600MB on a large tree) and was previously pinned at
|
|
634
|
+
// module scope for the life of the process, so it stacked on top of the main
|
|
635
|
+
// inspector program and pushed `pikku all` past a 2GB heap (#982). The result
|
|
636
|
+
// cache below is what the same-process fast path actually needs; the program
|
|
637
|
+
// only bought incremental reuse on a rebuild, at a cost that dwarfs it.
|
|
638
|
+
cachedSchemaProgram = undefined
|
|
639
|
+
|
|
617
640
|
cachedCustomTypesContent = customTypesContent
|
|
618
641
|
cachedTSSchemas = tsSchemas
|
|
619
642
|
|