@pikku/core 0.12.99 → 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 CHANGED
@@ -1,3 +1,51 @@
1
+ ## 0.12.101
2
+
3
+ ### Patch Changes
4
+
5
+ - e92e30b: Print a CLI failure as its message, not as a JS stack trace.
6
+
7
+ 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`.
8
+
9
+ 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.
10
+
11
+ 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.
12
+
13
+ - 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.
14
+ - 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.
15
+ - 4d0a548: Provision the declared personas from the fabric plugin instead of the server lifecycle.
16
+
17
+ `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.
18
+
19
+ `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.
20
+
21
+ 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.
22
+
23
+ `pikku persona sync <environment>` is unchanged: it still reports who an environment will provision and why anyone was skipped, and still writes nothing.
24
+
25
+ ## 0.12.100
26
+
27
+ ### Patch Changes
28
+
29
+ - a0ed1e8: Derive a persona's session and operator paths from the mount its sign-in path names.
30
+
31
+ Both `sessionRoles()` and the Fabric operator handshake asked for a fixed
32
+ `/auth/…` no matter where auth was mounted. An app serving better-auth under
33
+ `/api/auth` while keeping its RPCs at the root cannot put the mount in
34
+ `apiUrl`, so it moves `signInPath` — and the other two stayed behind.
35
+
36
+ For the session read that meant a 404, which returns `null`, which means "this
37
+ stage does not report roles": every `pikku persona run` on such an app warned
38
+ "running unverified" and lost the one thing that tells a permissions finding
39
+ from seed drift. For the operator handshake it was worse — `HttpPersona`
40
+ reused the _actor_ path verbatim, so an operator token was posted to the actor
41
+ endpoint and came back as a validation error about a missing email and secret,
42
+ which reads like a broken persona rather than a wrong URL. The browser provider
43
+ had the same fixed default.
44
+
45
+ All three now follow `signInPath`, and `environments[].sessionPath` in
46
+ pikku.config.json overrides the session read for a stage that reports it
47
+ elsewhere.
48
+
1
49
  ## 0.12.99
2
50
 
3
51
  ### Patch Changes
@@ -40,10 +40,26 @@ export interface HttpPersonasConfig {
40
40
  * {@link OperatorSignInOptions.signInPath} overrides it.
41
41
  */
42
42
  signInPath?: string;
43
- /** Where the session (and its roles) is read back. Default `/auth/get-session`. */
43
+ /**
44
+ * Where the session (and its roles) is read back. Defaults to `get-session`
45
+ * under the same auth mount as {@link HttpPersonasConfig.signInPath}, so an
46
+ * app that moved auth under `/api` moves this with it and does not have to
47
+ * say so twice.
48
+ */
44
49
  sessionPath?: string;
45
50
  /** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
46
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;
47
63
  /**
48
64
  * Default model a persona thinks with when `converse(...)` is called without
49
65
  * an explicit `model`. Its own turns/approvals/evaluation run in-process via
@@ -81,17 +97,36 @@ export declare class HttpPersona implements ScenarioPersona {
81
97
  /**
82
98
  * The roles the stage says this session holds.
83
99
  *
84
- * Read from better-auth's `get-session`, which is what most pikku apps are
85
- * running and where its admin plugin puts `role` on the user, as a
86
- * comma-separated list. A target that answers something else returns `null`
87
- * rather than an empty list, because "this stage does not report roles" and
88
- * "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.
89
105
  */
90
106
  sessionRoles(): Promise<string[] | null>;
91
- /** 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
+ */
92
126
  private agentRun;
93
127
  /** Answer the target agent's pending approvals over HTTP and continue. */
94
128
  private agentApprove;
129
+ private sendAgent;
95
130
  private postAgent;
96
131
  private postRpc;
97
132
  /** Drop the session, so the next call signs in again before it goes out. */
@@ -1,7 +1,7 @@
1
1
  import { readScenarioHttpResponse } from './personas-service.js';
2
2
  import { runConversation } from '../wirings/actor-flow/run-conversation.js';
3
3
  import { createCookieJar, } from '../wirings/workflow/scenario-cookie-jar.js';
4
- import { ActorSignIn, OperatorSignIn, } from './persona-sign-in.js';
4
+ import { ActorSignIn, OperatorSignIn, authMount, } from './persona-sign-in.js';
5
5
  import { getSingletonServices } from '../pikku-state.js';
6
6
  import { AIProviderNotConfiguredError } from '../errors/errors.js';
7
7
  /**
@@ -34,7 +34,10 @@ export class HttpPersona {
34
34
  if (config.operator) {
35
35
  this.signIn = new OperatorSignIn(config.apiUrl, {
36
36
  ...config.operator,
37
- signInPath: config.operator.signInPath ?? config.signInPath,
37
+ signInPath: config.operator.signInPath ??
38
+ (authMount(config.signInPath)
39
+ ? `${authMount(config.signInPath)}/sign-in/fabric`
40
+ : undefined),
38
41
  });
39
42
  }
40
43
  else if (config.secret) {
@@ -105,17 +108,48 @@ export class HttpPersona {
105
108
  /**
106
109
  * The roles the stage says this session holds.
107
110
  *
108
- * Read from better-auth's `get-session`, which is what most pikku apps are
109
- * running and where its admin plugin puts `role` on the user, as a
110
- * comma-separated list. A target that answers something else returns `null`
111
- * rather than an empty list, because "this stage does not report roles" and
112
- * "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.
113
116
  */
114
117
  async sessionRoles() {
115
118
  if (!this.signedIn) {
116
119
  await this.login();
117
120
  }
118
- const sessionPath = this.config.sessionPath ?? '/auth/get-session';
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() {
151
+ const mount = authMount(this.config.operator?.signInPath ?? this.config.signInPath);
152
+ const sessionPath = this.config.sessionPath ?? `${mount ?? '/auth'}/get-session`;
119
153
  const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`, {
120
154
  headers: this.signIn.headers(),
121
155
  });
