@pikku/core 0.12.100 → 0.12.101
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 +24 -0
- package/dist/services/http-personas.d.ts +36 -6
- package/dist/services/http-personas.js +128 -9
- 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/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/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
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
ConverseOptions,
|
|
11
11
|
ActorFlowVerdict,
|
|
12
12
|
TargetAgentReply,
|
|
13
|
+
TargetPendingApproval,
|
|
13
14
|
} from '../wirings/actor-flow/actor-flow.types.js'
|
|
14
15
|
import { runConversation } from '../wirings/actor-flow/run-conversation.js'
|
|
15
16
|
import {
|
|
@@ -75,6 +76,17 @@ export interface HttpPersonasConfig {
|
|
|
75
76
|
sessionPath?: string
|
|
76
77
|
/** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
|
|
77
78
|
rpcPath?: string
|
|
79
|
+
/**
|
|
80
|
+
* Exposed RPC that reports the CALLER's own roles, as `{ roles: string[] }`.
|
|
81
|
+
* Default `getMyScopes`. Pass `false` to skip it and read better-auth only.
|
|
82
|
+
*
|
|
83
|
+
* Asked before better-auth's `user.role`, because in an app that authorizes
|
|
84
|
+
* on scopes that column is a projection rather than the model — it exists so
|
|
85
|
+
* better-auth's own admin endpoints have something to read, is written by
|
|
86
|
+
* whatever keeps it in step, and is absent entirely from an app that declares
|
|
87
|
+
* no such field. A persona verified against it is verified against a copy.
|
|
88
|
+
*/
|
|
89
|
+
rolesRpc?: string | false
|
|
78
90
|
/**
|
|
79
91
|
* Default model a persona thinks with when `converse(...)` is called without
|
|
80
92
|
* an explicit `model`. Its own turns/approvals/evaluation run in-process via
|
|
@@ -207,16 +219,46 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
207
219
|
/**
|
|
208
220
|
* The roles the stage says this session holds.
|
|
209
221
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* comma-separated list. A target that answers
|
|
213
|
-
*
|
|
214
|
-
*
|
|
222
|
+
* {@link HttpPersonasConfig.rolesRpc} first, then better-auth's
|
|
223
|
+
* `get-session` — where its admin plugin puts `role` on the user, as a
|
|
224
|
+
* comma-separated list. A target that answers neither returns `null` rather
|
|
225
|
+
* than an empty list, because "this stage does not report roles" and "this
|
|
226
|
+
* person has none" call for opposite responses from the caller.
|
|
215
227
|
*/
|
|
216
228
|
async sessionRoles(): Promise<string[] | null> {
|
|
217
229
|
if (!this.signedIn) {
|
|
218
230
|
await this.login()
|
|
219
231
|
}
|
|
232
|
+
const fromRpc = await this.rolesFromRpc()
|
|
233
|
+
if (fromRpc) return fromRpc
|
|
234
|
+
return await this.rolesFromSession()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The caller's own roles, from the app's own RPC. `null` for every answer
|
|
239
|
+
* that is not a role list — an app without the RPC 404s here, which is a
|
|
240
|
+
* reason to go on and ask better-auth, not a reason to report "none".
|
|
241
|
+
*/
|
|
242
|
+
private async rolesFromRpc(): Promise<string[] | null> {
|
|
243
|
+
const rpcName = this.config.rolesRpc ?? 'getMyScopes'
|
|
244
|
+
if (rpcName === false) return null
|
|
245
|
+
let res: Response
|
|
246
|
+
try {
|
|
247
|
+
res = await this.postRpc(rpcName, {})
|
|
248
|
+
} catch {
|
|
249
|
+
return null
|
|
250
|
+
}
|
|
251
|
+
if (!res.ok) return null
|
|
252
|
+
const { body } = await readScenarioHttpResponse<{
|
|
253
|
+
roles?: unknown
|
|
254
|
+
data?: { roles?: unknown }
|
|
255
|
+
}>(res)
|
|
256
|
+
const roles = body?.roles ?? body?.data?.roles
|
|
257
|
+
if (!Array.isArray(roles)) return null
|
|
258
|
+
return roles.filter((name): name is string => typeof name === 'string')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async rolesFromSession(): Promise<string[] | null> {
|
|
220
262
|
const mount = authMount(
|
|
221
263
|
this.config.operator?.signInPath ?? this.config.signInPath
|
|
222
264
|
)
|
|
@@ -254,19 +296,30 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
254
296
|
return user ? [] : null
|
|
255
297
|
}
|
|
256
298
|
|
|
257
|
-
/**
|
|
299
|
+
/**
|
|
300
|
+
* Start/continue the target agent's run over HTTP as this persona.
|
|
301
|
+
*
|
|
302
|
+
* The SSE route, not the plain one. `POST /rpc/agent/:name` buffers the whole
|
|
303
|
+
* run before it sends a single byte, so a run longer than the client's
|
|
304
|
+
* headers timeout — 300s in undici, which is what Node and Bun both use —
|
|
305
|
+
* fails with `UND_ERR_HEADERS_TIMEOUT` and no way to tell it apart from a
|
|
306
|
+
* stage that is down. An agent that talks for several minutes, which is the
|
|
307
|
+
* normal case for anything conversational, cannot be driven that way at all.
|
|
308
|
+
* The stream sends its first event immediately and the run's length stops
|
|
309
|
+
* mattering.
|
|
310
|
+
*/
|
|
258
311
|
private async agentRun(
|
|
259
312
|
agentName: string,
|
|
260
313
|
message: string,
|
|
261
314
|
threadId: string,
|
|
262
315
|
resourceId: string
|
|
263
316
|
): Promise<TargetAgentReply> {
|
|
264
|
-
const
|
|
317
|
+
const res = await this.sendAgent(`agent/${agentName}/stream`, {
|
|
265
318
|
message,
|
|
266
319
|
threadId,
|
|
267
320
|
resourceId,
|
|
268
321
|
})
|
|
269
|
-
return
|
|
322
|
+
return await collectAgentStream(res)
|
|
270
323
|
}
|
|
271
324
|
|
|
272
325
|
/** Answer the target agent's pending approvals over HTTP and continue. */
|
|
@@ -283,7 +336,7 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
283
336
|
}
|
|
284
337
|
|
|
285
338
|
// knowledge: decisions/internals/scenario-agent-calls-sign-in-on-401-only.md
|
|
286
|
-
private async
|
|
339
|
+
private async sendAgent(subPath: string, body: unknown): Promise<Response> {
|
|
287
340
|
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
288
341
|
const url = `${this.config.apiUrl}${rpcPath}/${subPath}`
|
|
289
342
|
const send = () =>
|
|
@@ -308,6 +361,11 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
308
361
|
`[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`
|
|
309
362
|
)
|
|
310
363
|
}
|
|
364
|
+
return res
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private async postAgent(subPath: string, body: unknown): Promise<unknown> {
|
|
368
|
+
const res = await this.sendAgent(subPath, body)
|
|
311
369
|
if (res.status === 204) return undefined
|
|
312
370
|
const text = await res.text()
|
|
313
371
|
return text ? JSON.parse(text) : undefined
|
|
@@ -342,6 +400,77 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
342
400
|
}
|
|
343
401
|
}
|
|
344
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Reduce the agent's SSE run into the same reply shape the plain route returns.
|
|
405
|
+
*
|
|
406
|
+
* `RUN_ERROR` is raised rather than returned: the plain route answers a failed
|
|
407
|
+
* run with a non-2xx, and a scenario that read an error as an empty transcript
|
|
408
|
+
* would score the agent on silence it never produced.
|
|
409
|
+
*/
|
|
410
|
+
async function collectAgentStream(res: Response): Promise<TargetAgentReply> {
|
|
411
|
+
const body = res.body
|
|
412
|
+
if (!body) {
|
|
413
|
+
throw new Error('[scenario] the agent stream carried no body')
|
|
414
|
+
}
|
|
415
|
+
const decoder = new TextDecoder()
|
|
416
|
+
const reader = body.getReader()
|
|
417
|
+
let buffer = ''
|
|
418
|
+
let text = ''
|
|
419
|
+
let runId = ''
|
|
420
|
+
const pendingApprovals: TargetPendingApproval[] = []
|
|
421
|
+
|
|
422
|
+
const consume = (line: string) => {
|
|
423
|
+
if (!line.startsWith('data:')) return
|
|
424
|
+
const payload = line.slice(5).trim()
|
|
425
|
+
if (!payload) return
|
|
426
|
+
let event: Record<string, unknown>
|
|
427
|
+
try {
|
|
428
|
+
event = JSON.parse(payload)
|
|
429
|
+
} catch {
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
if (typeof event.runId === 'string' && event.runId) runId = event.runId
|
|
433
|
+
if (
|
|
434
|
+
event.type === 'TEXT_MESSAGE_CONTENT' &&
|
|
435
|
+
typeof event.delta === 'string'
|
|
436
|
+
) {
|
|
437
|
+
text += event.delta
|
|
438
|
+
} else if (event.type === 'approval-request') {
|
|
439
|
+
pendingApprovals.push({
|
|
440
|
+
toolCallId: String(event.toolCallId),
|
|
441
|
+
toolName: String(event.toolName),
|
|
442
|
+
args: event.args,
|
|
443
|
+
reason: typeof event.reason === 'string' ? event.reason : undefined,
|
|
444
|
+
})
|
|
445
|
+
} else if (event.type === 'RUN_ERROR' || event.type === 'error') {
|
|
446
|
+
const message = event.message ?? event.errorText ?? 'the agent run failed'
|
|
447
|
+
throw new Error(`[scenario] agent run failed: ${String(message)}`)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
for (;;) {
|
|
453
|
+
const { done, value } = await reader.read()
|
|
454
|
+
if (done) break
|
|
455
|
+
buffer += decoder.decode(value, { stream: true })
|
|
456
|
+
const lines = buffer.split('\n')
|
|
457
|
+
buffer = lines.pop() ?? ''
|
|
458
|
+
for (const line of lines) consume(line)
|
|
459
|
+
}
|
|
460
|
+
if (buffer) consume(buffer)
|
|
461
|
+
} finally {
|
|
462
|
+
await reader.cancel().catch(() => {})
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
text,
|
|
467
|
+
runId,
|
|
468
|
+
status: pendingApprovals.length > 0 ? 'suspended' : 'completed',
|
|
469
|
+
pendingApprovals:
|
|
470
|
+
pendingApprovals.length > 0 ? pendingApprovals : undefined,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
345
474
|
/** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
|
|
346
475
|
function normalizeAgentReply(raw: unknown): TargetAgentReply {
|
|
347
476
|
const r = (raw ?? {}) as Record<string, unknown>
|
|
@@ -14,7 +14,7 @@ const OPERATOR_TOKEN = 'operator.jwt.token'
|
|
|
14
14
|
*/
|
|
15
15
|
const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
16
16
|
const users = [...seeded]
|
|
17
|
-
|
|
17
|
+
const sentBodies: unknown[] = []
|
|
18
18
|
const server: Server = createServer((req, res) => {
|
|
19
19
|
const chunks: Buffer[] = []
|
|
20
20
|
req.on('data', (c) => chunks.push(c))
|
|
@@ -31,21 +31,15 @@ const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
|
31
31
|
}
|
|
32
32
|
let actAs: { userId: string } | undefined
|
|
33
33
|
if (body.actAs) {
|
|
34
|
-
|
|
34
|
+
sentBodies.push(body.actAs)
|
|
35
|
+
const user = users.find((u) => u.email === body.actAs.email)
|
|
35
36
|
if (!user) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
})
|
|
43
|
-
)
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
created++
|
|
47
|
-
user = { id: `made-${created}`, email: body.actAs.email }
|
|
48
|
-
users.push(user)
|
|
37
|
+
res.writeHead(404).end(
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
message: `No account on this stage for ${body.actAs.email}`,
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
return
|
|
49
43
|
}
|
|
50
44
|
actAs = { userId: user.id }
|
|
51
45
|
}
|
|
@@ -74,8 +68,8 @@ const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
|
74
68
|
return {
|
|
75
69
|
apiUrl: `http://127.0.0.1:${port}/api`,
|
|
76
70
|
server,
|
|
77
|
-
get
|
|
78
|
-
return
|
|
71
|
+
get sentBodies() {
|
|
72
|
+
return sentBodies
|
|
79
73
|
},
|
|
80
74
|
}
|
|
81
75
|
}
|
|
@@ -112,7 +106,6 @@ describe('operator persona sign-in', () => {
|
|
|
112
106
|
}
|
|
113
107
|
assert.equal(result.actingAs, 'user-7')
|
|
114
108
|
assert.match(result.cookie, /session=operator/)
|
|
115
|
-
assert.equal(stage.createdCount, 0)
|
|
116
109
|
})
|
|
117
110
|
|
|
118
111
|
// An app that mounts auth somewhere other than the root moves both sign-in
|
|
@@ -151,24 +144,25 @@ describe('operator persona sign-in', () => {
|
|
|
151
144
|
() => personas.customer!.invoke('whoami', {}),
|
|
152
145
|
/operator sign-in failed for 'customer' \(404\)/
|
|
153
146
|
)
|
|
154
|
-
assert.equal(stage.createdCount, 0)
|
|
155
147
|
})
|
|
156
148
|
|
|
157
|
-
|
|
158
|
-
|
|
149
|
+
// Provisioning moved into the stage, so the handshake carries an address and
|
|
150
|
+
// nothing else. A `create` flag or a role list here would be the caller
|
|
151
|
+
// deciding what the stage holds, which is the arrangement this replaced.
|
|
152
|
+
test('asks only to act as an address', async () => {
|
|
153
|
+
const stage = await startStage([
|
|
154
|
+
{ id: 'user-3', email: 'customer@personas.invalid' },
|
|
155
|
+
])
|
|
159
156
|
servers.push(stage.server)
|
|
160
157
|
|
|
161
158
|
const personas = createHttpPersonas({
|
|
162
159
|
apiUrl: stage.apiUrl,
|
|
163
|
-
operator: { token: OPERATOR_TOKEN
|
|
164
|
-
personas: { customer: persona('
|
|
160
|
+
operator: { token: OPERATOR_TOKEN },
|
|
161
|
+
personas: { customer: persona('customer@personas.invalid') },
|
|
165
162
|
})
|
|
166
163
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
assert.equal(result.actingAs, 'made-1')
|
|
171
|
-
assert.equal(stage.createdCount, 1)
|
|
164
|
+
await personas.customer!.invoke('whoami', {})
|
|
165
|
+
assert.deepEqual(stage.sentBodies, [{ email: 'customer@personas.invalid' }])
|
|
172
166
|
})
|
|
173
167
|
|
|
174
168
|
test('mints the operator session from a token factory', async () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { deriveActorSecret } from './persona-actor-secret.js'
|
|
2
|
+
import { PikkuError } from '../errors/error-handler.js'
|
|
2
3
|
import type { ResolvedPersona } from './personas-service.js'
|
|
3
4
|
import type { ScenarioCookieJar } from '../wirings/workflow/scenario-cookie-jar.js'
|
|
4
5
|
|
|
@@ -30,13 +31,19 @@ export interface PersonaSignIn {
|
|
|
30
31
|
headers(): Record<string, string>
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A sign-in the target refused. `PikkuError`, not `Error`, so the CLI prints
|
|
36
|
+
* this message alone: an expired token or a persona the stage has never seen is
|
|
37
|
+
* something to go and fix, and a stack trace through the fetch internals only
|
|
38
|
+
* buries the status and the body that say which one it is.
|
|
39
|
+
*/
|
|
33
40
|
const failed = async (
|
|
34
41
|
what: string,
|
|
35
42
|
personaId: string,
|
|
36
43
|
res: Response
|
|
37
44
|
): Promise<Error> => {
|
|
38
45
|
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
39
|
-
return new
|
|
46
|
+
return new PikkuError(
|
|
40
47
|
`[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`
|
|
41
48
|
)
|
|
42
49
|
}
|
|
@@ -123,16 +130,6 @@ export interface OperatorSignInOptions {
|
|
|
123
130
|
* the function: tokens expire, and a long run re-logs-in after a 401.
|
|
124
131
|
*/
|
|
125
132
|
token: string | (() => string | Promise<string>)
|
|
126
|
-
/**
|
|
127
|
-
* Create the persona's user row when the target has no account for that
|
|
128
|
-
* address.
|
|
129
|
-
*
|
|
130
|
-
* Off by default, which is the whole point of the deployed path: a persona is
|
|
131
|
-
* meant to be a real account somebody provisioned, and a test run that
|
|
132
|
-
* silently writes users into a live database is a side effect nobody asked
|
|
133
|
-
* for. Turn it on for throwaway stages.
|
|
134
|
-
*/
|
|
135
|
-
createMissing?: boolean
|
|
136
133
|
/** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
|
|
137
134
|
signInPath?: string
|
|
138
135
|
}
|
|
@@ -171,12 +168,7 @@ export const establishOperatorSession = async (
|
|
|
171
168
|
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
172
169
|
body: JSON.stringify({
|
|
173
170
|
token,
|
|
174
|
-
actAs: {
|
|
175
|
-
email: persona.email,
|
|
176
|
-
name: persona.name,
|
|
177
|
-
create: options.createMissing ?? false,
|
|
178
|
-
...(persona.roles[0] ? { role: persona.roles[0] } : {}),
|
|
179
|
-
},
|
|
171
|
+
actAs: { email: persona.email },
|
|
180
172
|
}),
|
|
181
173
|
})
|
|
182
174
|
if (!res.ok) {
|
|
@@ -184,7 +176,7 @@ export const establishOperatorSession = async (
|
|
|
184
176
|
}
|
|
185
177
|
const setCookies = res.headers.getSetCookie?.() ?? []
|
|
186
178
|
if (setCookies.length === 0) {
|
|
187
|
-
throw new
|
|
179
|
+
throw new PikkuError(
|
|
188
180
|
`[scenario] operator sign-in for '${persona.id}' returned no session cookie`
|
|
189
181
|
)
|
|
190
182
|
}
|
|
@@ -194,7 +186,7 @@ export const establishOperatorSession = async (
|
|
|
194
186
|
} | null
|
|
195
187
|
const userId = body?.actAs?.userId
|
|
196
188
|
if (!userId) {
|
|
197
|
-
throw new
|
|
189
|
+
throw new PikkuError(
|
|
198
190
|
`[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
|
|
199
191
|
'the target is running a @pikku/better-auth too old to resolve one'
|
|
200
192
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test, describe, beforeEach, afterEach } from 'node:test'
|
|
2
2
|
import * as assert from 'assert'
|
|
3
3
|
import { NotFoundError } from '../../errors/errors.js'
|
|
4
|
+
import { PikkuError } from '../../errors/error-handler.js'
|
|
4
5
|
import type { CorePikkuMiddleware } from '../../middleware/middleware.types.js'
|
|
5
6
|
import {
|
|
6
7
|
CLIError,
|
|
@@ -724,5 +725,108 @@ describe('CLI Runner', () => {
|
|
|
724
725
|
singletonServices
|
|
725
726
|
)
|
|
726
727
|
})
|
|
728
|
+
const wireFailingCommand = (error: unknown) => {
|
|
729
|
+
pikkuState(null, 'cli', 'meta', {
|
|
730
|
+
programs: {
|
|
731
|
+
'test-cli': {
|
|
732
|
+
program: 'test-cli',
|
|
733
|
+
commands: {
|
|
734
|
+
boom: {
|
|
735
|
+
command: 'boom',
|
|
736
|
+
pikkuFuncId: 'boomFunc',
|
|
737
|
+
positionals: [],
|
|
738
|
+
options: {},
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
options: {},
|
|
742
|
+
},
|
|
743
|
+
},
|
|
744
|
+
renderers: {},
|
|
745
|
+
})
|
|
746
|
+
pikkuState(null, 'cli', 'programs', {
|
|
747
|
+
'test-cli': {
|
|
748
|
+
defaultRenderer: undefined,
|
|
749
|
+
middleware: [],
|
|
750
|
+
renderers: {},
|
|
751
|
+
},
|
|
752
|
+
})
|
|
753
|
+
pikkuState(null, 'function', 'meta', {
|
|
754
|
+
boomFunc: {
|
|
755
|
+
pikkuFuncId: 'boomFunc',
|
|
756
|
+
inputSchemaName: null,
|
|
757
|
+
outputSchemaName: null,
|
|
758
|
+
sessionless: true,
|
|
759
|
+
},
|
|
760
|
+
})
|
|
761
|
+
addFunction('boomFunc', {
|
|
762
|
+
func: async () => {
|
|
763
|
+
throw error
|
|
764
|
+
},
|
|
765
|
+
auth: false,
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const captureStderr = async (run: () => Promise<void>) => {
|
|
770
|
+
const errors: string[] = []
|
|
771
|
+
const originalError = console.error
|
|
772
|
+
console.error = (message?: any) => {
|
|
773
|
+
errors.push(String(message))
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
await assert.rejects(run, CLIError)
|
|
777
|
+
} finally {
|
|
778
|
+
console.error = originalError
|
|
779
|
+
}
|
|
780
|
+
return errors
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
test('should print an expected failure as its message alone', async () => {
|
|
784
|
+
wireFailingCommand(new PikkuError('Refusing to run — the seed drifted.'))
|
|
785
|
+
|
|
786
|
+
const errors = await captureStderr(() =>
|
|
787
|
+
executeCLI({
|
|
788
|
+
programName: 'test-cli',
|
|
789
|
+
args: ['boom'],
|
|
790
|
+
createSingletonServices: async () => singletonServices,
|
|
791
|
+
})
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
assert.deepStrictEqual(errors, ['Refusing to run — the seed drifted.'])
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
test('should keep the stack of an unexpected failure', async () => {
|
|
798
|
+
wireFailingCommand(new TypeError('cannot read x of undefined'))
|
|
799
|
+
|
|
800
|
+
const errors = await captureStderr(() =>
|
|
801
|
+
executeCLI({
|
|
802
|
+
programName: 'test-cli',
|
|
803
|
+
args: ['boom'],
|
|
804
|
+
createSingletonServices: async () => singletonServices,
|
|
805
|
+
})
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
assert.strictEqual(errors.length, 1)
|
|
809
|
+
assert.ok(errors[0]!.startsWith('TypeError: cannot read x of undefined'))
|
|
810
|
+
assert.ok(errors[0]!.includes('at '))
|
|
811
|
+
assert.ok(!errors[0]!.includes('Error: Error:'))
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
test('should add the stack of an expected failure when --verbose is passed', async () => {
|
|
815
|
+
wireFailingCommand(new PikkuError('Refusing to run.'))
|
|
816
|
+
|
|
817
|
+
const errors = await captureStderr(() =>
|
|
818
|
+
executeCLI({
|
|
819
|
+
programName: 'test-cli',
|
|
820
|
+
args: ['boom', '--verbose'],
|
|
821
|
+
createSingletonServices: async () => singletonServices,
|
|
822
|
+
})
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
// `--verbose` is not an option this command declares, so the parser
|
|
826
|
+
// warns about it first — the trace is whatever it printed last.
|
|
827
|
+
const printed = errors.at(-1)!
|
|
828
|
+
assert.ok(printed.startsWith('Refusing to run.\n'))
|
|
829
|
+
assert.ok(printed.includes('at '))
|
|
830
|
+
})
|
|
727
831
|
})
|
|
728
832
|
})
|
|
@@ -1,5 +1,4 @@
|
|
|
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 type { CoreUserSession } from '../../types/core.types.js'
|
|
@@ -30,6 +29,7 @@ import {
|
|
|
30
29
|
} from '../../services/user-session-service.js'
|
|
31
30
|
import { LocalVariablesService } from '../../services/local-variables.js'
|
|
32
31
|
import { generateCommandHelp, parseCLIArguments } from './command-parser.js'
|
|
32
|
+
import { formatCLIError, wantsStackTrace } from './format-cli-error.js'
|
|
33
33
|
|
|
34
34
|
/** The caller is expected to catch this and call `process.exit(exitCode)`. */
|
|
35
35
|
export class CLIError extends Error {
|
|
@@ -532,16 +532,7 @@ export async function executeCLI({
|
|
|
532
532
|
throw error
|
|
533
533
|
}
|
|
534
534
|
|
|
535
|
-
|
|
536
|
-
if (isExpectedError(error)) {
|
|
537
|
-
console.error(error.message)
|
|
538
|
-
} else {
|
|
539
|
-
console.error('Error:', error)
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (args.includes('--verbose') || args.includes('-v')) {
|
|
543
|
-
console.error('Stack trace:', error.stack)
|
|
544
|
-
}
|
|
535
|
+
console.error(formatCLIError(error, { verbose: wantsStackTrace(args) }))
|
|
545
536
|
|
|
546
537
|
throw new CLIError(error.message || String(error), 1)
|
|
547
538
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import * as assert from 'assert'
|
|
3
|
+
import { PikkuError } from '../../errors/error-handler.js'
|
|
4
|
+
import { formatCLIError, wantsStackTrace } from './format-cli-error.js'
|
|
5
|
+
|
|
6
|
+
describe('formatCLIError', () => {
|
|
7
|
+
test('prints an expected error as its message alone', () => {
|
|
8
|
+
const error = new PikkuError(
|
|
9
|
+
"Persona 'guest' missing guest. Refusing to run."
|
|
10
|
+
)
|
|
11
|
+
assert.strictEqual(
|
|
12
|
+
formatCLIError(error),
|
|
13
|
+
"Persona 'guest' missing guest. Refusing to run."
|
|
14
|
+
)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('prints an error flagged as expected as its message alone', () => {
|
|
18
|
+
const error = Object.assign(new Error('token expired'), { expected: true })
|
|
19
|
+
assert.strictEqual(formatCLIError(error), 'token expired')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('keeps the stack for an unexpected error', () => {
|
|
23
|
+
const error = new TypeError('cannot read properties of undefined')
|
|
24
|
+
const output = formatCLIError(error)
|
|
25
|
+
assert.ok(output.includes('TypeError: cannot read properties of undefined'))
|
|
26
|
+
assert.ok(output.includes('at '))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('does not double the error name', () => {
|
|
30
|
+
const output = formatCLIError(new Error('boom'))
|
|
31
|
+
assert.ok(!output.includes('Error: Error:'))
|
|
32
|
+
assert.ok(output.startsWith('Error: boom'))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('adds the stack to an expected error when verbose', () => {
|
|
36
|
+
const error = new PikkuError('nope')
|
|
37
|
+
const output = formatCLIError(error, { verbose: true })
|
|
38
|
+
assert.ok(output.startsWith('nope\n'))
|
|
39
|
+
assert.ok(output.includes('at '))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('summarises a fetch failure without inspecting the response', () => {
|
|
43
|
+
const error = Object.assign(new Error('Bad Gateway'), {
|
|
44
|
+
status: 502,
|
|
45
|
+
statusText: 'Bad Gateway',
|
|
46
|
+
response: {
|
|
47
|
+
url: 'https://api.pikkufabric.com/rpc/getDeploymentStatus',
|
|
48
|
+
headers: { forbidden: 'do not print me' },
|
|
49
|
+
body: 'a stream',
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
assert.strictEqual(
|
|
53
|
+
formatCLIError(error),
|
|
54
|
+
'502 Bad Gateway from https://api.pikkufabric.com/rpc/getDeploymentStatus'
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('keeps a fetch failure message that says more than the status', () => {
|
|
59
|
+
const error = Object.assign(new Error('Deployment not found'), {
|
|
60
|
+
status: 404,
|
|
61
|
+
statusText: 'Not Found',
|
|
62
|
+
response: { url: 'https://api.pikkufabric.com/rpc/getDeploymentStatus' },
|
|
63
|
+
})
|
|
64
|
+
assert.strictEqual(
|
|
65
|
+
formatCLIError(error),
|
|
66
|
+
'Deployment not found\n' +
|
|
67
|
+
' 404 Not Found from https://api.pikkufabric.com/rpc/getDeploymentStatus'
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('falls back to a string for a thrown non-error', () => {
|
|
72
|
+
assert.strictEqual(formatCLIError('just a string'), 'just a string')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('wantsStackTrace', () => {
|
|
77
|
+
test('is off by default', () => {
|
|
78
|
+
assert.strictEqual(wantsStackTrace(['deploy'], {}), false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('honours --verbose and -v', () => {
|
|
82
|
+
assert.strictEqual(wantsStackTrace(['deploy', '--verbose'], {}), true)
|
|
83
|
+
assert.strictEqual(wantsStackTrace(['deploy', '-v'], {}), true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('honours PIKKU_DEBUG, but not when it is switched off', () => {
|
|
87
|
+
assert.strictEqual(wantsStackTrace(['deploy'], { PIKKU_DEBUG: '1' }), true)
|
|
88
|
+
assert.strictEqual(wantsStackTrace(['deploy'], { PIKKU_DEBUG: '0' }), false)
|
|
89
|
+
assert.strictEqual(wantsStackTrace(['deploy'], { PIKKU_DEBUG: '' }), false)
|
|
90
|
+
})
|
|
91
|
+
})
|