@pikku/inspector 0.12.109 → 0.12.111

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 (46) hide show
  1. package/dist/add/add-channel.js +8 -0
  2. package/dist/add/add-file-with-factory.d.ts +7 -1
  3. package/dist/add/add-file-with-factory.js +63 -2
  4. package/dist/add/add-functions.js +32 -1
  5. package/dist/add/add-pikku-studio.d.ts +15 -0
  6. package/dist/add/add-pikku-studio.js +307 -0
  7. package/dist/add/add-rpc-invocations.d.ts +1 -1
  8. package/dist/add/add-rpc-invocations.js +14 -2
  9. package/dist/add/add-wire-remote-addon.js +25 -4
  10. package/dist/add/check-disabled-references.d.ts +27 -0
  11. package/dist/add/check-disabled-references.js +226 -0
  12. package/dist/error-codes.d.ts +14 -0
  13. package/dist/error-codes.js +14 -0
  14. package/dist/finalize.js +3 -1
  15. package/dist/index.d.ts +6 -1
  16. package/dist/index.js +3 -0
  17. package/dist/initial-state.js +1 -0
  18. package/dist/inspector.js +4 -0
  19. package/dist/lite.d.ts +7 -1
  20. package/dist/lite.js +5 -1
  21. package/dist/types.d.ts +65 -13
  22. package/dist/utils/addon-dependencies.d.ts +43 -0
  23. package/dist/utils/addon-dependencies.js +91 -0
  24. package/dist/utils/contract-hashes.js +2 -2
  25. package/dist/utils/find-unused-functions.d.ts +20 -0
  26. package/dist/utils/find-unused-functions.js +132 -0
  27. package/dist/utils/find-wire-remote-addon-specs.d.ts +17 -0
  28. package/dist/utils/find-wire-remote-addon-specs.js +53 -0
  29. package/dist/utils/get-property-value.js +4 -1
  30. package/dist/utils/load-addon-functions-meta.js +69 -46
  31. package/dist/utils/post-process.js +58 -8
  32. package/dist/utils/remote-addon-sdk.d.ts +57 -0
  33. package/dist/utils/remote-addon-sdk.js +111 -0
  34. package/dist/utils/resolve-addon-package.d.ts +1 -0
  35. package/dist/utils/resolve-addon-package.js +1 -1
  36. package/dist/utils/resolve-versions.js +18 -0
  37. package/dist/utils/serialize-inspector-state.d.ts +5 -2
  38. package/dist/utils/serialize-inspector-state.js +12 -4
  39. package/dist/utils/validate-addon-zod-contract.d.ts +23 -0
  40. package/dist/utils/validate-addon-zod-contract.js +70 -0
  41. package/dist/utils/validate-exposed-functions-gated.d.ts +3 -1
  42. package/dist/utils/validate-exposed-functions-gated.js +15 -8
  43. package/dist/visit.js +6 -3
  44. package/package.json +3 -3
  45. package/dist/add/add-define-screens.d.ts +0 -8
  46. 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