@@ -148,14 +182,25 @@ export class HttpPersona {
148
182
  // stage reporting "none", not a stage that cannot report.
149
183
  return user ? [] : null;
150
184
  }
151
- /** 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
+ */
152
197
  async agentRun(agentName, message, threadId, resourceId) {
153
- const raw = await this.postAgent(`agent/${agentName}`, {
198
+ const res = await this.sendAgent(`agent/${agentName}/stream`, {
154
199
  message,
155
200
  threadId,
156
201
  resourceId,
157
202
  });
158
- return normalizeAgentReply(raw);
203
+ return await collectAgentStream(res);
159
204
  }
160
205
  /** Answer the target agent's pending approvals over HTTP and continue. */
161
206
  async agentApprove(agentName, runId, decisions) {
@@ -166,7 +211,7 @@ export class HttpPersona {
166
211
  return normalizeAgentReply(raw);
167
212
  }
168
213
  // knowledge: decisions/internals/scenario-agent-calls-sign-in-on-401-only.md
169
- async postAgent(subPath, body) {
214
+ async sendAgent(subPath, body) {
170
215
  const rpcPath = this.config.rpcPath ?? '/rpc';
171
216
  const url = `${this.config.apiUrl}${rpcPath}/${subPath}`;
172
217
  const send = () => this.jar.fetch(url, {
@@ -187,6 +232,10 @@ export class HttpPersona {
187
232
  const text = (await res.text().catch(() => '')).slice(0, 300);
188
233
  throw new Error(`[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`);
189
234
  }
235
+ return res;
236
+ }
237
+ async postAgent(subPath, body) {
238
+ const res = await this.sendAgent(subPath, body);
190
239
  if (res.status === 204)
191
240
  return undefined;
192
241
  const text = await res.text();
@@ -214,6 +263,80 @@ export class HttpPersona {
214
263
  this.signedIn = true;
215
264
  }
216
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
+ }
217
340
  /** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
218
341
  function normalizeAgentReply(raw) {
219
342
  const r = (raw ?? {});
@@ -53,22 +53,24 @@ export declare class ActorSignIn implements PersonaSignIn {
53
53
  login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void>;
54
54
  headers(): Record<string, string>;
55
55
  }
56
+ /**
57
+ * The auth mount a configured sign-in path sits under, or `undefined` when it
58
+ * names nothing recognisable.
59
+ *
60
+ * better-auth serves sign-in, operator sign-in and `get-session` from one
61
+ * prefix, so an app that mounts it at `/api/auth` moves all three together and
62
+ * says so once through `signInPath`. Reading the other two from a hardcoded
63
+ * `/auth` on such an app 404s — and for `get-session` a 404 reads as "this
64
+ * stage does not report roles", which silently turns off the check that tells a
65
+ * permissions finding from seed drift.
66
+ */
67
+ export declare const authMount: (signInPath?: string) => string | undefined;
56
68
  export interface OperatorSignInOptions {
57
69
  /**
58
70
  * The short-lived RS256 operator token, or a function that mints one. Prefer
59
71
  * the function: tokens expire, and a long run re-logs-in after a 401.
60
72
  */
61
73
  token: string | (() => string | Promise<string>);
62
- /**
63
- * Create the persona's user row when the target has no account for that
64
- * address.
65
- *
66
- * Off by default, which is the whole point of the deployed path: a persona is
67
- * meant to be a real account somebody provisioned, and a test run that
68
- * silently writes users into a live database is a side effect nobody asked
69
- * for. Turn it on for throwaway stages.
70
- */
71
- createMissing?: boolean;
72
74
  /** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
73
75
  signInPath?: string;
74
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
@@ -60,6 +67,21 @@ export class ActorSignIn {
60
67
  return {};
61
68
  }
62
69
  }
70
+ /**
71
+ * The auth mount a configured sign-in path sits under, or `undefined` when it
72
+ * names nothing recognisable.
73
+ *
74
+ * better-auth serves sign-in, operator sign-in and `get-session` from one
75
+ * prefix, so an app that mounts it at `/api/auth` moves all three together and
76
+ * says so once through `signInPath`. Reading the other two from a hardcoded
77
+ * `/auth` on such an app 404s — and for `get-session` a 404 reads as "this
78
+ * stage does not report roles", which silently turns off the check that tells a
79
+ * permissions finding from seed drift.
80
+ */
81
+ export const authMount = (signInPath) => {
82
+ const mount = signInPath ? signInPath.lastIndexOf('/sign-in/') : -1;
83
+ return !signInPath || mount === -1 ? undefined : signInPath.slice(0, mount);
84
+ };
63
85
  /**
64
86
  * Establish a Fabric operator session against `apiUrl` and resolve the target's
65
87
  * own id for `persona`, which is what the impersonation header names.
@@ -78,12 +100,7 @@ export const establishOperatorSession = async (fetchImpl, apiUrl, persona, optio
78
100
  headers: { 'content-type': 'application/json', ...extraHeaders },
79
101
  body: JSON.stringify({
80
102
  token,
81
- actAs: {
82
- email: persona.email,
83
- name: persona.name,
84
- create: options.createMissing ?? false,
85
- ...(persona.roles[0] ? { role: persona.roles[0] } : {}),
86
- },
103
+ actAs: { email: persona.email },
87
104
  }),
88
105
  });
89
106
  if (!res.ok) {
@@ -91,12 +108,12 @@ export const establishOperatorSession = async (fetchImpl, apiUrl, persona, optio
91
108
  }
92
109
  const setCookies = res.headers.getSetCookie?.() ?? [];
93
110
  if (setCookies.length === 0) {
94
- 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`);
95
112
  }
96
113
  const body = (await res.json().catch(() => null));
97
114
  const userId = body?.actAs?.userId;
98
115
  if (!userId) {
99
- 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 — ` +
100
117
  'the target is running a @pikku/better-auth too old to resolve one');
101
118
  }
102
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
+ };
@@ -18,6 +18,6 @@ export type { CorePersona, CorePersonas, PersonaAccountMeta, PersonaDefinitions,
18
18
  * Lambda deploy would load outright.
19
19
  */
20
20
  export { HttpPersona, createHttpPersonas, type HttpPersonasConfig, } from '../../services/http-personas.js';
21
- export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, type ActorSecretResolver, type PersonaSignIn, type OperatorSignInOptions, type OperatorSessionResult, } from '../../services/persona-sign-in.js';
21
+ export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, type ActorSecretResolver, type PersonaSignIn, type OperatorSignInOptions, type OperatorSessionResult, authMount, } from '../../services/persona-sign-in.js';
22
22
  export { ACTOR_ROOT_SECRET_MIN_LENGTH, ACTOR_SECRET_INFO, ACTOR_SECRET_NAME, actorSecretSubject, deriveActorSecret, verifyActorSecret, } from '../../services/persona-actor-secret.js';
23
23
  export { postScenarioJson, readScenarioHttpResponse, } from '../../services/personas-service.js';
@@ -14,6 +14,6 @@ export { APP_SCOPE_ROOT, appScopeId, buildAppScopeDefinition, } from './persona-
14
14
  * Lambda deploy would load outright.
15
15
  */
16
16
  export { HttpPersona, createHttpPersonas, } from '../../services/http-personas.js';
17
- export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, } from '../../services/persona-sign-in.js';
17
+ export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, authMount, } from '../../services/persona-sign-in.js';
18
18
  export { ACTOR_ROOT_SECRET_MIN_LENGTH, ACTOR_SECRET_INFO, ACTOR_SECRET_NAME, actorSecretSubject, deriveActorSecret, verifyActorSecret, } from '../../services/persona-actor-secret.js';
19
19
  export { postScenarioJson, readScenarioHttpResponse, } from '../../services/personas-service.js';
@@ -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.99",
3
+ "version": "0.12.101",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -299,6 +299,7 @@
299
299
  "OperatorSignIn",
300
300
  "actorSecretSubject",
301
301
  "appScopeId",
302
+ "authMount",
302
303
  "buildAppScopeDefinition",
303
304
  "createHttpPersonas",
304
305
  "definePersonas",