@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.
- package/dist/add/add-channel.js +8 -0
- package/dist/add/add-define-contributors.d.ts +10 -0
- package/dist/add/add-define-contributors.js +98 -0
- package/dist/add/add-file-with-factory.d.ts +7 -1
- package/dist/add/add-file-with-factory.js +63 -2
- package/dist/add/add-functions.js +18 -1
- package/dist/add/add-pikku-studio.d.ts +15 -0
- package/dist/add/add-pikku-studio.js +307 -0
- package/dist/add/add-rpc-invocations.js +12 -0
- package/dist/add/add-wire-addon.js +11 -2
- package/dist/add/check-disabled-references.d.ts +27 -0
- package/dist/add/check-disabled-references.js +226 -0
- package/dist/error-codes.d.ts +14 -0
- package/dist/error-codes.js +14 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/initial-state.js +1 -0
- package/dist/inspector.js +4 -0
- package/dist/lite.d.ts +3 -0
- package/dist/lite.js +2 -0
- package/dist/types.d.ts +78 -28
- package/dist/utils/addon-dependencies.d.ts +43 -0
- package/dist/utils/addon-dependencies.js +91 -0
- package/dist/utils/find-unused-functions.d.ts +20 -0
- package/dist/utils/find-unused-functions.js +132 -0
- package/dist/utils/get-property-value.js +4 -1
- package/dist/utils/load-addon-functions-meta.d.ts +1 -8
- package/dist/utils/load-addon-functions-meta.js +87 -76
- package/dist/utils/post-process.js +31 -7
- package/dist/utils/serialize-inspector-state.d.ts +6 -3
- package/dist/utils/serialize-inspector-state.js +12 -6
- package/dist/visit.js +8 -3
- package/package.json +3 -3
- package/dist/add/add-define-screens.d.ts +0 -8
- 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 {
|
|
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,
|
|
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
|
|
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
|
|
193
|
-
const
|
|
194
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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
|
-
|
|
219
|
-
const
|
|
220
|
-
if (!
|
|
221
|
-
return
|
|
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(
|
|
225
|
-
|
|
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
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
288
|
-
logger.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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 '${
|
|
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 !==
|
|
434
|
-
logger.critical(ErrorCode.ADDON_USES_NOT_WIRED,
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
/**
|