@pikku/inspector 0.12.108 → 0.12.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/add/add-cli.js +5 -0
  2. package/dist/add/add-functions.js +14 -0
  3. package/dist/add/add-rpc-invocations.d.ts +1 -1
  4. package/dist/add/add-rpc-invocations.js +2 -2
  5. package/dist/add/add-wire-remote-addon.js +25 -4
  6. package/dist/error-codes.d.ts +5 -0
  7. package/dist/error-codes.js +5 -0
  8. package/dist/finalize.js +3 -1
  9. package/dist/index.d.ts +5 -1
  10. package/dist/index.js +2 -0
  11. package/dist/lite.d.ts +4 -1
  12. package/dist/lite.js +3 -1
  13. package/dist/types.d.ts +31 -0
  14. package/dist/utils/contract-hashes.js +2 -2
  15. package/dist/utils/find-wire-remote-addon-specs.d.ts +17 -0
  16. package/dist/utils/find-wire-remote-addon-specs.js +53 -0
  17. package/dist/utils/load-addon-functions-meta.d.ts +8 -1
  18. package/dist/utils/load-addon-functions-meta.js +78 -3
  19. package/dist/utils/post-process.js +27 -1
  20. package/dist/utils/remote-addon-sdk.d.ts +57 -0
  21. package/dist/utils/remote-addon-sdk.js +111 -0
  22. package/dist/utils/resolve-addon-package.d.ts +1 -0
  23. package/dist/utils/resolve-addon-package.js +1 -1
  24. package/dist/utils/resolve-versions.js +18 -0
  25. package/dist/utils/serialize-inspector-state.d.ts +3 -0
  26. package/dist/utils/serialize-inspector-state.js +6 -0
  27. package/dist/utils/validate-addon-zod-contract.d.ts +23 -0
  28. package/dist/utils/validate-addon-zod-contract.js +70 -0
  29. package/dist/utils/validate-exposed-functions-gated.d.ts +3 -1
  30. package/dist/utils/validate-exposed-functions-gated.js +15 -8
  31. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +1 -0
  32. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +2 -0
  33. package/package.json +3 -3
@@ -363,6 +363,11 @@ function processCommand(logger, inspectorState, options, name, node, sourceFile,
363
363
  meta.isDefault = prop.initializer.kind === ts.SyntaxKind.TrueKeyword;
364
364
  }
365
365
  break;
366
+ case 'hidden':
367
+ if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) {
368
+ meta.hidden = true;
369
+ }
370
+ break;
366
371
  }
367
372
  }
368
373
  // --- validate auth/sessionless ---
@@ -40,6 +40,7 @@ import { findPiiPaths } from '../utils/check-pii-output.js';
40
40
  import { findClassifiedSinks } from '../utils/check-classified-sinks.js';
41
41
  import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js';
42
42
  import { resolveCalleeName } from '../utils/resolve-callee-name.js';
