@pikku/inspector 0.12.89 → 0.12.92

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/CHANGELOG.md CHANGED
@@ -1,3 +1,45 @@
1
+ ## 0.12.92
2
+
3
+ ### Patch Changes
4
+
5
+ - f84e173: An addon wired from a workspace package now resolves from that package. The installed-addon check (PKU340), the remote addon `devDependencies` check and `pikku db generate`'s addon schemas all looked only at the project root, so an addon declared where it is wired — the only place bun links it — failed codegen unless it was also declared at the root.
6
+ - Updated dependencies [f02585e]
7
+ - Updated dependencies [5586749]
8
+ - @pikku/core@0.12.127
9
+
10
+ ## 0.12.91
11
+
12
+ ### Patch Changes
13
+
14
+ - 5e93f30: Add `defineOutgoingWebhook({ event, title, description?, payload })` in `@pikku/core/webhook`. The CLI collects every exported declaration into `.pikku/webhooks/pikku-outgoing-webhooks-meta.gen.json` and `pikku-outgoing-webhooks.gen.ts`, which exports `OutgoingWebhooksMap`, `TypedWebhookService` and `typedWebhookService(service)`: `send` checks `data` against the declared payload for a declared event and accepts any other event unchanged. `MetaService.getOutgoingWebhooksMeta()` and the console addon's `outgoingWebhooksMeta` serve the declarations.
15
+ - 5e93f30: Add `wireTriggerWebhookSource({ name, method?, route?, secret?, events, receive?, check?, setup?, teardown? })` in `#pikku/trigger`. Each source becomes a `POST /webhooks/<name>` route whose events are validated against their schemas and queued on `pikku-incoming-webhooks` through `IncomingWebhookService`; a generated worker runs the matching `wireTrigger({ name: '<source>:<event>' })` and the queue retries it on failure. `pikku webhooks status | setup | teardown --url --labelPrefix [--previous]` registers the routes with the provider and prints one JSON line per source.
16
+
17
+ `KyselyIncomingWebhookService` (with the `incoming-webhook` schema) records a receipt per event, drops a provider's redelivery of an event it already accepted, and keeps each dispatch's attempts and last error. `pikku dev` and `pikku serve` use it when a Kysely database is configured.
18
+
19
+ - Updated dependencies [5e93f30]
20
+ - Updated dependencies [5e93f30]
21
+ - @pikku/core@0.12.125
22
+
23
+ ## 0.12.90
24
+
25
+ ### Patch Changes
26
+
27
+ - 4e05a10: Print the TypeScript text for a Zod schema directly, dropping `zod-to-ts`.
28
+
29
+ `processZodSchema` built a TypeScript AST only to print it straight back to a
30
+ string. `zodToTypeText` walks Zod's own definitions instead, which removes
31
+ `zod-to-ts`, `ts.createPrinter`, `ts.EmitHint` and `ts.createSourceFile` from
32
+ the Zod path — it no longer touches the compiler API at all. `@pikku/cli`
33
+ declared `zod-to-ts` without importing it; that is dropped too.
34
+
35
+ A defaulted field is now optional in the generated type. `processZodSchema`
36
+ already strips defaulted fields out of the JSON Schema's `required`, so the two
37
+ halves of the same contract disagreed: the validator accepted a payload that
38
+ omitted the field, while the type said a caller had to pass it.
39
+
40
+ - Updated dependencies [1394385]
41
+ - @pikku/core@0.12.124
42
+
1
43
  ## 0.12.89
2
44
 
3
45
  ### Patch Changes
@@ -1,5 +1,28 @@
1
1
  import * as ts from 'typescript';
2
2
  import type { InspectorLogger, InspectorState } from '../types.js';
3
+ /**
4
+ * A call is the pikku one when the symbol it resolves to is, whatever the file
5
+ * chose to call it locally — `import { defineAnalyticsEvents as declare }` is
6
+ * an ordinary thing to write, and matching on the callee's text alone both
7
+ * misses that and claims a same-named local helper.
8
+ */
9
+ export declare const callsImportedDefiner: (expression: ts.Expression, checker: ts.TypeChecker, definer: string) => boolean;
10
+ /**
11
+ * The name the generated ingest has to import the declaration by — which is
12
+ * the name the module exports it under, not the name of the const. A module
13
+ * that keeps the declaration to itself has no such name, so it is refused here
14
+ * rather than emitted into an ingest that cannot compile.
15
+ */
16
+ export declare const exportedName: (declaration: ts.VariableDeclaration, name: ts.Identifier, checker: ts.TypeChecker) => string | undefined;
17
+ /**
18
+ * An event's props, as source text rather than a resolved type — `z.string()`
19
+ * is what the declaration says and what a reader recognises, and resolving it
20
+ * would mean type-checking a third-party generic to render one table cell.
21
+ *
22
+ * Absent for a schema pikku cannot read the shape off (a shared const, a union,
23
+ * a vendor that is not zod). The catalog renders nothing rather than guessing.
24
+ */
25
+ export declare const readEventShape: (initializer: ts.Expression) => Record<string, string> | undefined;
3
26
  /**
4
27
  * Record where each `defineAnalyticsEvents` declaration is, which names it
5
28
  * declares, and the shape each name validates — the names drive the generated
@@ -7,18 +7,17 @@ const DEFINE_ANALYTICS_EVENTS = 'defineAnalyticsEvents';
7
7
  * an ordinary thing to write, and matching on the callee's text alone both
8
8
  * misses that and claims a same-named local helper.
9
9
  */
