@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.
- package/CHANGELOG.md +51 -0
- package/dist/services/email-service.d.ts +13 -0
- package/dist/services/http-personas.d.ts +36 -6
- package/dist/services/http-personas.js +128 -9
- package/dist/services/index.d.ts +1 -1
- package/dist/services/local-email-service.js +9 -0
- package/dist/services/persona-sign-in.d.ts +0 -10
- package/dist/services/persona-sign-in.js +11 -9
- package/dist/wirings/cli/cli-runner.js +2 -11
- package/dist/wirings/cli/format-cli-error.d.ts +24 -0
- package/dist/wirings/cli/format-cli-error.js +68 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +0 -1
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +0 -4
- package/package.json +1 -1
- package/src/app-leaf-surface.test.ts +5 -0
- package/src/no-root-barrel.test.ts +9 -1
- package/src/services/email-service.test.ts +100 -0
- package/src/services/email-service.ts +14 -0
- package/src/services/http-personas-converse.test.ts +29 -20
- package/src/services/http-personas.test.ts +66 -0
- package/src/services/http-personas.ts +138 -9
- package/src/services/index.ts +1 -0
- package/src/services/local-email-service.test.ts +74 -0
- package/src/services/local-email-service.ts +9 -0
- package/src/services/persona-sign-in.test.ts +22 -28
- package/src/services/persona-sign-in.ts +11 -19
- package/src/wirings/cli/cli-runner.test.ts +104 -0
- package/src/wirings/cli/cli-runner.ts +2 -11
- package/src/wirings/cli/format-cli-error.test.ts +91 -0
- package/src/wirings/cli/format-cli-error.ts +96 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.ts +0 -5
- package/tsconfig.tsbuildinfo +1 -1
- 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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* comma-separated list. A target that answers
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* comma-separated list. A target that answers
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
198
|
+
const res = await this.sendAgent(`agent/${agentName}/stream`, {
|
|
158
199
|
message,
|
|
159
200
|
threadId,
|
|
160
201
|
resourceId,
|
|
161
202
|
});
|
|
162
|
-
return
|
|
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
|
|
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 ?? {});
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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)
|