@pikku/core 0.12.111 → 0.12.113

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,90 @@
1
+ ## 0.12.113
2
+
3
+ ### Patch Changes
4
+
5
+ - c842054: A pikku command never ends in a node internals warning, and a secret can be set from a script.
6
+
7
+ `pikku fabric secrets set NAME` prompted for the value through readline in terminal mode. On a stdin that is not a tty that promise never settles at all, so the command printed `BETTER_AUTH_SECRET value:` and then node's "Detected unsettled top-level await", naming a line of `@pikku/cli`'s own bin — under bun it simply hung. The prompt now reads the first line of stdin when there is no tty, so `echo '<value>' | pikku fabric secrets set NAME` works, and refuses with the flag to reach for (`--value`) when stdin is closed or empty. `promptConfirm` gained the same backstop, so a caller that forgets its `isTTY` gate gets a refusal instead of a hang.
8
+
9
+ Alongside it, the places a raw stack could still reach a user:
10
+
11
+ - The `pikku` binary formats through `formatCLIError` instead of printing `error.message`, and installs `uncaughtException` / `unhandledRejection` handlers so nothing escaping a listener or a floating promise is dumped unformatted. A `CLIError` the runner already printed is no longer printed twice.
12
+ - The generated local and channel CLI bootstraps do the same, rather than `console.error('Fatal error:', error.message)` — which dropped the stack even when one was asked for.
13
+ - A missing `pikku.config.json` says where it looked and what to do, as a `PikkuCLIConfigError`, which is now a `PikkuError` along with `GitError` and every remaining plain `Error` raised by a `pikku fabric` command. A directory-wide test keeps it that way.
14
+ - `pikku dev`'s watcher and the MCP schema loader log their causes through the logger at debug level instead of `console.error(err)` over the top of the output.
15
+
16
+ Stacks are unchanged where they are the answer: an unexpected error still keeps its frames, and `--verbose` / `PIKKU_DEBUG` still prints the stack for a deliberate one. `formatCLIError` and `wantsStackTrace` are exported from `@pikku/core/cli` so every entrypoint that can be the last thing to catch an error prints it the same way.
17
+
18
+ - dfd7019: An MCP tool reads the claims its host verified
19
+
20
+ `PikkuHTTP` gains an `authInfo` of the new `PikkuHTTPAuthInfo`: the token,
21
+ client and scopes a transport that already verified a bearer token hands on.
22
+ It is strictly pass-through — nothing in pikku derives it from a request's own
23
+ headers, because verifying a token is the host's job. A function could always
24
+ read the `Authorization` header itself; what this adds is what the raw header
25
+ cannot carry.
26
+
27
+ `PikkuMCPServer`'s server factory now carries the SDK's `authInfo` onto the
28
+ wire beside the request, so the `authInfo` a host passes to
29
+ `createFetchHandler` reaches the tool rather than stopping at the SDK.
30
+
31
+ `pikkuCredentialOAuth` also names itself when it provisions the platform user.
32
+ Every other pikku plugin passes a source to `internalAdapter.createUser`, and
33
+ better-auth refuses a `user.validateUserInfo` gate that is handed none — so an
34
+ app with that hook configured could not link a singleton credential at all.
35
+
36
+ - dfd7019: An MCP call that needs a session is refused with an OAuth challenge
37
+
38
+ A tool fronting a session-requiring function used to answer an unauthenticated
39
+ caller with `200` and `isError: true`, which a client reads as a tool that
40
+ broke rather than one it has not authenticated for — so OAuth discovery never
41
+ began. Such a call now gets `401` with a `WWW-Authenticate: Bearer` challenge
42
+ naming the resource metadata, and `/.well-known/oauth-protected-resource` is
43
+ served alongside the MCP endpoint.
44
+
45
+ The endpoint is not gated as a whole. `mcpTargetRequiresSession` reads the
46
+ declarations the runner already enforces — a `pikkuFunc` needs a session, a
47
+ `pikkuSessionlessFunc` needs one only where it says `auth: true` — so public
48
+ and private tools can share one server and only the private ones are
49
+ challenged.
50
+
51
+ `createFetchHandler` and `createHTTPRequestHandler` take an optional `auth`
52
+ describing what to advertise (`authorizationServers`, `scopesSupported`,
53
+ `resourceName`), surfaced on both servers as an `mcpAuth` option. Every field
54
+ defaults from the request, because a pikku app is usually its own
55
+ authorization server. Both handlers now also return `ownsPath`, because the
56
+ discovery document lives outside `mcpPath` and a host routing on the endpoint
57
+ alone would 404 the document its own challenge points at.
58
+
59
+ - 9b978e7: A failed SSE stream reports the error in the protocol its client is parsing
60
+
61
+ An SSE route can now declare `streamProtocol: 'agui'`, and the generated agent
62
+ stream and resume routes do. A function that throws mid-stream then ends the
63
+ stream with a single AG-UI `RUN_ERROR` instead of Pikku's `error`/`done` frames,
64
+ which an AG-UI client could only surface as a Zod parse failure with the real
65
+ message nowhere in sight.
66
+
67
+ - 1469e73: The app names which of an addon's functions reach MCP
68
+
69
+ `wireAddon`'s `mcp` takes a list as well as `true`. `true` still offers every
70
+ function the addon declared `mcp: true`; a list names the tools this deployment
71
+ offers, whether or not the addon declared them, and is typed against the
72
+ function names that addon publishes — a typo is a compile error rather than a
73
+ tool silently missing from the menu.
74
+
75
+ ## 0.12.112
76
+
77
+ ### Patch Changes
78
+
79
+ - 02eeffb: fix: hot reload reads the changed source instead of a leftover compiled copy
80
+
81
+ `pikku dev` announced `Hot-reloaded: <fn>` and kept serving the previous
82
+ implementation whenever a `.js` from an earlier `tsc`, `pikku dist` or bundler
83
+ run sat beside the edited `.ts` — the reloader preferred that file, and dev
84
+ never rebuilds it. The changed source is now what runs, and the module runner
85
+ resolves and compiles the project's own TypeScript dependencies so a helper
86
+ edited alongside its caller reloads with it.
87
+
1
88
  ## 0.12.111
2
89
 
3
90
  ### Patch Changes