10
- const callsDefineAnalyticsEvents = (expression, checker) => {
10
+ export const callsImportedDefiner = (expression, checker, definer) => {
11
11
  if (!ts.isIdentifier(expression))
12
12
  return false;
13
13
  const symbol = checker.getSymbolAtLocation(expression);
14
14
  if (!symbol)
15
- return expression.text === DEFINE_ANALYTICS_EVENTS;
15
+ return expression.text === definer;
16
16
  // An import specifier names what it imported even when the module itself
17
17
  // does not resolve, which is every project whose deps are not installed yet.
18
18
  const declaration = symbol.declarations?.[0];
19
19
  if (declaration && ts.isImportSpecifier(declaration)) {
20
- return ((declaration.propertyName ?? declaration.name).text ===
21
- DEFINE_ANALYTICS_EVENTS);
20
+ return (declaration.propertyName ?? declaration.name).text === definer;
22
21
  }
23
22
  // Only an import can be pikku's. A file's own helper of the same name
24
23
  // resolves to a symbol whose `.name` matches, so resolving without this
@@ -26,7 +25,7 @@ const callsDefineAnalyticsEvents = (expression, checker) => {
26
25
  if (!(symbol.flags & ts.SymbolFlags.Alias))
27
26
  return false;
28
27
  const resolved = checker.getAliasedSymbol(symbol) ?? symbol;
29
- return resolved.name === DEFINE_ANALYTICS_EVENTS;
28
+ return resolved.name === definer;
30
29
  };
31
30
  /**
32
31
  * The name the generated ingest has to import the declaration by — which is
@@ -34,7 +33,7 @@ const callsDefineAnalyticsEvents = (expression, checker) => {
34
33
  * that keeps the declaration to itself has no such name, so it is refused here
35
34
  * rather than emitted into an ingest that cannot compile.
36
35
  */
37
- const exportedName = (declaration, name, checker) => {
36
+ export const exportedName = (declaration, name, checker) => {
38
37
  const moduleSymbol = checker.getSymbolAtLocation(declaration.getSourceFile());
39
38
  const declarationSymbol = checker.getSymbolAtLocation(name);
40
39
  if (!moduleSymbol || !declarationSymbol) {
@@ -78,7 +77,7 @@ const findObjectShape = (expression) => {
78
77
  * Absent for a schema pikku cannot read the shape off (a shared const, a union,
79
78
  * a vendor that is not zod). The catalog renders nothing rather than guessing.
80
79
  */
81
- const readEventShape = (initializer) => {
80
+ export const readEventShape = (initializer) => {
82
81
  const shape = findObjectShape(initializer);
83
82
  if (!shape)
84
83
  return undefined;
@@ -110,7 +109,7 @@ export const addAnalytics = (logger, node, checker, state) => {
110
109
  const { initializer, name } = node;
111
110
  if (!initializer || !ts.isCallExpression(initializer))
112
111
  return;
113
- if (!callsDefineAnalyticsEvents(initializer.expression, checker))
112
+ if (!callsImportedDefiner(initializer.expression, checker, DEFINE_ANALYTICS_EVENTS))
114
113
  return;
115
114
  if (!ts.isIdentifier(name))
116
115
  return;
@@ -0,0 +1,3 @@
1
+ import * as ts from 'typescript';
2
+ import type { InspectorLogger, InspectorState } from '../types.js';
3
+ export declare const addOutgoingWebhook: (logger: InspectorLogger, node: ts.Node, checker: ts.TypeChecker, state: InspectorState) => void;
@@ -0,0 +1,81 @@
1
+ import * as ts from 'typescript';
2
+ import { callsImportedDefiner, exportedName, readEventShape, } from './add-analytics.js';
3
+ const DEFINE_OUTGOING_WEBHOOK = 'defineOutgoingWebhook';
4
+ const stringProperty = (object, key) => {
5
+ for (const property of object.properties) {
6
+ if (!ts.isPropertyAssignment(property))
7
+ continue;
8
+ const name = property.name;
9
+ const text = ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined;
10
+ if (text !== key)
11
+ continue;
12
+ const value = property.initializer;
13
+ if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value))
14
+ return value.text;
15
+ return undefined;
16
+ }
17
+ return undefined;
18
+ };
19
+ const propertyInitializer = (object, key) => {
20
+ for (const property of object.properties) {
21
+ if (!ts.isPropertyAssignment(property))
22
+ continue;
23
+ const name = property.name;
24
+ if ((ts.isIdentifier(name) || ts.isStringLiteral(name)) &&
25
+ name.text === key)
26
+ return property.initializer;
27
+ }
28
+ return undefined;
29
+ };
30
+ export const addOutgoingWebhook = (logger, node, checker, state) => {
31
+ if (!ts.isVariableDeclaration(node))
32
+ return;
33
+ const { initializer, name } = node;
34
+ if (!initializer || !ts.isCallExpression(initializer))
35
+ return;
36
+ if (!callsImportedDefiner(initializer.expression, checker, DEFINE_OUTGOING_WEBHOOK))
37
+ return;
38
+ if (!ts.isIdentifier(name))
39
+ return;
40
+ const file = node.getSourceFile().fileName;
41
+ const [argument] = initializer.arguments;
42
+ if (!argument || !ts.isObjectLiteralExpression(argument)) {
43
+ logger.error(`defineOutgoingWebhook in ${file} must be called with an object literal: { event, title, payload }.`);
44
+ return;
45
+ }
46
+ const event = stringProperty(argument, 'event');
47
+ const title = stringProperty(argument, 'title');
48
+ if (!event || !title) {
49
+ logger.error(`defineOutgoingWebhook '${name.text}' in ${file} needs 'event' and 'title' as string literals, so they can be listed without running the app.`);
50
+ return;
51
+ }
52
+ const variable = exportedName(node, name, checker);
53
+ if (!variable) {
54
+ logger.error(`defineOutgoingWebhook '${event}' in ${file} is assigned to '${name.text}', which the module does not export. The generated webhook types import it by name, so export it.`);
55
+ return;
56
+ }
57
+ const description = stringProperty(argument, 'description');
58
+ const payloadExpression = propertyInitializer(argument, 'payload');
59
+ const payload = payloadExpression
60
+ ? readEventShape(payloadExpression)
61
+ : undefined;
62
+ const webhooks = (state.outgoingWebhooks ??= []);
63
+ const existing = webhooks.findIndex((webhook) => webhook.file === file && webhook.variable === variable);
64
+ const declared = webhooks.find((webhook, index) => webhook.event === event && index !== existing);
65
+ if (declared) {
66
+ logger.error(`Webhook event '${event}' is declared twice: ${declared.file} and ${file}. An event belongs to one defineOutgoingWebhook.`);
67
+ return;
68
+ }
69
+ const entry = {
70
+ file,
71
+ variable,
72
+ event,
73
+ title,
74
+ ...(description !== undefined ? { description } : {}),
75
+ ...(payload ? { payload } : {}),
76
+ };
77
+ if (existing >= 0)
78
+ webhooks[existing] = entry;
79
+ else
80
+ webhooks.push(entry);
81
+ };
@@ -1,2 +1,7 @@
1
1
  import type { AddWiring } from '../types.js';
2
2
  export declare const addTrigger: AddWiring;
3
+ /**
4
+ * A trigger named `<source>:<event>` must name an event its webhook source
5
+ * declares, or it would never fire.
6
+ */
7
+ export declare const validateWebhookSourceTriggers: (logger: Parameters<AddWiring>[0], state: Parameters<AddWiring>[3]) => void;
@@ -6,7 +6,9 @@ import { ensureInlineWiringFunction } from '../utils/ensure-function-metadata.js
6
6
  import { resolveMiddleware } from '../utils/middleware.js';
7
7
  import { extractWireNames } from '../utils/post-process.js';
8
8
  import { resolveAddonName } from '../utils/resolve-addon-package.js';
9
+ import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
9
10
  import { ErrorCode } from '../error-codes.js';
11
+ const WEBHOOK_SOURCE_STEPS = ['receive', 'check', 'setup', 'teardown'];
10
12
  export const addTrigger = (logger, node, checker, state, options) => {
11
13
  if (!ts.isCallExpression(node)) {
12
14
  return;
@@ -23,6 +25,9 @@ export const addTrigger = (logger, node, checker, state, options) => {
23
25
  else if (expression.text === 'wireTriggerSource') {
24
26
  addWireTriggerSource(logger, node, checker, state, options, firstArg);
25
27
  }
28
+ else if (expression.text === 'wireTriggerWebhookSource') {
29
+ addWireTriggerWebhookSource(logger, node, checker, state, firstArg);
30
+ }
26
31
  };
27
32
  const addWireTrigger = (logger, node, checker, state, firstArg) => {
28
33
  if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) {
@@ -88,3 +93,102 @@ const addWireTriggerSource = (logger, node, checker, state, options, firstArg) =
88
93
  }
89
94
  state.triggers.files.add(node.getSourceFile().fileName);
90
95
  };
96
+ const refTarget = (initializer) => {
97
+ if (!ts.isCallExpression(initializer) ||
98
+ !ts.isIdentifier(initializer.expression) ||
99
+ initializer.expression.text !== 'ref') {
100
+ return null;
101
+ }
102
+ const [target] = initializer.arguments;
103
+ return target && ts.isStringLiteral(target) && target.text.includes(':')
104
+ ? target.text
105
+ : null;
106
+ };
107
+ const addWireTriggerWebhookSource = (logger, node, checker, state, firstArg) => {
108
+ if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) {
109
+ return;
110
+ }
111
+ const obj = firstArg;
112
+ const name = getPropertyValue(obj, 'name');
113
+ if (!name) {
114
+ logger.critical(ErrorCode.MISSING_NAME, `wireTriggerWebhookSource in ${node.getSourceFile().fileName} needs 'name' as a string literal.`);
115
+ return;
116
+ }
117
+ const method = getPropertyValue(obj, 'method') ?? 'post';
118
+ const route = getPropertyValue(obj, 'route') ?? `/webhooks/${name}`;
119
+ let secret = getPropertyValue(obj, 'secret');
120
+ const eventsInitializer = getPropertyAssignmentInitializer(obj, 'events', false, checker);
121
+ const events = eventsInitializer
122
+ ? checker
123
+ .getTypeAtLocation(eventsInitializer)
124
+ .getProperties()
125
+ .map((property) => property.getName())
126
+ .sort()
127
+ : [];
128
+ const steps = {};
129
+ for (const step of WEBHOOK_SOURCE_STEPS) {
130
+ const initializer = getPropertyAssignmentInitializer(obj, step, true, checker);
131
+ if (!initializer)
132
+ continue;
133
+ const target = refTarget(initializer);
134
+ if (target) {
135
+ if (!resolveFunctionMeta(state, target)) {
136
+ logger.warn(`Webhook source '${name}': addon function metadata for '${target}' is not available yet.`);
137
+ }
138
+ steps[step] = target;
139
+ state.serviceAggregation.usedFunctions.add(target);
140
+ continue;
141
+ }
142
+ const extracted = extractFunctionName(initializer, checker, state.rootDir);
143
+ let pikkuFuncId = extracted.pikkuFuncId;
144
+ if (pikkuFuncId.startsWith('__temp_')) {
145
+ pikkuFuncId = makeContextBasedId('trigger-webhook', name, step);
146
+ }
147
+ ensureInlineWiringFunction(state, pikkuFuncId, `${name}:${step}`, initializer, checker, extracted.isHelper);
148
+ const meta = state.functions.meta[pikkuFuncId];
149
+ if (meta && meta.sessionless === undefined)
150
+ meta.sessionless = true;
151
+ steps[step] = pikkuFuncId;
152
+ state.serviceAggregation.usedFunctions.add(pikkuFuncId);
153
+ }
154
+ // An addon's receive reads the secret by its own name; the app may have
155
+ // renamed it for this instance.
156
+ const namespace = steps.receive?.includes(':')
157
+ ? steps.receive.slice(0, steps.receive.indexOf(':'))
158
+ : null;
159
+ const addon = namespace
160
+ ? state.rpc.wireAddonDeclarations.get(namespace)
161
+ : undefined;
162
+ if (secret && addon?.secretOverrides?.[secret]) {
163
+ secret = addon.secretOverrides[secret];
164
+ }
165
+ const meta = {
166
+ name,
167
+ method: method,
168
+ route,
169
+ events,
170
+ ...(secret ? { secret } : {}),
171
+ ...steps,
172
+ };
173
+ state.triggers.webhookSourceMeta[name] = meta;
174
+ state.triggers.files.add(node.getSourceFile().fileName);
175
+ };
176
+ /**
177
+ * A trigger named `<source>:<event>` must name an event its webhook source
178
+ * declares, or it would never fire.
179
+ */
180
+ export const validateWebhookSourceTriggers = (logger, state) => {
181
+ const sources = state.triggers.webhookSourceMeta;
182
+ for (const trigger of Object.keys(state.triggers.meta)) {
183
+ const colon = trigger.indexOf(':');
184
+ if (colon === -1)
185
+ continue;
186
+ const source = sources[trigger.slice(0, colon)];
187
+ if (!source || source.events.length === 0)
188
+ continue;
189
+ const event = trigger.slice(colon + 1);
190
+ if (!source.events.includes(event)) {
191
+ logger.error(`Trigger '${trigger}' listens for '${event}', which webhook source '${source.name}' does not declare. Declared: ${source.events.join(', ')}.`);
192
+ }
193
+ }
194
+ };
package/dist/index.d.ts CHANGED
@@ -26,3 +26,4 @@ export type { SerializedWorkflowGraph, SerializedWorkflowGraphs, } from './utils
26
26
  export { resolveCoreType } from './utils/resolve-core-type.js';
27
27
  export { readModuleSpecifiers } from './utils/read-module-specifiers.js';
28
28
  export { readTsconfigOutDir } from './utils/read-tsconfig-out-dir.js';
29
+ export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
package/dist/index.js CHANGED
@@ -15,3 +15,4 @@ export { resolveFunctionMeta } from './utils/resolve-function-meta.js';
15
15
  export { resolveCoreType } from './utils/resolve-core-type.js';
16
16
  export { readModuleSpecifiers } from './utils/read-module-specifiers.js';
17
17
  export { readTsconfigOutDir } from './utils/read-tsconfig-out-dir.js';
18
+ export { addonResolutionDirs, createAddonResolver, } from './utils/addon-resolution.js';
package/dist/inspector.js CHANGED
@@ -14,6 +14,7 @@ import { annotateHttpRouteAuth } from './utils/annotate-http-route-auth.js';
14
14
  import { generateOpenAPISpec } from './utils/serialize-openapi-json.js';
15
15
  import { pikkuState } from '@pikku/core/state';
16
16
  import { resolveLatestVersions } from './utils/resolve-versions.js';
17
+ import { validateWebhookSourceTriggers } from './add/add-trigger.js';
17
18
  import { finalizeWorkflows } from './utils/workflow/graph/finalize-workflows.js';
18
19
  import { finalizeWorkflowHelperTypes, finalizeWorkflowWires, } from './utils/workflow/graph/finalize-workflow-wires.js';
19
20
  import { generateAllSchemas } from './utils/schema-generator.js';
@@ -79,6 +80,7 @@ export function getInitialInspectorState(rootDir) {
79
80
  triggers: {
80
81
  meta: {},
81
82
  sourceMeta: {},
83
+ webhookSourceMeta: {},
82
84
  files: new Set(),
83
85
  },
84
86
  scheduledTasks: {
@@ -353,6 +355,7 @@ export const inspect = async (logger, routeFiles, options = {}) => {
353
355
  ts.forEachChild(sourceFile, (child) => visitRoutes(logger, checker, child, state, sourceOptions));
354
356
  }
355
357
  logger.debug(`Visit routes phase completed in ${(performance.now() - startRoutes).toFixed(0)}ms`);
358
+ validateWebhookSourceTriggers(logger, state);
356
359
  resolveLatestVersions(state, logger);
357
360
  if (options.schemaConfig) {
358
361
  const startSchemas = performance.now();
package/dist/types.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { ChannelMessageMeta, ChannelsMeta } from '@pikku/core/channel';
5
5
  import type { GatewaysMeta } from '@pikku/core/gateway';
6
6
  import type { HTTPWiringsMeta } from '@pikku/core/http';
7
7
  import type { ScheduledTasksMeta } from '@pikku/core/scheduler';
8
- import type { TriggerMeta, TriggerSourceMeta } from '@pikku/core/trigger';
8
+ import type { TriggerMeta, TriggerSourceMeta, WebhookSourcesMeta } from '@pikku/core/trigger';
9
9
  import type { QueueWorkersMeta } from '@pikku/core/queue';
10
10
  import type { WorkflowsMeta } from '@pikku/core/workflow';
11
11
  import type { MCPResourceMeta, MCPToolMeta, MCPPromptMeta } from '@pikku/core/mcp';
@@ -515,6 +515,14 @@ export interface InspectorState {
515
515
  * inspector cannot read off the declaration. */
516
516
  props?: Record<string, Record<string, string>>;
517
517
  }>;
518
+ outgoingWebhooks?: Array<{
519
+ file: string;
520
+ variable: string;
521
+ event: string;
522
+ title: string;
523
+ description?: string;
524
+ payload?: Record<string, string>;
525
+ }>;
518
526
  addonServerlessIncompatible: Map<string, string[]>;
519
527
  configFactories: PathToNameAndType;
520
528
  serverLifecycleFactories: PathToNameAndType;
@@ -539,6 +547,7 @@ export interface InspectorState {
539
547
  triggers: {
540
548
  meta: TriggerMeta;
541
549
  sourceMeta: TriggerSourceMeta;
550
+ webhookSourceMeta: WebhookSourcesMeta;
542
551
  files: Set<string>;
543
552
  };
544
553
  scheduledTasks: {
@@ -0,0 +1,10 @@
1
+ export type AddonResolver = {
2
+ resolve: (id: string) => string;
3
+ };
4
+ /**
5
+ * An addon is installed wherever the package that calls `wireAddon` lists it,
6
+ * which in a workspace is usually not the repo root. Resolve from the declaring
7
+ * package first, as its code does at runtime, then from the root.
8
+ */
9
+ export declare const addonResolutionDirs: (rootDir: string, declFile?: string) => string[];
10
+ export declare const createAddonResolver: (dirs: string[]) => AddonResolver;
@@ -0,0 +1,43 @@
1
+ import { existsSync } from 'fs';
2
+ import { createRequire } from 'module';
3
+ import { join, dirname, parse, resolve } from 'path';
4
+ const nearestPackageDir = (file) => {
5
+ const root = parse(file).root;
6
+ let dir = dirname(file);
7
+ while (dir && dir !== root) {
8
+ if (existsSync(join(dir, 'package.json')))
9
+ return dir;
10
+ const parent = dirname(dir);
11
+ if (parent === dir)
12
+ break;
13
+ dir = parent;
14
+ }
15
+ return null;
16
+ };
17
+ /**
18
+ * An addon is installed wherever the package that calls `wireAddon` lists it,
19
+ * which in a workspace is usually not the repo root. Resolve from the declaring
20
+ * package first, as its code does at runtime, then from the root.
21
+ */
22
+ export const addonResolutionDirs = (rootDir, declFile) => {
23
+ const root = resolve(rootDir);
24
+ const declDir = declFile ? nearestPackageDir(resolve(declFile)) : null;
25
+ return declDir && declDir !== root ? [declDir, root] : [root];
26
+ };
27
+ export const createAddonResolver = (dirs) => {
28
+ const requires = dirs.map((dir) => createRequire(join(dir, 'package.json')));
29
+ return {
30
+ resolve: (id) => {
31
+ let lastError;
32
+ for (const req of requires) {
33
+ try {
34
+ return req.resolve(id);
35
+ }
36
+ catch (e) {
37
+ lastError = e;
38
+ }
39
+ }
40
+ throw lastError;
41
+ },
42
+ };
43
+ };
@@ -6,9 +6,9 @@
6
6
  * contract has to describe one shape. Zod says so twice, in two different
7
7
  * places, and neither says it usefully on its own — `toJSONSchema` with
8
8
  * `unrepresentable: 'any'` quietly yields `{}`, a schema that accepts
9
- * everything, and `zodToTs` throws about TypeScript rather than about the
10
- * wiring. Finding it up front lets the diagnostic name the schema, the path,
11
- * and the call.
9
+ * everything, and `zodToTypeText` prints the input side of the pipe, which is
10
+ * not the shape the function receives. Finding it up front lets the diagnostic
11
+ * name the schema, the path, and the call.
12
12
  *
13
13
  * Zod 4 models `x.transform(fn)` as a `pipe` whose `out` is a `transform`, so
14
14
  * the walk looks for that node rather than for the method.
@@ -6,9 +6,9 @@
6
6
  * contract has to describe one shape. Zod says so twice, in two different
7
7
  * places, and neither says it usefully on its own — `toJSONSchema` with
8
8
  * `unrepresentable: 'any'` quietly yields `{}`, a schema that accepts
9
- * everything, and `zodToTs` throws about TypeScript rather than about the
10
- * wiring. Finding it up front lets the diagnostic name the schema, the path,
11
- * and the call.
9
+ * everything, and `zodToTypeText` prints the input side of the pipe, which is
10
+ * not the shape the function receives. Finding it up front lets the diagnostic
11
+ * name the schema, the path, and the call.
12
12
  *
13
13
  * Zod 4 models `x.transform(fn)` as a `pipe` whose `out` is a `transform`, so
14
14
  * the walk looks for that node rather than for the method.
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { readFile, readdir } from 'fs/promises';
3
- import { createRequire } from 'module';
4
3
  import { join, dirname, parse, relative } from 'path';
4
+ import { addonResolutionDirs, createAddonResolver, } from './addon-resolution.js';
5
5
  import { ErrorCode } from '../error-codes.js';
6
6
  const isHTTPRouteConfig = (value) => typeof value === 'object' &&
7
7
  value !== null &&
@@ -90,45 +90,6 @@ const resolveAddonMeta = (require, packageName, baseName) => {
90
90
  }
91
91
  return null;
92
92
  };
93
- const nearestPackageDir = (file) => {
94
- const root = parse(file).root;
95
- let dir = dirname(file);
96
- while (dir && dir !== root) {
97
- if (existsSync(join(dir, 'package.json')))
98
- return dir;
99
- const parent = dirname(dir);
100
- if (parent === dir)
101
- break;
102
- dir = parent;
103
- }
104
- return null;
105
- };
106
- /**
107
- * An addon is installed wherever the package that calls `wireAddon` lists it,
108
- * which in a workspace is usually not the repo root. Resolve from the declaring
109
- * package first, as its code does at runtime, then from the root.
110
- */
111
- const addonResolutionDirs = (rootDir, declFile) => {
112
- const declDir = declFile ? nearestPackageDir(declFile) : null;
113
- return declDir && declDir !== rootDir ? [declDir, rootDir] : [rootDir];
114
- };
115
- const createAddonResolver = (dirs) => {
116
- const requires = dirs.map((dir) => createRequire(join(dir, 'package.json')));
117
- return {
118
- resolve: (id) => {
119
- let lastError;
120
- for (const req of requires) {
121
- try {
122
- return req.resolve(id);
123
- }
124
- catch (e) {
125
- lastError = e;
126
- }
127
- }
128
- throw lastError;
129
- },
130
- };
131
- };
132
93
  const findInstalledPackageDir = (dirs, packageName) => {
133
94
  for (const start of dirs) {
134
95
  const root = parse(start).root;
@@ -10,6 +10,7 @@ import { relative } from 'node:path';
10
10
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js';
11
11
  import { flattenScopeDefinitions } from '@pikku/core/scope';
12
12
  import { DYNAMIC_SCENARIO_STEP_TARGET } from './workflow/dsl/patterns.js';
13
+ import { addonResolutionDirs } from './addon-resolution.js';
13
14
  /**
14
15
  * Stamp the inspected authorize/callbacks service set onto the generated auth
15
16
  * handler's function meta.
@@ -208,6 +209,15 @@ export function aggregateRequiredServices(state) {
208
209
  requiredServices.add('schedulerService');
209
210
  requiredServices.add('queueService');
210
211
  }
212
+ // Webhook source routes and their worker are generated and destructure
213
+ // nothing, so they are found by id: the route accepts into the queue through
214
+ // incomingWebhookService and the worker records attempts through it.
215
+ const hasWebhookSources = Object.values(state.triggers?.webhookSourceMeta ?? {}).some(({ method, route }) => state.functions.meta[`http:${method}:${route}`] !== undefined);
216
+ if (hasWebhookSources ||
217
+ state.functions.meta['queue:pikku-incoming-webhooks'] !== undefined) {
218
+ requiredServices.add('incomingWebhookService');
219
+ requiredServices.add('queueService');
220
+ }
211
221
  // 6b. Inject synthetic queue workers for workflow graph steps.
212
222
  // Each workflow gets an orchestrator queue and per-step queues.
213
223
  // Without these, the PikkuWorkflowService constructor can't find
@@ -451,32 +461,34 @@ export function validateVariableOverrides(logger, state) {
451
461
  }
452
462
  }
453
463
  /**
454
- * Can `pkg` be resolved from `rootDir`, by any means the loader would use?
464
+ * Can `pkg` be resolved from any of `dirs`, by any means the loader would use?
455
465
  *
456
466
  * Resolution is attempted against `<pkg>/package.json` first and the bare
457
467
  * specifier second, because neither alone is conclusive. A package with a
458
468
  * restrictive `exports` map refuses the first with
459
469
  * `ERR_PACKAGE_PATH_NOT_EXPORTED` — which is not a failure at all: reaching the
460
- * exports map means the package was found. Only a module-not-found from both
461
- * attempts means it is genuinely absent.
470
+ * exports map means the package was found. Only a module-not-found from every
471
+ * attempt means it is genuinely absent.
462
472
  */
463
- const isPackageResolvable = (pkg, rootDir) => {
473
+ const isPackageResolvable = (pkg, dirs) => {
464
474
  const NOT_FOUND = new Set(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND']);
465
- let req;
466
- try {
467
- req = createRequire(join(rootDir, 'package.json'));
468
- }
469
- catch {
470
- return true; // cannot ask; do not accuse
471
- }
472
- for (const specifier of [`${pkg}/package.json`, pkg]) {
475
+ for (const dir of dirs) {
476
+ let req;
473
477
  try {
474
- req.resolve(specifier);
475
- return true;
478
+ req = createRequire(join(dir, 'package.json'));
476
479
  }
477
- catch (e) {
478
- if (!NOT_FOUND.has(e?.code))
480
+ catch {
481
+ return true; // cannot ask; do not accuse
482
+ }
483
+ for (const specifier of [`${pkg}/package.json`, pkg]) {
484
+ try {
485
+ req.resolve(specifier);
479
486
  return true;
487
+ }
488
+ catch (e) {
489
+ if (!NOT_FOUND.has(e?.code))
490
+ return true;
491
+ }
480
492
  }
481
493
  }
482
494
  return false;
@@ -514,35 +526,45 @@ export function validateRemoteAddonDependencies(logger, state) {
514
526
  for (const [namespace, decl] of wireAddonDeclarations.entries()) {
515
527
  if (decl.remote)
516
528
  continue;
517
- if (isPackageResolvable(decl.package, state.rootDir))
529
+ const dirs = addonResolutionDirs(state.rootDir, decl.file);
530
+ if (isPackageResolvable(decl.package, dirs))
518
531
  continue;
519
- logger.critical(ErrorCode.ADDON_NOT_INSTALLED, `Addon '${namespace}' ('${decl.package}') is wired with wireAddon but cannot be resolved — every ref('${namespace}:…') will resolve to nothing and the surface will be dead at runtime. Install it, or remove the wireAddon call.`);
520
- }
521
- if (!Array.from(wireAddonDeclarations.values()).some((d) => d.remote))
522
- return;
523
- const pkgJsonPath = join(state.rootDir, 'package.json');
524
- if (!existsSync(pkgJsonPath))
525
- return; // no manifest to check (e.g. some tests)
526
- let pkgJson;
527
- try {
528
- pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
529
- }
530
- catch (e) {
531
- logger.warn(`Could not read ${pkgJsonPath} to verify remote addon dependencies: ${e?.message ?? e}`);
532
- return;
533
- }
534
- const prodDeps = pkgJson.dependencies ?? {};
535
- const devDeps = pkgJson.devDependencies ?? {};
532
+ logger.critical(ErrorCode.ADDON_NOT_INSTALLED, `Addon '${namespace}' ('${decl.package}') is wired with wireAddon but cannot be resolved from ${dirs.join(' or ')} — every ref('${namespace}:…') will resolve to nothing and the surface will be dead at runtime. Add it to the dependencies of ${join(dirs[0], 'package.json')} and install, or remove the wireAddon call.`);
533
+ }
534
+ // A remote addon belongs in the devDependencies of the package that wires
535
+ // it, which in a workspace is not the root.
536
+ const manifests = new Map();
537
+ const readManifest = (path) => {
538
+ if (manifests.has(path))
539
+ return manifests.get(path);
540
+ let manifest = null;
541
+ if (existsSync(path)) {
542
+ try {
543
+ manifest = JSON.parse(readFileSync(path, 'utf-8'));
544
+ }
545
+ catch (e) {
546
+ logger.warn(`Could not read ${path} to verify remote addon dependencies: ${e?.message ?? e}`);
547
+ }
548
+ }
549
+ manifests.set(path, manifest);
550
+ return manifest;
551
+ };
536
552
  for (const [namespace, decl] of wireAddonDeclarations.entries()) {
537
553
  if (!decl.remote)
538
554
  continue;
555
+ const pkgJsonPath = join(addonResolutionDirs(state.rootDir, decl.file)[0], 'package.json');
556
+ const pkgJson = readManifest(pkgJsonPath);
557
+ if (!pkgJson)
558
+ continue; // no manifest to check (e.g. some tests)
559
+ const prodDeps = pkgJson.dependencies ?? {};
560
+ const devDeps = pkgJson.devDependencies ?? {};
539
561
  if (decl.package in devDeps)
540
562
  continue; // correct
541
563
  if (decl.package in prodDeps) {
542
- logger.critical(ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY, `Remote addon '${namespace}' ('${decl.package}') is a production dependency, but wireRemoteAddon consumes it for types only — its handlers run on the host. Move '${decl.package}' from "dependencies" to "devDependencies".`);
564
+ logger.critical(ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY, `Remote addon '${namespace}' ('${decl.package}') is a production dependency in ${pkgJsonPath}, but wireRemoteAddon consumes it for types only — its handlers run on the host. Move '${decl.package}' from "dependencies" to "devDependencies".`);
543
565
  }
544
566
  else {
545
- logger.critical(ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY, `Remote addon '${namespace}' ('${decl.package}') is wired with wireRemoteAddon but is not in "devDependencies". Add '${decl.package}' to "devDependencies" (types only).`);
567
+ logger.critical(ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY, `Remote addon '${namespace}' ('${decl.package}') is wired with wireRemoteAddon but is not in the "devDependencies" of ${pkgJsonPath}. Add '${decl.package}' to "devDependencies" (types only).`);
546
568
  }
547
569
  }
548
570
  }
@@ -169,6 +169,12 @@ function updateWiringReferences(state, oldId, newId) {
169
169
  source.pikkuFuncId = newId;
170
170
  }
171
171
  }
172
+ for (const source of Object.values(state.triggers.webhookSourceMeta ?? {})) {
173
+ for (const step of ['receive', 'check', 'setup', 'teardown']) {
174
+ if (source[step] === oldId)
175
+ source[step] = newId;
176
+ }
177
+ }
172
178
  }
173
179
  // MCP resources, tools, and prompts
174
180
  if (state.mcpEndpoints) {
@@ -5,8 +5,8 @@ import { dirname, join, resolve } from 'path';
5
5
  import { createGenerator, RootlessError } from 'ts-json-schema-generator';
6
6
  import { register } from 'tsx/esm/api';
7
7
  import * as z from 'zod';
8
- import { zodToTs, createAuxiliaryTypeStore } from 'zod-to-ts';
9
8
  import { findZodTransform } from './find-zod-transform.js';
9
+ import { zodToTypeText } from './zod-to-type-text.js';
10
10
  import { ErrorCode } from '../error-codes.js';
11
11
  import { generateCustomTypes } from './custom-types-generator.js';
12
12
  const PRIMITIVE_TYPES = new Set([
@@ -382,7 +382,7 @@ async function importWithRegister(sourceFile) {
382
382
  void Promise.resolve(unregister()).catch(() => { });
383
383
  }
384
384
  }
385
- function processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger) {
385
+ function processZodSchema(schemaName, zodSchema, schemas, typesMap, logger) {
386
386
  const transformPath = findZodTransform(zodSchema);
387
387
  if (transformPath !== undefined) {
388
388
  const where = transformPath === '' ? 'the schema itself' : transformPath;
@@ -412,18 +412,13 @@ function processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTyp
412
412
  delete schema.required;
413
413
  }
414
414
  }
415
- const { node: tsType } = zodToTs(zodSchema, { auxiliaryTypeStore });
416
- const typeText = printer.printNode(ts.EmitHint.Unspecified, tsType, fakeSourceFile);
417
- typesMap.addCustomType(schemaName, typeText, []);
415
+ typesMap.addCustomType(schemaName, zodToTypeText(zodSchema), []);
418
416
  schemas[schemaName] = schema;
419
417
  logger.debug(`• Generated schema from Zod: ${schemaName}`);
420
418
  }
421
419
  async function generateZodSchemas(logger, schemaLookup, typesMap) {
422
420
  const schemas = {};
423
421
  const errors = [];
424
- const auxiliaryTypeStore = createAuxiliaryTypeStore();
425
- const printer = ts.createPrinter();
426
- const fakeSourceFile = ts.createSourceFile('zod-types.ts', '', ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
427
422
  // Validate all schemas are zod (or unspecified vendor)
428
423
  for (const [schemaName, ref] of schemaLookup.entries()) {
429
424
  if (ref.vendor && ref.vendor !== 'zod') {
@@ -453,7 +448,7 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
453
448
  continue;
454
449
  }
455
450
  try {
456
- processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger);
451
+ processZodSchema(schemaName, zodSchema, schemas, typesMap, logger);
457
452
  }
458
453
  catch (e) {
459
454
  errors.push(`Could not convert Zod schema '${schemaName}': ${e instanceof Error ? e.message : e}`);
@@ -478,7 +473,7 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
478
473
  errors.push(`Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`);
479
474
  continue;
480
475
  }
481
- processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger);
476
+ processZodSchema(schemaName, zodSchema, schemas, typesMap, logger);
482
477
  }
483
478
  catch (e) {
484
479
  errors.push(`Could not convert Zod schema '${schemaName}': ${e instanceof Error ? e.message : e}`);
@@ -143,6 +143,7 @@ export interface SerializableInspectorState {
143
143
  triggers: {
144
144
  meta: InspectorState['triggers']['meta'];
145
145
  sourceMeta: InspectorState['triggers']['sourceMeta'];
146
+ webhookSourceMeta?: InspectorState['triggers']['webhookSourceMeta'];
146
147
  files: string[];
147
148
  };
148
149
  scheduledTasks: {
@@ -56,6 +56,7 @@ export function serializeInspectorState(state) {
56
56
  triggers: {
57
57
  meta: state.triggers.meta,
58
58
  sourceMeta: state.triggers.sourceMeta,
59
+ webhookSourceMeta: state.triggers.webhookSourceMeta,
59
60
  files: Array.from(state.triggers.files),
60
61
  },
61
62
  scheduledTasks: {
@@ -243,6 +244,7 @@ export function deserializeInspectorState(data) {
243
244
  triggers: {
244
245
  meta: data.triggers?.meta ?? {},
245
246
  sourceMeta: data.triggers?.sourceMeta ?? {},
247
+ webhookSourceMeta: data.triggers?.webhookSourceMeta ?? {},
246
248
  files: new Set(data.triggers?.files ?? []),
247
249
  },
248
250
  scheduledTasks: {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Print the TypeScript type text for a Zod schema.
3
+ *
4
+ * `processZodSchema` needs two things from a schema: the JSON Schema, which
5
+ * `z.toJSONSchema` already gives it, and the TypeScript source text that goes
6
+ * into the generated types. Only the second needed a library, and only to build
7
+ * a TypeScript AST that was immediately printed back to a string — so this walks
8
+ * Zod's own definitions and writes the text directly.
9
+ *
10
+ * It prints from the Zod tree rather than from the JSON Schema on purpose. The
11
+ * JSON Schema has already lost what the text needs: `z.date()` is rewritten to
12
+ * `string`/`date-time` before anything can read it, and `z.any()` and
13
+ * `z.unknown()` both flatten to `{}`. The Zod tree still says `Date`, `any` and
14
+ * `unknown`.
15
+ *
16
+ * The walk mirrors {@link findZodTransform} — same `_zod.def` shapes, same
17
+ * seen-set and node budget, for the same reason.
18
+ */
19
+ /**
20
+ * The TypeScript type text for `schema`, as it would be written by hand.
21
+ *
22
+ * A schema that refers to itself cannot be written inline, so a cycle prints as
23
+ * `any` rather than recursing forever.
24
+ */
25
+ export declare function zodToTypeText(schema: unknown): string;
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Print the TypeScript type text for a Zod schema.
3
+ *
4
+ * `processZodSchema` needs two things from a schema: the JSON Schema, which
5
+ * `z.toJSONSchema` already gives it, and the TypeScript source text that goes
6
+ * into the generated types. Only the second needed a library, and only to build
7
+ * a TypeScript AST that was immediately printed back to a string — so this walks
8
+ * Zod's own definitions and writes the text directly.
9
+ *
10
+ * It prints from the Zod tree rather than from the JSON Schema on purpose. The
11
+ * JSON Schema has already lost what the text needs: `z.date()` is rewritten to
12
+ * `string`/`date-time` before anything can read it, and `z.any()` and
13
+ * `z.unknown()` both flatten to `{}`. The Zod tree still says `Date`, `any` and
14
+ * `unknown`.
15
+ *
16
+ * The walk mirrors {@link findZodTransform} — same `_zod.def` shapes, same
17
+ * seen-set and node budget, for the same reason.
18
+ */
19
+ /** Max nodes visited, so a pathological or cyclic schema cannot hang the walk. */
20
+ const MAX_NODES = 10_000;
21
+ const INDENT = ' ';
22
+ const isNode = (value) => typeof value === 'object' &&
23
+ value !== null &&
24
+ typeof value._zod?.def?.type === 'string';
25
+ const tight = (text) => ({ text, loose: false });
26
+ const loose = (text) => ({ text, loose: true });
27
+ const parenthesized = ({ text, loose }) => loose ? `(${text})` : text;
28
+ /** A JS value as a TypeScript literal type. */
29
+ const literalOf = (value) => {
30
+ if (typeof value === 'string')
31
+ return JSON.stringify(value);
32
+ if (typeof value === 'bigint')
33
+ return `${value}n`;
34
+ if (value === undefined)
35
+ return 'undefined';
36
+ if (value === null)
37
+ return 'null';
38
+ return String(value);
39
+ };
40
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
41
+ const propertyKey = (name) => IDENTIFIER.test(name) ? name : JSON.stringify(name);
42
+ /** A `.describe()` string as a JSDoc block at the given indentation. */
43
+ const jsDoc = (description, indent) => {
44
+ if (!description)
45
+ return '';
46
+ const lines = description.split('\n');
47
+ if (lines.length === 1)
48
+ return `${indent}/** ${lines[0]} */\n`;
49
+ return `${indent}/**\n${lines.map((line) => `${indent} * ${line}`).join('\n')}\n${indent} */\n`;
50
+ };
51
+ const union = (parts) => {
52
+ const texts = [...new Set(parts.map(parenthesized))];
53
+ if (texts.length === 0)
54
+ return tight('never');
55
+ if (texts.length === 1)
56
+ return tight(texts[0]);
57
+ return loose(texts.join(' | '));
58
+ };
59
+ /**
60
+ * The TypeScript type text for `schema`, as it would be written by hand.
61
+ *
62
+ * A schema that refers to itself cannot be written inline, so a cycle prints as
63
+ * `any` rather than recursing forever.
64
+ */
65
+ export function zodToTypeText(schema) {
66
+ const seen = new Set();
67
+ let budget = MAX_NODES;
68
+ const print = (node, indent) => {
69
+ if (budget-- <= 0 || !isNode(node))
70
+ return tight('any');
71
+ if (seen.has(node))
72
+ return tight('any');
73
+ seen.add(node);
74
+ try {
75
+ return printDef(node, indent);
76
+ }
77
+ finally {
78
+ seen.delete(node);
79
+ }
80
+ };
81
+ const printDef = (node, indent) => {
82
+ const def = node._zod.def;
83
+ const inner = indent + INDENT;
84
+ switch (def.type) {
85
+ case 'string':
86
+ case 'email':
87
+ case 'url':
88
+ case 'uuid':
89
+ case 'guid':
90
+ case 'cuid':
91
+ case 'cuid2':
92
+ case 'ulid':
93
+ case 'emoji':
94
+ case 'nanoid':
95
+ case 'jwt':
96
+ case 'base64':
97
+ case 'base64url':
98
+ case 'ipv4':
99
+ case 'ipv6':
100
+ case 'cidrv4':
101
+ case 'cidrv6':
102
+ case 'iso_date':
103
+ case 'iso_time':
104
+ case 'iso_datetime':
105
+ case 'iso_duration':
106
+ return tight('string');
107
+ case 'number':
108
+ case 'int':
109
+ case 'nan':
110
+ return tight('number');
111
+ case 'bigint':
112
+ return tight('bigint');
113
+ case 'boolean':
114
+ return tight('boolean');
115
+ case 'date':
116
+ return tight('Date');
117
+ case 'null':
118
+ return tight('null');
119
+ case 'undefined':
120
+ case 'void':
121
+ return tight('undefined');
122
+ case 'symbol':
123
+ return tight('symbol');
124
+ case 'file':
125
+ return tight('File');
126
+ case 'never':
127
+ return tight('never');
128
+ case 'any':
129
+ return tight('any');
130
+ case 'unknown':
131
+ return tight('unknown');
132
+ case 'literal':
133
+ return union((def.values ?? []).map((v) => tight(literalOf(v))));
134
+ case 'enum':
135
+ return union(Object.values(def.entries ?? {}).map((v) => tight(literalOf(v))));
136
+ case 'template_literal': {
137
+ const body = (def.parts ?? [])
138
+ .map((part) => isNode(part) ? `\${${print(part, indent).text}}` : String(part))
139
+ .join('');
140
+ return tight(`\`${body}\``);
141
+ }
142
+ case 'array':
143
+ return tight(`${parenthesized(print(def.element, indent))}[]`);
144
+ case 'set':
145
+ return tight(`Set<${print(def.valueType, indent).text}>`);
146
+ case 'map':
147
+ return tight(`Map<${print(def.keyType, indent).text}, ${print(def.valueType, indent).text}>`);
148
+ case 'promise':
149
+ return tight(`Promise<${print(def.innerType, indent).text}>`);
150
+ case 'tuple': {
151
+ const items = (def.items ?? []).map((item) => print(item, inner).text);
152
+ if (def.rest) {
153
+ items.push(`...${parenthesized(print(def.rest, inner))}[]`);
154
+ }
155
+ if (items.length === 0)
156
+ return tight('[]');
157
+ return tight(`[\n${items.map((item) => `${inner}${item}`).join(',\n')}\n${indent}]`);
158
+ }
159
+ case 'union':
160
+ return union((def.options ?? []).map((option) => print(option, indent)));
161
+ case 'intersection':
162
+ return loose(`${parenthesized(print(def.left, indent))} & ${parenthesized(print(def.right, indent))}`);
163
+ case 'record':
164
+ return tight(`{\n${inner}[key: ${print(def.keyType, inner).text}]: ${print(def.valueType, inner).text};\n${indent}}`);
165
+ case 'object':
166
+ case 'interface': {
167
+ const shape = def.shape ?? {};
168
+ const entries = Object.entries(shape);
169
+ if (entries.length === 0 && !def.catchall)
170
+ return tight('{}');
171
+ const members = entries.map(([name, property]) => {
172
+ const optional = isNode(property) && isOptional(property);
173
+ const printed = print(property, inner);
174
+ const type = optional
175
+ ? `${parenthesized(printed)} | undefined`
176
+ : printed.text;
177
+ const doc = jsDoc(isNode(property) ? property.description : undefined, inner);
178
+ return `${doc}${inner}${propertyKey(name)}${optional ? '?' : ''}: ${type};`;
179
+ });
180
+ if (def.catchall) {
181
+ members.push(`${inner}[key: string]: ${print(def.catchall, inner).text};`);
182
+ }
183
+ return tight(`{\n${members.join('\n')}\n${indent}}`);
184
+ }
185
+ case 'nullable':
186
+ return union([print(def.innerType, indent), tight('null')]);
187
+ case 'lazy': {
188
+ try {
189
+ return print(def.getter(), indent);
190
+ }
191
+ catch {
192
+ // A lazy schema that cannot be forced tells us nothing about its type.
193
+ return tight('any');
194
+ }
195
+ }
196
+ default:
197
+ // optional, default, prefault, nonoptional, readonly, catch, success,
198
+ // pipe — every wrapper Zod spells `innerType`, and a pipe's input is the
199
+ // shape a caller writes.
200
+ if (def.innerType)
201
+ return print(def.innerType, indent);
202
+ if (def.in)
203
+ return print(def.in, indent);
204
+ return tight('any');
205
+ }
206
+ };
207
+ /**
208
+ * Whether a property may be omitted.
209
+ *
210
+ * `.default()` counts. `processZodSchema` strips every defaulted field out of
211
+ * the JSON Schema's `required`, because a caller who omits one gets the
212
+ * default rather than a validation error — so the text has to say the same
213
+ * thing the validator does.
214
+ */
215
+ const isOptional = (node) => {
216
+ const def = node._zod.def;
217
+ if (def.type === 'optional')
218
+ return true;
219
+ if (def.type === 'default' || def.type === 'prefault')
220
+ return true;
221
+ if (def.type === 'nonoptional')
222
+ return false;
223
+ if (def.innerType && isNode(def.innerType))
224
+ return isOptional(def.innerType);
225
+ return false;
226
+ };
227
+ return print(schema, '').text;
228
+ }
package/dist/visit.js CHANGED
@@ -31,6 +31,7 @@ import { addWorkflowGraph } from './add/add-workflow-graph.js';
31
31
  import { addFeature } from './add/add-feature.js';
32
32
  import { addAgent } from './add/add-agent.js';
33
33
  import { addAnalytics } from './add/add-analytics.js';
34
+ import { addOutgoingWebhook } from './add/add-outgoing-webhook.js';
34
35
  import { addAgentScorer } from './add/add-agent-scorer.js';
35
36
  import { addApprovalDescription } from './add/add-approval-description.js';
36
37
  export const visitSetup = (logger, checker, node, state, options) => {
@@ -43,6 +44,7 @@ export const visitSetup = (logger, checker, node, state, options) => {
43
44
  addFileWithFactory(node, checker, state.configFactories, 'CreateConfig');
44
45
  addFileWithFactory(node, checker, state.serverLifecycleFactories, 'ServerLifecycle');
45
46
  addAnalytics(logger, node, checker, state);
47
+ addOutgoingWebhook(logger, node, checker, state);
46
48
  addRPCInvocations(node, state, logger);
47
49
  addWireAddon(node, state, logger);
48
50
  addWireRemoteAddon(node, state, logger);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.89",
3
+ "version": "0.12.92",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -51,14 +51,13 @@
51
51
  "ts-json-schema-generator": "2.9.1-next.18",
52
52
  "tsx": "^4.23.12",
53
53
  "typescript": "^6.0.3",
54
- "zod": "^4.4.3",
55
- "zod-to-ts": "^2.1.0"
54
+ "zod": "^4.4.3"
56
55
  },
57
56
  "peerDependencies": {
58
- "@pikku/core": "^0.12.123"
57
+ "@pikku/core": "^0.12.127"
59
58
  },
60
59
  "devDependencies": {
61
- "@pikku/core": "^0.12.123",
60
+ "@pikku/core": "^0.12.127",
62
61
  "@types/node": "^24.13.3"
63
62
  },
64
63
  "engines": {