@pikku/core 0.12.100 → 0.12.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/services/email-service.d.ts +13 -0
  3. package/dist/services/http-personas.d.ts +36 -6
  4. package/dist/services/http-personas.js +128 -9
  5. package/dist/services/index.d.ts +1 -1
  6. package/dist/services/local-email-service.js +9 -0
  7. package/dist/services/persona-sign-in.d.ts +0 -10
  8. package/dist/services/persona-sign-in.js +11 -9
  9. package/dist/wirings/cli/cli-runner.js +2 -11
  10. package/dist/wirings/cli/format-cli-error.d.ts +24 -0
  11. package/dist/wirings/cli/format-cli-error.js +68 -0
  12. package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +0 -1
  13. package/dist/wirings/virtual-user/virtual-user-scaffold.js +0 -4
  14. package/package.json +1 -1
  15. package/src/app-leaf-surface.test.ts +5 -0
  16. package/src/no-root-barrel.test.ts +9 -1
  17. package/src/services/email-service.test.ts +100 -0
  18. package/src/services/email-service.ts +14 -0
  19. package/src/services/http-personas-converse.test.ts +29 -20
  20. package/src/services/http-personas.test.ts +66 -0
  21. package/src/services/http-personas.ts +138 -9
  22. package/src/services/index.ts +1 -0
  23. package/src/services/local-email-service.test.ts +74 -0
  24. package/src/services/local-email-service.ts +9 -0
  25. package/src/services/persona-sign-in.test.ts +22 -28
  26. package/src/services/persona-sign-in.ts +11 -19
  27. package/src/wirings/cli/cli-runner.test.ts +104 -0
  28. package/src/wirings/cli/cli-runner.ts +2 -11
  29. package/src/wirings/cli/format-cli-error.test.ts +91 -0
  30. package/src/wirings/cli/format-cli-error.ts +96 -0
  31. package/src/wirings/virtual-user/virtual-user-scaffold.ts +0 -5
  32. package/tsconfig.tsbuildinfo +1 -1
  33. package/tsconfig.type-tests.json +1 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,54 @@
1
+ ## 0.12.102
2
+
3
+ ### Patch Changes
4
+
5
+ - f4e2e89: Add file attachments to `EmailService`.
6
+
7
+ `BaseSendEmailInput` gains an optional `attachments: EmailAttachment[]`, so it is
8
+ available on all three input variants — text, HTML and template. The new
9
+ `EmailAttachment` type is exported from `@pikku/core/services` alongside the
10
+ input types, and is shaped so that mapping it onto Resend, SendGrid, Nodemailer
11
+ or SES v2 is a straight field rename rather than a translation.
12
+
13
+ `content` is `Uint8Array | string`, where a string is always read as base64.
14
+ Both forms are accepted because both are what callers already hold: bytes come
15
+ out of a fetch or a file read, and base64 comes out of a database column or a
16
+ provider API. `Buffer` is deliberately absent from the type — it is a subclass
17
+ of `Uint8Array`, so Node callers can still pass one, while the type stays usable
18
+ in Cloudflare Workers, where `Buffer` does not exist.
19
+
20
+ `LocalEmailService` now logs attachment metadata — filename, content type,
21
+ content id, disposition and content length — instead of dropping the field
22
+ silently. The content itself is deliberately not logged.
23
+
24
+ The template-rendering wrapper documented in the emails skill rebuilt its
25
+ delegate payload field by field and therefore dropped `attachments`; it now
26
+ forwards them.
27
+
28
+ ## 0.12.101
29
+
30
+ ### Patch Changes
31
+
32
+ - e92e30b: Print a CLI failure as its message, not as a JS stack trace.
33
+
34
+ Every error that reached the top of `executeCLI` was logged with `console.error('Error:', error)`, which node renders as the full stack — and prefixed it a second time, so a refusal read `Error: Error: Persona 'guest' missing guest…` above ten frames of pikku internals. A `PikkuFetchError` was worse: node inspects an error's own properties, so the whole `Response` came out with it, headers and body stream included, to say `502`.
35
+
36
+ An expected failure — a `PikkuError`, or anything carrying `expected: true` — now prints its message alone, and a fetch failure prints `502 Bad Gateway from <url>` without touching the response. Anything else keeps its stack, because a `TypeError` with its frames removed is undiagnosable. `--verbose`/`-v`, or `PIKKU_DEBUG=1` where the flag cannot be typed, adds the stack back to an expected failure.
37
+
38
+ The refusals behind the examples — a persona whose roles have drifted, a sign-in the stage rejected — are raised as `PikkuError` so they are classed as deliberate.
39
+
40
+ - 781797c: Drive a persona's agent turn over the SSE route. The plain `POST /rpc/agent/:name` buffers the entire run before sending a byte, so any run longer than undici's 300s headers timeout failed with `UND_ERR_HEADERS_TIMEOUT` — which is most conversational agents.
41
+ - ccab6ed: Verify a persona's roles against the app's own `getMyScopes` RPC before better-auth's `user.role`. In an app that authorizes on scopes that column is a projection — kept in step for better-auth's own admin endpoints, and absent entirely from an app that declares no such field — so a persona holding exactly what it should was refused for "roles drifted". Configurable via `rolesRpc`; `false` reads better-auth only.
42
+ - 4d0a548: Provision the declared personas from the fabric plugin instead of the server lifecycle.
43
+
44
+ `provisionPersonas` was documented as a call an app makes from `pikkuServerLifecycle`'s `afterStart`. That hook is invoked by `pikku serve` and `pikku dev` and by nothing else — no deploy runtime calls it — so on any stage deployed to Workers or a serverless target the provisioning never ran, and every persona signed in holding no roles.
45
+
46
+ `pikkuFabric` now takes `personas`. The operator endpoint resolves the address the caller wants to act as; a miss provisions the declaration and looks again. On a stage that already holds the persona that is one query, and the pass only runs when there is genuinely something absent to create.
47
+
48
+ Sign-in no longer creates accounts of its own. `OperatorSignInOptions.createMissing` and `PIKKU_PERSONA_CREATE_MISSING` are gone, and an address no declaration claims stays a 404 however many times it is asked for. `provisionPersonas` is no longer exported — the plugin is the only caller.
49
+
50
+ `pikku persona sync <environment>` is unchanged: it still reports who an environment will provision and why anyone was skipped, and still writes nothing.
51
+
1
52
  ## 0.12.100
