@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.
- package/dist/add/add-channel.js +8 -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 +32 -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.d.ts +1 -1
- package/dist/add/add-rpc-invocations.js +14 -2
- package/dist/add/add-wire-remote-addon.js +25 -4
- 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/finalize.js +3 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -0
- package/dist/initial-state.js +1 -0
- package/dist/inspector.js +4 -0
- package/dist/lite.d.ts +7 -1
- package/dist/lite.js +5 -1
- package/dist/types.d.ts +65 -13
- package/dist/utils/addon-dependencies.d.ts +43 -0
- package/dist/utils/addon-dependencies.js +91 -0
- package/dist/utils/contract-hashes.js +2 -2
- package/dist/utils/find-unused-functions.d.ts +20 -0
- package/dist/utils/find-unused-functions.js +132 -0
- package/dist/utils/find-wire-remote-addon-specs.d.ts +17 -0
- package/dist/utils/find-wire-remote-addon-specs.js +53 -0
- package/dist/utils/get-property-value.js +4 -1
- package/dist/utils/load-addon-functions-meta.js +69 -46
- package/dist/utils/post-process.js +58 -8
- package/dist/utils/remote-addon-sdk.d.ts +57 -0
- package/dist/utils/remote-addon-sdk.js +111 -0
- package/dist/utils/resolve-addon-package.d.ts +1 -0
- package/dist/utils/resolve-addon-package.js +1 -1
- package/dist/utils/resolve-versions.js +18 -0
- package/dist/utils/serialize-inspector-state.d.ts +5 -2
- package/dist/utils/serialize-inspector-state.js +12 -4
- package/dist/utils/validate-addon-zod-contract.d.ts +23 -0
- package/dist/utils/validate-addon-zod-contract.js +70 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +3 -1
- package/dist/utils/validate-exposed-functions-gated.js +15 -8
- package/dist/visit.js +6 -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
|
+
};
|
|
@@ -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.
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
351
|
-
// function the app believes
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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 '${
|
|
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 !==
|
|
433
|
-
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}'.`);
|
|
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);
|