@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,57 @@
|
|
|
1
|
+
/** Committed beside pikku.config.json: the SDK version and integrity each versioned `wireRemoteAddon` resolved to. */
|
|
2
|
+
export declare const ADDONS_LOCK_FILE = "pikku-addons.lock";
|
|
3
|
+
/** Where `pikku all` extracts SDKs, relative to the project root. Gitignored with the rest of `.pikku`. */
|
|
4
|
+
export declare const REMOTE_SDK_DIR: string;
|
|
5
|
+
/** Written into an extracted SDK: the integrity of the tarball it came from. */
|
|
6
|
+
export declare const SDK_INTEGRITY_MARKER = ".integrity";
|
|
7
|
+
export type AddonsLockEntry = {
|
|
8
|
+
/** The range the spec asked for when this entry was written. */
|
|
9
|
+
range: string;
|
|
10
|
+
version: string;
|
|
11
|
+
/** `sha512-<base64>` of the SDK tarball. */
|
|
12
|
+
integrity: string;
|
|
13
|
+
};
|
|
14
|
+
export type AddonsLock = {
|
|
15
|
+
lockfileVersion: 1;
|
|
16
|
+
sdks: Record<string, AddonsLockEntry>;
|
|
17
|
+
};
|
|
18
|
+
/** `manifest.json` at the root of an SDK. */
|
|
19
|
+
export type RemoteAddonSdkManifest = {
|
|
20
|
+
name: string;
|
|
21
|
+
version: string;
|
|
22
|
+
/** The @pikku/core range the SDK's types were generated against. */
|
|
23
|
+
pikkuCore?: string;
|
|
24
|
+
contractHash?: string;
|
|
25
|
+
};
|
|
26
|
+
export type RemoteAddonPackageSpec = {
|
|
27
|
+
name: string;
|
|
28
|
+
range?: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* `@scope/name@^1.4.0` → name and range, split at the last `@` after the first
|
|
32
|
+
* character. Mirrors `parseRemoteAddonPackage` in @pikku/core, which the
|
|
33
|
+
* runtime uses on the same literal.
|
|
34
|
+
*/
|
|
35
|
+
export declare const parseRemoteAddonPackage: (spec: string) => RemoteAddonPackageSpec;
|
|
36
|
+
export declare const emptyAddonsLock: () => AddonsLock;
|
|
37
|
+
/** The project's lock, an empty one when there is none. Throws when the file is not a lock. */
|
|
38
|
+
export declare const readAddonsLock: (rootDir: string) => AddonsLock;
|
|
39
|
+
/** Sorted by package name, each entry's keys in a fixed order, so the same lock always writes the same bytes. */
|
|
40
|
+
export declare const serializeAddonsLock: (lock: AddonsLock) => string;
|
|
41
|
+
export declare const remoteSdkDir: (rootDir: string, name: string, version: string) => string;
|
|
42
|
+
export declare const readSdkManifest: (dir: string) => RemoteAddonSdkManifest | null;
|
|
43
|
+
export type LockedSdk = {
|
|
44
|
+
ok: true;
|
|
45
|
+
entry: AddonsLockEntry;
|
|
46
|
+
dir: string;
|
|
47
|
+
manifest: RemoteAddonSdkManifest;
|
|
48
|
+
} | {
|
|
49
|
+
ok: false;
|
|
50
|
+
reason: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Whether the SDK for `name@range` is on disk exactly as the lock pins it: the
|
|
54
|
+
* lock was written for this range, the extracted directory carries the lock's
|
|
55
|
+
* integrity, and its manifest names this package and version.
|
|
56
|
+
*/
|
|
57
|
+
export declare const checkLockedSdk: (rootDir: string, name: string, range: string, lock?: AddonsLock) => LockedSdk;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
/** Committed beside pikku.config.json: the SDK version and integrity each versioned `wireRemoteAddon` resolved to. */
|
|
4
|
+
export const ADDONS_LOCK_FILE = 'pikku-addons.lock';
|
|
5
|
+
/** Where `pikku all` extracts SDKs, relative to the project root. Gitignored with the rest of `.pikku`. */
|
|
6
|
+
export const REMOTE_SDK_DIR = join('.pikku', 'remote');
|
|
7
|
+
/** Written into an extracted SDK: the integrity of the tarball it came from. */
|
|
8
|
+
export const SDK_INTEGRITY_MARKER = '.integrity';
|
|
9
|
+
/**
|
|
10
|
+
* `@scope/name@^1.4.0` → name and range, split at the last `@` after the first
|
|
11
|
+
* character. Mirrors `parseRemoteAddonPackage` in @pikku/core, which the
|
|
12
|
+
* runtime uses on the same literal.
|
|
13
|
+
*/
|
|
14
|
+
export const parseRemoteAddonPackage = (spec) => {
|
|
15
|
+
const trimmed = spec.trim();
|
|
16
|
+
const at = trimmed.lastIndexOf('@');
|
|
17
|
+
if (at <= 0) {
|
|
18
|
+
if (!trimmed || trimmed === '@') {
|
|
19
|
+
throw new Error(`'${spec}' is not a package name`);
|
|
20
|
+
}
|
|
21
|
+
return { name: trimmed };
|
|
22
|
+
}
|
|
23
|
+
const name = trimmed.slice(0, at);
|
|
24
|
+
const range = trimmed.slice(at + 1).trim();
|
|
25
|
+
if (!range) {
|
|
26
|
+
throw new Error(`'${spec}' ends in '@' with no version range after it. Write '${name}@^1.0.0', or drop the '@'.`);
|
|
27
|
+
}
|
|
28
|
+
return { name, range };
|
|
29
|
+
};
|
|
30
|
+
export const emptyAddonsLock = () => ({
|
|
31
|
+
lockfileVersion: 1,
|
|
32
|
+
sdks: {},
|
|
33
|
+
});
|
|
34
|
+
/** The project's lock, an empty one when there is none. Throws when the file is not a lock. */
|
|
35
|
+
export const readAddonsLock = (rootDir) => {
|
|
36
|
+
const path = join(rootDir, ADDONS_LOCK_FILE);
|
|
37
|
+
if (!existsSync(path))
|
|
38
|
+
return emptyAddonsLock();
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
throw new Error(`${path} is not valid JSON: ${e?.message ?? e}`);
|
|
45
|
+
}
|
|
46
|
+
if (!parsed ||
|
|
47
|
+
typeof parsed !== 'object' ||
|
|
48
|
+
typeof parsed.sdks !== 'object') {
|
|
49
|
+
throw new Error(`${path} has no "sdks" object`);
|
|
50
|
+
}
|
|
51
|
+
return { lockfileVersion: 1, sdks: parsed.sdks ?? {} };
|
|
52
|
+
};
|
|
53
|
+
/** Sorted by package name, each entry's keys in a fixed order, so the same lock always writes the same bytes. */
|
|
54
|
+
export const serializeAddonsLock = (lock) => {
|
|
55
|
+
const sdks = {};
|
|
56
|
+
for (const name of Object.keys(lock.sdks).sort()) {
|
|
57
|
+
const { range, version, integrity } = lock.sdks[name];
|
|
58
|
+
sdks[name] = { range, version, integrity };
|
|
59
|
+
}
|
|
60
|
+
return `${JSON.stringify({ lockfileVersion: 1, sdks }, null, 2)}\n`;
|
|
61
|
+
};
|
|
62
|
+
export const remoteSdkDir = (rootDir, name, version) => join(rootDir, REMOTE_SDK_DIR, `${name}@${version}`);
|
|
63
|
+
export const readSdkManifest = (dir) => {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf-8'));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Whether the SDK for `name@range` is on disk exactly as the lock pins it: the
|
|
73
|
+
* lock was written for this range, the extracted directory carries the lock's
|
|
74
|
+
* integrity, and its manifest names this package and version.
|
|
75
|
+
*/
|
|
76
|
+
export const checkLockedSdk = (rootDir, name, range, lock = readAddonsLock(rootDir)) => {
|
|
77
|
+
const entry = lock.sdks[name];
|
|
78
|
+
if (!entry) {
|
|
79
|
+
return { ok: false, reason: `${ADDONS_LOCK_FILE} has no entry for it` };
|
|
80
|
+
}
|
|
81
|
+
if (entry.range !== range) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: `${ADDONS_LOCK_FILE} was resolved for '${entry.range}', not '${range}'`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const dir = remoteSdkDir(rootDir, name, entry.version);
|
|
88
|
+
let integrity = null;
|
|
89
|
+
try {
|
|
90
|
+
integrity = readFileSync(join(dir, SDK_INTEGRITY_MARKER), 'utf-8').trim();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return { ok: false, reason: `${dir} is missing` };
|
|
94
|
+
}
|
|
95
|
+
if (integrity !== entry.integrity) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: `${dir} was extracted from a different tarball than ${ADDONS_LOCK_FILE} pins`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const manifest = readSdkManifest(dir);
|
|
102
|
+
if (!manifest ||
|
|
103
|
+
manifest.name !== name ||
|
|
104
|
+
manifest.version !== entry.version) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
reason: `${dir}/manifest.json does not name ${name}@${entry.version}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, entry, dir, manifest };
|
|
111
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CredentialOverrideMeta } from '../types.js';
|
|
2
2
|
import * as ts from 'typescript';
|
|
3
|
+
export declare const findPackageNameForFile: (filePath: string) => string | null;
|
|
3
4
|
/**
|
|
4
5
|
* Resolve the addon package name from an imported identifier.
|
|
5
6
|
* Checks if the identifier's import module specifier matches any
|
|
@@ -2,7 +2,7 @@ import * as ts from 'typescript';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'fs';
|
|
3
3
|
import { dirname, join, parse } from 'path';
|
|
4
4
|
const packageNameCache = new Map();
|
|
5
|
-
const findPackageNameForFile = (filePath) => {
|
|
5
|
+
export const findPackageNameForFile = (filePath) => {
|
|
6
6
|
if (packageNameCache.has(filePath)) {
|
|
7
7
|
return packageNameCache.get(filePath);
|
|
8
8
|
}
|
|
@@ -90,6 +90,24 @@ export function resolveLatestVersions(state, logger) {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
+
// A remote caller names a version the same way: `fn` is the latest remote
|
|
94
|
+
// version, and every remote `fn@vN` stays callable by its own id.
|
|
95
|
+
const remote = [
|
|
96
|
+
...group.explicit,
|
|
97
|
+
...(group.unversioned
|
|
98
|
+
? [{ id: state.rpc.internalMeta[baseName], version: maxVersion + 1 }]
|
|
99
|
+
: []),
|
|
100
|
+
].filter(({ id }) => functionsMeta[id]?.remote === true);
|
|
101
|
+
if (remote.length > 0) {
|
|
102
|
+
state.rpc.remoteMeta[baseName] = remote.reduce((a, b) => a.version > b.version ? a : b).id;
|
|
103
|
+
for (const { id } of remote) {
|
|
104
|
+
state.rpc.remoteMeta[id] = id;
|
|
105
|
+
const fileEntry = state.rpc.internalFiles.get(id);
|
|
106
|
+
if (fileEntry) {
|
|
107
|
+
state.rpc.remoteFiles.set(id, fileEntry);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
93
111
|
for (const entry of group.explicit) {
|
|
94
112
|
state.rpc.invokedFunctions.add(entry.id);
|
|
95
113
|
}
|
|
@@ -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<[
|
|
@@ -194,6 +195,7 @@ export interface SerializableInspectorState {
|
|
|
194
195
|
string,
|
|
195
196
|
{
|
|
196
197
|
package: string;
|
|
198
|
+
range?: string;
|
|
197
199
|
file?: string;
|
|
198
200
|
rpcEndpoint?: string;
|
|
199
201
|
remote?: boolean;
|
|
@@ -326,8 +328,9 @@ export interface SerializableInspectorState {
|
|
|
326
328
|
openAPISpec: Record<string, any> | null;
|
|
327
329
|
diagnostics: InspectorDiagnostic[];
|
|
328
330
|
addonFunctions: InspectorState['addonFunctions'];
|
|
329
|
-
|
|
330
|
-
|
|
331
|
+
remoteAddonFunctions?: InspectorState['remoteAddonFunctions'];
|
|
332
|
+
studio?: InspectorState['studio'];
|
|
333
|
+
addonStudio?: InspectorState['addonStudio'];
|
|
331
334
|
addonContributors?: InspectorState['addonContributors'];
|
|
332
335
|
exportedContracts: InspectorState['exportedContracts'];
|
|
333
336
|
}
|
|
@@ -41,6 +41,9 @@ export function serializeInspectorState(state) {
|
|
|
41
41
|
files: Array.from(state.functions.files.entries()),
|
|
42
42
|
approvalDescriptions: state.functions.approvalDescriptions,
|
|
43
43
|
dynamicImportIds: Array.from(state.functions.dynamicImportIds ?? []),
|
|
44
|
+
...(Object.keys(state.functions.disabled ?? {}).length > 0
|
|
45
|
+
? { disabled: state.functions.disabled }
|
|
46
|
+
: {}),
|
|
44
47
|
},
|
|
45
48
|
http: {
|
|
46
49
|
metaInputTypes: Array.from(state.http.metaInputTypes.entries()),
|
|
@@ -190,8 +193,9 @@ export function serializeInspectorState(state) {
|
|
|
190
193
|
openAPISpec: state.openAPISpec,
|
|
191
194
|
diagnostics: state.diagnostics,
|
|
192
195
|
addonFunctions: state.addonFunctions,
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
remoteAddonFunctions: state.remoteAddonFunctions,
|
|
197
|
+
studio: state.studio,
|
|
198
|
+
addonStudio: state.addonStudio,
|
|
195
199
|
addonContributors: state.addonContributors,
|
|
196
200
|
exportedContracts: state.exportedContracts,
|
|
197
201
|
};
|
|
@@ -239,6 +243,7 @@ export function deserializeInspectorState(data) {
|
|
|
239
243
|
files: new Map(data.functions.files),
|
|
240
244
|
approvalDescriptions: data.functions.approvalDescriptions || {},
|
|
241
245
|
dynamicImportIds: new Set(data.functions.dynamicImportIds ?? []),
|
|
246
|
+
disabled: data.functions.disabled ?? {},
|
|
242
247
|
},
|
|
243
248
|
http: {
|
|
244
249
|
metaInputTypes: new Map(data.http.metaInputTypes),
|
|
@@ -396,8 +401,11 @@ export function deserializeInspectorState(data) {
|
|
|
396
401
|
openAPISpec: data.openAPISpec || null,
|
|
397
402
|
diagnostics: data.diagnostics || [],
|
|
398
403
|
addonFunctions: data.addonFunctions || {},
|
|
399
|
-
|
|
400
|
-
|
|
404
|
+
...(data.remoteAddonFunctions
|
|
405
|
+
? { remoteAddonFunctions: data.remoteAddonFunctions }
|
|
406
|
+
: {}),
|
|
407
|
+
studio: data.studio,
|
|
408
|
+
addonStudio: data.addonStudio,
|
|
401
409
|
addonContributors: data.addonContributors,
|
|
402
410
|
exportedContracts: data.exportedContracts || {
|
|
403
411
|
http: {},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TypesMap } from '../types-map.js';
|
|
2
|
+
/**
|
|
3
|
+
* The named, source-declared types a function's input or output resolves to:
|
|
4
|
+
* every name the typed maps would have to import rather than declare inline.
|
|
5
|
+
* A custom alias (a union, an array of rows) is followed through to the names
|
|
6
|
+
* it references.
|
|
7
|
+
*/
|
|
8
|
+
export declare const namedSourceTypes: (typesMap: TypesMap, names: string[]) => string[];
|
|
9
|
+
export interface AddonContractTypeSource {
|
|
10
|
+
name: string;
|
|
11
|
+
usesGenerics: boolean;
|
|
12
|
+
hasInputSchema: boolean;
|
|
13
|
+
hasOutputSchema: boolean;
|
|
14
|
+
inputNames: string[];
|
|
15
|
+
outputNames: string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Why a `remote: true` addon function cannot ship in an SDK, or null when it
|
|
19
|
+
* can. Its input and output have to be zod schemas: a TS generic or a named
|
|
20
|
+
* source type makes codegen emit `import type { X } from '../../../src/...'`
|
|
21
|
+
* into the remote map, a path that does not exist on the consumer's machine.
|
|
22
|
+
*/
|
|
23
|
+
export declare const addonZodContractViolation: (typesMap: TypesMap, fn: AddonContractTypeSource) => string | null;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { findPackageNameForFile } from './resolve-addon-package.js';
|
|
2
|
+
/**
|
|
3
|
+
* A type declared in `@pikku/core` is part of the SDK's allowed import set, so
|
|
4
|
+
* naming one (an MCP response, say) does not drag the addon's source along.
|
|
5
|
+
* Core is recognised by its install path or by the name in the nearest
|
|
6
|
+
* package.json, never by a directory name an addon could also use.
|
|
7
|
+
*/
|
|
8
|
+
const isPikkuCorePath = (path) => /[\\/]node_modules[\\/]@pikku[\\/]core[\\/]/.test(path) ||
|
|
9
|
+
findPackageNameForFile(path) === '@pikku/core';
|
|
10
|
+
/**
|
|
11
|
+
* The named, source-declared types a function's input or output resolves to:
|
|
12
|
+
* every name the typed maps would have to import rather than declare inline.
|
|
13
|
+
* A custom alias (a union, an array of rows) is followed through to the names
|
|
14
|
+
* it references.
|
|
15
|
+
*/
|
|
16
|
+
export const namedSourceTypes = (typesMap, names) => {
|
|
17
|
+
const found = new Set();
|
|
18
|
+
const visit = (name, seen) => {
|
|
19
|
+
if (seen.has(name))
|
|
20
|
+
return;
|
|
21
|
+
seen.add(name);
|
|
22
|
+
const custom = typesMap.customTypes.get(name);
|
|
23
|
+
if (custom) {
|
|
24
|
+
for (const ref of custom.references)
|
|
25
|
+
visit(ref, seen);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
let path = null;
|
|
29
|
+
let originalName = name;
|
|
30
|
+
try {
|
|
31
|
+
const meta = typesMap.getTypeMeta(name);
|
|
32
|
+
path = meta.path;
|
|
33
|
+
originalName = meta.originalName;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (path && !isPikkuCorePath(path))
|
|
39
|
+
found.add(originalName);
|
|
40
|
+
};
|
|
41
|
+
for (const name of names)
|
|
42
|
+
visit(name, new Set());
|
|
43
|
+
return [...found].sort();
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Why a `remote: true` addon function cannot ship in an SDK, or null when it
|
|
47
|
+
* can. Its input and output have to be zod schemas: a TS generic or a named
|
|
48
|
+
* source type makes codegen emit `import type { X } from '../../../src/...'`
|
|
49
|
+
* into the remote map, a path that does not exist on the consumer's machine.
|
|
50
|
+
*/
|
|
51
|
+
export const addonZodContractViolation = (typesMap, fn) => {
|
|
52
|
+
const problems = [];
|
|
53
|
+
if (fn.usesGenerics) {
|
|
54
|
+
problems.push('declares its input/output with TypeScript generics');
|
|
55
|
+
}
|
|
56
|
+
const named = [
|
|
57
|
+
...(fn.hasInputSchema ? [] : namedSourceTypes(typesMap, fn.inputNames)),
|
|
58
|
+
...(fn.hasOutputSchema ? [] : namedSourceTypes(typesMap, fn.outputNames)),
|
|
59
|
+
];
|
|
60
|
+
if (named.length > 0) {
|
|
61
|
+
problems.push(`types its input/output with named source types (${[...new Set(named)].join(', ')})`);
|
|
62
|
+
}
|
|
63
|
+
if (problems.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
return (`Addon function '${fn.name}' is remote: true but ${problems.join(' and ')}. ` +
|
|
66
|
+
`A remote addon's contract ships as a types-only SDK, so its input and output must be zod schemas: ` +
|
|
67
|
+
`export each schema as a zod variable from a file that does not import #pikku (a sibling <fn>.schemas.ts), ` +
|
|
68
|
+
`reference it as \`input:\` / \`output:\` on the function, and drop the generics. ` +
|
|
69
|
+
`TypeScript types would make the SDK import from the addon's src/, which the consumer does not have.`);
|
|
70
|
+
};
|
|
@@ -11,4 +11,6 @@ import type { InspectorLogger, InspectorState } from '../types.js';
|
|
|
11
11
|
*
|
|
12
12
|
* knowledge: decisions/security/console-addon-privileged-functions-gate-themselves.md
|
|
13
13
|
*/
|
|
14
|
-
export declare function validateExposedFunctionsGated(logger: InspectorLogger, state: InspectorState
|
|
14
|
+
export declare function validateExposedFunctionsGated(logger: InspectorLogger, state: InspectorState, { isAddon }?: {
|
|
15
|
+
isAddon?: boolean;
|
|
16
|
+
}): void;
|
|
@@ -11,9 +11,13 @@ import { ErrorCode } from '../error-codes.js';
|
|
|
11
11
|
*
|
|
12
12
|
* knowledge: decisions/security/console-addon-privileged-functions-gate-themselves.md
|
|
13
13
|
*/
|
|
14
|
-
export function validateExposedFunctionsGated(logger, state) {
|
|
14
|
+
export function validateExposedFunctionsGated(logger, state, { isAddon = false } = {}) {
|
|
15
15
|
const ungated = [];
|
|
16
|
-
|
|
16
|
+
// An addon's own `expose: true` only makes a function eligible: nothing is
|
|
17
|
+
// reachable until a host's `wireAddon` opts in, and that host is warned below.
|
|
18
|
+
for (const [funcName, meta] of isAddon
|
|
19
|
+
? []
|
|
20
|
+
: Object.entries(state.functions.meta)) {
|
|
17
21
|
if (!meta.expose)
|
|
18
22
|
continue;
|
|
19
23
|
if (gatesItself(meta))
|
|
@@ -22,23 +26,26 @@ export function validateExposedFunctionsGated(logger, state) {
|
|
|
22
26
|
continue;
|
|
23
27
|
ungated.push(funcName);
|
|
24
28
|
}
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
29
|
+
// An addon function is exposed only when the app's `wireAddon` opts in, so
|
|
30
|
+
// the app that opted in is the one to tell. The namespaced call is governed
|
|
31
|
+
// by that instance's config alone.
|
|
28
32
|
//
|
|
29
33
|
// knowledge: decisions/security/wire-addon-expose-selects-the-rpc-surface.md
|
|
30
34
|
for (const [namespace, declaration] of state.rpc?.wireAddonDeclarations ??
|
|
31
35
|
[]) {
|
|
32
|
-
if (!
|
|
36
|
+
if (!declaration.expose)
|
|
33
37
|
continue;
|
|
34
38
|
if (declaration.auth === true)
|
|
35
39
|
continue;
|
|
36
40
|
if (declaration.scopes && declaration.scopes.length > 0)
|
|
37
41
|
continue;
|
|
38
42
|
const addonMeta = state.addonFunctions?.[namespace] ?? {};
|
|
39
|
-
|
|
43
|
+
const selected = Array.isArray(declaration.expose)
|
|
44
|
+
? declaration.expose
|
|
45
|
+
: Object.keys(addonMeta);
|
|
46
|
+
for (const funcName of selected) {
|
|
40
47
|
const meta = addonMeta[funcName];
|
|
41
|
-
if (!meta || gatesItself(meta))
|
|
48
|
+
if (!meta?.expose || gatesItself(meta))
|
|
42
49
|
continue;
|
|
43
50
|
ungated.push(`${namespace}:${funcName}`);
|
|
44
51
|
}
|
package/dist/visit.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
|
-
import { addFileWithFactory } from './add/add-file-with-factory.js';
|
|
2
|
+
import { addFileWithFactory, checkHiddenFactory, } from './add/add-file-with-factory.js';
|
|
3
3
|
import { addFileExtendsCoreType } from './add/add-file-extends-core-type.js';
|
|
4
4
|
import { addHTTPRoute } from './add/add-http-route.js';
|
|
5
5
|
import { addHTTPRoutes } from './add/add-http-routes.js';
|
|
@@ -15,7 +15,7 @@ import { addChannel } from './add/add-channel.js';
|
|
|
15
15
|
import { addGateway } from './add/add-gateway.js';
|
|
16
16
|
import { addRPCInvocations } from './add/add-rpc-invocations.js';
|
|
17
17
|
import { addWireAddon } from './add/add-wire-addon.js';
|
|
18
|
-
import {
|
|
18
|
+
import { addPikkuStudio } from './add/add-pikku-studio.js';
|
|
19
19
|
import { addWireRemoteAddon } from './add/add-wire-remote-addon.js';
|
|
20
20
|
import { addMiddleware } from './add/add-middleware.js';
|
|
21
21
|
import { addPermission } from './add/add-permission.js';
|
|
@@ -36,6 +36,7 @@ import { addOutgoingWebhook } from './add/add-outgoing-webhook.js';
|
|
|
36
36
|
import { addAgentScorer } from './add/add-agent-scorer.js';
|
|
37
37
|
import { addApprovalDescription } from './add/add-approval-description.js';
|
|
38
38
|
import { checkIndirectRegistrations } from './add/check-indirect-registrations.js';
|
|
39
|
+
import { checkDisabledReferences } from './add/check-disabled-references.js';
|
|
39
40
|
export const visitSetup = (logger, checker, node, state, options) => {
|
|
40
41
|
addFileExtendsCoreType(node, checker, state.singletonServicesTypeImportMap, 'CoreSingletonServices', state);
|
|
41
42
|
addFileExtendsCoreType(node, checker, state.wireServicesTypeImportMap, 'CoreServices', state);
|
|
@@ -45,11 +46,12 @@ export const visitSetup = (logger, checker, node, state, options) => {
|
|
|
45
46
|
addFileWithFactory(node, checker, state.wireServicesFactories, 'CreateWireServices', state);
|
|
46
47
|
addFileWithFactory(node, checker, state.configFactories, 'CreateConfig');
|
|
47
48
|
addFileWithFactory(node, checker, state.serverLifecycleFactories, 'ServerLifecycle');
|
|
49
|
+
checkHiddenFactory(logger, node, state);
|
|
48
50
|
addAnalytics(logger, node, checker, state);
|
|
49
51
|
addOutgoingWebhook(logger, node, checker, state);
|
|
50
52
|
addRPCInvocations(node, state, logger);
|
|
51
53
|
addWireAddon(node, state, logger);
|
|
52
|
-
|
|
54
|
+
addPikkuStudio(node, state, logger);
|
|
53
55
|
addWireRemoteAddon(node, state, logger);
|
|
54
56
|
addMiddleware(logger, node, checker, state, options);
|
|
55
57
|
addPermission(logger, node, checker, state, options);
|
|
@@ -100,5 +102,6 @@ export const visitRoutes = (logger, checker, node, state, options) => {
|
|
|
100
102
|
addFeature(logger, node, checker, state, nextOptions);
|
|
101
103
|
addAgent(logger, node, checker, state, nextOptions);
|
|
102
104
|
addAgentScorer(logger, node, checker, state, nextOptions);
|
|
105
|
+
checkDisabledReferences(logger, node, checker, state, nextOptions);
|
|
103
106
|
ts.forEachChild(node, (child) => visitRoutes(logger, checker, child, state, nextOptions));
|
|
104
107
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.111",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/pikkujs/pikku.git",
|
|
@@ -70,10 +70,10 @@
|
|
|
70
70
|
"zod": "^4.6.7"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@pikku/core": "^0.12.
|
|
73
|
+
"@pikku/core": "^0.12.147"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@pikku/core": "^0.12.
|
|
76
|
+
"@pikku/core": "^0.12.147",
|
|
77
77
|
"@types/node": "^26.6.5"
|
|
78
78
|
},
|
|
79
79
|
"engines": {
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import * as ts from 'typescript';
|
|
2
|
-
import type { InspectorState, InspectorLogger } from '../types.js';
|
|
3
|
-
/**
|
|
4
|
-
* Detect `defineScreens({ title, icon, screens })` and record the manifest.
|
|
5
|
-
* A screen with a field that is not a literal is reported and left out: a
|
|
6
|
-
* navigation entry the host cannot read would otherwise vanish with no sign.
|
|
7
|
-
*/
|
|
8
|
-
export declare function addDefineScreens(node: ts.Node, state: InspectorState, logger: InspectorLogger): void;
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import * as ts from 'typescript';
|
|
2
|
-
import { ErrorCode } from '../error-codes.js';
|
|
3
|
-
const propName = (prop) => {
|
|
4
|
-
if (!ts.isPropertyAssignment(prop))
|
|
5
|
-
return undefined;
|
|
6
|
-
return ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
|
|
7
|
-
? prop.name.text
|
|
8
|
-
: undefined;
|
|
9
|
-
};
|
|
10
|
-
const stringArray = (node) => {
|
|
11
|
-
if (!ts.isArrayLiteralExpression(node))
|
|
12
|
-
return undefined;
|
|
13
|
-
const values = [];
|
|
14
|
-
for (const element of node.elements) {
|
|
15
|
-
if (!ts.isStringLiteral(element))
|
|
16
|
-
return undefined;
|
|
17
|
-
values.push(element.text);
|
|
18
|
-
}
|
|
19
|
-
return values;
|
|
20
|
-
};
|
|
21
|
-
/**
|
|
22
|
-
* The module specifier of `() => import('./screens/x')`. Anything else cannot
|
|
23
|
-
* be handed to the host's bundler as a lazy import, so it is not read.
|
|
24
|
-
*/
|
|
25
|
-
const lazyImportSpecifier = (node) => {
|
|
26
|
-
if (!ts.isArrowFunction(node))
|
|
27
|
-
return undefined;
|
|
28
|
-
const body = node.body;
|
|
29
|
-
if (!ts.isCallExpression(body) ||
|
|
30
|
-
body.expression.kind !== ts.SyntaxKind.ImportKeyword)
|
|
31
|
-
return undefined;
|
|
32
|
-
const [specifier] = body.arguments;
|
|
33
|
-
return specifier && ts.isStringLiteral(specifier) ? specifier.text : undefined;
|
|
34
|
-
};
|
|
35
|
-
/**
|
|
36
|
-
* Detect `defineScreens({ title, icon, screens })` and record the manifest.
|
|
37
|
-
* A screen with a field that is not a literal is reported and left out: a
|
|
38
|
-
* navigation entry the host cannot read would otherwise vanish with no sign.
|
|
39
|
-
*/
|
|
40
|
-
export function addDefineScreens(node, state, logger) {
|
|
41
|
-
if (!ts.isCallExpression(node))
|
|
42
|
-
return;
|
|
43
|
-
const { expression, arguments: args } = node;
|
|
44
|
-
if (!ts.isIdentifier(expression) || expression.text !== 'defineScreens')
|
|
45
|
-
return;
|
|
46
|
-
const [firstArg] = args;
|
|
47
|
-
if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
|
|
48
|
-
return;
|
|
49
|
-
const fail = (message) => logger.critical(ErrorCode.ADDON_SCREENS_NOT_STATIC, message);
|
|
50
|
-
let title;
|
|
51
|
-
let icon;
|
|
52
|
-
const screens = [];
|
|
53
|
-
for (const prop of firstArg.properties) {
|
|
54
|
-
const key = propName(prop);
|
|
55
|
-
if (!key || !ts.isPropertyAssignment(prop))
|
|
56
|
-
continue;
|
|
57
|
-
if (key === 'title' && ts.isStringLiteral(prop.initializer)) {
|
|
58
|
-
title = prop.initializer.text;
|
|
59
|
-
}
|
|
60
|
-
else if (key === 'icon' && ts.isStringLiteral(prop.initializer)) {
|
|
61
|
-
icon = prop.initializer.text;
|
|
62
|
-
}
|
|
63
|
-
else if (key === 'screens') {
|
|
64
|
-
if (!ts.isArrayLiteralExpression(prop.initializer)) {
|
|
65
|
-
fail(`defineScreens's screens must be an array literal, got: ${prop.initializer.getText()}`);
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
for (const element of prop.initializer.elements) {
|
|
69
|
-
if (!ts.isObjectLiteralExpression(element)) {
|
|
70
|
-
fail(`A defineScreens screen must be an object literal, got: ${element.getText()}`);
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
let path;
|
|
74
|
-
let screenTitle;
|
|
75
|
-
let nav;
|
|
76
|
-
let scopes;
|
|
77
|
-
let component;
|
|
78
|
-
let app;
|
|
79
|
-
for (const field of element.properties) {
|
|
80
|
-
const fieldKey = propName(field);
|
|
81
|
-
if (!fieldKey || !ts.isPropertyAssignment(field))
|
|
82
|
-
continue;
|
|
83
|
-
const value = field.initializer;
|
|
84
|
-
if (fieldKey === 'path' && ts.isStringLiteral(value)) {
|
|
85
|
-
path = value.text;
|
|
86
|
-
}
|
|
87
|
-
else if (fieldKey === 'title' && ts.isStringLiteral(value)) {
|
|
88
|
-
screenTitle = value.text;
|
|
89
|
-
}
|
|
90
|
-
else if (fieldKey === 'nav' &&
|
|
91
|
-
(value.kind === ts.SyntaxKind.TrueKeyword ||
|
|
92
|
-
value.kind === ts.SyntaxKind.FalseKeyword)) {
|
|
93
|
-
nav = value.kind === ts.SyntaxKind.TrueKeyword;
|
|
94
|
-
}
|
|
95
|
-
else if (fieldKey === 'scopes') {
|
|
96
|
-
scopes = stringArray(value);
|
|
97
|
-
if (!scopes) {
|
|
98
|
-
fail(`A screen's scopes must be an array of string literals, got: ${value.getText()}`);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
else if (fieldKey === 'component') {
|
|
102
|
-
component = lazyImportSpecifier(value);
|
|
103
|
-
if (!component) {
|
|
104
|
-
fail(`A screen's component must be () => import('<literal>'), got: ${value.getText()}`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
else if (fieldKey === 'app' && ts.isStringLiteral(value)) {
|
|
108
|
-
app = value.text;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (!path || !screenTitle || (!component && !app)) {
|
|
112
|
-
fail(`A defineScreens screen needs a literal path and title, and either a lazy component or an app: ${element.getText()}`);
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
if (component && app) {
|
|
116
|
-
fail(`Screen '${path}' sets both component and app; a screen is one or the other`);
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
screens.push({
|
|
120
|
-
path,
|
|
121
|
-
title: screenTitle,
|
|
122
|
-
...(nav !== undefined ? { nav } : {}),
|
|
123
|
-
...(scopes ? { scopes } : {}),
|
|
124
|
-
...(component ? { component } : { app: app }),
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
if (!title) {
|
|
130
|
-
fail(`defineScreens needs a literal title`);
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
logger.debug(`• Found defineScreens: ${title} (${screens.length} screens)`);
|
|
134
|
-
state.screensManifest = {
|
|
135
|
-
title,
|
|
136
|
-
...(icon ? { icon } : {}),
|
|
137
|
-
file: node.getSourceFile().fileName,
|
|
138
|
-
screens,
|
|
139
|
-
};
|
|
140
|
-
}
|