2
53
 
3
54
  ### Patch Changes
@@ -4,6 +4,18 @@ export interface EmailTemplateReference {
4
4
  locale?: string;
5
5
  data?: Record<string, unknown>;
6
6
  }
7
+ export interface EmailAttachment {
8
+ filename: string;
9
+ /**
10
+ * Raw bytes, or the content already base64-encoded. A `string` is always
11
+ * read as base64 — never as a plain-text body — so text attachments must be
12
+ * encoded by the caller.
13
+ */
14
+ content: Uint8Array | string;
15
+ contentType?: string;
16
+ contentId?: string;
17
+ disposition?: 'attachment' | 'inline';
18
+ }
7
19
  export interface BaseSendEmailInput {
8
20
  to: string | string[];
9
21
  from?: string;
@@ -12,6 +24,7 @@ export interface BaseSendEmailInput {
12
24
  replyTo?: string | string[];
13
25
  headers?: Record<string, string>;
14
26
  subject?: string;
27
+ attachments?: EmailAttachment[];
15
28
  }
16
29
  export interface SendTextEmailInput extends BaseSendEmailInput {
17
30
  text: string;
@@ -49,6 +49,17 @@ export interface HttpPersonasConfig {
49
49
  sessionPath?: string;
50
50
  /** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
51
51
  rpcPath?: string;
52
+ /**
53
+ * Exposed RPC that reports the CALLER's own roles, as `{ roles: string[] }`.
54
+ * Default `getMyScopes`. Pass `false` to skip it and read better-auth only.
55
+ *
56
+ * Asked before better-auth's `user.role`, because in an app that authorizes
57
+ * on scopes that column is a projection rather than the model — it exists so
58
+ * better-auth's own admin endpoints have something to read, is written by
59
+ * whatever keeps it in step, and is absent entirely from an app that declares
60
+ * no such field. A persona verified against it is verified against a copy.
61
+ */
62
+ rolesRpc?: string | false;
52
63
  /**
53
64
  * Default model a persona thinks with when `converse(...)` is called without
54
65
  * an explicit `model`. Its own turns/approvals/evaluation run in-process via
@@ -86,17 +97,36 @@ export declare class HttpPersona implements ScenarioPersona {
86
97
  /**
87
98
  * The roles the stage says this session holds.
88
99
  *
89
- * Read from better-auth's `get-session`, which is what most pikku apps are
90
- * running and where its admin plugin puts `role` on the user, as a
91
- * comma-separated list. A target that answers something else returns `null`
92
- * rather than an empty list, because "this stage does not report roles" and
93
- * "this person has none" call for opposite responses from the caller.
100
+ * {@link HttpPersonasConfig.rolesRpc} first, then better-auth's
101
+ * `get-session` where its admin plugin puts `role` on the user, as a
102
+ * comma-separated list. A target that answers neither returns `null` rather
103
+ * than an empty list, because "this stage does not report roles" and "this
104
+ * person has none" call for opposite responses from the caller.
94
105
  */
95
106
  sessionRoles(): Promise<string[] | null>;
96
- /** Start/continue the target agent's run over HTTP as this persona. */
107
+ /**
108
+ * The caller's own roles, from the app's own RPC. `null` for every answer
109
+ * that is not a role list — an app without the RPC 404s here, which is a
110
+ * reason to go on and ask better-auth, not a reason to report "none".
111
+ */
112
+ private rolesFromRpc;
113
+ private rolesFromSession;
114
+ /**
115
+ * Start/continue the target agent's run over HTTP as this persona.
116
+ *
117
+ * The SSE route, not the plain one. `POST /rpc/agent/:name` buffers the whole
118
+ * run before it sends a single byte, so a run longer than the client's
119
+ * headers timeout — 300s in undici, which is what Node and Bun both use —
120
+ * fails with `UND_ERR_HEADERS_TIMEOUT` and no way to tell it apart from a
121
+ * stage that is down. An agent that talks for several minutes, which is the
122
+ * normal case for anything conversational, cannot be driven that way at all.
123
+ * The stream sends its first event immediately and the run's length stops
124
+ * mattering.
125
+ */
97
126
  private agentRun;
98
127
  /** Answer the target agent's pending approvals over HTTP and continue. */
99
128
  private agentApprove;
129
+ private sendAgent;
100
130
  private postAgent;
101
131
  private postRpc;
102
132
  /** Drop the session, so the next call signs in again before it goes out. */
@@ -108,16 +108,46 @@ export class HttpPersona {
108
108
  /**
109
109
  * The roles the stage says this session holds.
110
110
  *
111
- * Read from better-auth's `get-session`, which is what most pikku apps are
112
- * running and where its admin plugin puts `role` on the user, as a
113
- * comma-separated list. A target that answers something else returns `null`
114
- * rather than an empty list, because "this stage does not report roles" and
115
- * "this person has none" call for opposite responses from the caller.
111
+ * {@link HttpPersonasConfig.rolesRpc} first, then better-auth's
112
+ * `get-session` where its admin plugin puts `role` on the user, as a
113
+ * comma-separated list. A target that answers neither returns `null` rather
114
+ * than an empty list, because "this stage does not report roles" and "this
115
+ * person has none" call for opposite responses from the caller.
116
116
  */
117
117
  async sessionRoles() {
118
118
  if (!this.signedIn) {
119
119
  await this.login();
120
120
  }
121
+ const fromRpc = await this.rolesFromRpc();
122
+ if (fromRpc)
123
+ return fromRpc;
124
+ return await this.rolesFromSession();
125
+ }
126
+ /**
127
+ * The caller's own roles, from the app's own RPC. `null` for every answer
128
+ * that is not a role list — an app without the RPC 404s here, which is a
129
+ * reason to go on and ask better-auth, not a reason to report "none".
130
+ */
131
+ async rolesFromRpc() {
132
+ const rpcName = this.config.rolesRpc ?? 'getMyScopes';
133
+ if (rpcName === false)
134
+ return null;
135
+ let res;
136
+ try {
137
+ res = await this.postRpc(rpcName, {});
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ if (!res.ok)
143
+ return null;
144
+ const { body } = await readScenarioHttpResponse(res);
145
+ const roles = body?.roles ?? body?.data?.roles;
146
+ if (!Array.isArray(roles))
147
+ return null;
148
+ return roles.filter((name) => typeof name === 'string');
149
+ }
150
+ async rolesFromSession() {
121
151
  const mount = authMount(this.config.operator?.signInPath ?? this.config.signInPath);
122
152
  const sessionPath = this.config.sessionPath ?? `${mount ?? '/auth'}/get-session`;
123
153
  const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`, {
@@ -152,14 +182,25 @@ export class HttpPersona {
152
182
  // stage reporting "none", not a stage that cannot report.
153
183
  return user ? [] : null;
154
184
  }
155
- /** Start/continue the target agent's run over HTTP as this persona. */
185
+ /**
186
+ * Start/continue the target agent's run over HTTP as this persona.
187
+ *
188
+ * The SSE route, not the plain one. `POST /rpc/agent/:name` buffers the whole
189
+ * run before it sends a single byte, so a run longer than the client's
190
+ * headers timeout — 300s in undici, which is what Node and Bun both use —
191
+ * fails with `UND_ERR_HEADERS_TIMEOUT` and no way to tell it apart from a
192
+ * stage that is down. An agent that talks for several minutes, which is the
193
+ * normal case for anything conversational, cannot be driven that way at all.
194
+ * The stream sends its first event immediately and the run's length stops
195
+ * mattering.
196
+ */
156
197
  async agentRun(agentName, message, threadId, resourceId) {
157
- const raw = await this.postAgent(`agent/${agentName}`, {
198
+ const res = await this.sendAgent(`agent/${agentName}/stream`, {
158
199
  message,
159
200
  threadId,
160
201
  resourceId,
161
202
  });
162
- return normalizeAgentReply(raw);
203
+ return await collectAgentStream(res);
163
204
  }
164
205
  /** Answer the target agent's pending approvals over HTTP and continue. */
165
206
  async agentApprove(agentName, runId, decisions) {
@@ -170,7 +211,7 @@ export class HttpPersona {
170
211
  return normalizeAgentReply(raw);
171
212
  }
172
213
  // knowledge: decisions/internals/scenario-agent-calls-sign-in-on-401-only.md
173
- async postAgent(subPath, body) {
214
+ async sendAgent(subPath, body) {
174
215
  const rpcPath = this.config.rpcPath ?? '/rpc';
175
216
  const url = `${this.config.apiUrl}${rpcPath}/${subPath}`;
176
217
  const send = () => this.jar.fetch(url, {
@@ -191,6 +232,10 @@ export class HttpPersona {
191
232
  const text = (await res.text().catch(() => '')).slice(0, 300);
192
233
  throw new Error(`[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`);
193
234
  }
235
+ return res;
236
+ }
237
+ async postAgent(subPath, body) {
238
+ const res = await this.sendAgent(subPath, body);
194
239
  if (res.status === 204)
195
240
  return undefined;
196
241
  const text = await res.text();
@@ -218,6 +263,80 @@ export class HttpPersona {
218
263
  this.signedIn = true;
219
264
  }
220
265
  }
266
+ /**
267
+ * Reduce the agent's SSE run into the same reply shape the plain route returns.
268
+ *
269
+ * `RUN_ERROR` is raised rather than returned: the plain route answers a failed
270
+ * run with a non-2xx, and a scenario that read an error as an empty transcript
271
+ * would score the agent on silence it never produced.
272
+ */
273
+ async function collectAgentStream(res) {
274
+ const body = res.body;
275
+ if (!body) {
276
+ throw new Error('[scenario] the agent stream carried no body');
277
+ }
278
+ const decoder = new TextDecoder();
279
+ const reader = body.getReader();
280
+ let buffer = '';
281
+ let text = '';
282
+ let runId = '';
283
+ const pendingApprovals = [];
284
+ const consume = (line) => {
285
+ if (!line.startsWith('data:'))
286
+ return;
287
+ const payload = line.slice(5).trim();
288
+ if (!payload)
289
+ return;
290
+ let event;
291
+ try {
292
+ event = JSON.parse(payload);
293
+ }
294
+ catch {
295
+ return;
296
+ }
297
+ if (typeof event.runId === 'string' && event.runId)
298
+ runId = event.runId;
299
+ if (event.type === 'TEXT_MESSAGE_CONTENT' &&
300
+ typeof event.delta === 'string') {
301
+ text += event.delta;
302
+ }
303
+ else if (event.type === 'approval-request') {
304
+ pendingApprovals.push({
305
+ toolCallId: String(event.toolCallId),
306
+ toolName: String(event.toolName),
307
+ args: event.args,
308
+ reason: typeof event.reason === 'string' ? event.reason : undefined,
309
+ });
310
+ }
311
+ else if (event.type === 'RUN_ERROR' || event.type === 'error') {
312
+ const message = event.message ?? event.errorText ?? 'the agent run failed';
313
+ throw new Error(`[scenario] agent run failed: ${String(message)}`);
314
+ }
315
+ };
316
+ try {
317
+ for (;;) {
318
+ const { done, value } = await reader.read();
319
+ if (done)
320
+ break;
321
+ buffer += decoder.decode(value, { stream: true });
322
+ const lines = buffer.split('\n');
323
+ buffer = lines.pop() ?? '';
324
+ for (const line of lines)
325
+ consume(line);
326
+ }
327
+ if (buffer)
328
+ consume(buffer);
329
+ }
330
+ finally {
331
+ await reader.cancel().catch(() => { });
332
+ }
333
+ return {
334
+ text,
335
+ runId,
336
+ status: pendingApprovals.length > 0 ? 'suspended' : 'completed',
337
+ pendingApprovals: pendingApprovals.length > 0 ? pendingApprovals : undefined,
338
+ };
339
+ }
221
340
  /** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
222
341
  function normalizeAgentReply(raw) {
223
342
  const r = (raw ?? {});
@@ -20,7 +20,7 @@ export { FileScenarioRunStore, scenarioArtifactContentType, scenarioRunSummary,
20
20
  export type { ContentService, SignContentKeyArgs, SignURLArgs, GetUploadURLArgs, UploadURLResult, BucketKeyArgs, WriteFileArgs, CopyFileArgs, } from './content-service.js';
21
21
  export type { ScenarioPersona, ResolvedPersona, ScenarioPersonas, } from './personas-service.js';
22
22
  export type { JWTService } from './jwt-service.js';
23
- export type { EmailService, SendEmailInput, SendEmailResult, SendHTMLEmailInput, SendTemplateEmailInput, SendTextEmailInput, } from './email-service.js';
23
+ export type { EmailAttachment, EmailService, SendEmailInput, SendEmailResult, SendHTMLEmailInput, SendTemplateEmailInput, SendTextEmailInput, } from './email-service.js';
24
24
  export { renderEmail, type EmailAssets, type EmailTemplateAssets, type EmailTemplateHashes, type RenderEmailRequest, type RenderedEmailResult, } from './email-template.js';
25
25
  export { DEFAULT_WEBHOOK_RETRIES, PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, WebhookService, type SendWebhookInput, type SendWebhookResult, type WebhookAttemptResult, type WebhookDeliveryRecord, type WebhookDeliveryWithAttempts, type WebhookJobData, type WebhookServiceConfig, } from './webhook-service.js';
26
26
  export { ACTOR_ROOT_SECRET_MIN_LENGTH, ACTOR_SECRET_INFO, ACTOR_SECRET_NAME, actorSecretSubject, deriveActorSecret, verifyActorSecret, } from './persona-actor-secret.js';
@@ -20,6 +20,15 @@ export class LocalEmailService {
20
20
  if ('text' in input && typeof input.text === 'string') {
21
21
  payload.textLength = input.text.length;
22
22
  }
23
+ if (input.attachments?.length) {
24
+ payload.attachments = input.attachments.map((attachment) => ({
25
+ filename: attachment.filename,
26
+ contentType: attachment.contentType ?? null,
27
+ contentId: attachment.contentId ?? null,
28
+ disposition: attachment.disposition ?? 'attachment',
29
+ contentLength: attachment.content.length,
30
+ }));
31
+ }
23
32
  console.info(JSON.stringify(payload));
24
33
  return {};
25
34
  }
@@ -71,16 +71,6 @@ export interface OperatorSignInOptions {
71
71
  * the function: tokens expire, and a long run re-logs-in after a 401.
72
72
  */
73
73
  token: string | (() => string | Promise<string>);
74
- /**
75
- * Create the persona's user row when the target has no account for that
76
- * address.
77
- *
78
- * Off by default, which is the whole point of the deployed path: a persona is
79
- * meant to be a real account somebody provisioned, and a test run that
80
- * silently writes users into a live database is a side effect nobody asked
81
- * for. Turn it on for throwaway stages.
82
- */
83
- createMissing?: boolean;
84
74
  /** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
85
75
  signInPath?: string;
86
76
  }
@@ -1,4 +1,5 @@
1
1
  import { deriveActorSecret } from './persona-actor-secret.js';
2
+ import { PikkuError } from '../errors/error-handler.js';
2
3
  /**
3
4
  * The header `resolveImpersonatedSession` reads the target user id from.
4
5
  *
@@ -7,9 +8,15 @@ import { deriveActorSecret } from './persona-actor-secret.js';
7
8
  * it back. The two agree by protocol, the way an HTTP header always does.
8
9
  */
9
10
  export const IMPERSONATE_USER_ID_HEADER = 'x-pikku-impersonate-user-id';
11
+ /**
12
+ * A sign-in the target refused. `PikkuError`, not `Error`, so the CLI prints
13
+ * this message alone: an expired token or a persona the stage has never seen is
14
+ * something to go and fix, and a stack trace through the fetch internals only
15
+ * buries the status and the body that say which one it is.
16
+ */
10
17
  const failed = async (what, personaId, res) => {
11
18
  const body = (await res.text().catch(() => '')).slice(0, 300);
12
- return new Error(`[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`);
19
+ return new PikkuError(`[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`);
13
20
  };
14
21
  /**
15
22
  * Sign a persona in through the Better Auth actor plugin — the local-development
@@ -93,12 +100,7 @@ export const establishOperatorSession = async (fetchImpl, apiUrl, persona, optio
93
100
  headers: { 'content-type': 'application/json', ...extraHeaders },
94
101
  body: JSON.stringify({
95
102
  token,
96
- actAs: {
97
- email: persona.email,
98
- name: persona.name,
99
- create: options.createMissing ?? false,
100
- ...(persona.roles[0] ? { role: persona.roles[0] } : {}),
101
- },
103
+ actAs: { email: persona.email },
102
104
  }),
103
105
  });
104
106
  if (!res.ok) {
@@ -106,12 +108,12 @@ export const establishOperatorSession = async (fetchImpl, apiUrl, persona, optio
106
108
  }
107
109
  const setCookies = res.headers.getSetCookie?.() ?? [];
108
110
  if (setCookies.length === 0) {
109
- throw new Error(`[scenario] operator sign-in for '${persona.id}' returned no session cookie`);
111
+ throw new PikkuError(`[scenario] operator sign-in for '${persona.id}' returned no session cookie`);
110
112
  }
111
113
  const body = (await res.json().catch(() => null));
112
114
  const userId = body?.actAs?.userId;
113
115
  if (!userId) {
114
- throw new Error(`[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
116
+ throw new PikkuError(`[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
115
117
  'the target is running a @pikku/better-auth too old to resolve one');
116
118
  }
117
119
  return { setCookies, userId: String(userId) };
@@ -1,11 +1,11 @@
1
1
  import { NotFoundError } from '../../errors/errors.js';
2
- import { isExpectedError } from '../../errors/error-handler.js';
3
2
  import { addFunction, runPikkuFunc } from '../../function/function-runner.js';
4
3
  import { pikkuState } from '../../pikku-state.js';
5
4
  import { unsupportedChannelRemote } from '../channel/channel-rpc.types.js';
6
5
  import { PikkuSessionService, createMiddlewareSessionWireProps, } from '../../services/user-session-service.js';
7
6
  import { LocalVariablesService } from '../../services/local-variables.js';
8
7
  import { generateCommandHelp, parseCLIArguments } from './command-parser.js';
8
+ import { formatCLIError, wantsStackTrace } from './format-cli-error.js';
9
9
  /** The caller is expected to catch this and call `process.exit(exitCode)`. */
10
10
  export class CLIError extends Error {
11
11
  exitCode;
@@ -323,16 +323,7 @@ export async function executeCLI({ programName, args, createConfig, createSingle
323
323
  if (error instanceof CLIError) {
324
324
  throw error;
325
325
  }
326
- // An expected PikkuError's message is written to be the whole output.
327
- if (isExpectedError(error)) {
328
- console.error(error.message);
329
- }
330
- else {
331
- console.error('Error:', error);
332
- }
333
- if (args.includes('--verbose') || args.includes('-v')) {
334
- console.error('Stack trace:', error.stack);
335
- }
326
+ console.error(formatCLIError(error, { verbose: wantsStackTrace(args) }));
336
327
  throw new CLIError(error.message || String(error), 1);
337
328
  }
338
329
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The user asked to see the machinery. `--verbose`/`-v` is the flag the CLI
3
+ * already documents; `PIKKU_DEBUG` is for the times the flag cannot be typed —
4
+ * a command that does not declare the option, or a CLI run from a script.
5
+ */
6
+ export declare const wantsStackTrace: (args: string[], env?: Record<string, string | undefined>) => boolean;
7
+ /**
8
+ * What the CLI prints when a command throws.
9
+ *
10
+ * A stack trace is an answer to "which line of pikku broke", and almost every
11
+ * failure a user actually hits is not that question: a missing role, an expired
12
+ * token, a gateway that is down. Those errors are written to be read, so the
13
+ * message alone is the whole output — an expected error is one deliberately
14
+ * raised as `PikkuError` (or carrying `expected: true`), and everything else
15
+ * keeps its stack, because an unexpected `TypeError` with its frames removed is
16
+ * a bug nobody can diagnose.
17
+ *
18
+ * A stack already begins with `Name: message`, so it is returned as-is: the
19
+ * `console.error('Error:', error)` this replaced produced the doubled
20
+ * `Error: Error: …` prefix that made even real traces look broken.
21
+ */
22
+ export declare const formatCLIError: (error: unknown, { verbose }?: {
23
+ verbose?: boolean;
24
+ }) => string;
@@ -0,0 +1,68 @@
1
+ import { isExpectedError } from '../../errors/error-handler.js';
2
+ const isFetchFailure = (error) => {
3
+ const candidate = error;
4
+ return (typeof candidate?.status === 'number' &&
5
+ typeof candidate?.statusText === 'string' &&
6
+ typeof candidate?.response === 'object' &&
7
+ candidate.response !== null);
8
+ };
9
+ /**
10
+ * The user asked to see the machinery. `--verbose`/`-v` is the flag the CLI
11
+ * already documents; `PIKKU_DEBUG` is for the times the flag cannot be typed —
12
+ * a command that does not declare the option, or a CLI run from a script.
13
+ */
14
+ export const wantsStackTrace = (args, env = process.env) => args.includes('--verbose') ||
15
+ args.includes('-v') ||
16
+ (env.PIKKU_DEBUG !== undefined &&
17
+ env.PIKKU_DEBUG !== '' &&
18
+ env.PIKKU_DEBUG !== '0');
19
+ /**
20
+ * What the CLI prints when a command throws.
21
+ *
22
+ * A stack trace is an answer to "which line of pikku broke", and almost every
23
+ * failure a user actually hits is not that question: a missing role, an expired
24
+ * token, a gateway that is down. Those errors are written to be read, so the
25
+ * message alone is the whole output — an expected error is one deliberately
26
+ * raised as `PikkuError` (or carrying `expected: true`), and everything else
27
+ * keeps its stack, because an unexpected `TypeError` with its frames removed is
28
+ * a bug nobody can diagnose.
29
+ *
30
+ * A stack already begins with `Name: message`, so it is returned as-is: the
31
+ * `console.error('Error:', error)` this replaced produced the doubled
32
+ * `Error: Error: …` prefix that made even real traces look broken.
33
+ */
34
+ export const formatCLIError = (error, { verbose = false } = {}) => {
35
+ if (isFetchFailure(error)) {
36
+ return formatFetchFailure(error, verbose);
37
+ }
38
+ const stack = error?.stack;
39
+ const message = error?.message;
40
+ if (!isExpectedError(error)) {
41
+ return typeof stack === 'string' && stack ? stack : String(error);
42
+ }
43
+ const text = typeof message === 'string' && message ? message : String(error);
44
+ return verbose && typeof stack === 'string' && stack
45
+ ? `${text}\n${stack}`
46
+ : text;
47
+ };
48
+ /**
49
+ * A failed HTTP call, as the line the user needs: which status, from which URL.
50
+ *
51
+ * Never the `Response` itself. Node inspects an error's own properties when it
52
+ * prints one, so a thrown fetch error used to dump the headers, the body stream
53
+ * and the redirect flags — pages of output whose only real content was the
54
+ * status code.
55
+ */
56
+ const formatFetchFailure = (error, verbose) => {
57
+ const url = error.response?.url;
58
+ const where = url ? ` from ${url}` : '';
59
+ const summary = `${error.status} ${error.statusText}${where}`;
60
+ const message = error.message;
61
+ const text = message && message !== error.statusText
62
+ ? `${message}\n ${summary}`
63
+ : summary;
64
+ const stack = error.stack;
65
+ return verbose && typeof stack === 'string' && stack
66
+ ? `${text}\n${stack}`
67
+ : text;
68
+ };
@@ -57,7 +57,6 @@ export declare const VIRTUAL_USER_VARIABLES: {
57
57
  * fallback for a run nobody handed a token to, which is what a schedule is.
58
58
  */
59
59
  readonly operatorToken: "FABRIC_OPERATOR_TOKEN";
60
- readonly createMissing: "PIKKU_PERSONA_CREATE_MISSING";
61
60
  };
62
61
  /**
63
62
  * Which door under the auth mount, given the credential in hand.
@@ -34,7 +34,6 @@ export const VIRTUAL_USER_VARIABLES = {
34
34
  * fallback for a run nobody handed a token to, which is what a schedule is.
35
35
  */
36
36
  operatorToken: 'FABRIC_OPERATOR_TOKEN',
37
- createMissing: 'PIKKU_PERSONA_CREATE_MISSING',
38
37
  };
39
38
  /**
40
39
  * Which door under the auth mount, given the credential in hand.
@@ -330,8 +329,6 @@ export const executeVirtualUserRun = async ({ runStore, metaService, agentRunner
330
329
  throw new Error(`Neither an operator token nor ${VIRTUAL_USER_VARIABLES.secret} is available — there is nobody for the virtual user to be. ` +
331
330
  `Hand a Fabric operator token in with the run against a deployed stage, or export ${VIRTUAL_USER_VARIABLES.secret} against a local \`pikku dev\` target.`);
332
331
  }
333
- const createMissing = String(await variables.get(VIRTUAL_USER_VARIABLES.createMissing)) ===
334
- 'true';
335
332
  const model = await variables.get(VIRTUAL_USER_VARIABLES.model);
336
333
  if (!model) {
337
334
  throw new Error(`${VIRTUAL_USER_VARIABLES.model} is not set — no model to think with.`);
@@ -362,7 +359,6 @@ export const executeVirtualUserRun = async ({ runStore, metaService, agentRunner
362
359
  ? {
363
360
  operator: {
364
361
  token,
365
- createMissing,
366
362
  signInPath: signInPathFor(configuredSignInPath, 'fabric'),
367
363
  },
368
364
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.100",
3
+ "version": "0.12.102",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -39,6 +39,11 @@ const skipped = new Set([
39
39
  'build',
40
40
  '.git',
41
41
  'coverage',
42
+ // Agent scratch worktrees are whole Pikku projects generated by whatever CLI
43
+ // version happened to scaffold them, so discovery finds them and reports a
44
+ // barrel this repo does not own. They are gitignored and belong to no
45
+ // workspace, which is what makes them out of scope rather than merely noisy.
46
+ '.claude',
42
47
  ])
43
48
 
44
49
  const findProjects = (dir: string, out: string[] = []): string[] => {
@@ -22,11 +22,19 @@ const skipDirectory = new Set([
22
22
  '.git',
23
23
  'build',
24
24
  'coverage',
25
+ // Agent scratch worktrees are whole Pikku projects scaffolded by whatever CLI
26
+ // version happened to write them, so they carry specifiers this repo does not
27
+ // own. They are gitignored and belong to no workspace, which is what puts them
28
+ // out of scope rather than merely making them noisy.
29
+ '.claude',
25
30
  ])
26
31
 
32
+ /** A verifier's scratch project, left behind by a run that did not clean up. */
33
+ const isScratchProject = (entry: string) => entry.startsWith('.tmp-')
34
+
27
35
  const walk = (dir: string, out: string[] = []): string[] => {
28
36
  for (const entry of readdirSync(dir)) {
29
- if (skipDirectory.has(entry)) continue
37
+ if (skipDirectory.has(entry) || isScratchProject(entry)) continue
30
38
  const path = join(dir, entry)
31
39
  if (statSync(path).isDirectory()) walk(path, out)
32
40
  else if (path.endsWith('.ts') || path.endsWith('.tsx')) out.push(path)