@pikku/inspector 0.12.110 → 0.12.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/add/add-channel.js +8 -0
  2. package/dist/add/add-define-contributors.d.ts +10 -0
  3. package/dist/add/add-define-contributors.js +98 -0
  4. package/dist/add/add-file-with-factory.d.ts +7 -1
  5. package/dist/add/add-file-with-factory.js +63 -2
  6. package/dist/add/add-functions.js +18 -1
  7. package/dist/add/add-pikku-studio.d.ts +15 -0
  8. package/dist/add/add-pikku-studio.js +307 -0
  9. package/dist/add/add-rpc-invocations.js +12 -0
  10. package/dist/add/add-wire-addon.js +11 -2
  11. package/dist/add/check-disabled-references.d.ts +27 -0
  12. package/dist/add/check-disabled-references.js +226 -0
  13. package/dist/error-codes.d.ts +14 -0
  14. package/dist/error-codes.js +14 -0
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.js +1 -0
  17. package/dist/initial-state.js +1 -0
  18. package/dist/inspector.js +4 -0
  19. package/dist/lite.d.ts +3 -0
  20. package/dist/lite.js +2 -0
  21. package/dist/types.d.ts +78 -28
  22. package/dist/utils/addon-dependencies.d.ts +43 -0
  23. package/dist/utils/addon-dependencies.js +91 -0
  24. package/dist/utils/find-unused-functions.d.ts +20 -0
  25. package/dist/utils/find-unused-functions.js +132 -0
  26. package/dist/utils/get-property-value.js +4 -1
  27. package/dist/utils/load-addon-functions-meta.d.ts +1 -8
  28. package/dist/utils/load-addon-functions-meta.js +87 -76
  29. package/dist/utils/post-process.js +31 -7
  30. package/dist/utils/serialize-inspector-state.d.ts +6 -3
  31. package/dist/utils/serialize-inspector-state.js +12 -6
  32. package/dist/visit.js +8 -3
  33. package/package.json +3 -3
  34. package/dist/add/add-define-screens.d.ts +0 -8
  35. package/dist/add/add-define-screens.js +0 -140