+ };
@@ -73,7 +73,7 @@ export function buildCurrentContracts(functionsMeta, allSchemas, typesMap) {
73
73
  for (const [funcId, meta] of Object.entries(functionsMeta)) {
74
74
  // A webhook receive has no callers but its own route, so there is no
75
75
  // contract for a version to protect.
76
- if (meta.remote === true || meta.webhookReceive) {
76
+ if (meta.webhookReceive) {
77
77
  continue;
78
78
  }
79
79
  const parsed = parseVersionedId(funcId);
@@ -253,7 +253,7 @@ export function updateManifest(existing, currentContracts) {
253
253
  export function extractContractsFromMeta(functionsMeta) {
254
254
  const result = new Map();
255
255
  for (const [funcId, meta] of Object.entries(functionsMeta)) {
256
- if (meta.remote === true || meta.webhookReceive || !meta.contractHash) {
256
+ if (meta.webhookReceive || !meta.contractHash) {
257
257
  continue;
258
258
  }
259
259
  const parsed = parseVersionedId(funcId);
@@ -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
+ };
@@ -0,0 +1,17 @@
1
+ /** A `wireRemoteAddon({ package })` found in source, with the line its literal sits on. */
2
+ export type WireRemoteAddonSpec = {
3
+ /** The `name` option, when it is a literal. */
4
+ namespace?: string;
5
+ /** The literal as written, e.g. `@scope/name@^1.4.0`. */
6
+ spec: string;
7
+ name: string;
8
+ range?: string;
9
+ /** 1-based line of the literal. */
10
+ line: number;
11
+ };
12
+ /**
13
+ * Every `wireRemoteAddon({ package: '<literal>' })` in one file. A `package`
14
+ * that is not a literal, or that does not parse, is skipped: the inspection
15
+ * reports those.
16
+ */
17
+ export declare const findWireRemoteAddonSpecs: (fileName: string, sourceText: string) => WireRemoteAddonSpec[];
@@ -0,0 +1,53 @@
1
+ import * as ts from 'typescript';
2
+ import { parseRemoteAddonPackage } from './remote-addon-sdk.js';
3
+ /**
4
+ * Every `wireRemoteAddon({ package: '<literal>' })` in one file. A `package`
5
+ * that is not a literal, or that does not parse, is skipped: the inspection
6
+ * reports those.
7
+ */
8
+ export const findWireRemoteAddonSpecs = (fileName, sourceText) => {
9
+ if (!sourceText.includes('wireRemoteAddon'))
10
+ return [];
11
+ const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true);
12
+ const found = [];
13
+ const visit = (node) => {
14
+ if (ts.isCallExpression(node) &&
15
+ ts.isIdentifier(node.expression) &&
16
+ node.expression.text === 'wireRemoteAddon' &&
17
+ node.arguments[0] &&
18
+ ts.isObjectLiteralExpression(node.arguments[0])) {
19
+ let namespace;
20
+ let literal;
21
+ for (const prop of node.arguments[0].properties) {
22
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
23
+ continue;
24
+ if (prop.name.text === 'name' &&
25
+ ts.isStringLiteralLike(prop.initializer)) {
26
+ namespace = prop.initializer.text;
27
+ }
28
+ else if (prop.name.text === 'package' &&
29
+ ts.isStringLiteralLike(prop.initializer)) {
30
+ literal = prop.initializer;
31
+ }
32
+ }
33
+ if (literal) {
34
+ try {
35
+ const { name, range } = parseRemoteAddonPackage(literal.text);
36
+ found.push({
37
+ namespace,
38
+ spec: literal.text,
39
+ name,
40
+ range,
41
+ line: sourceFile.getLineAndCharacterOfPosition(literal.getStart(sourceFile)).line + 1,
42
+ });
43
+ }
44
+ catch {
45
+ // reported by the inspection
46
+ }
47
+ }
48
+ }
49
+ ts.forEachChild(node, visit);
50
+ };
51
+ visit(sourceFile);
52
+ return found;
53
+ };
@@ -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,8 +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
+ import { checkLockedSdk } from './remote-addon-sdk.js';
7
+ import { declaredAddonNames, findInstalledPackageDir, readAddonDependencies, } from './addon-dependencies.js';
6
8
  const isHTTPRouteConfig = (value) => typeof value === 'object' &&
7
9
  value !== null &&
8
10
  'method' in value &&
@@ -91,24 +93,6 @@ const resolveAddonMeta = (require, packageName, baseName) => {
91
93
  }
92
94
  return null;
93
95
  };
94
- const findInstalledPackageDir = (dirs, packageName) => {
95
- for (const start of dirs) {
96
- const root = parse(start).root;
97
- let dir = start;
98
- while (dir) {
99
- const candidate = join(dir, 'node_modules', packageName);
100
- if (existsSync(join(candidate, 'package.json')))
101
- return candidate;
102
- if (dir === root)
103
- break;
104
- const parent = dirname(dir);
105
- if (parent === dir)
106
- break;
107
- dir = parent;
108
- }
109
- }
110
- return null;
111
- };
112
96
  export const describeMissingAddonMeta = (namespace, packageName, dirs, declFile) => {
113
97
  const caller = declFile
114
98
  ? relative(dirs[dirs.length - 1], declFile) || declFile
@@ -182,27 +166,12 @@ export const namespaceAddonWebhookSources = (sources, namespace) => {
182
166
  * package's function metadata so that wiring handlers (channels, HTTP routes,
183
167
  * schedules, etc.) can look up addon function types during the routes sweep.
184
168
  */
185
- const loadScreensManifest = async (require, packageName) => {
169
+ const loadStudioMeta = async (require, packageName) => {
186
170
  const path = resolveAddonMeta(require, packageName, 'screens/pikku-screens-meta');
187
171
  if (!path)
188
172
  return null;
189
173
  return JSON.parse(await readFile(path, 'utf-8'));
190
174
  };
191
- const readAddonNeeds = async (dirs, packageName) => {
192
- const dir = findInstalledPackageDir(dirs, packageName);
193
- if (!dir)
194
- return [];
195
- try {
196
- const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf-8'));
197
- const uses = pkg?.pikku?.uses;
198
- return Array.isArray(uses)
199
- ? uses.filter((u) => typeof u === 'string')
200
- : [];
201
- }
202
- catch {
203
- return [];
204
- }
205
- };
206
175
  const isContributorRef = (value) => !!value &&
207
176
  typeof value === 'object' &&
208
177
  typeof value.module === 'string' &&
@@ -239,6 +208,56 @@ export const readAddonContributors = async (dirs, packageName) => {
239
208
  contributors: declared.map(({ module, export: name }) => name === undefined ? { module } : { module, export: name }),
240
209
  };
241
210
  };
211
+ /**
212
+ * A versioned remote addon's function metadata comes from the SDK `pikku addon
213
+ * sync` extracted, and only when it matches the lock — a stale or missing SDK
214
+ * is reported by `validateRemoteAddonDependencies`, not loaded half-way.
215
+ */
216
+ const loadRemoteAddonSdkMeta = async (state, namespace, packageName, range) => {
217
+ let locked;
218
+ try {
219
+ locked = checkLockedSdk(state.rootDir, packageName, range);
220
+ }
221
+ catch {
222
+ return;
223
+ }
224
+ if (!locked.ok)
225
+ return;
226
+ const metaPath = join(locked.dir, 'function', 'pikku-functions-meta.gen.json');
227
+ if (!existsSync(metaPath))
228
+ return;
229
+ (state.remoteAddonFunctions ??= {})[namespace] = JSON.parse(await readFile(metaPath, 'utf-8'));
230
+ };
231
+ /**
232
+ * Reads the addons a wired package says it calls (`pikku.uses` in its
233
+ * package.json) onto its declaration, and reports each one this wiring leaves
234
+ * unmapped. Whether a mapping points at the right package, and whether the
235
+ * wiring maps a name the package never declared, is `validateAddonUses`' call
236
+ * once every wiring is known.
237
+ */
238
+ const recordDeclaredUses = (logger, namespace, decl, dirs) => {
239
+ const packageDir = findInstalledPackageDir(dirs, decl.package);
240
+ if (!packageDir)
241
+ return;
242
+ const declared = readAddonDependencies(packageDir);
243
+ if (declared.form === 'none')
244
+ return;
245
+ if (declared.form === 'map') {
246
+ for (const message of declared.invalid) {
247
+ logger.critical(ErrorCode.ADDON_DEPENDENCY_INVALID, `${decl.package} (wired as '${namespace}'): ${message}`);
248
+ }
249
+ decl.dependencies = declared.dependencies;
250
+ }
251
+ decl.declaredUses = declaredAddonNames(declared);
252
+ for (const needed of decl.declaredUses) {
253
+ if (decl.uses?.[needed])
254
+ continue;
255
+ const dependency = decl.dependencies?.find((d) => d.alias === needed);
256
+ logger.critical(ErrorCode.ADDON_NEEDS_NOT_MAPPED, dependency
257
+ ? `${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.`
258
+ : `${decl.package} needs ${needed}, but wireAddon('${namespace}') does not map it. Wire ${needed} and add uses: { '${needed}': '<name>' } to this wireAddon.`);
259
+ }
260
+ };
242
261
  export async function loadAddonFunctionsMeta(logger, state) {
243
262
  const { wireAddonDeclarations } = state.rpc;
244
263
  if (wireAddonDeclarations.size === 0)
@@ -247,8 +266,12 @@ export async function loadAddonFunctionsMeta(logger, state) {
247
266
  // Remote addons (wireRemoteAddon) ship as a devDependency: types only.
248
267
  // Their functions, secrets, variables, schemas and services live on the
249
268
  // HOST that runs them — never load or require any of that here.
250
- if (decl.remote)
269
+ if (decl.remote) {
270
+ if (decl.range) {
271
+ await loadRemoteAddonSdkMeta(state, namespace, decl.package, decl.range);
272
+ }
251
273
  continue;
274
+ }
252
275
  const dirs = addonResolutionDirs(state.rootDir, decl.file);
253
276
  const require = createAddonResolver(dirs);
254
277
  try {
@@ -262,16 +285,12 @@ export async function loadAddonFunctionsMeta(logger, state) {
262
285
  catch (error) {
263
286
  logger.error(`wireAddon('${namespace}'): ${error instanceof Error ? error.message : error}`);
264
287
  }
265
- for (const needed of await readAddonNeeds(dirs, decl.package)) {
266
- if (!decl.uses?.[needed]) {
267
- 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.`);
268
- }
269
- }
288
+ recordDeclaredUses(logger, namespace, decl, dirs);
270
289
  if (decl.ui) {
271
290
  let manifest;
272
291
  let unreadable = false;
273
292
  try {
274
- manifest = await loadScreensManifest(require, decl.package);
293
+ manifest = await loadStudioMeta(require, decl.package);
275
294
  }
276
295
  catch (e) {
277
296
  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}`);
@@ -281,11 +300,11 @@ export async function loadAddonFunctionsMeta(logger, state) {
281
300
  // reported above; the addon's functions and scopes still load
282
301
  }
283
302
  else if (!manifest || manifest.screens.length === 0) {
284
- 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.`);
303
+ logger.critical(ErrorCode.ADDON_UI_HAS_NO_SCREENS, `wireAddon('${namespace}') sets ui: true, but ${decl.package} declares no screens. Add a pikkuStudio call to the package, or drop ui.`);
285
304
  }
286
305
  else {
287
306
  ;
288
- (state.addonScreens ??= {})[namespace] = {
307
+ (state.addonStudio ??= {})[namespace] = {
289
308
  ...manifest,
290
309
  scopes: [
291
310
  ...new Set([...(manifest.scopes ?? []), ...(decl.scopes ?? [])]),
@@ -347,13 +366,17 @@ export async function loadAddonFunctionsMeta(logger, state) {
347
366
  }
348
367
  }
349
368
  }
350
- // Same reasoning as mcp: a listed name the addon does not publish is a
351
- // function the app believes callable and never is.
369
+ // Same reasoning as mcp: a listed name the addon does not publish, or
370
+ // does not declare `expose: true`, is a function the app believes
371
+ // callable and never is.
352
372
  if (Array.isArray(decl.expose)) {
353
373
  for (const funcName of decl.expose) {
354
374
  if (!Object.hasOwn(meta, funcName)) {
355
375
  logger.critical(ErrorCode.ADDON_EXPOSE_FUNCTION_NOT_FOUND, `wireAddon('${namespace}') lists '${funcName}' under expose, but ${decl.package} publishes no such function.`);
356
376
  }
377
+ else if (meta[funcName]?.expose !== true) {
378
+ logger.critical(ErrorCode.ADDON_EXPOSE_FUNCTION_NOT_FOUND, `wireAddon('${namespace}') lists '${funcName}' under expose, but ${decl.package} does not declare it expose: true, so it stays unexposed.`);
379
+ }
357
380
  }
358
381
  }
359
382
  // Load addon secrets meta
@@ -7,6 +7,7 @@ import { relative } from 'node:path';
7
7
  import { AUTH_HANDLER_FUNC_ID, DYNAMIC_SCENARIO_STEP_TARGET, } from './inspector-constants.js';
8
8
  import { flattenScopeDefinitions } from '@pikku/core/scope';
9
9
  import { addonResolutionDirs } from './addon-resolution.js';
10
+ import { ADDONS_LOCK_FILE, checkLockedSdk, readAddonsLock, } from './remote-addon-sdk.js';
10
11
  /**
11
12
  * Stamp the inspected authorize/callbacks service set onto the generated auth
12
13
  * handler's function meta.
@@ -238,8 +239,9 @@ export function aggregateRequiredServices(state) {
238
239
  // nothing, so they are found by id: the route accepts into the queue through
239
240
  // incomingWebhookService and the worker records attempts through it.
240
241
  const hasWebhookSources = Object.values(state.triggers?.webhookSourceMeta ?? {}).some(({ method, route }) => state.functions.meta[`http:${method}:${route}`] !== undefined);
241
- if (hasWebhookSources ||
242
- state.functions.meta['queue:pikku-incoming-webhooks'] !== undefined) {
242
+ const wiresIncomingWebhooks = hasWebhookSources ||
243
+ state.functions.meta['queue:pikku-incoming-webhooks'] !== undefined;
244
+ if (wiresIncomingWebhooks) {
243
245
  requiredServices.add('incomingWebhookService');
244
246
  requiredServices.add('queueService');
245
247
  }
@@ -332,6 +334,12 @@ export function aggregateRequiredServices(state) {
332
334
  addonFnServices.set(`${namespace}:${id}`, meta?.services?.services);
333
335
  }
334
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;
335
343
  const parentDeclared = state.addonRequiredParentServices ?? [];
336
344
  const parentDeclaredSet = new Set(parentDeclared);
337
345
  const defaultServices = new Set([
@@ -353,6 +361,9 @@ export function aggregateRequiredServices(state) {
353
361
  continue;
354
362
  }
355
363
  for (const service of services) {
364
+ if (readOnlyIfPresent(service)) {
365
+ continue;
366
+ }
356
367
  if (parentDeclaredSet.has(service)) {
357
368
  requiredServices.add(service);
358
369
  }
@@ -364,7 +375,9 @@ export function aggregateRequiredServices(state) {
364
375
  }
365
376
  if (usesAddonFn && addonFactoryNeeded) {
366
377
  for (const service of parentDeclared) {
367
- requiredServices.add(service);
378
+ if (!readOnlyIfPresent(service)) {
379
+ requiredServices.add(service);
380
+ }
368
381
  }
369
382
  }
370
383
  // 8. When this project IS an addon, what it needs from its parent is whatever
@@ -401,6 +414,11 @@ export function validateSecretOverrides(logger, state) {
401
414
  // passes to getSecret — so validate against secretId, falling back to name for
402
415
  // older meta without a secretId field.
403
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');
404
422
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
405
423
  for (const [logicalName, resolvedName] of Object.entries(addonDecl.secretOverrides ?? {})) {
406
424
  if (!secretIds.has(resolvedName)) {
@@ -424,13 +442,20 @@ export function validateAddonUses(logger, state) {
424
442
  if (!wireAddonDeclarations || wireAddonDeclarations.size === 0)
425
443
  return;
426
444
  for (const [namespace, addonDecl] of wireAddonDeclarations.entries()) {
427
- 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;
428
451
  const wired = wireAddonDeclarations.get(target);
429
452
  if (!wired) {
430
- 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(', ')}`);
431
454
  }
432
- else if (wired.package !== pkg) {
433
- 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}'.`);
434
459
  }
435
460
  }
436
461
  }
@@ -607,8 +632,33 @@ export function validateRemoteAddonDependencies(logger, state) {
607
632
  manifests.set(path, manifest);
608
633
  return manifest;
609
634
  };
635
+ // A versioned remote addon's contract is the SDK `pikku all`
636
+ // downloaded, so what has to hold is that the SDK on disk is the one the
637
+ // lock pins, not that a package is installed.
638
+ let lock;
610
639
  for (const [namespace, decl] of wireAddonDeclarations.entries()) {
611
- if (!decl.remote)
640
+ if (!decl.remote || !decl.range)
641
+ continue;
642
+ if (lock === undefined) {
643
+ try {
644
+ lock = readAddonsLock(state.rootDir);
645
+ }
646
+ catch (e) {
647
+ lock = e instanceof Error ? e : new Error(String(e));
648
+ }
649
+ }
650
+ const reason = lock instanceof Error
651
+ ? lock.message
652
+ : (() => {
653
+ const locked = checkLockedSdk(state.rootDir, decl.package, decl.range, lock);
654
+ return locked.ok ? null : locked.reason;
655
+ })();
656
+ if (reason === null)
657
+ continue;
658
+ logger.critical(ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY, `Remote addon '${namespace}' ('${decl.package}@${decl.range}') has no SDK matching ${ADDONS_LOCK_FILE}: ${reason}. Run 'pikku all' to download it.`);
659
+ }
660
+ for (const [namespace, decl] of wireAddonDeclarations.entries()) {
661
+ if (!decl.remote || decl.range)
612
662
  continue;
613
663
  const pkgJsonPath = join(addonResolutionDirs(state.rootDir, decl.file)[0], 'package.json');
614
664
  const pkgJson = readManifest(pkgJsonPath);