43
+ import { addonZodContractViolation } from '../utils/validate-addon-zod-contract.js';
43
44
  const isValidVariableName = (name) => {
44
45
  const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
45
46
  return regex.test(name);
@@ -887,6 +888,19 @@ export const addFunctions = (logger, node, checker, state, options) => {
887
888
  if (inputNames.length > 1) {
888
889
  logger.warn('More than one input type detected, only the first one will be used as a schema.');
889
890
  }
891
+ if (options.isAddon && remote && !notRpc) {
892
+ const violation = addonZodContractViolation(state.functions.typesMap, {
893
+ name,
894
+ usesGenerics: (typeArguments?.length ?? 0) > 0,
895
+ hasInputSchema: !!inputSchemaRef,
896
+ hasOutputSchema: !!outputSchemaRef,
897
+ inputNames,
898
+ outputNames,
899
+ });
900
+ if (violation) {
901
+ logger.critical(ErrorCode.ADDON_REMOTE_CONTRACT_NOT_ZOD, violation);
902
+ }
903
+ }
890
904
  // Store the input type for later use
891
905
  if (inputTypes.length > 0) {
892
906
  state.typesLookup.set(pikkuFuncId, inputTypes);
@@ -2,7 +2,7 @@ import * as ts from 'typescript';
2
2
  import type { InspectorState, InspectorLogger } from '../types.js';
3
3
  /**
4
4
  * The RPC names a function handler calls through `rpc.invoke(...)` /
5
- * `rpc.remote(...)`, read from its own body.
5
+ * `rpc.mesh(...)`, read from its own body.
6
6
  *
7
7
  * This is what the deploy planner uses to bind a unit to the units its
8
8
  * functions call: without it, a caller and callee split into different units
@@ -44,7 +44,7 @@ function isRpcReceiver(node) {
44
44
  * is not bundled in the caller's deploy unit, cross to another unit through the
45
45
  * `DeploymentService`.
46
46
  */
47
- const CROSS_UNIT_RPC_METHODS = new Set(['invoke', 'remote']);
47
+ const CROSS_UNIT_RPC_METHODS = new Set(['invoke', 'mesh']);
48
48
  /** The name argument when it is statically known: `'x'`, `"x"` or `` `x` ``. */
49
49
  function literalRpcName(arg) {
50
50
  if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
@@ -54,7 +54,7 @@ function literalRpcName(arg) {
54
54
  }
55
55
  /**
56
56
  * The RPC names a function handler calls through `rpc.invoke(...)` /
57
- * `rpc.remote(...)`, read from its own body.
57
+ * `rpc.mesh(...)`, read from its own body.
58
58
  *
59
59
  * This is what the deploy planner uses to bind a unit to the units its
60
60
  * functions call: without it, a caller and callee split into different units
@@ -1,4 +1,6 @@
1
1
  import * as ts from 'typescript';
2
+ import { ErrorCode } from '../error-codes.js';
3
+ import { parseRemoteAddonPackage } from '../utils/remote-addon-sdk.js';
2
4
  /**
3
5
  * Detect wireRemoteAddon({ name: '...', package: '...' }) call expressions and
4
6
  * record the namespace as a **remote** addon declaration. Only name + package
@@ -17,6 +19,7 @@ export function addWireRemoteAddon(node, state, logger) {
17
19
  return;
18
20
  let name;
19
21
  let pkg;
22
+ let pkgNode;
20
23
  // The auth binding is a runtime closure; we only extract the statically
21
24
  // knowable slot names so `pikku verify` can check they exist in the
22
25
  // consumer's own wirings. `hasAuth` distinguishes "public" (omitted) from
@@ -31,8 +34,11 @@ export function addWireRemoteAddon(node, state, logger) {
31
34
  if (key === 'name' && ts.isStringLiteral(prop.initializer)) {
32
35
  name = prop.initializer.text;
33
36
  }
34
- else if (key === 'package' && ts.isStringLiteral(prop.initializer)) {
35
- pkg = prop.initializer.text;
37
+ else if (key === 'package') {
38
+ pkgNode = prop.initializer;
39
+ if (ts.isStringLiteralLike(prop.initializer)) {
40
+ pkg = prop.initializer.text;
41
+ }
36
42
  }
37
43
  else if (key === 'auth' &&
38
44
  ts.isObjectLiteralExpression(prop.initializer)) {
@@ -52,11 +58,26 @@ export function addWireRemoteAddon(node, state, logger) {
52
58
  }
53
59
  }
54
60
  }
61
+ if (pkgNode && pkg === undefined) {
62
+ logger.critical(ErrorCode.REMOTE_ADDON_PACKAGE_NOT_STATIC, `wireRemoteAddon(${name ? `'${name}'` : '…'}) in ${node.getSourceFile().fileName} sets package to '${pkgNode.getText()}', which is not a string literal. Write the package (and its version range) inline, e.g. package: '@scope/addon@^1.0.0', so it can be read without running the file.`);
63
+ return;
64
+ }
55
65
  if (!name || !pkg)
56
66
  return;
57
- logger.debug(`• Found wireRemoteAddon: ${name} → ${pkg} (remote)`);
67
+ let packageName;
68
+ let range;
69
+ try {
70
+ ;
71
+ ({ name: packageName, range } = parseRemoteAddonPackage(pkg));
72
+ }
73
+ catch (e) {
74
+ logger.critical(ErrorCode.REMOTE_ADDON_PACKAGE_INVALID, `wireRemoteAddon('${name}'): ${e instanceof Error ? e.message : e}`);
75
+ return;
76
+ }
77
+ logger.debug(`• Found wireRemoteAddon: ${name} → ${packageName}${range ? `@${range}` : ''} (remote)`);
58
78
  state.rpc.wireAddonDeclarations.set(name, {
59
- package: pkg,
79
+ package: packageName,
80
+ ...(range ? { range } : {}),
60
81
  file: node.getSourceFile().fileName,
61
82
  remote: true,
62
83
  hasAuth,
@@ -42,6 +42,10 @@ export declare enum ErrorCode {
42
42
  ADDON_USES_NOT_WIRED = "PKU347",
43
43
  ADDON_NEEDS_NOT_MAPPED = "PKU348",
44
44
  ADDON_CONTENT_GRANT_INVALID = "PKU349",
45
+ REMOTE_ADDON_PACKAGE_NOT_STATIC = "PKU350",
46
+ REMOTE_ADDON_SDK_CORE_MISMATCH = "PKU351",
47
+ REMOTE_ADDON_SDK_INTEGRITY_MISMATCH = "PKU352",
48
+ REMOTE_ADDON_PACKAGE_INVALID = "PKU353",
45
49
  DB_COLUMN_NAME_TYPE_CONTRADICTION = "PKU480",
46
50
  DB_JSON_COLUMN_UNTYPED = "PKU481",
47
51
  DB_FORMAT_ON_NON_STRING = "PKU482",
@@ -89,6 +93,7 @@ export declare enum ErrorCode {
89
93
  PII_IN_OUTPUT = "PKU910",
90
94
  ADDON_WIRING_NOT_ALLOWED = "PKU920",
91
95
  ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = "PKU921",
96
+ ADDON_REMOTE_CONTRACT_NOT_ZOD = "PKU922",
92
97
  RPC_INVOCATION_TYPE_CAST = "PKU940",
93
98
  KNOWLEDGE_NOTE_MISSING = "PKU960",
94
99
  SECRET_SERVICE_ALIASED = "PKU950",
@@ -45,6 +45,10 @@ export var ErrorCode;
45
45
  ErrorCode["ADDON_USES_NOT_WIRED"] = "PKU347";
46
46
  ErrorCode["ADDON_NEEDS_NOT_MAPPED"] = "PKU348";
47
47
  ErrorCode["ADDON_CONTENT_GRANT_INVALID"] = "PKU349";
48
+ ErrorCode["REMOTE_ADDON_PACKAGE_NOT_STATIC"] = "PKU350";
49
+ ErrorCode["REMOTE_ADDON_SDK_CORE_MISMATCH"] = "PKU351";
50
+ ErrorCode["REMOTE_ADDON_SDK_INTEGRITY_MISMATCH"] = "PKU352";
51
+ ErrorCode["REMOTE_ADDON_PACKAGE_INVALID"] = "PKU353";
48
52
  // Database schema codegen warnings
49
53
  ErrorCode["DB_COLUMN_NAME_TYPE_CONTRADICTION"] = "PKU480";
50
54
  ErrorCode["DB_JSON_COLUMN_UNTYPED"] = "PKU481";
@@ -114,6 +118,7 @@ export var ErrorCode;
114
118
  // Addon authoring errors
115
119
  ErrorCode["ADDON_WIRING_NOT_ALLOWED"] = "PKU920";
116
120
  ErrorCode["ADDON_CONTRACT_HANDLERS_NOT_ALLOWED"] = "PKU921";
121
+ ErrorCode["ADDON_REMOTE_CONTRACT_NOT_ZOD"] = "PKU922";
117
122
  ErrorCode["RPC_INVOCATION_TYPE_CAST"] = "PKU940";
118
123
  // Knowledge base errors
119
124
  ErrorCode["KNOWLEDGE_NOTE_MISSING"] = "PKU960";
package/dist/finalize.js CHANGED
@@ -77,7 +77,9 @@ export const finalizeInspectorState = async (logger, analyzed, options = {}) =>
77
77
  annotateHttpRouteAuth(state);
78
78
  // Last of the validators: they read function meta and every wireAddon
79
79
  // declaration together, so they need the whole program inspected.
80
- validateExposedFunctionsGated(logger, state);
80
+ validateExposedFunctionsGated(logger, state, {
81
+ isAddon: options.isAddon === true,
82
+ });
81
83
  validateTagsResolveToMiddleware(logger, state);
82
84
  if (options.openAPI) {
83
85
  state.openAPISpec = await generateOpenAPISpec(logger, state.functions.meta, state.http.meta, state.schemas, options.openAPI.additionalInfo, pikkuState(null, 'misc', 'errors'));
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { inspect, analyzeProgram } from './inspector.js';
2
2
  export { getInitialInspectorState } from './initial-state.js';
3
3
  export { finalizeInspectorState } from './finalize.js';
4
4
  export type { TypesMap } from './types-map.js';
5
- export type { AddonConfig, AuthDefinition, CredentialOverrideMeta, ScreensManifestMeta, AddonScreenMeta, InspectorDiagnostic, InspectorFilters, InspectorHTTPState, InspectorLogger, InspectorMiddlewareState, InspectorOptions, InspectorPermissionState, InspectorServiceTypes, InspectorState, MetaInputTypes, MiddlewareGroupMeta, MiddlewareRegistration, SchemaRef, } from './types.js';
5
+ export type { AddonConfig, AuthDefinition, CredentialOverrideMeta, ScreensManifestMeta, AddonScreenMeta, AddonContributorRef, AddonContributorsMeta, InspectorDiagnostic, InspectorFilters, InspectorHTTPState, InspectorLogger, InspectorMiddlewareState, InspectorOptions, InspectorPermissionState, InspectorServiceTypes, InspectorState, MetaInputTypes, MiddlewareGroupMeta, MiddlewareRegistration, SchemaRef, } from './types.js';
6
6
  export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js';
7
7
  export { ErrorCode } from './error-codes.js';
8
8
  export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js';
@@ -30,3 +30,7 @@ export { readModuleSpecifiers } from './utils/read-module-specifiers.js';
30
30
  export { readJsxLiteralText } from './utils/read-jsx-literal-text.js';
31
31
  export { readTsconfigOutDir } from './utils/read-tsconfig-out-dir.js';
32
32
  export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
33
+ export { ADDONS_LOCK_FILE, REMOTE_SDK_DIR, SDK_INTEGRITY_MARKER, parseRemoteAddonPackage, emptyAddonsLock, readAddonsLock, serializeAddonsLock, remoteSdkDir, readSdkManifest, checkLockedSdk, } from './utils/remote-addon-sdk.js';
34
+ export { findWireRemoteAddonSpecs } from './utils/find-wire-remote-addon-specs.js';
35
+ export type { AddonsLock, AddonsLockEntry, RemoteAddonSdkManifest, RemoteAddonPackageSpec, LockedSdk, } from './utils/remote-addon-sdk.js';
36
+ export type { WireRemoteAddonSpec } from './utils/find-wire-remote-addon-specs.js';
package/dist/index.js CHANGED
@@ -18,3 +18,5 @@ export { readModuleSpecifiers } from './utils/read-module-specifiers.js';
18
18
  export { readJsxLiteralText } from './utils/read-jsx-literal-text.js';
19
19
  export { readTsconfigOutDir } from './utils/read-tsconfig-out-dir.js';
20
20
  export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
21
+ export { ADDONS_LOCK_FILE, REMOTE_SDK_DIR, SDK_INTEGRITY_MARKER, parseRemoteAddonPackage, emptyAddonsLock, readAddonsLock, serializeAddonsLock, remoteSdkDir, readSdkManifest, checkLockedSdk, } from './utils/remote-addon-sdk.js';
22
+ export { findWireRemoteAddonSpecs } from './utils/find-wire-remote-addon-specs.js';
package/dist/lite.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * reachable from this file imports `typescript`.
10
10
  */
11
11
  export { getInitialInspectorState } from './initial-state.js';
12
+ export { TypesMap } from './types-map.js';
12
13
  export { ErrorCode } from './error-codes.js';
13
14
  export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js';
14
15
  export { AUTH_HANDLER_FUNC_ID } from './utils/inspector-constants.js';
@@ -18,9 +19,11 @@ export { filterInspectorState } from './utils/filter-inspector-state.js';
18
19
  export { incompatibleServicesFor, resolveDeployTarget, } from './utils/resolve-deploy-target.js';
19
20
  export { customTypeNames, generateCustomTypes, sanitizeTypeName, } from './utils/custom-types-generator.js';
20
21
  export { unresolvedSchemaReferences } from './utils/post-process.js';
21
- export { createEmptyManifest, serializeManifest, } from './utils/contract-hashes.js';
22
+ export { computeContractHash, createEmptyManifest, serializeManifest, } from './utils/contract-hashes.js';
22
23
  export { serializeMCPJson } from './utils/serialize-mcp-json.js';
23
24
  export { deserializeDslWorkflow, deserializeGraphWorkflow, deserializeAllDslWorkflows, } from './utils/workflow/dsl/deserialize-dsl-workflow.js';
24
25
  export { resolveFunctionMeta } from './utils/resolve-function-meta.js';
25
26
  export { resolveCoreType } from './utils/resolve-core-type.js';
26
27
  export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
28
+ export { ADDONS_LOCK_FILE, REMOTE_SDK_DIR, SDK_INTEGRITY_MARKER, checkLockedSdk, emptyAddonsLock, parseRemoteAddonPackage, readAddonsLock, readSdkManifest, remoteSdkDir, serializeAddonsLock, } from './utils/remote-addon-sdk.js';
29
+ export type { AddonsLock, AddonsLockEntry, RemoteAddonSdkManifest, RemoteAddonPackageSpec, LockedSdk, } from './utils/remote-addon-sdk.js';
package/dist/lite.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * reachable from this file imports `typescript`.
10
10
  */
11
11
  export { getInitialInspectorState } from './initial-state.js';
12
+ export { TypesMap } from './types-map.js';
12
13
  export { ErrorCode } from './error-codes.js';
13
14
  export { AUTH_HANDLER_FUNC_ID } from './utils/inspector-constants.js';
14
15
  export { SCENARIO_INSTRUMENTATION_FUNCTIONS, isScenarioInstrumentationFunction, } from './add/scenario-instrumentation.js';
@@ -17,9 +18,10 @@ export { filterInspectorState } from './utils/filter-inspector-state.js';
17
18
  export { incompatibleServicesFor, resolveDeployTarget, } from './utils/resolve-deploy-target.js';
18
19
  export { customTypeNames, generateCustomTypes, sanitizeTypeName, } from './utils/custom-types-generator.js';
19
20
  export { unresolvedSchemaReferences } from './utils/post-process.js';
20
- export { createEmptyManifest, serializeManifest, } from './utils/contract-hashes.js';
21
+ export { computeContractHash, createEmptyManifest, serializeManifest, } from './utils/contract-hashes.js';
21
22
  export { serializeMCPJson } from './utils/serialize-mcp-json.js';
22
23
  export { deserializeDslWorkflow, deserializeGraphWorkflow, deserializeAllDslWorkflows, } from './utils/workflow/dsl/deserialize-dsl-workflow.js';
23
24
  export { resolveFunctionMeta } from './utils/resolve-function-meta.js';
24
25
  export { resolveCoreType } from './utils/resolve-core-type.js';
25
26
  export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
27
+ export { ADDONS_LOCK_FILE, REMOTE_SDK_DIR, SDK_INTEGRITY_MARKER, checkLockedSdk, emptyAddonsLock, parseRemoteAddonPackage, readAddonsLock, readSdkManifest, remoteSdkDir, serializeAddonsLock, } from './utils/remote-addon-sdk.js';
package/dist/types.d.ts CHANGED
@@ -23,6 +23,14 @@ import type { VirtualUserDefinitions } from '@pikku/core/virtual-user';
23
23
  import type { VariableDefinitions } from '@pikku/core/variable';
24
24
  import type { TypesMap } from './types-map.js';
25
25
  import type { FunctionsMeta } from '@pikku/core/services';
26
+ /**
27
+ * A remote addon SDK's function meta: each contract function, with the
28
+ * versions of its name the SDK can call and the one a call without `@vN` goes to.
29
+ */
30
+ export type RemoteAddonFunctionsMeta = Record<string, FunctionsMeta[string] & {
31
+ versions?: number[];
32
+ latestVersion?: number;
33
+ }>;
26
34
  import type { FunctionServicesMeta, FunctionWiresMeta } from '@pikku/core/function';
27
35
  import type { JSONValue } from '@pikku/core/utils';
28
36
  import type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js';
@@ -544,11 +552,26 @@ export interface InspectorServiceTypes {
544
552
  /** Singleton services that are a `SecretService` under another name. */
545
553
  secretAliases: string[];
546
554
  }
555
+ export interface AddonContributorRef {
556
+ module: string;
557
+ export?: string;
558
+ }
559
+ export interface AddonContributorsMeta {
560
+ packageName: string;
561
+ packageDir: string;
562
+ contributors: AddonContributorRef[];
563
+ }
547
564
  export interface InspectorState {
548
565
  /** Set when this package calls `defineScreens`: the screens it ships. */
549
566
  screensManifest?: ScreensManifestMeta;
550
567
  /** The screens of the addons this project wires with `ui: true`, by namespace. */
551
568
  addonScreens?: Record<string, ScreensManifestMeta>;
569
+ /**
570
+ * Platform service contributors each wired, non-remote addon declares in its
571
+ * `package.json` under `pikku.contributors`, by namespace. `module` resolves
572
+ * from `packageDir`.
573
+ */
574
+ addonContributors?: Record<string, AddonContributorsMeta>;
552
575
  rootDir: string;
553
576
  singletonServicesTypeImportMap: PathToNameAndType;
554
577
  wireServicesTypeImportMap: PathToNameAndType;
@@ -698,6 +721,12 @@ export interface InspectorState {
698
721
  scopes?: string[];
699
722
  /** True when declared via `wireRemoteAddon` — import the addon's `.remote.gen` map, not `.internal.gen` */
700
723
  remote?: boolean;
724
+ /**
725
+ * wireRemoteAddon: the semver range written after the package name
726
+ * (`@scope/name@^1.4.0`). Present means the contract comes from the
727
+ * SDK `pikku all` downloads, not from an installed package.
728
+ */
729
+ range?: string;
701
730
  /** wireRemoteAddon: whether an `auth` binding was supplied (vs a public surface) */
702
731
  hasAuth?: boolean;
703
732
  /** wireRemoteAddon: statically-known `auth.credentialId`, for verify */
@@ -863,6 +892,8 @@ export interface InspectorState {
863
892
  openAPISpec: Record<string, any> | null;
864
893
  diagnostics: InspectorDiagnostic[];
865
894
  addonFunctions: Record<string, FunctionsMeta>;
895
+ /** The function metadata of each versioned `wireRemoteAddon`'s SDK, by namespace. Kept apart from `addonFunctions`: none of it runs here. */
896
+ remoteAddonFunctions?: Record<string, RemoteAddonFunctionsMeta>;
866
897
  exportedContracts: InspectorExportedContractsState;
867
898
  program: ts.Program | null;
868
899
  }
@@ -73,7 +73,7 @@ export function buildCurrentContracts(functionsMeta, allSchemas, typesMap) {
73
73
  for (const [funcId, meta] of Object.entries(functionsMeta)) {
74
74
  // A webhook receive has no callers but its own route, so there is no
75
75
  // contract for a version to protect.
76
- if (meta.remote === true || meta.webhookReceive) {
76
+ if (meta.webhookReceive) {
77
77
  continue;
78
78
  }
79
79
  const parsed = parseVersionedId(funcId);
@@ -253,7 +253,7 @@ export function updateManifest(existing, currentContracts) {
253
253
  export function extractContractsFromMeta(functionsMeta) {
254
254
  const result = new Map();
255
255
  for (const [funcId, meta] of Object.entries(functionsMeta)) {
256
- if (meta.remote === true || meta.webhookReceive || !meta.contractHash) {
256
+ if (meta.webhookReceive || !meta.contractHash) {
257
257
  continue;
258
258
  }
259
259
  const parsed = parseVersionedId(funcId);
@@ -0,0 +1,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
+ };
@@ -1,4 +1,4 @@
1
- import type { InspectorState, InspectorLogger } from '../types.js';
1
+ import type { AddonContributorsMeta, 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,6 +8,13 @@ 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>;
11
18
  export declare function loadAddonFunctionsMeta(logger: InspectorLogger, state: InspectorState): Promise<void>;
12
19
  /**
13
20
  * Load addon schemas into state.schemas. Called after generateAllSchemas
@@ -3,6 +3,7 @@ import { readFile, readdir } from 'fs/promises';
3
3
  import { join, dirname, parse, 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';
6
7
  const isHTTPRouteConfig = (value) => typeof value === 'object' &&
7
8
  value !== null &&
8
9
  'method' in value &&
@@ -203,6 +204,62 @@ const readAddonNeeds = async (dirs, packageName) => {
203
204
  return [];
204
205
  }
205
206
  };
207
+ const isContributorRef = (value) => !!value &&
208
+ typeof value === 'object' &&
209
+ typeof value.module === 'string' &&
210
+ (value.export === undefined ||
211
+ typeof value.export === 'string');
212
+ /**
213
+ * The platform service contributors an addon declares in its package.json,
214
+ * `"pikku": { "contributors": [{ "module", "export" }] }` — the same shape as
215
+ * `dev.contributors`. `wireAddon` has no place for them: a contributor emits
216
+ * code into the host's entry, so it belongs to the package, not the wiring.
217
+ */
218
+ export const readAddonContributors = async (dirs, packageName) => {
219
+ const packageDir = findInstalledPackageDir(dirs, packageName);
220
+ if (!packageDir)
221
+ return null;
222
+ let declared;
223
+ try {
224
+ const pkg = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf-8'));
225
+ declared = pkg?.pikku?.contributors;
226
+ }
227
+ catch {
228
+ return null;
229
+ }
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
+ };
243
+ /**
244
+ * A versioned remote addon's function metadata comes from the SDK `pikku addon
245
+ * sync` extracted, and only when it matches the lock — a stale or missing SDK
246
+ * is reported by `validateRemoteAddonDependencies`, not loaded half-way.
247
+ */
248
+ const loadRemoteAddonSdkMeta = async (state, namespace, packageName, range) => {
249
+ let locked;
250
+ try {
251
+ locked = checkLockedSdk(state.rootDir, packageName, range);
252
+ }
253
+ catch {
254
+ return;
255
+ }
256
+ if (!locked.ok)
257
+ return;
258
+ const metaPath = join(locked.dir, 'function', 'pikku-functions-meta.gen.json');
259
+ if (!existsSync(metaPath))
260
+ return;
261
+ (state.remoteAddonFunctions ??= {})[namespace] = JSON.parse(await readFile(metaPath, 'utf-8'));
262
+ };
206
263
  export async function loadAddonFunctionsMeta(logger, state) {
207
264
  const { wireAddonDeclarations } = state.rpc;
208
265
  if (wireAddonDeclarations.size === 0)
@@ -211,11 +268,25 @@ export async function loadAddonFunctionsMeta(logger, state) {
211
268
  // Remote addons (wireRemoteAddon) ship as a devDependency: types only.
212
269
  // Their functions, secrets, variables, schemas and services live on the
213
270
  // HOST that runs them — never load or require any of that here.
214
- if (decl.remote)
271
+ if (decl.remote) {
272
+ if (decl.range) {
273
+ await loadRemoteAddonSdkMeta(state, namespace, decl.package, decl.range);
274
+ }
215
275
  continue;
276
+ }
216
277
  const dirs = addonResolutionDirs(state.rootDir, decl.file);
217
278
  const require = createAddonResolver(dirs);
218
279
  try {
280
+ try {
281
+ const contributors = await readAddonContributors(dirs, decl.package);
282
+ if (contributors) {
283
+ ;
284
+ (state.addonContributors ??= {})[namespace] = contributors;
285
+ }
286
+ }
287
+ catch (error) {
288
+ logger.error(`wireAddon('${namespace}'): ${error instanceof Error ? error.message : error}`);
289
+ }
219
290
  for (const needed of await readAddonNeeds(dirs, decl.package)) {
220
291
  if (!decl.uses?.[needed]) {
221
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.`);
@@ -301,13 +372,17 @@ export async function loadAddonFunctionsMeta(logger, state) {
301
372
  }
302
373
  }
303
374
  }
304
- // Same reasoning as mcp: a listed name the addon does not publish is a
305
- // function the app believes callable and never is.
375
+ // Same reasoning as mcp: a listed name the addon does not publish, or
376
+ // does not declare `expose: true`, is a function the app believes
377
+ // callable and never is.
306
378
  if (Array.isArray(decl.expose)) {
307
379
  for (const funcName of decl.expose) {
308
380
  if (!Object.hasOwn(meta, funcName)) {
309
381
  logger.critical(ErrorCode.ADDON_EXPOSE_FUNCTION_NOT_FOUND, `wireAddon('${namespace}') lists '${funcName}' under expose, but ${decl.package} publishes no such function.`);
310
382
  }
383
+ else if (meta[funcName]?.expose !== true) {
384
+ 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.`);
385
+ }
311
386
  }
312
387
  }
313
388
  // 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.
@@ -607,8 +608,33 @@ export function validateRemoteAddonDependencies(logger, state) {
607
608
  manifests.set(path, manifest);
608
609
  return manifest;
609
610
  };
611
+ // A versioned remote addon's contract is the SDK `pikku all`
612
+ // downloaded, so what has to hold is that the SDK on disk is the one the
613
+ // lock pins, not that a package is installed.
614
+ let lock;
610
615
  for (const [namespace, decl] of wireAddonDeclarations.entries()) {
611
- if (!decl.remote)
616
+ if (!decl.remote || !decl.range)
617
+ continue;
618
+ if (lock === undefined) {
619
+ try {
620
+ lock = readAddonsLock(state.rootDir);
621
+ }
622
+ catch (e) {
623
+ lock = e instanceof Error ? e : new Error(String(e));
624
+ }
625
+ }
626
+ const reason = lock instanceof Error
627
+ ? lock.message
628
+ : (() => {
629
+ const locked = checkLockedSdk(state.rootDir, decl.package, decl.range, lock);
630
+ return locked.ok ? null : locked.reason;
631
+ })();
632
+ if (reason === null)
633
+ continue;
634
+ 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.`);
635
+ }
636
+ for (const [namespace, decl] of wireAddonDeclarations.entries()) {
637
+ if (!decl.remote || decl.range)
612
638
  continue;
613
639
  const pkgJsonPath = join(addonResolutionDirs(state.rootDir, decl.file)[0], 'package.json');
614
640
  const pkgJson = readManifest(pkgJsonPath);
@@ -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
  }
@@ -194,6 +194,7 @@ export interface SerializableInspectorState {
194
194
  string,
195
195
  {
196
196
  package: string;
197
+ range?: string;
197
198
  file?: string;
198
199
  rpcEndpoint?: string;
199
200
  remote?: boolean;
@@ -326,8 +327,10 @@ export interface SerializableInspectorState {
326
327
  openAPISpec: Record<string, any> | null;
327
328
  diagnostics: InspectorDiagnostic[];
328
329
  addonFunctions: InspectorState['addonFunctions'];
330
+ remoteAddonFunctions?: InspectorState['remoteAddonFunctions'];
329
331
  screensManifest?: InspectorState['screensManifest'];
330
332
  addonScreens?: InspectorState['addonScreens'];
333
+ addonContributors?: InspectorState['addonContributors'];
331
334
  exportedContracts: InspectorState['exportedContracts'];
332
335
  }
333
336
  /**
@@ -190,8 +190,10 @@ export function serializeInspectorState(state) {
190
190
  openAPISpec: state.openAPISpec,
191
191
  diagnostics: state.diagnostics,
192
192
  addonFunctions: state.addonFunctions,
193
+ remoteAddonFunctions: state.remoteAddonFunctions,
193
194
  screensManifest: state.screensManifest,
194
195
  addonScreens: state.addonScreens,
196
+ addonContributors: state.addonContributors,
195
197
  exportedContracts: state.exportedContracts,
196
198
  };
197
199
  }
@@ -395,8 +397,12 @@ export function deserializeInspectorState(data) {
395
397
  openAPISpec: data.openAPISpec || null,
396
398
  diagnostics: data.diagnostics || [],
397
399
  addonFunctions: data.addonFunctions || {},
400
+ ...(data.remoteAddonFunctions
401
+ ? { remoteAddonFunctions: data.remoteAddonFunctions }
402
+ : {}),
398
403
  screensManifest: data.screensManifest,
399
404
  addonScreens: data.addonScreens,
405
+ addonContributors: data.addonContributors,
400
406
  exportedContracts: data.exportedContracts || {
401
407
  http: {},
402
408
  cli: {},
@@ -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): void;
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
- for (const [funcName, meta] of Object.entries(state.functions.meta)) {
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
- // A `wireAddon` expose list can open an addon function its author never
26
- // exposed, so the app that listed it is the one to tell. The namespaced call
27
- // is governed by that instance's config alone.
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 (!Array.isArray(declaration.expose))
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
- for (const funcName of declaration.expose) {
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
  }
@@ -413,6 +413,7 @@ export function convertDslToGraph(workflowName, meta) {
413
413
  name: workflowName,
414
414
  pikkuFuncId: meta.pikkuFuncId,
415
415
  source,
416
+ scenario: meta.scenario || undefined,
416
417
  title: meta.title,
417
418
  description: meta.description,
418
419
  tags: meta.tags,
@@ -156,6 +156,8 @@ export interface SerializedWorkflowGraph {
156
156
  pikkuFuncId: string;
157
157
  /** Source type: 'dsl' for pikkuWorkflowFunc, 'graph' for pikkuWorkflowGraph */
158
158
  source: WorkflowSourceType;
159
+ /** True for a scenario, so meta readers can tell it from a real workflow */
160
+ scenario?: boolean;
159
161
  /** Optional short display name */
160
162
  title?: string;
161
163
  /** Optional description */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.108",
3
+ "version": "0.12.110",
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.144"
73
+ "@pikku/core": "^0.12.146"
74
74
  },
75
75
  "devDependencies": {
76
- "@pikku/core": "^0.12.144",
76
+ "@pikku/core": "^0.12.146",
77
77
  "@types/node": "^26.6.5"
78
78
  },
79
79
  "engines": {