@@ -0,0 +1,91 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { dirname, join, parse } from 'path';
3
+ import { parseRemoteAddonPackage } from './remote-addon-sdk.js';
4
+ /** An alias becomes a namespace prefix and part of a generated type name. */
5
+ const ALIAS = /^[A-Za-z][A-Za-z0-9_-]*$/;
6
+ /** The names a wiring's `uses` may map: the declared aliases, or the listed names. */
7
+ export const declaredAddonNames = (declared) => declared.form === 'list'
8
+ ? declared.names
9
+ : declared.form === 'map'
10
+ ? declared.dependencies.map((d) => d.alias)
11
+ : [];
12
+ /** Reads the `pikku.uses` value of an addon's package.json. */
13
+ export const parseAddonDependencies = (uses) => {
14
+ if (uses === undefined || uses === null)
15
+ return { form: 'none' };
16
+ if (Array.isArray(uses)) {
17
+ return {
18
+ form: 'list',
19
+ names: uses.filter((u) => typeof u === 'string'),
20
+ };
21
+ }
22
+ if (typeof uses !== 'object') {
23
+ return {
24
+ form: 'map',
25
+ dependencies: [],
26
+ invalid: [
27
+ `pikku.uses must be an object of { "<alias>": "<package>@<range>" }, got ${JSON.stringify(uses)}.`,
28
+ ],
29
+ };
30
+ }
31
+ const dependencies = [];
32
+ const invalid = [];
33
+ for (const [alias, spec] of Object.entries(uses)) {
34
+ if (!ALIAS.test(alias)) {
35
+ invalid.push(`pikku.uses names '${alias}', which is not a valid alias. An alias starts with a letter and holds only letters, digits, '-' and '_'.`);
36
+ continue;
37
+ }
38
+ if (typeof spec !== 'string') {
39
+ invalid.push(`pikku.uses.${alias} must be a string '<package>@<range>', got ${JSON.stringify(spec)}.`);
40
+ continue;
41
+ }
42
+ let parsed;
43
+ try {
44
+ parsed = parseRemoteAddonPackage(spec);
45
+ }
46
+ catch (e) {
47
+ invalid.push(`pikku.uses.${alias}: ${e instanceof Error ? e.message : String(e)}`);
48
+ continue;
49
+ }
50
+ if (!parsed.range) {
51
+ invalid.push(`pikku.uses.${alias} is '${spec}', which has no version range. Write '${parsed.name}@^1.0.0': the app that wires this addon is checked against it.`);
52
+ continue;
53
+ }
54
+ dependencies.push({ alias, package: parsed.name, range: parsed.range });
55
+ }
56
+ return { form: 'map', dependencies, invalid };
57
+ };
58
+ /** The `pikku.uses` an addon's package.json declares. `none` when there is no readable package.json. */
59
+ export const readAddonDependencies = (packageDir) => {
60
+ let pkg;
61
+ try {
62
+ pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf-8'));
63
+ }
64
+ catch {
65
+ return { form: 'none' };
66
+ }
67
+ return parseAddonDependencies(pkg?.pikku?.uses);
68
+ };
69
+ /**
70
+ * The directory `packageName` is installed in, looking in `node_modules` from
71
+ * each of `dirs` upwards, as Node's resolution would. Null when it is not
72
+ * installed.
73
+ */
74
+ export const findInstalledPackageDir = (dirs, packageName) => {
75
+ for (const start of dirs) {
76
+ const root = parse(start).root;
77
+ let dir = start;
78
+ while (dir) {
79
+ const candidate = join(dir, 'node_modules', packageName);
80
+ if (existsSync(join(candidate, 'package.json')))
81
+ return candidate;
82
+ if (dir === root)
83
+ break;
84
+ const parent = dirname(dir);
85
+ if (parent === dir)
86
+ break;
87
+ dir = parent;
88
+ }
89
+ }
90
+ return null;
91
+ };
@@ -0,0 +1,20 @@
1
+ import type { InspectorState } from '../types.js';
2
+ export interface UnusedFunction {
3
+ pikkuFuncId: string;
4
+ name?: string;
5
+ sourceFile?: string;
6
+ }
7
+ /**
8
+ * The app's own functions that nothing uses: not wired to any transport, not
9
+ * exposed, published or offered to MCP, and not called by name. Addon
10
+ * functions are never judged: an addon project's own functions are all addon
11
+ * functions, and a consumer's state holds no addon functions in
12
+ * `functions.meta`. Neither are scenarios, inline wiring functions or disabled
13
+ * functions. A workflow is a function like any other: it is used when it is
14
+ * wired, exposed, or started by a literal name — `rpc.startWorkflow`,
15
+ * `workflowService.startWorkflow` / `runToCompletion`, `rpc.invoke`, another
16
+ * workflow's step, or the generated client helpers.
17
+ */
18
+ export declare const findUnusedFunctions: (state: InspectorState, { addon }?: {
19
+ addon?: boolean;
20
+ }) => UnusedFunction[];
@@ -0,0 +1,132 @@
1
+ /** Scenarios and their steps are run by the scenario runner, never wired or called. */
2
+ const RUN_BY_SCENARIOS = new Set([
3
+ 'pikkuScenario',
4
+ 'pikkuScenarioStep',
5
+ 'pikkuPlatformScenarioStep',
6
+ 'pikkuAddonScenarioStep',
7
+ ]);
8
+ /** Sections that describe functions rather than use them. */
9
+ const NOT_USES = new Set([
10
+ 'functions',
11
+ 'schemas',
12
+ 'schemaLookup',
13
+ 'serviceAggregation',
14
+ 'nodes',
15
+ 'resolvedIOTypes',
16
+ 'addonFunctions',
17
+ 'remoteAddonFunctions',
18
+ 'program',
19
+ 'stats',
20
+ ]);
21
+ /** Keys under which a wiring names the function it runs. */
22
+ const FUNCTION_KEYS = new Set(['pikkuFuncId', 'rpcName', 'receive']);
23
+ const collectUses = (value, into, seen, key) => {
24
+ if (typeof value === 'string') {
25
+ if (key && FUNCTION_KEYS.has(key))
26
+ into.add(value);
27
+ return;
28
+ }
29
+ if (!value || typeof value !== 'object' || seen.has(value))
30
+ return;
31
+ seen.add(value);
32
+ if (value instanceof Map) {
33
+ for (const child of value.values())
34
+ collectUses(child, into, seen);
35
+ return;
36
+ }
37
+ if (value instanceof Set)
38
+ return;
39
+ if (Array.isArray(value)) {
40
+ for (const child of value) {
41
+ if (key === 'tools' && typeof child === 'string')
42
+ into.add(child);
43
+ else
44
+ collectUses(child, into, seen);
45
+ }
46
+ return;
47
+ }
48
+ // Only plain data: the TypeScript inspector's state also holds compiler
49
+ // objects (types, nodes) whose getters throw when read.
50
+ const proto = Object.getPrototypeOf(value);
51
+ if (proto !== Object.prototype && proto !== null)
52
+ return;
53
+ for (const [childKey, child] of Object.entries(value)) {
54
+ collectUses(child, into, seen, childKey);
55
+ }
56
+ };
57
+ /**
58
+ * The app's own functions that nothing uses: not wired to any transport, not
59
+ * exposed, published or offered to MCP, and not called by name. Addon
60
+ * functions are never judged: an addon project's own functions are all addon
61
+ * functions, and a consumer's state holds no addon functions in
62
+ * `functions.meta`. Neither are scenarios, inline wiring functions or disabled
63
+ * functions. A workflow is a function like any other: it is used when it is
64
+ * wired, exposed, or started by a literal name — `rpc.startWorkflow`,
65
+ * `workflowService.startWorkflow` / `runToCompletion`, `rpc.invoke`, another
66
+ * workflow's step, or the generated client helpers.
67
+ */
68
+ export const findUnusedFunctions = (state, { addon = false } = {}) => {
69
+ if (addon)
70
+ return [];
71
+ const uses = new Set(state.serviceAggregation.usedFunctions);
72
+ for (const name of state.rpc.invokedFunctions)
73
+ uses.add(name);
74
+ for (const id of Object.values(state.rpc.exposedMeta))
75
+ uses.add(id);
76
+ const seen = new WeakSet();
77
+ for (const [section, value] of Object.entries(state)) {
78
+ if (NOT_USES.has(section) || section === 'rpc' || section === 'workflows')
79
+ continue;
80
+ collectUses(value, uses, seen);
81
+ }
82
+ // A workflow's own meta and graph name the workflow itself; only what its
83
+ // steps call counts as a use.
84
+ const { meta: workflowsMeta, graphMeta } = state.workflows;
85
+ for (const entries of [workflowsMeta, graphMeta]) {
86
+ for (const [workflowName, entry] of Object.entries(entries ?? {})) {
87
+ const calls = new Set();
88
+ collectUses(entry, calls, seen);
89
+ calls.delete(workflowName);
90
+ if (entry?.pikkuFuncId)
91
+ calls.delete(entry.pikkuFuncId);
92
+ for (const call of calls)
93
+ uses.add(call);
94
+ }
95
+ }
96
+ // Workflows started by name: client helpers, `startWorkflow` and
97
+ // `runToCompletion` calls, and each function's own `rpc.startWorkflow`.
98
+ const startedWorkflows = new Set(state.workflows.invokedWorkflows);
99
+ for (const meta of Object.values(state.functions.meta)) {
100
+ for (const name of meta.startsWorkflows ?? [])
101
+ startedWorkflows.add(name);
102
+ }
103
+ for (const workflowName of startedWorkflows)
104
+ uses.add(workflowName);
105
+ // A workflow named by its workflow name, by any of the above or by
106
+ // `rpc.invoke` and step calls, uses the function behind it.
107
+ for (const [workflowName, entry] of Object.entries(workflowsMeta)) {
108
+ if (uses.has(workflowName) && entry?.pikkuFuncId)
109
+ uses.add(entry.pikkuFuncId);
110
+ }
111
+ const unused = [];
112
+ for (const [pikkuFuncId, meta] of Object.entries(state.functions.meta)) {
113
+ if (meta.functionType !== 'user')
114
+ continue;
115
+ if (meta.funcWrapper && RUN_BY_SCENARIOS.has(meta.funcWrapper))
116
+ continue;
117
+ if (meta.expose || meta.remote || meta.mcp)
118
+ continue;
119
+ // A generated scaffold is rewritten on every run, so `disabled` cannot be
120
+ // added to it; what it leaves unwired it leaves for the project to wire.
121
+ if (meta.sourceFile?.endsWith('.gen.ts'))
122
+ continue;
123
+ if (uses.has(pikkuFuncId) || (meta.name && uses.has(meta.name)))
124
+ continue;
125
+ unused.push({
126
+ pikkuFuncId,
127
+ ...(meta.name ? { name: meta.name } : {}),
128
+ ...(meta.sourceFile ? { sourceFile: meta.sourceFile } : {}),
129
+ });
130
+ }
131
+ return unused;
132
+ };
@@ -202,7 +202,10 @@ export const getCommonWireMetaData = (obj, wiringType, wiringName, logger, check
202
202
  if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
203
203
  const propName = prop.name.text;
204
204
  if (propName === 'disabled' &&
205
- prop.initializer.kind === ts.SyntaxKind.TrueKeyword) {
205
+ (prop.initializer.kind === ts.SyntaxKind.TrueKeyword ||
206
+ ((ts.isStringLiteral(prop.initializer) ||
207
+ ts.isNoSubstitutionTemplateLiteral(prop.initializer)) &&
208
+ prop.initializer.text !== ''))) {
206
209
  metadata.disabled = true;
207
210
  }
208
211
  else if (propName === 'title') {
@@ -1,4 +1,4 @@
1
- import type { AddonContributorsMeta, InspectorState, InspectorLogger } from '../types.js';
1
+ import type { InspectorState, InspectorLogger } from '../types.js';
2
2
  import type { WebhookSourcesMeta } from '@pikku/core/trigger';
3
3
  export declare const describeMissingAddonMeta: (namespace: string, packageName: string, dirs: string[], declFile?: string) => string;
4
4
  /**
@@ -8,13 +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
- * The platform service contributors an addon declares in its package.json,
13
- * `"pikku": { "contributors": [{ "module", "export" }] }` — the same shape as
14
- * `dev.contributors`. `wireAddon` has no place for them: a contributor emits
15
- * code into the host's entry, so it belongs to the package, not the wiring.
16
- */
17
- export declare const readAddonContributors: (dirs: string[], packageName: string) => Promise<AddonContributorsMeta | null>;
18
11
  export declare function loadAddonFunctionsMeta(logger: InspectorLogger, state: InspectorState): Promise<void>;
19
12
  /**
20
13
  * Load addon schemas into state.schemas. Called after generateAllSchemas
@@ -1,9 +1,10 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { readFile, readdir } from 'fs/promises';
3
- import { join, dirname, parse, relative } from 'path';
3
+ import { join, dirname, relative } from 'path';
4
4
  import { addonResolutionDirs, createAddonResolver, } from './addon-resolution.js';
5
5
  import { ErrorCode } from '../error-codes.js';
6
6
  import { checkLockedSdk } from './remote-addon-sdk.js';
7
+ import { declaredAddonNames, findInstalledPackageDir, readAddonDependencies, } from './addon-dependencies.js';
7
8
  const isHTTPRouteConfig = (value) => typeof value === 'object' &&
8
9
  value !== null &&
9
10
  'method' in value &&
@@ -92,24 +93,6 @@ const resolveAddonMeta = (require, packageName, baseName) => {
92
93
  }
93
94
  return null;
94
95
  };
95
- const findInstalledPackageDir = (dirs, packageName) => {
96
- for (const start of dirs) {
97
- const root = parse(start).root;
98
- let dir = start;
99
- while (dir) {
100
- const candidate = join(dir, 'node_modules', packageName);
101
- if (existsSync(join(candidate, 'package.json')))
102
- return candidate;
103
- if (dir === root)
104
- break;
105
- const parent = dirname(dir);
106
- if (parent === dir)
107
- break;
108
- dir = parent;
109
- }
110
- }
111
- return null;
112
- };
113
96
  export const describeMissingAddonMeta = (namespace, packageName, dirs, declFile) => {
114
97
  const caller = declFile
115
98
  ? relative(dirs[dirs.length - 1], declFile) || declFile
@@ -183,62 +166,68 @@ export const namespaceAddonWebhookSources = (sources, namespace) => {
183
166
  * package's function metadata so that wiring handlers (channels, HTTP routes,
184
167
  * schedules, etc.) can look up addon function types during the routes sweep.
185
168
  */
186
- const loadScreensManifest = async (require, packageName) => {
169
+ const loadStudioMeta = async (require, packageName) => {
187
170
  const path = resolveAddonMeta(require, packageName, 'screens/pikku-screens-meta');
188
171
  if (!path)
189
172
  return null;
190
173
  return JSON.parse(await readFile(path, 'utf-8'));
191
174
  };
192
- const readAddonNeeds = async (dirs, packageName) => {
193
- const dir = findInstalledPackageDir(dirs, packageName);
194
- if (!dir)
195
- return [];
175
+ const loadContributorsManifest = async (require, packageName) => {
176
+ const path = resolveAddonMeta(require, packageName, 'contributors/pikku-contributors-meta');
177
+ if (!path)
178
+ return {};
179
+ const meta = JSON.parse(await readFile(path, 'utf-8'));
180
+ return (meta?.contributors ?? {});
181
+ };
182
+ /**
183
+ * Resolves `wireAddon({ contributors })` against what the addon registered.
184
+ * A name the addon does not register is an error rather than a skip: the
185
+ * project asked for a service and would otherwise build without it.
186
+ */
187
+ const recordOptedInContributors = async (logger, state, require, namespace, packageName, names) => {
188
+ let registered;
196
189
  try {
197
- const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf-8'));
198
- const uses = pkg?.pikku?.uses;
199
- return Array.isArray(uses)
200
- ? uses.filter((u) => typeof u === 'string')
201
- : [];
190
+ registered = await loadContributorsManifest(require, packageName);
202
191
  }
203
- catch {
204
- return [];
192
+ catch (e) {
193
+ logger.critical(ErrorCode.ADDON_CONTRIBUTOR_UNKNOWN, `wireAddon('${namespace}') opts in to contributors, but the contributors manifest of ${packageName} could not be read: ${e instanceof Error ? e.message : e}`);
194
+ return;
195
+ }
196
+ const selected = {};
197
+ for (const name of names) {
198
+ if (!Object.hasOwn(registered, name)) {
199
+ const known = Object.keys(registered);
200
+ logger.critical(ErrorCode.ADDON_CONTRIBUTOR_UNKNOWN, `wireAddon('${namespace}') opts in to contributor '${name}', but ${packageName} registers no such contributor. ${known.length > 0
201
+ ? `It registers: ${known.join(', ')}.`
202
+ : 'It registers none; add a defineContributors call to the package and run pikku all in it.'}`);
203
+ continue;
204
+ }
205
+ selected[name] = registered[name];
206
+ }
207
+ if (Object.keys(selected).length > 0) {
208
+ ;
209
+ (state.optedInContributors ??= {})[namespace] = {
210
+ packageName,
211
+ contributors: selected,
212
+ };
205
213
  }
206
214
  };
207
- const isContributorRef = (value) => !!value &&
208
- typeof value === 'object' &&
209
- typeof value.module === 'string' &&
210
- (value.export === undefined ||
211
- typeof value.export === 'string');
212
215
  /**
213
- * The platform service contributors an addon declares in its package.json,
214
- * `"pikku": { "contributors": [{ "module", "export" }] }` — the same shape as
215
- * `dev.contributors`. `wireAddon` has no place for them: a contributor emits
216
- * code into the host's entry, so it belongs to the package, not the wiring.
216
+ * `pikku.contributors` in an addon's package.json was the string-emitting
217
+ * contributor route. It is no longer read, so it is reported rather than left
218
+ * to stop applying silently.
217
219
  */
218
- export const readAddonContributors = async (dirs, packageName) => {
219
- const packageDir = findInstalledPackageDir(dirs, packageName);
220
- if (!packageDir)
221
- return null;
222
- let declared;
220
+ const hasLegacyContributors = async (dirs, packageName) => {
221
+ const dir = findInstalledPackageDir(dirs, packageName);
222
+ if (!dir)
223
+ return false;
223
224
  try {
224
- const pkg = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf-8'));
225
- declared = pkg?.pikku?.contributors;
225
+ const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf-8'));
226
+ return pkg?.pikku?.contributors !== undefined;
226
227
  }
227
228
  catch {
228
- return null;
229
+ return false;
229
230
  }
230
- if (declared === undefined)
231
- return null;
232
- if (!Array.isArray(declared) || !declared.every(isContributorRef)) {
233
- throw new Error(`${packageName} declares pikku.contributors in its package.json, but it is not a list of { module, export? }.`);
234
- }
235
- if (declared.length === 0)
236
- return null;
237
- return {
238
- packageName,
239
- packageDir,
240
- contributors: declared.map(({ module, export: name }) => name === undefined ? { module } : { module, export: name }),
241
- };
242
231
  };
243
232
  /**
244
233
  * A versioned remote addon's function metadata comes from the SDK `pikku addon
@@ -260,6 +249,36 @@ const loadRemoteAddonSdkMeta = async (state, namespace, packageName, range) => {
260
249
  return;
261
250
  (state.remoteAddonFunctions ??= {})[namespace] = JSON.parse(await readFile(metaPath, 'utf-8'));
262
251
  };
252
+ /**
253
+ * Reads the addons a wired package says it calls (`pikku.uses` in its
254
+ * package.json) onto its declaration, and reports each one this wiring leaves
255
+ * unmapped. Whether a mapping points at the right package, and whether the
256
+ * wiring maps a name the package never declared, is `validateAddonUses`' call
257
+ * once every wiring is known.
258
+ */
259
+ const recordDeclaredUses = (logger, namespace, decl, dirs) => {
260
+ const packageDir = findInstalledPackageDir(dirs, decl.package);
261
+ if (!packageDir)
262
+ return;
263
+ const declared = readAddonDependencies(packageDir);
264
+ if (declared.form === 'none')
265
+ return;
266
+ if (declared.form === 'map') {
267
+ for (const message of declared.invalid) {
268
+ logger.critical(ErrorCode.ADDON_DEPENDENCY_INVALID, `${decl.package} (wired as '${namespace}'): ${message}`);
269
+ }
270
+ decl.dependencies = declared.dependencies;
271
+ }
272
+ decl.declaredUses = declaredAddonNames(declared);
273
+ for (const needed of decl.declaredUses) {
274
+ if (decl.uses?.[needed])
275
+ continue;
276
+ const dependency = decl.dependencies?.find((d) => d.alias === needed);
277
+ logger.critical(ErrorCode.ADDON_NEEDS_NOT_MAPPED, dependency
278
+ ? `${decl.package} calls ${dependency.package}@${dependency.range} as '${needed}', but wireAddon('${namespace}') does not map it. Wire ${dependency.package} and add uses: { '${needed}': '<name>' } to this wireAddon.`
279
+ : `${decl.package} needs ${needed}, but wireAddon('${namespace}') does not map it. Wire ${needed} and add uses: { '${needed}': '<name>' } to this wireAddon.`);
280
+ }
281
+ };
263
282
  export async function loadAddonFunctionsMeta(logger, state) {
264
283
  const { wireAddonDeclarations } = state.rpc;
265
284
  if (wireAddonDeclarations.size === 0)
@@ -277,26 +296,18 @@ export async function loadAddonFunctionsMeta(logger, state) {
277
296
  const dirs = addonResolutionDirs(state.rootDir, decl.file);
278
297
  const require = createAddonResolver(dirs);
279
298
  try {
280
- try {
281
- const contributors = await readAddonContributors(dirs, decl.package);
282
- if (contributors) {
283
- ;
284
- (state.addonContributors ??= {})[namespace] = contributors;
285
- }
299
+ recordDeclaredUses(logger, namespace, decl, dirs);
300
+ if (decl.contributors && decl.contributors.length > 0) {
301
+ await recordOptedInContributors(logger, state, require, namespace, decl.package, decl.contributors);
286
302
  }
287
- catch (error) {
288
- logger.error(`wireAddon('${namespace}'): ${error instanceof Error ? error.message : error}`);
289
- }
290
- for (const needed of await readAddonNeeds(dirs, decl.package)) {
291
- if (!decl.uses?.[needed]) {
292
- 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.`);
293
- }
303
+ if (await hasLegacyContributors(dirs, decl.package)) {
304
+ logger.critical(ErrorCode.ADDON_LEGACY_CONTRIBUTORS, `${decl.package} still declares "pikku.contributors" in its package.json, which is no longer read. Remove it, register the contributors with defineContributors in the addon's source, and opt in with wireAddon('${namespace}', { contributors: [...] }). See docs/contributors.md.`);
294
305
  }
295
306
  if (decl.ui) {
296
307
  let manifest;
297
308
  let unreadable = false;
298
309
  try {
299
- manifest = await loadScreensManifest(require, decl.package);
310
+ manifest = await loadStudioMeta(require, decl.package);
300
311
  }
301
312
  catch (e) {
302
313
  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}`);
@@ -306,11 +317,11 @@ export async function loadAddonFunctionsMeta(logger, state) {
306
317
  // reported above; the addon's functions and scopes still load
307
318
  }
308
319
  else if (!manifest || manifest.screens.length === 0) {
309
- 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.`);
320
+ logger.critical(ErrorCode.ADDON_UI_HAS_NO_SCREENS, `wireAddon('${namespace}') sets ui: true, but ${decl.package} declares no screens. Add a definePlugin call to the package, or drop ui.`);
310
321
  }
311
322
  else {
312
323
  ;
313
- (state.addonScreens ??= {})[namespace] = {
324
+ (state.addonStudio ??= {})[namespace] = {
314
325
  ...manifest,
315
326
  scopes: [
316
327
  ...new Set([...(manifest.scopes ?? []), ...(decl.scopes ?? [])]),
@@ -239,8 +239,9 @@ export function aggregateRequiredServices(state) {
239
239
  // nothing, so they are found by id: the route accepts into the queue through
240
240
  // incomingWebhookService and the worker records attempts through it.
241
241
  const hasWebhookSources = Object.values(state.triggers?.webhookSourceMeta ?? {}).some(({ method, route }) => state.functions.meta[`http:${method}:${route}`] !== undefined);
242
- if (hasWebhookSources ||
243
- state.functions.meta['queue:pikku-incoming-webhooks'] !== undefined) {
242
+ const wiresIncomingWebhooks = hasWebhookSources ||
243
+ state.functions.meta['queue:pikku-incoming-webhooks'] !== undefined;
244
+ if (wiresIncomingWebhooks) {
244
245
  requiredServices.add('incomingWebhookService');
245
246
  requiredServices.add('queueService');
246
247
  }
@@ -333,6 +334,12 @@ export function aggregateRequiredServices(state) {
333
334
  addonFnServices.set(`${namespace}:${id}`, meta?.services?.services);
334
335
  }
335
336
  }
337
+ // An addon forwarding `incomingWebhookService` (pikku-admin lists receipts)
338
+ // reads it when the app has one; it never makes the app keep receipts. Only
339
+ // the app's own webhook sources do, so only they bring in the service and its
340
+ // tables — otherwise an app with no webhook wired fails to boot on a
341
+ // `webhook_receipt` table nothing writes to.
342
+ const readOnlyIfPresent = (service) => service === 'incomingWebhookService' && !wiresIncomingWebhooks;
336
343
  const parentDeclared = state.addonRequiredParentServices ?? [];
337
344
  const parentDeclaredSet = new Set(parentDeclared);
338
345
  const defaultServices = new Set([
@@ -354,6 +361,9 @@ export function aggregateRequiredServices(state) {
354
361
  continue;
355
362
  }
356
363
  for (const service of services) {
364
+ if (readOnlyIfPresent(service)) {
365
+ continue;
366
+ }
357
367
  if (parentDeclaredSet.has(service)) {
358
368
  requiredServices.add(service);
359
369
  }
@@ -365,7 +375,9 @@ export function aggregateRequiredServices(state) {
365
375
  }
366
376
  if (usesAddonFn && addonFactoryNeeded) {
367
377
  for (const service of parentDeclared) {
368
- requiredServices.add(service);
378
+ if (!readOnlyIfPresent(service)) {
379
+ requiredServices.add(service);
380
+ }
369
381
  }
370
382
  }
371
383
  // 8. When this project IS an addon, what it needs from its parent is whatever
@@ -402,6 +414,11 @@ export function validateSecretOverrides(logger, state) {
402
414
  // passes to getSecret — so validate against secretId, falling back to name for
403
415
  // older meta without a secretId field.
404
416
  const secretIds = new Set(state.secrets.definitions.map((d) => d.secretId ?? d.name));
417
+ // auth-secrets.gen.ts declares it, but that file is written after the first
418
+ // inspection of a fresh project, so its defineSecret is not seen yet — the
419
+ // same fallback validateSecretUsage takes.
420
+ if (state.auth?.definition)
421
+ secretIds.add('BETTER_AUTH_SECRET');
405
422
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
406
423
  for (const [logicalName, resolvedName] of Object.entries(addonDecl.secretOverrides ?? {})) {
407
424
  if (!secretIds.has(resolvedName)) {
@@ -425,13 +442,20 @@ export function validateAddonUses(logger, state) {
425
442
  if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
426
443
  return;
427
444
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
428
- for (const [pkg, target] of Object.entries(addonDecl.uses ?? {})) {
445
+ for (const [name, target] of Object.entries(addonDecl.uses ?? {})) {
446
+ if (addonDecl.declaredUses && !addonDecl.declaredUses.includes(name)) {
447
+ logger.critical(ErrorCode.ADDON_USES_NOT_DECLARED, `Addon '${namespace}' maps '${name}' to '${target}', but ${addonDecl.package} does not declare '${name}' in the pikku.uses of its package.json. It declares: ${addonDecl.declaredUses.join(', ') || 'nothing'}. Remove '${name}' from uses, or have the addon declare it.`);
448
+ continue;
449
+ }
450
+ const expected = addonDecl.dependencies?.find((d) => d.alias === name)?.package ?? name;
429
451
  const wired = wireAddonDeclarations.get(target);
430
452
  if (!wired) {
431
- 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(', ')}`);
453
+ logger.critical(ErrorCode.ADDON_USES_NOT_WIRED, `Addon '${namespace}' uses '${name}' as '${target}', but no addon is wired under the name '${target}'. Wired: ${Array.from(wireAddonDeclarations.keys()).join(', ')}`);
432
454
  }
433
- else if (wired.package !== pkg) {
434
- logger.critical(ErrorCode.ADDON_USES_NOT_WIRED, `Addon '${namespace}' uses '${pkg}' as '${target}', but '${target}' is wired from '${wired.package}'.`);
455
+ else if (wired.package !== expected) {
456
+ logger.critical(ErrorCode.ADDON_USES_NOT_WIRED, expected === name
457
+ ? `Addon '${namespace}' uses '${name}' as '${target}', but '${target}' is wired from '${wired.package}'.`
458
+ : `Addon '${namespace}' calls ${expected} as '${name}', mapped to '${target}', but '${target}' is wired from '${wired.package}'.`);
435
459
  }
436
460
  }
437
461
  }
@@ -118,6 +118,7 @@ export interface SerializableInspectorState {
118
118
  }]>;
119
119
  approvalDescriptions: InspectorState['functions']['approvalDescriptions'];
120
120
  dynamicImportIds?: string[];
121
+ disabled?: InspectorState['functions']['disabled'];
121
122
  };
122
123
  http: {
123
124
  metaInputTypes: Array<[
@@ -211,6 +212,7 @@ export interface SerializableInspectorState {
211
212
  contentBucket?: string;
212
213
  contentGrants?: Record<string, string>;
213
214
  globalContent?: string;
215
+ contributors?: string[];
214
216
  }
215
217
  ]>;
216
218
  wireAddonFiles: string[];
@@ -328,9 +330,10 @@ export interface SerializableInspectorState {
328
330
  diagnostics: InspectorDiagnostic[];
329
331
  addonFunctions: InspectorState['addonFunctions'];
330
332
  remoteAddonFunctions?: InspectorState['remoteAddonFunctions'];
331
- screensManifest?: InspectorState['screensManifest'];
332
- addonScreens?: InspectorState['addonScreens'];
333
- addonContributors?: InspectorState['addonContributors'];
333
+ studio?: InspectorState['studio'];
334
+ addonStudio?: InspectorState['addonStudio'];
335
+ contributorsManifest?: InspectorState['contributorsManifest'];
336
+ optedInContributors?: InspectorState['optedInContributors'];
334
337
  exportedContracts: InspectorState['exportedContracts'];
335
338
  }
336
339
  /**