@pikku/inspector 0.12.101 → 0.12.103

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 CHANGED
@@ -1,3 +1,28 @@
1
+ ## 0.12.103
2
+
3
+ ### Patch Changes
4
+
5
+ - 53ac40d: An addon can ship screens: `wireAddon({ ui: true })` mounts what the package declares with `defineScreens`, the inspector reads them, `pikku all` emits the screens manifest and the installed-addons registry, and `x-pikku-addon` narrows a session to the addon's role. `pikku new addon --ui` scaffolds one; its tables must be prefixed `<name>_`.
6
+ - Updated dependencies [a196a21]
7
+ - Updated dependencies [53ac40d]
8
+ - Updated dependencies [443fd40]
9
+ - Updated dependencies [56c8047]
10
+ - @pikku/core@0.12.139
11
+
12
+ ## 0.12.102
13
+
14
+ ### Patch Changes
15
+
16
+ - 3c0b151: `pikku validate` checks block libraries. The project's `pikku.config.json` lists them by package name under `blocks`; each listed package's `src/blocks` is checked. A block that renders a literal string is an error, because its words have to come from messages, and a block with no `.stories.tsx` is a warning. A block with no errors is reported as productized. A listed name that matches no package, or a package with no `src/blocks`, is an error.
17
+ - 0963b73: Every scope tree now records where it came from: `origin` on `ScopeDefinitionMeta` is `{ kind: 'app' }` for the app's own `defineScope`, `{ kind: 'generated' }` for a tree the CLI wrote into the project (a `.gen.ts`, or the `app` root derived from personas), and `{ kind: 'addon', package, displayName }` for one an installed addon declared. An addon's build stamps its own package and display name on its trees, and the host keeps that name while recording the package it actually installed. `MetaService.getScopesMeta()` reads the result, and the console's all-meta payload carries it as `scopes`.
18
+
19
+ The console's Scopes page becomes **Permissions** and is grouped by that origin: the app's own permissions first, then what Pikku generated into the project, then one card per addon (Pikku's own before third-party, each alphabetical), each card folding to its header — the app's own starts open, every addon's starts folded, and any search or filter opens them all. Above the cards, two filters narrow the page to what one role is given (or to what no role is given yet) and to one source. A permission group no longer says "Not part of any role yet" above lines that are each given to a role.
20
+
21
+ - Updated dependencies [0c865d9]
22
+ - Updated dependencies [c36b066]
23
+ - Updated dependencies [0963b73]
24
+ - @pikku/core@0.12.137
25
+
1
26
  ## 0.12.101
2
27
 
3
28
  ### Patch Changes
@@ -0,0 +1,8 @@
1
+ import * as ts from 'typescript';
2
+ import type { InspectorState, InspectorLogger } from '../types.js';
3
+ /**
4
+ * Detect `defineScreens({ title, icon, screens })` and record the manifest.
5
+ * A screen with a field that is not a literal is reported and left out: a
6
+ * navigation entry the host cannot read would otherwise vanish with no sign.
7
+ */
8
+ export declare function addDefineScreens(node: ts.Node, state: InspectorState, logger: InspectorLogger): void;
@@ -0,0 +1,140 @@
1
+ import * as ts from 'typescript';
2
+ import { ErrorCode } from '../error-codes.js';
3
+ const propName = (prop) => {
4
+ if (!ts.isPropertyAssignment(prop))
5
+ return undefined;
6
+ return ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
7
+ ? prop.name.text
8
+ : undefined;
9
+ };
10
+ const stringArray = (node) => {
11
+ if (!ts.isArrayLiteralExpression(node))
12
+ return undefined;
13
+ const values = [];
14
+ for (const element of node.elements) {
15
+ if (!ts.isStringLiteral(element))
16
+ return undefined;
17
+ values.push(element.text);
18
+ }
19
+ return values;
20
+ };
21
+ /**
22
+ * The module specifier of `() => import('./screens/x')`. Anything else cannot
23
+ * be handed to the host's bundler as a lazy import, so it is not read.
24
+ */
25
+ const lazyImportSpecifier = (node) => {
26
+ if (!ts.isArrowFunction(node))
27
+ return undefined;
28
+ const body = node.body;
29
+ if (!ts.isCallExpression(body) ||
30
+ body.expression.kind !== ts.SyntaxKind.ImportKeyword)
31
+ return undefined;
32
+ const [specifier] = body.arguments;
33
+ return specifier && ts.isStringLiteral(specifier) ? specifier.text : undefined;
34
+ };
35
+ /**
36
+ * Detect `defineScreens({ title, icon, screens })` and record the manifest.
37
+ * A screen with a field that is not a literal is reported and left out: a
38
+ * navigation entry the host cannot read would otherwise vanish with no sign.
39
+ */
40
+ export function addDefineScreens(node, state, logger) {
41
+ if (!ts.isCallExpression(node))
42
+ return;
43
+ const { expression, arguments: args } = node;
44
+ if (!ts.isIdentifier(expression) || expression.text !== 'defineScreens')
45
+ return;
46
+ const [firstArg] = args;
47
+ if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
48
+ return;
49
+ const fail = (message) => logger.critical(ErrorCode.ADDON_SCREENS_NOT_STATIC, message);
50
+ let title;
51
+ let icon;
52
+ const screens = [];
53
+ for (const prop of firstArg.properties) {
54
+ const key = propName(prop);
55
+ if (!key || !ts.isPropertyAssignment(prop))
56
+ continue;
57
+ if (key === 'title' && ts.isStringLiteral(prop.initializer)) {
58
+ title = prop.initializer.text;
59
+ }
60
+ else if (key === 'icon' && ts.isStringLiteral(prop.initializer)) {
61
+ icon = prop.initializer.text;
62
+ }
63
+ else if (key === 'screens') {
64
+ if (!ts.isArrayLiteralExpression(prop.initializer)) {
65
+ fail(`defineScreens's screens must be an array literal, got: ${prop.initializer.getText()}`);
66
+ continue;
67
+ }
68
+ for (const element of prop.initializer.elements) {
69
+ if (!ts.isObjectLiteralExpression(element)) {
70
+ fail(`A defineScreens screen must be an object literal, got: ${element.getText()}`);
71
+ continue;
72
+ }
73
+ let path;
74
+ let screenTitle;
75
+ let nav;
76
+ let scopes;
77
+ let component;
78
+ let app;
79
+ for (const field of element.properties) {
80
+ const fieldKey = propName(field);
81
+ if (!fieldKey || !ts.isPropertyAssignment(field))
82
+ continue;
83
+ const value = field.initializer;
84
+ if (fieldKey === 'path' && ts.isStringLiteral(value)) {
85
+ path = value.text;
86
+ }
87
+ else if (fieldKey === 'title' && ts.isStringLiteral(value)) {
88
+ screenTitle = value.text;
89
+ }
90
+ else if (fieldKey === 'nav' &&
91
+ (value.kind === ts.SyntaxKind.TrueKeyword ||
92
+ value.kind === ts.SyntaxKind.FalseKeyword)) {
93
+ nav = value.kind === ts.SyntaxKind.TrueKeyword;
94
+ }
95
+ else if (fieldKey === 'scopes') {
96
+ scopes = stringArray(value);
97
+ if (!scopes) {
98
+ fail(`A screen's scopes must be an array of string literals, got: ${value.getText()}`);
99
+ }
100
+ }
101
+ else if (fieldKey === 'component') {
102
+ component = lazyImportSpecifier(value);
103
+ if (!component) {
104
+ fail(`A screen's component must be () => import('<literal>'), got: ${value.getText()}`);
105
+ }
106
+ }
107
+ else if (fieldKey === 'app' && ts.isStringLiteral(value)) {
108
+ app = value.text;
109
+ }
110
+ }
111
+ if (!path || !screenTitle || (!component && !app)) {
112
+ fail(`A defineScreens screen needs a literal path and title, and either a lazy component or an app: ${element.getText()}`);
113
+ continue;
114
+ }
115
+ if (component && app) {
116
+ fail(`Screen '${path}' sets both component and app; a screen is one or the other`);
117
+ continue;
118
+ }
119
+ screens.push({
120
+ path,
121
+ title: screenTitle,
122
+ ...(nav !== undefined ? { nav } : {}),
123
+ ...(scopes ? { scopes } : {}),
124
+ ...(component ? { component } : { app: app }),
125
+ });
126
+ }
127
+ }
128
+ }
129
+ if (!title) {
130
+ fail(`defineScreens needs a literal title`);
131
+ return;
132
+ }
133
+ logger.debug(`• Found defineScreens: ${title} (${screens.length} screens)`);
134
+ state.screensManifest = {
135
+ title,
136
+ ...(icon ? { icon } : {}),
137
+ file: node.getSourceFile().fileName,
138
+ screens,
139
+ };
140
+ }
@@ -1,2 +1,9 @@
1
1
  import type { AddWiring } from '../types.js';
2
+ /**
3
+ * Folds `declareScopes` ids into `state.scopes.definitions`. An id the
4
+ * definitions already hold (an addon's tree, a `defineScope`) is left alone;
5
+ * a missing one is added as a bare node under its root, creating the root when
6
+ * nothing declared it.
7
+ */
8
+ export declare const applyDeclaredScopes: (state: Parameters<AddWiring>[3]) => void;
2
9
  export declare const addScope: AddWiring;
@@ -84,11 +84,63 @@ const extractScopeNodes = (obj, scopeName, logger) => {
84
84
  const unwrapAs = (node) => ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
85
85
  ? unwrapAs(node.expression)
86
86
  : node;
87
+ /**
88
+ * Records the ids of a `declareScopes([...])` call. They are merged into the
89
+ * definitions later (`applyDeclaredScopes`), after addon scope trees have
90
+ * loaded, so an addon's richer declaration is never shadowed by a bare id.
91
+ */
92
+ const addDeclareScopes = (logger, node, state) => {
93
+ const arg = node.arguments[0] && unwrapAs(node.arguments[0]);
94
+ if (!arg || !ts.isArrayLiteralExpression(arg)) {
95
+ return;
96
+ }
97
+ const sourceFile = node.getSourceFile().fileName;
98
+ for (const element of arg.elements) {
99
+ if (!ts.isStringLiteralLike(element)) {
100
+ logger.critical(ErrorCode.NON_LITERAL_WIRE_NAME, 'declareScopes takes string literal scope ids.');
101
+ continue;
102
+ }
103
+ const id = element.text;
104
+ if (!id.split(SEPARATOR).every((s) => isValidSegment(s, id, logger))) {
105
+ continue;
106
+ }
107
+ state.scopes.declared ??= new Map();
108
+ if (!state.scopes.declared.has(id)) {
109
+ state.scopes.declared.set(id, sourceFile);
110
+ }
111
+ }
112
+ state.scopes.files.add(sourceFile);
113
+ };
114
+ /**
115
+ * Folds `declareScopes` ids into `state.scopes.definitions`. An id the
116
+ * definitions already hold (an addon's tree, a `defineScope`) is left alone;
117
+ * a missing one is added as a bare node under its root, creating the root when
118
+ * nothing declared it.
119
+ */
120
+ export const applyDeclaredScopes = (state) => {
121
+ for (const [id, sourceFile] of state.scopes.declared ?? []) {
122
+ const [rootName, ...rest] = id.split(SEPARATOR);
123
+ let root = state.scopes.definitions.find((d) => d.name === rootName);
124
+ if (!root) {
125
+ root = { name: rootName, sourceFile };
126
+ state.scopes.definitions.push(root);
127
+ }
128
+ let level = root;
129
+ for (const segment of rest) {
130
+ level.scopes ??= {};
131
+ level = level.scopes[segment] ??= {};
132
+ }
133
+ }
134
+ };
87
135
  export const addScope = (logger, node, checker, state, _options) => {
88
136
  if (!ts.isCallExpression(node)) {
89
137
  return;
90
138
  }
91
139
  const expression = node.expression;
140
+ if (ts.isIdentifier(expression) && expression.text === 'declareScopes') {
141
+ addDeclareScopes(logger, node, state);
142
+ return;
143
+ }
92
144
  if (!ts.isIdentifier(expression) || expression.text !== 'defineScope') {
93
145
  return;
94
146
  }
@@ -101,6 +153,11 @@ export const addScope = (logger, node, checker, state, _options) => {
101
153
  return;
102
154
  }
103
155
  const sourceFile = node.getSourceFile().fileName;
156
+ // A `.gen.ts` is one the CLI wrote into the project, the same test
157
+ // `claimSingleDeclaration` exempts it by.
158
+ const origin = sourceFile.endsWith('.gen.ts')
159
+ ? { kind: 'generated' }
160
+ : { kind: 'app' };
104
161
  // Roots are keyed exactly like the nodes beneath them, so each property of
105
162
  // the call's single argument is one tree.
106
163
  for (const prop of unwrapped.properties) {
@@ -141,6 +198,7 @@ export const addScope = (logger, node, checker, state, _options) => {
141
198
  description: descriptionValue || undefined,
142
199
  scopes,
143
200
  sourceFile,
201
+ origin,
144
202
  });
145
203
  }
146
204
  };
@@ -87,7 +87,8 @@ export function addWireAddon(node, state, logger) {
87
87
  if (!ts.isCallExpression(node))
88
88
  return;
89
89
  const { expression, arguments: args } = node;
90
- if (!ts.isIdentifier(expression) || expression.text !== 'wireAddon')
90
+ if (!ts.isIdentifier(expression) ||
91
+ expression.text !== 'wireAddon')
91
92
  return;
92
93
  const [firstArg] = args;
93
94
  if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
@@ -99,8 +100,10 @@ export function addWireAddon(node, state, logger) {
99
100
  let expose;
100
101
  let mcpEndpoint;
101
102
  let auth;
103
+ let ui;
102
104
  let tags;
103
105
  let scopes;
106
+ let uses;
104
107
  let secretOverrides;
105
108
  let variableOverrides;
106
109
  let credentialOverrides;
@@ -108,6 +111,9 @@ export function addWireAddon(node, state, logger) {
108
111
  let credentialGrants;
109
112
  let globalSecrets;
110
113
  let globalCredentials;
114
+ let contentBucket;
115
+ let contentGrants;
116
+ let globalContent;
111
117
  for (const prop of firstArg.properties) {
112
118
  if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
113
119
  continue;
@@ -154,12 +160,25 @@ export function addWireAddon(node, state, logger) {
154
160
  prop.initializer.kind === ts.SyntaxKind.FalseKeyword)) {
155
161
  auth = prop.initializer.kind === ts.SyntaxKind.TrueKeyword;
156
162
  }
163
+ else if (key === 'ui' &&
164
+ (prop.initializer.kind === ts.SyntaxKind.TrueKeyword ||
165
+ prop.initializer.kind === ts.SyntaxKind.FalseKeyword)) {
166
+ ui = prop.initializer.kind === ts.SyntaxKind.TrueKeyword;
167
+ }
157
168
  else if (key === 'tags') {
158
169
  tags = parseStringArray(prop.initializer);
159
170
  }
160
171
  else if (key === 'scopes') {
161
172
  scopes = parseStringArray(prop.initializer);
162
173
  }
174
+ else if (key === 'uses') {
175
+ if (ts.isObjectLiteralExpression(prop.initializer)) {
176
+ uses = parseStringRecord(prop.initializer);
177
+ }
178
+ else {
179
+ logger.critical(ErrorCode.ADDON_EXPOSE_NOT_STATIC, `wireAddon's uses must be an object literal, got: ${prop.initializer.getText()}`);
180
+ }
181
+ }
163
182
  else if (key === 'secretOverrides' &&
164
183
  ts.isObjectLiteralExpression(prop.initializer)) {
165
184
  secretOverrides = parseStringRecord(prop.initializer);
@@ -190,12 +209,26 @@ export function addWireAddon(node, state, logger) {
190
209
  ? prop.initializer.text
191
210
  : prop.initializer.getText();
192
211
  }
212
+ else if (key === 'contentBucket' &&
213
+ ts.isStringLiteral(prop.initializer)) {
214
+ contentBucket = prop.initializer.text;
215
+ }
216
+ else if (key === 'contentGrants' &&
217
+ ts.isObjectLiteralExpression(prop.initializer)) {
218
+ contentGrants = parseStringRecord(prop.initializer);
219
+ }
220
+ else if (key === 'globalContent') {
221
+ globalContent = ts.isStringLiteral(prop.initializer)
222
+ ? prop.initializer.text
223
+ : prop.initializer.getText();
224
+ }
193
225
  }
194
226
  if (!name || !pkg)
195
227
  return;
196
- logger.debug(`• Found wireAddon: ${name} → ${pkg}`);
228
+ logger.debug(`• Found ${expression.text}: ${name} → ${pkg}`);
197
229
  state.rpc.wireAddonDeclarations.set(name, {
198
230
  package: pkg,
231
+ ...(ui ? { ui } : {}),
199
232
  file: node.getSourceFile().fileName,
200
233
  rpcEndpoint,
201
234
  mcp,
@@ -204,6 +237,7 @@ export function addWireAddon(node, state, logger) {
204
237
  auth,
205
238
  tags,
206
239
  scopes,
240
+ uses,
207
241
  secretOverrides,
208
242
  variableOverrides,
209
243
  credentialOverrides,
@@ -211,6 +245,9 @@ export function addWireAddon(node, state, logger) {
211
245
  credentialGrants,
212
246
  globalSecrets,
213
247
  globalCredentials,
248
+ contentBucket,
249
+ contentGrants,
250
+ globalContent,
214
251
  });
215
252
  state.rpc.usedAddons.add(name);
216
253
  state.rpc.wireAddonFiles.add(node.getSourceFile().fileName);
@@ -36,6 +36,11 @@ export declare enum ErrorCode {
36
36
  ADDON_MCP_FUNCTION_NOT_FOUND = "PKU341",
37
37
  ADDON_EXPOSE_FUNCTION_NOT_FOUND = "PKU343",
38
38
  ADDON_EXPOSE_NOT_STATIC = "PKU344",
39
+ ADDON_SCREENS_NOT_STATIC = "PKU345",
40
+ ADDON_UI_HAS_NO_SCREENS = "PKU346",
41
+ ADDON_USES_NOT_WIRED = "PKU347",
42
+ ADDON_NEEDS_NOT_MAPPED = "PKU348",
43
+ ADDON_CONTENT_GRANT_INVALID = "PKU349",
39
44
  DB_COLUMN_NAME_TYPE_CONTRADICTION = "PKU480",
40
45
  DB_JSON_COLUMN_UNTYPED = "PKU481",
41
46
  DB_FORMAT_ON_NON_STRING = "PKU482",
@@ -39,6 +39,11 @@ export var ErrorCode;
39
39
  ErrorCode["ADDON_MCP_FUNCTION_NOT_FOUND"] = "PKU341";
40
40
  ErrorCode["ADDON_EXPOSE_FUNCTION_NOT_FOUND"] = "PKU343";
41
41
  ErrorCode["ADDON_EXPOSE_NOT_STATIC"] = "PKU344";
42
+ ErrorCode["ADDON_SCREENS_NOT_STATIC"] = "PKU345";
43
+ ErrorCode["ADDON_UI_HAS_NO_SCREENS"] = "PKU346";
44
+ ErrorCode["ADDON_USES_NOT_WIRED"] = "PKU347";
45
+ ErrorCode["ADDON_NEEDS_NOT_MAPPED"] = "PKU348";
46
+ ErrorCode["ADDON_CONTENT_GRANT_INVALID"] = "PKU349";
42
47
  // Database schema codegen warnings
43
48
  ErrorCode["DB_COLUMN_NAME_TYPE_CONTRADICTION"] = "PKU480";
44
49
  ErrorCode["DB_JSON_COLUMN_UNTYPED"] = "PKU481";
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { inspect, getInitialInspectorState } from './inspector.js';
2
2
  export type { TypesMap } from './types-map.js';
3
- export type { AddonConfig, AuthDefinition, CredentialOverrideMeta, InspectorDiagnostic, InspectorFilters, InspectorHTTPState, InspectorLogger, InspectorMiddlewareState, InspectorOptions, InspectorPermissionState, InspectorState, MetaInputTypes, MiddlewareGroupMeta, MiddlewareRegistration, SchemaRef, } from './types.js';
3
+ export type { AddonConfig, AuthDefinition, CredentialOverrideMeta, ScreensManifestMeta, AddonScreenMeta, InspectorDiagnostic, InspectorFilters, InspectorHTTPState, InspectorLogger, InspectorMiddlewareState, InspectorOptions, InspectorPermissionState, InspectorState, MetaInputTypes, MiddlewareGroupMeta, MiddlewareRegistration, SchemaRef, } from './types.js';
4
4
  export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js';
5
5
  export { ErrorCode } from './error-codes.js';
6
6
  export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js';
@@ -25,5 +25,6 @@ export { resolveFunctionMeta } from './utils/resolve-function-meta.js';
25
25
  export type { SerializedWorkflowGraph, SerializedWorkflowGraphs, } from './utils/workflow/graph/index.js';
26
26
  export { resolveCoreType } from './utils/resolve-core-type.js';
27
27
  export { readModuleSpecifiers } from './utils/read-module-specifiers.js';
28
+ export { readJsxLiteralText } from './utils/read-jsx-literal-text.js';
28
29
  export { readTsconfigOutDir } from './utils/read-tsconfig-out-dir.js';
29
30
  export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
package/dist/index.js CHANGED
@@ -14,5 +14,6 @@ export { getFilesAndMethods } from './utils/get-files-and-methods.js';
14
14
  export { resolveFunctionMeta } from './utils/resolve-function-meta.js';
15
15
  export { resolveCoreType } from './utils/resolve-core-type.js';
16
16
  export { readModuleSpecifiers } from './utils/read-module-specifiers.js';
17
+ export { readJsxLiteralText } from './utils/read-jsx-literal-text.js';
17
18
  export { readTsconfigOutDir } from './utils/read-tsconfig-out-dir.js';
18
19
  export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
package/dist/inspector.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as ts from 'typescript';
2
+ import { applyDeclaredScopes } from './add/add-scope.js';
2
3
  import { performance } from 'perf_hooks';
3
4
  import { dirname, relative, resolve } from 'path';
4
5
  import { visitSetup, visitFunctions, visitRoutes } from './visit.js';
@@ -8,7 +9,7 @@ import { findCommonAncestor } from './utils/find-root-dir.js';
8
9
  import { createSourceFileCacheHost } from './source-file-cache.js';
9
10
  import { createNestedProjectFilter } from './utils/nested-project-filter.js';
10
11
  import { toProgramPath } from './utils/program-path.js';
11
- import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateAgentToolReferences, validateAgentScorerReferences, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, validateSystemRoleScopes, validatePersonaRoles, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, validateNoSecretAliasServices, registerDerivedOAuth2AppSecrets, validateSecretUsage, computeDiagnostics, validateSchemaReferences, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateScenarioFeatures, validateWorkflowGraphAddons, } from './utils/post-process.js';
12
+ import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateAgentToolReferences, validateAgentScorerReferences, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateAddonUses, validateAddonContentGrants, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, validateSystemRoleScopes, validatePersonaRoles, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, validateNoSecretAliasServices, registerDerivedOAuth2AppSecrets, validateSecretUsage, computeDiagnostics, validateSchemaReferences, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateScenarioFeatures, validateWorkflowGraphAddons, } from './utils/post-process.js';
12
13
  import { validateExposedFunctionsGated } from './utils/validate-exposed-functions-gated.js';
13
14
  import { validateTagsResolveToMiddleware } from './utils/validate-tags-resolve-to-middleware.js';
14
15
  import { annotateHttpRouteAuth } from './utils/annotate-http-route-auth.js';
@@ -426,8 +427,11 @@ export const inspect = async (logger, routeFiles, options = {}) => {
426
427
  validateSecretOverrides(logger, state);
427
428
  validateVariableOverrides(logger, state);
428
429
  validateCredentialOverrides(logger, state);
430
+ validateAddonUses(logger, state);
431
+ validateAddonContentGrants(logger, state);
429
432
  validateRemoteAddonDependencies(logger, state);
430
433
  validateRemoteAddonAuth(logger, state);
434
+ applyDeclaredScopes(state);
431
435
  validateScopeReferences(logger, state);
432
436
  validateSystemRoleScopes(logger, state);
433
437
  validatePersonaRoles(logger, state);
package/dist/types.d.ts CHANGED
@@ -491,7 +491,38 @@ export type CredentialOverrideMeta = string | {
491
491
  name?: string;
492
492
  mode?: 'singleton' | 'wire';
493
493
  };
494
+ /** A screen of an addon as `defineScreens` declares it, read statically. */
495
+ export type AddonScreenMeta = {
496
+ path: string;
497
+ title: string;
498
+ nav?: boolean;
499
+ scopes?: string[];
500
+ } & ({
501
+ component: string;
502
+ app?: undefined;
503
+ } | {
504
+ app: string;
505
+ component?: undefined;
506
+ });
507
+ export type ScreensManifestMeta = {
508
+ title: string;
509
+ icon?: string;
510
+ /** The source file whose `defineScreens` call declared this manifest. */
511
+ file: string;
512
+ screens: AddonScreenMeta[];
513
+ /**
514
+ * The addon's role: every scope its own functions require, derived by
515
+ * `pikku all` and never declared. Absent from the raw `defineScreens` read.
516
+ * On a wired instance it also carries the host's `wireAddon` scopes, which
517
+ * the runner demands of every function in the package.
518
+ */
519
+ scopes?: string[];
520
+ };
494
521
  export interface InspectorState {
522
+ /** Set when this package calls `defineScreens`: the screens it ships. */
523
+ screensManifest?: ScreensManifestMeta;
524
+ /** The screens of the addons this project wires with `ui: true`, by namespace. */
525
+ addonScreens?: Record<string, ScreensManifestMeta>;
495
526
  rootDir: string;
496
527
  singletonServicesTypeImportMap: PathToNameAndType;
497
528
  wireServicesTypeImportMap: PathToNameAndType;
@@ -605,7 +636,11 @@ export interface InspectorState {
605
636
  package: string;
606
637
  /** The app source file whose `wireAddon` call declared this instance. */
607
638
  file?: string;
639
+ /** Declared with `ui: true`: the package must ship screens, and the host mounts them. */
640
+ ui?: boolean;
608
641
  rpcEndpoint?: string;
642
+ /** The other addons this instance may call: package -> the name it is wired under. */
643
+ uses?: Record<string, string>;
609
644
  /**
610
645
  * `true` offers every function the addon declared `mcp: true`; a list
611
646
  * names the functions to offer, whatever the addon declared. Absent
@@ -660,6 +695,13 @@ export interface InspectorState {
660
695
  * `CredentialService` instead of one scoped to its declared credentials.
661
696
  */
662
697
  globalCredentials?: string;
698
+ contentBucket?: string;
699
+ contentGrants?: Record<string, string>;
700
+ /**
701
+ * The app's stated reason for handing this instance the whole
702
+ * `ContentService` instead of one scoped to its own folder.
703
+ */
704
+ globalContent?: string;
663
705
  }>;
664
706
  wireAddonFiles: Set<string>;
665
707
  };
@@ -731,6 +773,8 @@ export interface InspectorState {
731
773
  scopes: {
732
774
  definitions: ScopeDefinitions;
733
775
  files: Set<string>;
776
+ /** Ids from `declareScopes([...])`, merged into `definitions` once addon scopes are loaded. */
777
+ declared?: Map<string, string>;
734
778
  };
735
779
  systemRoles: {
736
780
  definitions: SystemRoleDefinitions;
@@ -8,11 +8,6 @@ export declare const describeMissingAddonMeta: (namespace: string, packageName:
8
8
  * Every source starts off: the app turns one on at runtime.
9
9
  */
10
10
  export declare const namespaceAddonWebhookSources: (sources: WebhookSourcesMeta, namespace: string) => WebhookSourcesMeta;
11
- /**
12
- * After the setup sweep discovers wireAddon() declarations, load each addon
13
- * package's function metadata so that wiring handlers (channels, HTTP routes,
14
- * schedules, etc.) can look up addon function types during the routes sweep.
15
- */
16
11
  export declare function loadAddonFunctionsMeta(logger: InspectorLogger, state: InspectorState): Promise<void>;
17
12
  /**
18
13
  * Load addon schemas into state.schemas. Called after generateAllSchemas
@@ -181,6 +181,27 @@ export const namespaceAddonWebhookSources = (sources, namespace) => {
181
181
  * package's function metadata so that wiring handlers (channels, HTTP routes,
182
182
  * schedules, etc.) can look up addon function types during the routes sweep.
183
183
  */
184
+ const loadScreensManifest = async (require, packageName) => {
185
+ const path = resolveAddonMeta(require, packageName, 'screens/pikku-screens-meta');
186
+ if (!path)
187
+ return null;
188
+ return JSON.parse(await readFile(path, 'utf-8'));
189
+ };
190
+ const readAddonNeeds = async (dirs, packageName) => {
191
+ const dir = findInstalledPackageDir(dirs, packageName);
192
+ if (!dir)
193
+ return [];
194
+ try {
195
+ const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf-8'));
196
+ const uses = pkg?.pikku?.uses;
197
+ return Array.isArray(uses)
198
+ ? uses.filter((u) => typeof u === 'string')
199
+ : [];
200
+ }
201
+ catch {
202
+ return [];
203
+ }
204
+ };
184
205
  export async function loadAddonFunctionsMeta(logger, state) {
185
206
  const { wireAddonDeclarations } = state.rpc;
186
207
  if (wireAddonDeclarations.size === 0)
@@ -194,6 +215,37 @@ export async function loadAddonFunctionsMeta(logger, state) {
194
215
  const dirs = addonResolutionDirs(state.rootDir, decl.file);
195
216
  const require = createAddonResolver(dirs);
196
217
  try {
218
+ for (const needed of await readAddonNeeds(dirs, decl.package)) {
219
+ if (!decl.uses?.[needed]) {
220
+ logger.critical(ErrorCode.ADDON_NEEDS_NOT_MAPPED, `${decl.package} needs ${needed}, but wireAddon('${namespace}') does not map it. Wire ${needed} and add uses: { '${needed}': '<name>' } to this wireAddon.`);
221
+ }
222
+ }
223
+ if (decl.ui) {
224
+ let manifest;
225
+ let unreadable = false;
226
+ try {
227
+ manifest = await loadScreensManifest(require, decl.package);
228
+ }
229
+ catch (e) {
230
+ logger.critical(ErrorCode.ADDON_UI_HAS_NO_SCREENS, `wireAddon('${namespace}') sets ui: true, but the screens manifest of ${decl.package} could not be read: ${e instanceof Error ? e.message : e}`);
231
+ unreadable = true;
232
+ }
233
+ if (unreadable) {
234
+ // reported above; the addon's functions and scopes still load
235
+ }
236
+ else if (!manifest || manifest.screens.length === 0) {
237
+ logger.critical(ErrorCode.ADDON_UI_HAS_NO_SCREENS, `wireAddon('${namespace}') sets ui: true, but ${decl.package} declares no screens. Add a defineScreens call to the package, or drop ui.`);
238
+ }
239
+ else {
240
+ ;
241
+ (state.addonScreens ??= {})[namespace] = {
242
+ ...manifest,
243
+ scopes: [
244
+ ...new Set([...(manifest.scopes ?? []), ...(decl.scopes ?? [])]),
245
+ ].sort(),
246
+ };
247
+ }
248
+ }
197
249
  // Verbose first. `description` is one of the fields stripped from the
198
250
  // minimal copy, and it is what an addon's function is offered to a model
199
251
  // under — both as an MCP tool below and as an agent tool. Read the minimal
@@ -296,7 +348,16 @@ export async function loadAddonFunctionsMeta(logger, state) {
296
348
  for (const [key, def] of Object.entries(scopesMeta)) {
297
349
  const existing = state.scopes.definitions.find((d) => d.name === key);
298
350
  if (!existing) {
299
- state.scopes.definitions.push(def);
351
+ // The addon's build stamped its own name on the trees it declared;
352
+ // the package it was installed as is the one this host knows.
353
+ const origin = {
354
+ kind: 'addon',
355
+ package: decl.package,
356
+ displayName: def.origin?.kind === 'addon'
357
+ ? def.origin.displayName
358
+ : undefined,
359
+ };
360
+ state.scopes.definitions.push({ ...def, origin });
300
361
  logger.debug(`Loaded addon scope '${key}' from ${decl.package}`);
301
362
  }
302
363
  }
@@ -31,6 +31,8 @@ export declare function extractWireNames(list?: Array<MiddlewareMetadata | Permi
31
31
  */
32
32
  export declare function aggregateRequiredServices(state: InspectorState | Omit<InspectorState, "typesLookup">): void;
33
33
  export declare function validateSecretOverrides(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, "typesLookup">): void;
34
+ export declare function validateAddonUses(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, "typesLookup">): void;
35
+ export declare function validateAddonContentGrants(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, "typesLookup">): void;
34
36
  export declare function validateCredentialOverrides(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, "typesLookup">): void;
35
37
  export declare function validateVariableOverrides(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, "typesLookup">): void;
36
38
  /**
@@ -393,6 +393,39 @@ export function validateSecretOverrides(logger, state) {
393
393
  }
394
394
  }
395
395
  }
396
+ export function validateAddonUses(logger, state) {
397
+ const { wireAddonDeclarations } = state.rpc;
398
+ if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
399
+ return;
400
+ for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
401
+ for (const [pkg, target] of Object.entries(addonDecl.uses ?? {})) {
402
+ const wired = wireAddonDeclarations.get(target);
403
+ if (!wired) {
404
+ logger.critical(ErrorCode.ADDON_USES_NOT_WIRED, `Addon '${namespace}' uses '${pkg}' as '${target}', but no addon is wired under the name '${target}'. Wired: ${Array.from(wireAddonDeclarations.keys()).join(", ")}`);
405
+ }
406
+ else if (wired.package !== pkg) {
407
+ logger.critical(ErrorCode.ADDON_USES_NOT_WIRED, `Addon '${namespace}' uses '${pkg}' as '${target}', but '${target}' is wired from '${wired.package}'.`);
408
+ }
409
+ }
410
+ }
411
+ }
412
+ export function validateAddonContentGrants(logger, state) {
413
+ const { wireAddonDeclarations } = state.rpc;
414
+ if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
415
+ return;
416
+ const buckets = new Set(Array.from(wireAddonDeclarations.entries()).map(([namespace, decl]) => decl.contentBucket ?? namespace));
417
+ for (const [namespace, decl] of wireAddonDeclarations.entries()) {
418
+ for (const [prefix, mode] of Object.entries(decl.contentGrants ?? {})) {
419
+ if (mode !== "read" && mode !== "write") {
420
+ logger.critical(ErrorCode.ADDON_CONTENT_GRANT_INVALID, `Addon '${namespace}' grants '${prefix}' as '${mode}', but a content grant must be 'read' or 'write'.`);
421
+ }
422
+ const first = prefix.split("/").find((segment) => segment !== "");
423
+ if (!first || !buckets.has(first)) {
424
+ logger.critical(ErrorCode.ADDON_CONTENT_GRANT_INVALID, `Addon '${namespace}' grants content under '${prefix}', but '${first ?? ""}' is not the content bucket of any wired addon. Buckets: ${Array.from(buckets).join(", ")}`);
425
+ }
426
+ }
427
+ }
428
+ }
396
429
  export function validateCredentialOverrides(logger, state) {
397
430
  const { wireAddonDeclarations } = state.rpc;
398
431
  if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
@@ -0,0 +1,5 @@
1
+ export type JsxLiteralText = {
2
+ text: string;
3
+ line: number;
4
+ };
5
+ export declare const readJsxLiteralText: (fileName: string, content: string) => JsxLiteralText[];
@@ -0,0 +1,126 @@
1
+ import ts from 'typescript';
2
+ const TEXT_ATTRIBUTES = new Set([
3
+ 'placeholder',
4
+ 'title',
5
+ 'alt',
6
+ 'aria-label',
7
+ 'label',
8
+ 'description',
9
+ ]);
10
+ const TEXT_PROPERTIES = new Set([
11
+ ...TEXT_ATTRIBUTES,
12
+ 'text',
13
+ 'body',
14
+ 'why',
15
+ 'hint',
16
+ 'caption',
17
+ 'heading',
18
+ 'subtitle',
19
+ 'message',
20
+ 'tooltip',
21
+ ]);
22
+ const NON_TEXT_ATTRIBUTES = new Set([
23
+ 'className',
24
+ 'class',
25
+ 'style',
26
+ 'd',
27
+ 'viewBox',
28
+ 'preserveAspectRatio',
29
+ 'points',
30
+ 'transform',
31
+ 'fill',
32
+ 'stroke',
33
+ 'href',
34
+ 'src',
35
+ 'rel',
36
+ 'target',
37
+ 'role',
38
+ 'to',
39
+ 'id',
40
+ 'name',
41
+ 'type',
42
+ 'value',
43
+ 'key',
44
+ ]);
45
+ const isProse = (text) => /^\p{Lu}[^\s]*(\s+[^\s]+)+/u.test(text.trim()) &&
46
+ !/[:/[\]=_#]/.test(text.split(/\s+/)[0] ?? '');
47
+ const isSentence = (text) => /\p{L}+\s+\p{L}+/u.test(text);
48
+ const hasLetter = (text) => /\p{L}/u.test(text);
49
+ const isLiteral = (node) => ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
50
+ const rendered = (node) => {
51
+ if (isLiteral(node))
52
+ return [node];
53
+ if (ts.isParenthesizedExpression(node))
54
+ return rendered(node.expression);
55
+ if (ts.isConditionalExpression(node)) {
56
+ return [...rendered(node.whenTrue), ...rendered(node.whenFalse)];
57
+ }
58
+ if (ts.isBinaryExpression(node)) {
59
+ const operator = node.operatorToken.kind;
60
+ if (operator === ts.SyntaxKind.AmpersandAmpersandToken) {
61
+ return rendered(node.right);
62
+ }
63
+ if (operator === ts.SyntaxKind.BarBarToken ||
64
+ operator === ts.SyntaxKind.QuestionQuestionToken) {
65
+ return [...rendered(node.left), ...rendered(node.right)];
66
+ }
67
+ }
68
+ return [];
69
+ };
70
+ export const readJsxLiteralText = (fileName, content) => {
71
+ const source = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
72
+ const found = [];
73
+ const seen = new Set();
74
+ const add = (node, text) => {
75
+ if (!hasLetter(text) || seen.has(node.getStart(source)))
76
+ return;
77
+ seen.add(node.getStart(source));
78
+ const { line } = source.getLineAndCharacterOfPosition(node.getStart(source));
79
+ found.push({ text: text.trim(), line: line + 1 });
80
+ };
81
+ const visit = (node) => {
82
+ if (ts.isJsxText(node)) {
83
+ add(node, node.text);
84
+ }
85
+ else if (ts.isJsxExpression(node) &&
86
+ node.expression &&
87
+ (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
88
+ for (const literal of rendered(node.expression))
89
+ add(literal, literal.text);
90
+ }
91
+ else if (ts.isJsxAttribute(node) && node.initializer) {
92
+ const name = node.name.getText(source);
93
+ const value = ts.isJsxExpression(node.initializer)
94
+ ? node.initializer.expression
95
+ : node.initializer;
96
+ if (value &&
97
+ (ts.isStringLiteral(value) ||
98
+ ts.isNoSubstitutionTemplateLiteral(value)) &&
99
+ (TEXT_ATTRIBUTES.has(name) ||
100
+ (!NON_TEXT_ATTRIBUTES.has(name) &&
101
+ !name.startsWith('data-') &&
102
+ isSentence(value.text)))) {
103
+ add(value, value.text);
104
+ }
105
+ }
106
+ else if (ts.isPropertyAssignment(node) &&
107
+ TEXT_PROPERTIES.has(node.name.getText(source)) &&
108
+ (ts.isStringLiteral(node.initializer) ||
109
+ ts.isNoSubstitutionTemplateLiteral(node.initializer))) {
110
+ add(node.initializer, node.initializer.text);
111
+ }
112
+ if (isLiteral(node) &&
113
+ isProse(node.text) &&
114
+ !ts.isImportDeclaration(node.parent) &&
115
+ !ts.isExportDeclaration(node.parent) &&
116
+ !ts.isLiteralTypeNode(node.parent) &&
117
+ !(ts.isPropertyAssignment(node.parent) && node.parent.name === node) &&
118
+ !(ts.isJsxAttribute(node.parent) &&
119
+ NON_TEXT_ATTRIBUTES.has(node.parent.name.getText(source)))) {
120
+ add(node, node.text);
121
+ }
122
+ ts.forEachChild(node, visit);
123
+ };
124
+ visit(source);
125
+ return found;
126
+ };
@@ -204,6 +204,9 @@ export interface SerializableInspectorState {
204
204
  credentialGrants?: string[];
205
205
  globalSecrets?: string;
206
206
  globalCredentials?: string;
207
+ contentBucket?: string;
208
+ contentGrants?: Record<string, string>;
209
+ globalContent?: string;
207
210
  }
208
211
  ]>;
209
212
  wireAddonFiles: string[];
@@ -317,6 +320,8 @@ export interface SerializableInspectorState {
317
320
  openAPISpec: Record<string, any> | null;
318
321
  diagnostics: InspectorDiagnostic[];
319
322
  addonFunctions: InspectorState['addonFunctions'];
323
+ screensManifest?: InspectorState['screensManifest'];
324
+ addonScreens?: InspectorState['addonScreens'];
320
325
  exportedContracts: InspectorState['exportedContracts'];
321
326
  }
322
327
  /**
@@ -182,6 +182,8 @@ export function serializeInspectorState(state) {
182
182
  openAPISpec: state.openAPISpec,
183
183
  diagnostics: state.diagnostics,
184
184
  addonFunctions: state.addonFunctions,
185
+ screensManifest: state.screensManifest,
186
+ addonScreens: state.addonScreens,
185
187
  exportedContracts: state.exportedContracts,
186
188
  };
187
189
  }
@@ -378,6 +380,8 @@ export function deserializeInspectorState(data) {
378
380
  openAPISpec: data.openAPISpec || null,
379
381
  diagnostics: data.diagnostics || [],
380
382
  addonFunctions: data.addonFunctions || {},
383
+ screensManifest: data.screensManifest,
384
+ addonScreens: data.addonScreens,
381
385
  exportedContracts: data.exportedContracts || {
382
386
  http: {},
383
387
  cli: {},
package/dist/visit.js CHANGED
@@ -15,6 +15,7 @@ import { addChannel } from './add/add-channel.js';
15
15
  import { addGateway } from './add/add-gateway.js';
16
16
  import { addRPCInvocations } from './add/add-rpc-invocations.js';
17
17
  import { addWireAddon } from './add/add-wire-addon.js';
18
+ import { addDefineScreens } from './add/add-define-screens.js';
18
19
  import { addWireRemoteAddon } from './add/add-wire-remote-addon.js';
19
20
  import { addMiddleware } from './add/add-middleware.js';
20
21
  import { addPermission } from './add/add-permission.js';
@@ -47,6 +48,7 @@ export const visitSetup = (logger, checker, node, state, options) => {
47
48
  addOutgoingWebhook(logger, node, checker, state);
48
49
  addRPCInvocations(node, state, logger);
49
50
  addWireAddon(node, state, logger);
51
+ addDefineScreens(node, state, logger);
50
52
  addWireRemoteAddon(node, state, logger);
51
53
  addMiddleware(logger, node, checker, state, options);
52
54
  addPermission(logger, node, checker, state, options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.101",
3
+ "version": "0.12.103",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -54,10 +54,10 @@
54
54
  "zod": "^4.4.3"
55
55
  },
56
56
  "peerDependencies": {
57
- "@pikku/core": "^0.12.136"
57
+ "@pikku/core": "^0.12.139"
58
58
  },
59
59
  "devDependencies": {
60
- "@pikku/core": "^0.12.136",
60
+ "@pikku/core": "^0.12.139",
61
61
  "@types/node": "^24.13.3"
62
62
  },
63
63
  "engines": {