@@ -3,7 +3,6 @@ export { reloadGeneratedMeta, reconcileAddonRegistry } from './reload-meta.js';
3
3
  interface PikkuDevReloaderOptions {
4
4
  srcDirectories: string[];
5
5
  logger: Logger;
6
- pikkuDir?: string;
7
6
  }
8
7
  export interface PikkuDevReloaderHandle {
9
8
  close: () => void;
@@ -1,5 +1,4 @@
1
1
  import { watch } from 'node:fs';
2
- import { stat } from 'node:fs/promises';
3
2
  import { basename, join, resolve, relative } from 'node:path';
4
3
  import { pikkuState } from '../pikku-state.js';
5
4
  import { clearMiddlewareCache } from '../middleware-runner.js';
@@ -14,24 +13,6 @@ const isFunctionConfig = (value) => {
14
13
  'func' in value &&
15
14
  typeof value.func === 'function');
16
15
  };
17
- const findCompiledFile = async (tsFile, srcDir, pikkuDir) => {
18
- const rel = relative(srcDir, tsFile).replace(/\.ts$/, '.js');
19
- const candidates = [
20
- join(pikkuDir, 'dist', rel),
21
- join(srcDir, rel),
22
- tsFile.replace(/\.ts$/, '.js'),
23
- ];
24
- for (const candidate of candidates) {
25
- try {
26
- await stat(candidate);
27
- return candidate;
28
- }
29
- catch {
30
- // not found, try next
31
- }
32
- }
33
- return null;
34
- };
35
16
  const isWatchedTsFile = (filename) => {
36
17
  return (filename.endsWith('.ts') &&
37
18
  !filename.endsWith('.test.ts') &&
@@ -55,9 +36,8 @@ const reloadFailureReason = (error) => {
55
36
  return ` ${error.stack ?? error.message}`;
56
37
  };
57
38
  export async function pikkuDevReloader(options) {
58
- const { srcDirectories, logger, pikkuDir = '.pikku' } = options;
39
+ const { srcDirectories, logger } = options;
59
40
  const absSrcDirs = srcDirectories.map((d) => resolve(d));
60
- const absPikkuDir = resolve(pikkuDir);
61
41
  const watchers = [];
62
42
  const functionsMap = pikkuState(null, 'function', 'functions');
63
43
  const moduleRunner = createModuleRunner();
@@ -68,15 +48,14 @@ export async function pikkuDevReloader(options) {
68
48
  const srcDir = absSrcDirs.find((d) => changedTsFile.startsWith(d));
69
49
  if (!srcDir)
70
50
  return;
71
- const compiledFile = await findCompiledFile(changedTsFile, srcDir, absPikkuDir);
72
- const importPath = compiledFile ?? changedTsFile;
73
- const result = await moduleRunner.run(importPath);
51
+ // knowledge: decisions/internals/hot-reload-reads-the-changed-source-never-a-compiled-copy.md
52
+ const result = await moduleRunner.run(changedTsFile);
74
53
  if (!result.ok) {
75
54
  // Keeping the old code leaves the process disagreeing with the file on
76
55
  // disk, and the only symptom is stale output from a function that looks
77
56
  // correct in the editor — so the reason has to be printed here, where it
78
57
  // is still known, rather than left for the developer to reconstruct.
79
- logger.error(`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)\n` +
58
+ logger.error(`Failed to import: ${relative(process.cwd(), changedTsFile)} (keeping old code)\n` +
80
59
  reloadFailureReason(result.error));
81
60
  return;
82
61
  }
@@ -1,6 +1,6 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { readFileSync, statSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
- import { dirname, resolve } from 'node:path';
3
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { compileFunction } from 'node:vm';
6
6
  let transformSync;
@@ -18,7 +18,78 @@ const loadTransform = async () => {
18
18
  * is correct, and no amount of re-reading it will reveal that the reloader —
19
19
  * not the file — is what cannot cope. */
20
20
  export const isTopLevelAwaitLimitation = (error) => /top-level await/i.test(error.message);
21
+ const TYPESCRIPT_FILE = /\.(m|c)?tsx?$/;
22
+ const isFile = (path) => {
23
+ try {
24
+ return statSync(path).isFile();
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ };
30
+ /** Where a relative specifier's TypeScript source lives, for the specifiers a
31
+ * plain `require` cannot resolve. A TS project writes `./sibling.js` for a file
32
+ * that only exists as `./sibling.ts`, and a reloaded module is the source, so
33
+ * every one of its relative imports arrives in that unresolvable form. */
34
+ const findTypeScriptSource = (specifier, importerDir) => {
35
+ if (!specifier.startsWith('.') && !isAbsolute(specifier))
36
+ return undefined;
37
+ const base = resolve(importerDir, specifier);
38
+ const withoutJs = base.replace(/\.(m|c)?js$/, '');
39
+ const candidates = [
40
+ `${withoutJs}.ts`,
41
+ `${withoutJs}.tsx`,
42
+ `${withoutJs}.mts`,
43
+ `${withoutJs}.cts`,
44
+ join(base, 'index.ts'),
45
+ join(base, 'index.tsx'),
46
+ ];
47
+ return candidates.find(isFile);
48
+ };
49
+ const isProjectTypeScript = (path) => TYPESCRIPT_FILE.test(path) && !path.includes(`${sep}node_modules${sep}`);
50
+ /** JSX is a syntax esbuild only parses when asked, so a `.tsx` helper handed to
51
+ * the `ts` loader fails to compile and the file importing it keeps its old
52
+ * code. */
53
+ const loaderFor = (path) => {
54
+ if (path.endsWith('.tsx'))
55
+ return 'tsx';
56
+ if (TYPESCRIPT_FILE.test(path))
57
+ return 'ts';
58
+ if (path.endsWith('.jsx'))
59
+ return 'jsx';
60
+ return 'js';
61
+ };
62
+ const evaluateModule = (absPath, cache) => {
63
+ const cached = cache.get(absPath);
64
+ if (cached)
65
+ return cached;
66
+ const source = readFileSync(absPath, 'utf-8');
67
+ const { code } = transformSync(source, {
68
+ loader: loaderFor(absPath),
69
+ format: 'cjs',
70
+ sourcefile: absPath,
71
+ });
72
+ const fn = compileFunction(code, ['require', 'exports', 'module', '__filename', '__dirname'], { filename: absPath });
73
+ const moduleObj = { exports: {} };
74
+ // Seeded before evaluation so an import cycle terminates the way CJS's own
75
+ // cache makes it terminate, and overwritten after because esbuild's `cjs`
76
+ // emit assigns a fresh `module.exports` on the way out.
77
+ cache.set(absPath, moduleObj.exports);
78
+ fn(createRequireForFile(absPath, cache), moduleObj.exports, moduleObj, absPath, dirname(absPath));
79
+ cache.set(absPath, moduleObj.exports);
80
+ return moduleObj.exports;
81
+ };
21
82
  /** The `require` a reloaded module is run with.
83
+ *
84
+ * Resolution goes through the host first, so every package binds to the
85
+ * instance the rest of the process holds and a user file's `wire*` side
86
+ * effect mutates live state rather than a private copy.
87
+ *
88
+ * A resolved TypeScript file outside `node_modules` is the project's own
89
+ * source, and is compiled here instead of handed back to the host: both hosts
90
+ * cache what they load, so a helper edited alongside the function importing it
91
+ * would otherwise keep answering with the version loaded at startup — the same
92
+ * stale-code-under-a-successful-reload the reloader exists to avoid.
22
93
  *
23
94
  * On Bun, `createRequire`'s referrer stops being honoured somewhere in a
24
95
  * long-running process: the same relative specifier that resolves in a fresh
@@ -27,47 +98,55 @@ export const isTopLevelAwaitLimitation = (error) => /top-level await/i.test(erro
27
98
  * is left serving the previous code from an edit that looked applied. Bun's
28
99
  * own resolver still answers correctly when handed the importer's directory
29
100
  * outright, so the specifier is resolved to an absolute path first and the
30
- * referrer never has to survive the trip. Anything Bun's resolver declines —
31
- * builtins among them — falls through to `require` unchanged.
101
+ * referrer never has to survive the trip.
102
+ *
103
+ * What neither host resolves is a relative `./sibling.js` whose only file on
104
+ * disk is `./sibling.ts` — node's `require` has no `.js → .ts` rewrite, and
105
+ * the reloader hands it source. Those, and only those, are compiled here.
32
106
  */
33
- const createRequireForFile = (absPath) => {
107
+ const createRequireForFile = (absPath, cache) => {
34
108
  const base = createRequire(pathToFileURL(absPath));
35
109
  const bun = globalThis.Bun;
36
- if (!bun)
37
- return base;
38
110
  const importerDir = dirname(absPath);
39
- const resolveThroughBun = (specifier) => {
111
+ const resolveThroughHost = (specifier) => {
112
+ if (bun) {
113
+ try {
114
+ return bun.resolveSync(specifier, importerDir);
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
40
120
  try {
41
- return bun.resolveSync(specifier, importerDir);
121
+ return base.resolve(specifier);
42
122
  }
43
123
  catch {
44
- return specifier;
124
+ return undefined;
45
125
  }
46
126
  };
47
127
  // Only resolution is delegated; the require itself stays outside the guard so
48
128
  // a module that throws while evaluating reports its own error rather than
49
129
  // being retried under a second specifier.
50
- const bunRequire = ((specifier) => base(resolveThroughBun(specifier)));
51
- return Object.assign(bunRequire, base);
130
+ const loadingRequire = ((specifier) => {
131
+ const resolved = resolveThroughHost(specifier) ??
132
+ findTypeScriptSource(specifier, importerDir);
133
+ if (!resolved)
134
+ return base(specifier);
135
+ if (isProjectTypeScript(resolved))
136
+ return evaluateModule(resolved, cache);
137
+ return base(resolved);
138
+ });
139
+ return Object.assign(loadingRequire, base);
52
140
  };
53
141
  export const createModuleRunner = () => {
54
142
  const registry = new Map();
55
143
  const run = async (filePath) => {
56
144
  const absPath = resolve(filePath);
57
145
  try {
58
- const transform = await loadTransform();
59
- const source = await readFile(absPath, 'utf-8');
60
- const { code } = transform(source, {
61
- loader: absPath.endsWith('.ts') ? 'ts' : 'js',
62
- format: 'cjs',
63
- sourcefile: absPath,
64
- });
65
- const fn = compileFunction(code, ['require', 'exports', 'module', '__filename', '__dirname'], { filename: absPath });
66
- const require = createRequireForFile(absPath);
67
- const moduleObj = { exports: {} };
68
- fn(require, moduleObj.exports, moduleObj, absPath, dirname(absPath));
69
- registry.set(absPath, moduleObj.exports);
70
- return { ok: true, exports: moduleObj.exports };
146
+ await loadTransform();
147
+ const exports = evaluateModule(absPath, new Map());
148
+ registry.set(absPath, exports);
149
+ return { ok: true, exports };
71
150
  }
72
151
  catch (thrown) {
73
152
  // A bad edit, or the one known limitation: a file using top-level
@@ -8,8 +8,13 @@ export type WireAddonConfig = {
8
8
  rpcEndpoint?: string;
9
9
  /** Requires a session for every function in the addon, whatever each one declares. Gates an addon whose functions are individually open. */
10
10
  auth?: boolean;
11
- /** Offers the addon's functions to MCP clients as tools, without wiring each one. */
12
- mcp?: boolean;
11
+ /**
12
+ * Offers the addon's functions to MCP clients as tools, without wiring each
13
+ * one. `true` offers every function the addon itself declared `mcp: true`; a
14
+ * list names the functions to offer, whether or not the addon declared them,
15
+ * and is typed against the addon's function names.
16
+ */
17
+ mcp?: boolean | string[];
13
18
  /** Filters this addon in and out of a build — see the `tags` option on `pikku all`. It has no effect at runtime. */
14
19
  tags?: string[];
15
20
  /** Required of every function in the addon, on top of the function's own. */
@@ -1,4 +1,10 @@
1
1
  export { wireCLI, runCLICommand, pikkuCLIRender, executeCLI, CLIError, } from './cli-runner.js';
2
2
  export { parseCLIArguments, generateCommandHelp } from './command-parser.js';
3
3
  export { defineCLICommands } from './define-cli-commands.js';
4
+ /**
5
+ * Exported so every entrypoint that can be the last thing to catch an error —
6
+ * the `pikku` binary, a generated bootstrap, a channel client — prints it the
7
+ * same way, instead of each inventing its own `console.error(error.message)`.
8
+ */
9
+ export { formatCLIError, wantsStackTrace } from './format-cli-error.js';
4
10
  export type { CLIMeta, CLICommandMeta, CLIProgramMeta, CoreCLI, CoreCLICommandConfig, CorePikkuCLIRender, } from './cli.types.js';
@@ -1,3 +1,9 @@
1
1
  export { wireCLI, runCLICommand, pikkuCLIRender, executeCLI, CLIError, } from './cli-runner.js';
2
2
  export { parseCLIArguments, generateCommandHelp } from './command-parser.js';
3
3
  export { defineCLICommands } from './define-cli-commands.js';
4
+ /**
5
+ * Exported so every entrypoint that can be the last thing to catch an error —
6
+ * the `pikku` binary, a generated bootstrap, a channel client — prints it the
7
+ * same way, instead of each inventing its own `console.error(error.message)`.
8
+ */
9
+ export { formatCLIError, wantsStackTrace } from './format-cli-error.js';
@@ -54,6 +54,7 @@ function registerRoute(route, groupConfig) {
54
54
  timeout: route.timeout,
55
55
  headers: route.headers,
56
56
  sse: route.sse,
57
+ streamProtocol: route.streamProtocol,
57
58
  // `CoreHTTPFunctionWiring` is discriminated by `method`, and a group builds
58
59
  // its routes from a method chosen at runtime — no arm can be narrowed to.
59
60
  });
@@ -6,6 +6,7 @@ import { getErrorResponse } from '../../errors/error-handler.js';
6
6
  import { handleHTTPError } from '../../handle-error.js';
7
7
  import { isProduction } from '../../env.js';
8
8
  import { pikkuState } from '../../pikku-state.js';
9
+ import { streamErrorFrames } from './http-stream-protocol.js';
9
10
  import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js';
10
11
  import { PikkuFetchHTTPRequest } from './pikku-fetch-http-request.js';
11
12
  // The leaf module, not the channel-rpc barrel: http needs one refusal
@@ -194,11 +195,10 @@ const executeRoute = async (services, matchedRoute, http, options) => {
194
195
  singletonServices.logger.error(e instanceof Error ? e.message : e);
195
196
  try {
196
197
  const errorResponse = getErrorResponse(e);
197
- http?.response?.arrayBuffer(JSON.stringify({
198
- type: 'error',
199
- errorText: errorResponse?.message ?? 'Internal server error',
200
- }));
201
- http?.response?.arrayBuffer(JSON.stringify({ type: 'done' }));
198
+ const message = errorResponse?.message ?? 'Internal server error';
199
+ for (const frame of streamErrorFrames(matchedRoute.route.streamProtocol, message)) {
200
+ http?.response?.arrayBuffer(JSON.stringify(frame));
201
+ }
202
202
  }
203
203
  catch { }
204
204
  channel?.close();
@@ -0,0 +1,12 @@
1
+ import type { HTTPStreamProtocol } from './http.types.js';
2
+ /**
3
+ * The frames that terminate a stream whose function threw after the response
4
+ * was already committed to streaming.
5
+ *
6
+ * A stream's consumer parses every frame against the protocol it was promised,
7
+ * so an error announced in the wrong one reaches it as a parser failure with
8
+ * the actual message nowhere in sight. AG-UI accepts `RUN_ERROR` as the first
9
+ * event as readily as the last, and forbids anything after it, so a failure
10
+ * there is that one frame — never a trailing `done`.
11
+ */
12
+ export declare const streamErrorFrames: (protocol: HTTPStreamProtocol | undefined, message: string) => Array<Record<string, unknown>>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The frames that terminate a stream whose function threw after the response
3
+ * was already committed to streaming.
4
+ *
5
+ * A stream's consumer parses every frame against the protocol it was promised,
6
+ * so an error announced in the wrong one reaches it as a parser failure with
7
+ * the actual message nowhere in sight. AG-UI accepts `RUN_ERROR` as the first
8
+ * event as readily as the last, and forbids anything after it, so a failure
9
+ * there is that one frame — never a trailing `done`.
10
+ */
11
+ export const streamErrorFrames = (protocol, message) => {
12
+ if (protocol === 'agui') {
13
+ return [{ type: 'RUN_ERROR', message }];
14
+ }
15
+ return [{ type: 'error', errorText: message }, { type: 'done' }];
16
+ };
@@ -40,9 +40,33 @@ export type CoreHTTPFunction = HTTPRouteBaseConfig & {
40
40
  /** Sends the returned value as-is rather than JSON-encoding it, for a route whose body is binary or already serialised. */
41
41
  returnsJSON?: false;
42
42
  };
43
+ /**
44
+ * The claims a transport that already verified a bearer token hands on.
45
+ *
46
+ * Strictly pass-through: nothing in pikku derives this from a request's own
47
+ * headers, because verifying a token is the host's job, not the wire's. A
48
+ * function can always read the `Authorization` header itself — what this adds
49
+ * is what the raw header cannot carry, the scopes and client the token was
50
+ * actually issued for.
51
+ *
52
+ * Shaped to match the MCP SDK's `AuthInfo`, which is the one caller that
53
+ * populates it today.
54
+ */
55
+ export interface PikkuHTTPAuthInfo {
56
+ token: string;
57
+ clientId: string;
58
+ scopes: string[];
59
+ /** Seconds since the epoch. */
60
+ expiresAt?: number;
61
+ /** The RFC 8707 resource server this token is valid for. */
62
+ resource?: URL;
63
+ extra?: Record<string, unknown>;
64
+ }
43
65
  export interface PikkuHTTP<In = unknown> {
44
66
  request?: PikkuHTTPRequest<In>;
45
67
  response?: PikkuHTTPResponse;
68
+ /** Verified token claims, when the transport was handed them. */
69
+ authInfo?: PikkuHTTPAuthInfo;
46
70
  }
47
71
  export type PikkuQuery<T = Record<string, string | undefined>> = Record<string, string | T | null | Array<T | null>>;
48
72
  /**
@@ -78,6 +102,13 @@ type HTTPWiringAuth<In, Out, PikkuFunction extends CorePikkuFunction<In, Out, an
78
102
  /** On an open route there is no session, so this must be a sessionless function. */
79
103
  func: CorePikkuFunctionConfig<PikkuFunctionSessionless, PikkuPermission, PikkuMiddleware>;
80
104
  };
105
+ /**
106
+ * The event protocol a streaming route's frames follow, so the layer that
107
+ * terminates a failed stream can speak the same one the client is parsing.
108
+ * `'pikku'` frames are `{ type: 'error' | 'done' }`; `'agui'` frames are
109
+ * AG-UI events, where a failure is a single `RUN_ERROR`.
110
+ */
111
+ export type HTTPStreamProtocol = 'pikku' | 'agui';
81
112
  /**
82
113
  * `sse` and `query` are each valid on one method only, so the method carries
83
114
  * them: streaming is a GET, and naming which input keys arrive in the query
@@ -92,6 +123,8 @@ type HTTPWiringMethod<In> = {
92
123
  method: 'get';
93
124
  /** Streams the response as server-sent events instead of returning it once. GET only. */
94
125
  sse?: boolean;
126
+ /** Which event protocol the frames on this stream follow. Defaults to `'pikku'`. */
127
+ streamProtocol?: HTTPStreamProtocol;
95
128
  } | {
96
129
  /** The HTTP method. A route and method together address one wiring. */
97
130
  method: 'post';
@@ -162,6 +195,8 @@ export type HTTPRouteConfig<PikkuFunction extends CorePikkuFunction<any, any, an
162
195
  auth?: boolean;
163
196
  middleware?: PikkuMiddleware[];
164
197
  sse?: boolean;
198
+ /** Which event protocol the frames on this stream follow. Defaults to `'pikku'`. */
199
+ streamProtocol?: HTTPStreamProtocol;
165
200
  };
166
201
  export type HTTPRoutesGroupConfig<PikkuPermission extends CorePikkuPermission<any, any, any> = CorePikkuPermission<any, any, any>, PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<any, any>> = {
167
202
  basePath?: string;
@@ -5,4 +5,4 @@ export { fetch, fetchData, wireHTTP, addHTTPMiddleware } from './http-runner.js'
5
5
  export { wireHTTPRoutes, defineHTTPRoutes } from './http-routes.js';
6
6
  export { toWebRequest, applyWebResponse } from './web-request.js';
7
7
  export { httpRouter } from './routers/http-router.js';
8
- export type { AssertHTTPWiringParams, CoreHTTPFunctionWiring, HTTPMethod, HTTPRouteBaseConfig, HTTPRouteContract, HTTPRouteMap, HTTPWiringsMeta, PikkuHTTP, PikkuHTTPRequest, PikkuHTTPResponse, PikkuQuery, RunHTTPWiringOptions, } from './http.types.js';
8
+ export type { AssertHTTPWiringParams, CoreHTTPFunctionWiring, HTTPMethod, HTTPRouteBaseConfig, HTTPRouteContract, HTTPRouteMap, HTTPWiringsMeta, PikkuHTTP, PikkuHTTPAuthInfo, PikkuHTTPRequest, PikkuHTTPResponse, PikkuQuery, RunHTTPWiringOptions, } from './http.types.js';
@@ -1,4 +1,4 @@
1
1
  export { MCPEndpointRegistry } from './mcp-endpoint-registry.js';
2
2
  export { MCPError, wireMCPResource, wireMCPPrompt, runMCPResource, runMCPTool, runMCPPrompt, } from './mcp-runner.js';
3
- export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, } from './mcp-runner.js';
3
+ export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, mcpTargetRequiresSession, } from './mcp-runner.js';
4
4
  export type { AssertMCPResourceURIParams, CoreMCPPrompt, CoreMCPResource, MCPPromptResponse, MCPResourceMeta, MCPResourceResponse, MCPToolMeta, MCPToolResponse, MCPPromptMeta, PikkuMCP, } from './mcp.types.js';
@@ -1,3 +1,3 @@
1
1
  export { MCPEndpointRegistry } from './mcp-endpoint-registry.js';
2
2
  export { MCPError, wireMCPResource, wireMCPPrompt, runMCPResource, runMCPTool, runMCPPrompt, } from './mcp-runner.js';
3
- export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, } from './mcp-runner.js';
3
+ export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, mcpTargetRequiresSession, } from './mcp-runner.js';
@@ -32,3 +32,18 @@ export declare function runMCPPrompt(request: JsonRpcRequest, params: RunMCPEndp
32
32
  export declare const getMCPResourcesMeta: () => import("./mcp.types.js").MCPResourceMeta;
33
33
  export declare const getMCPToolsMeta: () => import("./mcp.types.js").MCPToolMeta;
34
34
  export declare const getMCPPromptsMeta: () => import("./mcp.types.js").MCPPromptMeta;
35
+ /**
36
+ * Whether a call to this MCP target would need a session to run.
37
+ *
38
+ * Read from the same declarations the runner enforces: a `pikkuFunc` always
39
+ * needs one, and a `pikkuSessionlessFunc` needs one only where it says
40
+ * `auth: true`. A transport asks this to answer an unauthenticated call with a
41
+ * `401` challenge instead of dispatching it — the status and the
42
+ * `WWW-Authenticate` header have to be chosen before the response starts, which
43
+ * is earlier than the refusal itself can be known.
44
+ *
45
+ * Unknown targets are treated as open: a name nobody registered is a
46
+ * `Method not found`, and answering it with a challenge would invite a client
47
+ * to authenticate its way towards a tool that does not exist.
48
+ */
49
+ export declare const mcpTargetRequiresSession: (type: 'tool' | 'resource' | 'prompt', name: string) => boolean;
@@ -187,3 +187,41 @@ export const getMCPToolsMeta = () => {
187
187
  export const getMCPPromptsMeta = () => {
188
188
  return pikkuState(null, 'mcp', 'promptsMeta');
189
189
  };
190
+ /**
191
+ * Whether a call to this MCP target would need a session to run.
192
+ *
193
+ * Read from the same declarations the runner enforces: a `pikkuFunc` always
194
+ * needs one, and a `pikkuSessionlessFunc` needs one only where it says
195
+ * `auth: true`. A transport asks this to answer an unauthenticated call with a
196
+ * `401` challenge instead of dispatching it — the status and the
197
+ * `WWW-Authenticate` header have to be chosen before the response starts, which
198
+ * is earlier than the refusal itself can be known.
199
+ *
200
+ * Unknown targets are treated as open: a name nobody registered is a
201
+ * `Method not found`, and answering it with a challenge would invite a client
202
+ * to authenticate its way towards a tool that does not exist.
203
+ */
204
+ export const mcpTargetRequiresSession = (type, name) => {
205
+ const meta = type === 'tool'
206
+ ? pikkuState(null, 'mcp', 'toolsMeta')[name]
207
+ : type === 'resource'
208
+ ? pikkuState(null, 'mcp', 'resourcesMeta')[name]
209
+ : pikkuState(null, 'mcp', 'promptsMeta')[name];
210
+ if (!meta) {
211
+ return false;
212
+ }
213
+ let funcName = meta.pikkuFuncId;
214
+ let packageName = null;
215
+ if (funcName.includes(':')) {
216
+ const resolved = resolveNamespace(funcName);
217
+ if (resolved) {
218
+ funcName = resolved.function;
219
+ packageName = resolved.package;
220
+ }
221
+ }
222
+ const funcMeta = pikkuState(packageName, 'function', 'meta')[funcName];
223
+ if (!funcMeta) {
224
+ return false;
225
+ }
226
+ return !funcMeta.sessionless || funcMeta.auth === true;
227
+ };
@@ -0,0 +1,50 @@
1
+ ---
2
+ type: decision
3
+ title: With scenarios.reset on, the dev seed is the fixture every assertion is written against
4
+ description: The rollback restores the database as the suite found it, so seed rows are the shared baseline — which makes an absolute date in the seed a test that expires
5
+ tags: cli, scenarios
6
+ ---
7
+
8
+ # A reset suite makes the dev seed the fixture
9
+
10
+ `scenarios.reset` takes a copy of every table when the suite starts and restores
11
+ that copy between scenarios. It is a snapshot-and-restore, not a
12
+ truncate-and-reseed: nothing replays the seed file, and nothing regenerates its
13
+ rows.
14
+
15
+ Two things follow, and both are easy to get wrong.
16
+
17
+ **The seed is the fixture.** Before a reset was available, a project usually had
18
+ a hand-rolled reset RPC that wiped and re-created the handful of rows its
19
+ scenarios touched. That RPC was the fixture and the seed was scenery. With the
20
+ rollback on, the whole seed is in scope — every booking, invoice and event a
21
+ developer put there for their own convenience is now a row some scenario can
22
+ count. Assertions written as counts against the old narrow fixture go wrong the
23
+ moment the baseline widens.
24
+
25
+ **A date literal in the seed expires.** A seed is applied once, and from then on
26
+ the restore keeps handing back the same rows. Write `'2026-03-14'` and the
27
+ fixture is correct on the day it was written and quietly wrong a month later —
28
+ the booking that was supposed to be upcoming is in the past, and the scenario
29
+ that asserts on it fails for a reason that has nothing to do with the code. Seed
30
+ dates belong relative to the moment the seed is applied:
31
+
32
+ ```sql
33
+ INSERT INTO booking (id, starts_at, ends_at) VALUES
34
+ ('b_upcoming', date('now', '+200 days'), date('now', '+207 days')),
35
+ ('b_ended', date('now', '-8 days'), date('now', '-1 days'));
36
+ ```
37
+
38
+ `date('now', ...)` in sqlite, `now() + interval '200 days'` in postgres. Pick the
39
+ offsets so each row sits unambiguously on the side of whatever boundary the
40
+ scenarios test, rather than a day away from it.
41
+
42
+ There is a third limit worth stating plainly: the baseline is the database _as
43
+ the suite starts_, so it is not a pristine seed. Run N+1 inherits whatever run N
44
+ left behind. A value that must be unique in the database — an invoice number, a
45
+ reference code — has to be scoped to the run, not hard-coded, or the second run
46
+ collides with the first.
47
+
48
+ **What this rules out:** treating the reset as "the suite starts from the seed
49
+ every time". It starts from wherever the database was, and keeps returning
50
+ there.
@@ -0,0 +1,38 @@
1
+ ---
2
+ type: decision
3
+ title: Hot reload reads the changed source, never a compiled copy of it
4
+ description: A leftover .js beside a .ts made the reloader announce a reload while re-registering the previous implementation; project TypeScript is now compiled by the reloader itself
5
+ tags: core, dev
6
+ ---
7
+
8
+ # Hot reload reads the changed source, never a compiled copy of it
9
+
10
+ The reloader watches `.ts` files and runs the file that changed. It used to
11
+ look for a compiled `.js` first — `<outDir>/dist/<rel>.js`, then the sibling
12
+ `<file>.js` — and import the source only when neither existed.
13
+
14
+ `pikku dev` compiles nothing. Every one of those `.js` files is a leftover from
15
+ an earlier `tsc`, `pikku dist` or bundler run, so reading one re-registered the
16
+ implementation the developer had just replaced and logged `Hot-reloaded: <fn>`
17
+ over the top of it. The log line reads as confirmation, so the change looks
18
+ applied and wrong rather than unapplied — which is the expensive way round
19
+ (#1721).
20
+
21
+ The lookup was a leftover of the first implementation, which imported raw file
22
+ content through a `data:` URL and genuinely could not handle TypeScript. The
23
+ module runner has transpiled with esbuild since, so the source was loadable all
24
+ along and the compiled candidates could only ever supply stale code.
25
+
26
+ Loading the source means the reloader also owns resolution. `require('./x.js')`
27
+ where only `x.ts` exists is what every relative import in a TypeScript project
28
+ looks like, and node's `require` has no `.js → .ts` rewrite. Resolution goes
29
+ through the host first — so packages bind to the instance the rest of the
30
+ process holds and `wire*` side effects mutate live state — and a resolved
31
+ TypeScript file outside `node_modules` is then compiled by the reloader rather
32
+ than handed back, because both node and Bun cache what they load and a helper
33
+ edited alongside its caller would otherwise keep answering with the copy loaded
34
+ at startup. That cache is scoped to a single reload for the same reason.
35
+
36
+ **What this rules out:** reintroducing a compiled-output lookup as a
37
+ "performance" shortcut, and letting the host load project `.ts` dependencies so
38
+ reloads inherit its module cache.
@@ -12,6 +12,7 @@ caller is entitled to assume.
12
12
  <!-- pikku:knowledge-index -->
13
13
 
14
14
  - [A non-streaming agent run registers with aiRunState on the same terms as a streaming one](a-non-streaming-agent-run-registers-with-airunstate-too.md) — Otherwise interruptAIAgent finds the run, passes the ownership check, then cannot stop it — and reports that as if the run were on another host
15
+ - [With scenarios.reset on, the dev seed is the fixture every assertion is written against](a-reset-suite-makes-the-dev-seed-the-fixture.md) — The rollback restores the database as the suite found it, so seed rows are the shared baseline — which makes an absolute date in the seed a test that expires
15
16
  - [A resumed turn is as interruptible as the first one](a-resumed-agent-turn-is-as-interruptible-as-the-first.md) — It is the same person listening to the same voice, and after an approval it is where most of the reply actually gets spoken
16
17
  - [A scenario step's prose template is offered to a virtual user unfilled](a-scenario-step-template-is-offered-unfilled.md) — A reporter fills placeholders from a run that happened; there is no run yet, and the filled form would answer the question the user is there to answer
17
18
  - [A secret that fails to decrypt fails the whole read](a-secret-that-fails-to-decrypt-fails-the-whole-read.md) — getSecrets throws naming the key and its key_version rather than omitting the row, because a silent omission surfaces as an unrelated failure much later
@@ -73,6 +74,7 @@ caller is entitled to assume.
73
74
  - [Gateway webhook challenges echo bytes not JSON](gateway-webhook-challenges-echo-bytes-not-json.md) — String verification challenges are returned raw with returnsJSON false, because platforms byte-compare the echo and JSON quoting fails the handshake
74
75
  - [Gateway wiring is a meta-wiring over HTTP and channels](gateway-wiring-is-a-meta-wiring-over-http-and-channels.md) — wireGateway writes handler implementations into the HTTP and channel state directly while the inspector compiles the corresponding meta, so runtime registration deliberately writes no meta
75
76
  - [Generated src paths in pikku meta are absolute](generated-src-paths-in-pikku-meta-are-absolute.md) — emailsMeta.src is resolved by the CLI at generation time, so reading through the project-relative helpers produces a wrong compound path
77
+ - [Hot reload reads the changed source, never a compiled copy of it](hot-reload-reads-the-changed-source-never-a-compiled-copy.md) — A leftover .js beside a .ts made the reloader announce a reload while re-registering the previous implementation; project TypeScript is now compiled by the reloader itself
76
78
  - [Hot reload writes into the function map captured at startup, not pikkuState's current one](hot-reload-writes-into-the-function-map-captured-at-startup.md) — A dev-server watcher may have swapped in a codegen-scoped map whose writes are discarded on restore, and schemas are deliberately left alone
77
79
  - [HTTP request bodies are read once and shared between consumers](http-request-bodies-are-read-once-and-shared.md) — The fetch request wrapper memoises the single-use body and builds web Requests lazily, at the cost of holding the whole body in memory
78
80
  - [HTTP route groups cascade config in a fixed, per-field order](http-route-groups-cascade-config-in-a-fixed-order.md) — basePath concatenates, tags and middleware merge outward-in, auth is overridden by the innermost group
@@ -0,0 +1,48 @@
1
+ ---
2
+ type: decision
3
+ title: An MCP refusal is decided before dispatch, not raised from the tool
4
+ description: mcpTargetRequiresSession lets the transport answer 401 with an OAuth challenge, because the MCP response streams and the status is gone by the time the runner refuses
5
+ tags: mcp, permissions, transport
6
+ ---
7
+
8
+ # An MCP refusal is decided before dispatch, not raised from the tool
9
+
10
+ An MCP client discovers how to authenticate from a `401` carrying a
11
+ `WWW-Authenticate` challenge — that header names the RFC 9728 Protected Resource
12
+ Metadata document, which names the authorization server, which is where OAuth
13
+ starts. Delivered any other way, the refusal is not a refusal: a `MissingSessionError`
14
+ flattened into a JSON-RPC result arrives as `200` with `isError: true`, which a
15
+ client reads as a tool that failed, and no discovery happens.
16
+
17
+ The obvious implementation — catch the refusal in `tools/call` and set the status
18
+ afterwards — cannot work, and not for a reason a test makes obvious. The MCP HTTP
19
+ response **streams**: the entry returns a `Response` whose body the transport is
20
+ still writing when the JSON-RPC handler runs. Instrumenting both ends shows the
21
+ entry observing its own flag as unset *before* the tool ever refuses. Status and
22
+ headers are chosen at the top of the response; by the time anything knows a
23
+ session was missing, they are already on the wire.
24
+
25
+ So the decision moves earlier, to the only two things knowable before dispatch:
26
+ which target the request body names, and whether the request presents anything to
27
+ authenticate with. `mcpTargetRequiresSession` answers the first from the same
28
+ declarations the runner enforces — a `pikkuFunc` always needs a session, a
29
+ `pikkuSessionlessFunc` needs one only where it says `auth: true` — so the
30
+ transport's answer and the runner's cannot disagree. An unregistered name is
31
+ treated as open, because a name nobody registered is a `Method not found`, and
32
+ challenging it invites a client to authenticate its way towards a tool that does
33
+ not exist.
34
+
35
+ This is what makes a single endpoint serve public and private tools together,
36
+ which the MCP spec allows and pikku's per-function `auth` flag already describes.
37
+ `tools/list` stays ungated, so a client can see the menu before it has a token.
38
+
39
+ **What this rules out:** verifying tokens in the transport. Only a request with
40
+ *no* credentials is challenged; a present-but-expired token is dispatched and its
41
+ refusal reaches the client as a tool error. Doing better means resolving the
42
+ session before dispatch, which runs the app's middleware twice per call and puts
43
+ the transport in the business of second-guessing the middleware that owns session
44
+ resolution. The cheaper half — no credentials at all — covers the case that
45
+ actually matters, because that is the state every client starts in.
46
+
47
+ Also ruled out: gating the endpoint as a whole with `requireBearerAuth`. It would
48
+ work, and it would make every public tool private, which is a different product.
@@ -0,0 +1,40 @@
1
+ ---
2
+ type: decision
3
+ title: An OAuth2 credential is read by its account row, not by its provider name
4
+ description: better-auth 1.7.5 selects an account by row id under a strict body schema, so BetterAuthCredentialService resolves the row through the internal adapter before asking for a token
5
+ tags: better-auth, credentials, oauth2
6
+ ---
7
+
8
+ # An OAuth2 credential is read by its account row, not by its provider name
9
+
10
+ A pikku OAuth2 credential is named — `user-oauth`, `company-slack` — and that
11
+ name is the better-auth `providerId` its account row carries. Reading the
12
+ credential used to mean handing better-auth the name:
13
+ `getAccessToken({ body: { providerId, userId } })`.
14
+
15
+ better-auth 1.7.5 stopped accepting that. `get-access-token` and
16
+ `unlink-account` both take a strict union selecting the account by its **own row
17
+ id**, so a provider name is not merely ignored, it is rejected — `[body] Invalid
18
+ input`, a 500 on every read of a linked credential: a tool's token, an agent's,
19
+ the console's status card. An agent that cannot resolve its credential never
20
+ makes a model call, so the failure surfaces far from its cause.
21
+
22
+ So the name is resolved to a row first, through
23
+ `context.internalAdapter.findAccounts(userId)` — the same lookup the link
24
+ callback writes the row with and `unlinkAccount` already used. That is also the
25
+ only account lookup that takes a userId at all: `listUserAccounts` resolves the
26
+ *caller's session* and throws UNAUTHORIZED server-side, which is no use for the
27
+ two revocations that have no session — a platform credential, whose owner never
28
+ signs in, and an admin acting on someone else's.
29
+
30
+ A useful consequence: an unlinked provider is now answered before better-auth is
31
+ asked, rather than by catching its `ACCOUNT_NOT_FOUND`. The catch stays, because
32
+ a failed refresh must still not read as "not connected yet" — that would show a
33
+ connect button for an account that is linked but broken.
34
+
35
+ **What this rules out:** treating `providerId` as a credential's address
36
+ anywhere a token is read or revoked. It remains the credential's *name* — what
37
+ an app declares and what the link flow writes — but the row id is what
38
+ better-auth is spoken to in. A fake of better-auth that keys tokens by provider
39
+ name will pass while the real thing rejects every call, which is how this
40
+ survived a bump with 222 green unit tests behind it.
@@ -34,6 +34,8 @@ A rule about who may do what, and which way it fails when it is unsure.
34
34
  - [Agent tool permission filtering reads the live function config, not the metadata](ai-agent-tool-filtering-reads-the-live-function-config.md) — The pikkuAuth brand survives only on live permission objects, so a metadata-driven check would silently admit every gated tool
35
35
  - [An agent approval is claimed before the tool runs](an-agent-approval-is-claimed-before-the-tool-runs.md) — resolveApproval is a compare-and-swap returning whether this caller won, because the read that precedes it is not a claim and ten concurrent approvals would otherwise mean ten refunds
36
36
  - [An empty owners constraint matches nothing](an-empty-owners-constraint-matches-nothing.md) — owners is an authorization boundary, so every storage backend must treat [] as no rows rather than no filter
37
+ - [An MCP refusal is decided before dispatch, not raised from the tool](an-mcp-refusal-is-decided-before-dispatch.md) — mcpTargetRequiresSession lets the transport answer 401 with an OAuth challenge, because the MCP response streams and the status is gone by the time the runner refuses
38
+ - [An OAuth2 credential is read by its account row, not by its provider name](an-oauth2-credential-is-read-by-its-account-row.md) — better-auth 1.7.5 selects an account by row id under a strict body schema, so BetterAuthCredentialService resolves the row through the internal adapter before asking for a token
37
39
  - [An exposed function with no gate is reported at codegen, not at boot](an-exposed-ungated-function-is-a-codegen-warning.md) — The check runs in the inspector where function meta and every wireAddon declaration are both in hand, because neither source alone can tell a gated function from an ungated one
38
40
  - [An upload is counted as it arrives, not buffered and then measured](an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md) — Reading the whole body before checking its size hands an unauthenticated caller a way to spend the server's memory
39
41
  - [The console addon's privileged functions gate themselves](console-addon-privileged-functions-gate-themselves.md) — Thread listing is owner-scoped unless the caller holds admin, and addon installation requires an admin session, rather than trusting the host to register a global permission
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.111",
3
+ "version": "0.12.113",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -216,6 +216,7 @@
216
216
  "getMCPPromptsMeta",
217
217
  "getMCPResourcesMeta",
218
218
  "getMCPToolsMeta",
219
+ "mcpTargetRequiresSession",
219
220
  "runMCPPrompt",
220
221
  "runMCPResource",
221
222
  "runMCPTool",
@@ -263,6 +264,18 @@
263
264
  "pikkuAgentScorer",
264
265
  "wireAgentScorerQueueWorkers"
265
266
  ],
267
+ "./analytics": [
268
+ "LoggerAnalyticsService",
269
+ "anonymousAnalyticsIdentity",
270
+ "composeAnalyticsIdentity",
271
+ "cookieAnalyticsIdentity",
272
+ "createInvocationAnalytics",
273
+ "defineAnalyticsEvents",
274
+ "fanOutAnalytics",
275
+ "flattenAnalyticsEvent",
276
+ "mintCookie",
277
+ "randomDigits"
278
+ ],
266
279
  "./gateway": [
267
280
  "createListenerMessageHandler",
268
281
  "resolveGatewayAdapter",
@@ -272,10 +285,12 @@
272
285
  "CLIError",
273
286
  "defineCLICommands",
274
287
  "executeCLI",
288
+ "formatCLIError",
275
289
  "generateCommandHelp",
276
290
  "parseCLIArguments",
277
291
  "pikkuCLIRender",
278
292
  "runCLICommand",
293
+ "wantsStackTrace",
279
294
  "wireCLI"
280
295
  ],
281
296
  "./cli/command-parser": [
@@ -563,17 +578,5 @@
563
578
  "pikkuDevReloader",
564
579
  "reconcileAddonRegistry",
565
580
  "reloadGeneratedMeta"
566
- ],
567
- "./analytics": [
568
- "LoggerAnalyticsService",
569
- "anonymousAnalyticsIdentity",
570
- "composeAnalyticsIdentity",
571
- "cookieAnalyticsIdentity",
572
- "createInvocationAnalytics",
573
- "defineAnalyticsEvents",
574
- "fanOutAnalytics",
575
- "flattenAnalyticsEvent",
576
- "mintCookie",
577
- "randomDigits"
578
581
  ]
579
582
  }