@pikku/skills 0.12.10 → 0.12.12
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 +819 -0
- package/dist/skills.gen.js +1 -1
- package/package.json +4 -4
- package/skills/pikku-addon/SKILL.md +17 -11
- package/skills/pikku-addon/references/addon-package-manifest.md +2 -2
- package/skills/pikku-agent/SKILL.md +4 -5
- package/skills/pikku-audit/SKILL.md +28 -13
- package/skills/pikku-aws/SKILL.md +2 -2
- package/skills/pikku-better-auth/SKILL.md +97 -17
- package/skills/pikku-build-app/SKILL.md +621 -0
- package/skills/pikku-build-app/references/multi-app.md +117 -0
- package/skills/pikku-build-app/references/ship.md +98 -0
- package/skills/pikku-build-app/references/theming.md +70 -0
- package/skills/pikku-build-platform/SKILL.md +239 -0
- package/skills/pikku-build-quick/SKILL.md +238 -0
- package/skills/pikku-cli/SKILL.md +7 -7
- package/skills/pikku-cli/references/complete-example.md +1 -1
- package/skills/pikku-concepts/SKILL.md +5 -2
- package/skills/pikku-concepts/references/concept-mapping.md +1 -1
- package/skills/pikku-config/SKILL.md +5 -3
- package/skills/pikku-deploy-azure/SKILL.md +5 -4
- package/skills/pikku-emails/SKILL.md +28 -7
- package/skills/pikku-fabric/SKILL.md +27 -3
- package/skills/pikku-fabric-debug/SKILL.md +1 -1
- package/skills/pikku-feature/SKILL.md +5 -4
- package/skills/pikku-http/SKILL.md +4 -4
- package/skills/pikku-http/references/http-options.md +13 -13
- package/skills/pikku-i18n/SKILL.md +2 -1
- package/skills/pikku-info/SKILL.md +1 -1
- package/skills/pikku-knowledge/SKILL.md +13 -13
- package/skills/pikku-mcp/SKILL.md +4 -4
- package/skills/pikku-middleware/SKILL.md +5 -5
- package/skills/pikku-middleware/references/middleware-patterns.md +14 -8
- package/skills/pikku-n8n-import/SKILL.md +12 -12
- package/skills/pikku-n8n-import/SPEC.md +3 -0
- package/skills/pikku-n8n-import/references/code-translation.md +26 -22
- package/skills/pikku-n8n-import/references/loops-and-control.md +7 -7
- package/skills/pikku-paraglide/SKILL.md +11 -6
- package/skills/pikku-permissions/SKILL.md +19 -15
- package/skills/pikku-product-second-opinion/README.md +3 -3
- package/skills/pikku-product-second-opinion/example/sample-report.md +15 -12
- package/skills/pikku-product-second-opinion/references/report-template.md +10 -7
- package/skills/pikku-queue/SKILL.md +1 -1
- package/skills/pikku-react/SKILL.md +53 -13
- package/skills/pikku-realtime/SKILL.md +52 -21
- package/skills/pikku-rpc/SKILL.md +4 -2
- package/skills/pikku-rtl/SKILL.md +1 -1
- package/skills/pikku-scenario/SKILL.md +131 -20
- package/skills/pikku-schedule/SKILL.md +6 -1
- package/skills/pikku-schema-ajv/SKILL.md +2 -2
- package/skills/pikku-schema-cfworker/SKILL.md +1 -1
- package/skills/pikku-security/SKILL.md +9 -5
- package/skills/pikku-services/SKILL.md +27 -18
- package/skills/pikku-services/references/audit-wire-service.md +14 -8
- package/skills/pikku-software-archaeology/scripts/validate.mjs +146 -69
- package/skills/pikku-tag-middleware/SKILL.md +1 -0
- package/skills/pikku-template-clone/SKILL.md +2 -1
- package/skills/pikku-trigger/SKILL.md +3 -3
- package/skills/pikku-websocket/SKILL.md +4 -3
- package/skills/pikku-workflow/SKILL.md +2 -2
- package/skills/pikku-workflow/references/workflow-reference.md +24 -10
|
@@ -6,7 +6,8 @@ reader. Delete any section that would be empty rather than padding it.
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# {App name}, in plain English — and where it could get better
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
_A second opinion on {scope: the whole app / the competitor-tracking system / …}_
|
|
10
11
|
|
|
11
12
|
**How to read this:** no technical background needed. Part 1 is the summary — if
|
|
12
13
|
you read nothing else, read that. Parts 2–3 go area by area for anyone who wants
|
|
@@ -21,9 +22,9 @@ opportunities. No jargon.}
|
|
|
21
22
|
|
|
22
23
|
**If it were me, this is the order I'd tackle things:**
|
|
23
24
|
|
|
24
|
-
| Fix | Why it matters to you | Effort
|
|
25
|
-
|
|
26
|
-
| {…} | {business impact}
|
|
25
|
+
| Fix | Why it matters to you | Effort | Payoff |
|
|
26
|
+
| --- | --------------------- | ------------------ | --------------- |
|
|
27
|
+
| {…} | {business impact} | Small/Medium/Large | High/Medium/Low |
|
|
27
28
|
|
|
28
29
|
---
|
|
29
30
|
|
|
@@ -38,6 +39,7 @@ opportunities. No jargon.}
|
|
|
38
39
|
**What's working.** {genuine credit — the parts that are solid and worth keeping}
|
|
39
40
|
|
|
40
41
|
**What's holding you back.**
|
|
42
|
+
|
|
41
43
|
- **{Problem in plain terms}.** What it means for you: {business impact}.
|
|
42
44
|
Severity: {Minor / Worth fixing / Serious / Urgent}. Effort to fix: {Small /
|
|
43
45
|
Medium / Large}.
|
|
@@ -54,11 +56,12 @@ off. Say whether it's a cheap rewire or an expensive rebuild.}
|
|
|
54
56
|
libraries — AND anything a rebuild would move them ONTO. Each gets both sides.}
|
|
55
57
|
|
|
56
58
|
**{Technology}.**
|
|
57
|
-
|
|
58
|
-
-
|
|
59
|
+
|
|
60
|
+
- _Buys you:_ {in business terms}
|
|
61
|
+
- _Costs you:_ {in business terms — bills, hiring, shipping speed, upgrade work,
|
|
59
62
|
the risk of betting on something young. Don't soften it. If it hasn't shipped a
|
|
60
63
|
stable 1.0, say so and say what that means: pin the version, budget upgrades.}
|
|
61
|
-
-
|
|
64
|
+
- _Usually:_ {recommendation tied to their stage — normally "keep it, watch this"}
|
|
62
65
|
|
|
63
66
|
{The same bar applies to anything you're recommending they move to. A stack you
|
|
64
67
|
propose with no cons listed is a pitch, not a second opinion.}
|
|
@@ -63,7 +63,7 @@ Not every adapter supports every option. Each adapter declares a
|
|
|
63
63
|
silently ignored — so check the startup logs if a setting appears to have no
|
|
64
64
|
effect.
|
|
65
65
|
|
|
66
|
-
`groupConcurrency` limits how many jobs run concurrently
|
|
66
|
+
`groupConcurrency` limits how many jobs run concurrently _per group_ (jobs
|
|
67
67
|
carrying a `JobGroup` with an `id` and optional `tier`), so one noisy tenant
|
|
68
68
|
cannot consume the whole worker:
|
|
69
69
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pikku-react
|
|
3
|
-
description: 'Set up @pikku/react in a React app: PikkuProvider context, createPikku factory, and the usePikkuRPC / usePikkuFetch hooks for direct (non-React-Query) calls. TRIGGER when: the user is bootstrapping a React frontend that talks to a Pikku backend, asks how to wire `PikkuProvider`, or needs to make one-off RPC calls outside of useQuery/useMutation. DO NOT TRIGGER when: the user is asking about useQuery/useMutation hooks (use pikku-react-query) or about workflows (use pikku-workflows-client).'
|
|
3
|
+
description: 'Set up @pikku/react in a React app: PikkuProvider context, createPikku factory, and the usePikkuRPC / usePikkuFetch hooks for direct (non-React-Query) calls. TRIGGER when: the user is bootstrapping a React frontend that talks to a Pikku backend, asks how to wire `PikkuProvider`, or needs to make one-off RPC calls outside of useQuery/useMutation. TRIGGER when: user asks about the dev actor switcher, "sign in as" / quick-login UI, useDevActors, VITE_DEV_ACTORS, or the app-missing-actor-quick-login validate finding. DO NOT TRIGGER when: the user is asking about useQuery/useMutation hooks (use pikku-react-query) or about workflows (use pikku-workflows-client).'
|
|
4
4
|
installGroups: [core]
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -58,8 +58,8 @@ export function apiUrl(): string {
|
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
**Never fall back to `http://localhost:3000`.** `import.meta.env.VITE_API_URL`
|
|
61
|
-
is substituted by Vite at
|
|
62
|
-
a
|
|
61
|
+
is substituted by Vite at _build_ time, so any deploy that supplies the URL as
|
|
62
|
+
a _runtime_ env var or platform binding leaves it `undefined` in the shipped
|
|
63
63
|
bundle — the fallback is then the only branch that ever runs in the browser. A
|
|
64
64
|
localhost fallback means every request from a deployed app goes to the user's
|
|
65
65
|
own machine. `origin + '/api'` is same-origin, needs no build-time knowledge of
|
|
@@ -173,17 +173,17 @@ helpers live in **pikku-realtime**.
|
|
|
173
173
|
|
|
174
174
|
## When to reach for what
|
|
175
175
|
|
|
176
|
-
| Need | Use
|
|
177
|
-
| ----------------------------------- |
|
|
178
|
-
| Render data, dedupe + cache | **usePikkuQuery** (react-query)
|
|
179
|
-
| Trigger a write, wait for result | **usePikkuMutation** (react-query)
|
|
180
|
-
| Paginate | **usePikkuInfiniteQuery** (react-query)
|
|
181
|
-
| One-off call from an event handler | `usePikkuRPC()` direct
|
|
182
|
-
| Hit a REST endpoint (not RPC) | `usePikkuFetch()`
|
|
176
|
+
| Need | Use |
|
|
177
|
+
| ----------------------------------- | -------------------------------------------------- |
|
|
178
|
+
| Render data, dedupe + cache | **usePikkuQuery** (react-query) |
|
|
179
|
+
| Trigger a write, wait for result | **usePikkuMutation** (react-query) |
|
|
180
|
+
| Paginate | **usePikkuInfiniteQuery** (react-query) |
|
|
181
|
+
| One-off call from an event handler | `usePikkuRPC()` direct |
|
|
182
|
+
| Hit a REST endpoint (not RPC) | `usePikkuFetch()` |
|
|
183
183
|
| Run one named workflow | `usePikkuWorkflow('name')` → `.start/.run/.status` |
|
|
184
184
|
| Talk to one named AI agent | `usePikkuAgent('name')` → `.run/.stream/.approve` |
|
|
185
|
-
| Longer-running workflow UX | **pikku-workflows-client**
|
|
186
|
-
| Subscribe to events / SSE / channel | `usePikkuRealtime()` (see **pikku-realtime**)
|
|
185
|
+
| Longer-running workflow UX | **pikku-workflows-client** |
|
|
186
|
+
| Subscribe to events / SSE / channel | `usePikkuRealtime()` (see **pikku-realtime**) |
|
|
187
187
|
|
|
188
188
|
The first three live in your generated `api.gen.ts` (see the
|
|
189
189
|
**pikku-react-query** skill). This skill covers the rest.
|
|
@@ -203,7 +203,7 @@ const state = await workflow.status(runId)
|
|
|
203
203
|
## Authentication
|
|
204
204
|
|
|
205
205
|
Auth is handled at the `PikkuFetch` layer, and `createPikku`'s options object
|
|
206
|
-
|
|
206
|
+
_is_ `CorePikkuFetchOptions` plus `serverUrl` — flat, not nested under a
|
|
207
207
|
`fetchOptions` key:
|
|
208
208
|
|
|
209
209
|
```tsx
|
|
@@ -228,6 +228,46 @@ pikku.fetch.setHeader('x-tenant', tenantId)
|
|
|
228
228
|
`authHeaders.jwt` becomes `Authorization: Bearer …` and `authHeaders.apiKey`
|
|
229
229
|
becomes `X-API-KEY`; setting a JWT takes precedence over an API key.
|
|
230
230
|
|
|
231
|
+
### Dev actor sign-in (`useDevActors`)
|
|
232
|
+
|
|
233
|
+
The dev-only "Sign in as …" control: one click signs in as a declared scenario
|
|
234
|
+
persona with no password, so the app can be reviewed as each kind of user.
|
|
235
|
+
`pikku fabric validate` **requires** any frontend with a login screen to ship one
|
|
236
|
+
(`app-missing-actor-quick-login-<app>`) — without it a reviewer is locked out of
|
|
237
|
+
their own sandbox.
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
import { useDevActors } from '@pikku/react'
|
|
241
|
+
|
|
242
|
+
const { actors, signInAs, pendingEmail, isPending, error } = useDevActors({
|
|
243
|
+
// Gate both reads on the bundler's dev flag so the secret cannot reach a
|
|
244
|
+
// production bundle. The sandbox dev server bakes them from your personas.
|
|
245
|
+
actors: import.meta.env.DEV ? import.meta.env.VITE_DEV_ACTORS : undefined,
|
|
246
|
+
secret: import.meta.env.DEV
|
|
247
|
+
? import.meta.env.VITE_SCENARIO_ACTOR_SECRET
|
|
248
|
+
: undefined,
|
|
249
|
+
apiUrl: apiUrl(),
|
|
250
|
+
onSignedIn: () => navigate({ to: '/' }),
|
|
251
|
+
})
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
- **It is UI-free**, so render it however you like. For the default rendering use
|
|
255
|
+
`<DevActorSwitcher />` from `@pikku/mantine/dev` — a separate entry point from
|
|
256
|
+
`@pikku/mantine/core`, whose contract is "drop-in alias for `@mantine/core`"
|
|
257
|
+
and so must not export components Mantine has no counterpart for.
|
|
258
|
+
- **`actors` is empty unless the host supplied both a list and a secret**, so a
|
|
259
|
+
production build renders nothing without you testing for it.
|
|
260
|
+
- **It takes `onSignedIn` rather than a router**, and takes the env values rather
|
|
261
|
+
than reading them, because how env is spelled is a bundler fact
|
|
262
|
+
(`import.meta.env.VITE_*` vs `process.env.NEXT_PUBLIC_*`).
|
|
263
|
+
- The underlying `signInAsActor()` and `parseDevActors()` are exported too, for a
|
|
264
|
+
non-React caller. The endpoint only accepts rows flagged `actor: true`, so it
|
|
265
|
+
can never impersonate a real user — see **pikku-better-auth**.
|
|
266
|
+
|
|
267
|
+
Do not hand-write the `devActors()` / `signInAsActor()` pair per app; that
|
|
268
|
+
copy-paste, including the `import.meta.env.DEV` gate, is exactly what this
|
|
269
|
+
replaced.
|
|
270
|
+
|
|
231
271
|
## What NOT to do
|
|
232
272
|
|
|
233
273
|
- Don't instantiate `PikkuFetch`/`PikkuRPC` inside a component — `createPikku`
|
|
@@ -66,8 +66,7 @@ see `pikku-services`.
|
|
|
66
66
|
## 2. Enable the server side
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
yarn pikku enable events
|
|
70
|
-
yarn pikku enable events --noAuth # public events
|
|
69
|
+
yarn pikku enable events
|
|
71
70
|
```
|
|
72
71
|
|
|
73
72
|
This sets `scaffold.events` in `pikku.config.json`. The next `pikku all` generates
|
|
@@ -97,19 +96,38 @@ a one-word change, not a different import:
|
|
|
97
96
|
|
|
98
97
|
```ts
|
|
99
98
|
export class PikkuRealtime {
|
|
100
|
-
constructor(options?: {
|
|
101
|
-
|
|
99
|
+
constructor(options?: {
|
|
100
|
+
reconnect?: boolean
|
|
101
|
+
reconnectDelayMs?: number
|
|
102
|
+
reconnectMaxDelayMs?: number
|
|
103
|
+
})
|
|
104
|
+
setPikkuFetch(fetch: PikkuFetch): void // server URL + auth come from here, not the constructor
|
|
102
105
|
|
|
103
106
|
// WebSocket at /events — many topics on one connection
|
|
104
|
-
subscribe<K extends keyof EventHubTopics>(
|
|
105
|
-
|
|
107
|
+
subscribe<K extends keyof EventHubTopics>(
|
|
108
|
+
topic: K,
|
|
109
|
+
handler: (data: EventHubTopics[K]) => void
|
|
110
|
+
): () => void
|
|
111
|
+
unsubscribe<K extends keyof EventHubTopics>(
|
|
112
|
+
topic: K,
|
|
113
|
+
handler?: (data: EventHubTopics[K]) => void
|
|
114
|
+
): void
|
|
106
115
|
|
|
107
116
|
// SSE at GET /events/:topic — one EventSource per topic
|
|
108
|
-
subscribeToTopic<K extends keyof EventHubTopics>(
|
|
117
|
+
subscribeToTopic<K extends keyof EventHubTopics>(
|
|
118
|
+
topic: K,
|
|
119
|
+
handler: (data: EventHubTopics[K]) => void
|
|
120
|
+
): { close: () => void }
|
|
109
121
|
|
|
110
122
|
// generic escape hatches — see references/other-routes.md
|
|
111
|
-
subscribeToSSE<T>(
|
|
112
|
-
|
|
123
|
+
subscribeToSSE<T>(
|
|
124
|
+
path: string,
|
|
125
|
+
handler: (data: T) => void
|
|
126
|
+
): { close: () => void }
|
|
127
|
+
connectToChannel(
|
|
128
|
+
channelRoute: string,
|
|
129
|
+
protocols?: string | string[]
|
|
130
|
+
): WebSocket
|
|
113
131
|
|
|
114
132
|
close(): void
|
|
115
133
|
}
|
|
@@ -137,14 +155,16 @@ Envelope the payload as `{ topic, data }`: the generated client dispatches on th
|
|
|
137
155
|
`topic` field, so a bare payload arrives but no handler fires.
|
|
138
156
|
|
|
139
157
|
```ts
|
|
140
|
-
import { pikkuFunc } from '#pikku'
|
|
158
|
+
import { pikkuFunc } from '#pikku/function'
|
|
141
159
|
|
|
142
160
|
export const createTodo = pikkuFunc({
|
|
143
161
|
input: CreateTodoInput,
|
|
144
162
|
output: CreateTodoOutput,
|
|
145
163
|
func: async ({ kysely, eventHub }, data) => {
|
|
146
164
|
const todo = await kysely
|
|
147
|
-
.insertInto('todos')
|
|
165
|
+
.insertInto('todos')
|
|
166
|
+
.values(data)
|
|
167
|
+
.returningAll()
|
|
148
168
|
.executeTakeFirstOrThrow()
|
|
149
169
|
|
|
150
170
|
await eventHub.publish('todo-created', null, {
|
|
@@ -160,7 +180,9 @@ A thin helper removes the duplication:
|
|
|
160
180
|
|
|
161
181
|
```ts
|
|
162
182
|
async function publishEvent<K extends keyof EventHubTopics>(
|
|
163
|
-
hub: EventHubService<EventHubTopics>,
|
|
183
|
+
hub: EventHubService<EventHubTopics>,
|
|
184
|
+
topic: K,
|
|
185
|
+
data: EventHubTopics[K]
|
|
164
186
|
) {
|
|
165
187
|
return hub.publish(topic, null, { topic, data })
|
|
166
188
|
}
|
|
@@ -187,7 +209,9 @@ const pikku = createPikku(
|
|
|
187
209
|
// pikku.fetch / pikku.rpc / pikku.realtime — all share the same fetch.
|
|
188
210
|
|
|
189
211
|
createRoot(document.getElementById('root')!).render(
|
|
190
|
-
<PikkuProvider pikku={pikku}
|
|
212
|
+
<PikkuProvider pikku={pikku}>
|
|
213
|
+
<App />
|
|
214
|
+
</PikkuProvider>
|
|
191
215
|
)
|
|
192
216
|
```
|
|
193
217
|
|
|
@@ -214,7 +238,8 @@ function TodoList() {
|
|
|
214
238
|
useEffect(() => {
|
|
215
239
|
// WebSocket multi-topic:
|
|
216
240
|
const off = realtime.subscribe('todo-created', ({ todo }) =>
|
|
217
|
-
setTodos((prev) => [...prev, todo])
|
|
241
|
+
setTodos((prev) => [...prev, todo])
|
|
242
|
+
)
|
|
218
243
|
return off
|
|
219
244
|
|
|
220
245
|
// Single-topic SSE (auto-cleanup on close) instead:
|
|
@@ -223,7 +248,13 @@ function TodoList() {
|
|
|
223
248
|
// return () => sub.close()
|
|
224
249
|
}, [realtime])
|
|
225
250
|
|
|
226
|
-
return
|
|
251
|
+
return (
|
|
252
|
+
<ul>
|
|
253
|
+
{todos.map((t) => (
|
|
254
|
+
<li key={t.id}>{t.title}</li>
|
|
255
|
+
))}
|
|
256
|
+
</ul>
|
|
257
|
+
)
|
|
227
258
|
}
|
|
228
259
|
```
|
|
229
260
|
|
|
@@ -235,12 +266,12 @@ sockets (`subscribeToSSE`, `connectToChannel`). See
|
|
|
235
266
|
|
|
236
267
|
## When to pick which transport
|
|
237
268
|
|
|
238
|
-
| Need | Use
|
|
239
|
-
| ------------------------------------------ |
|
|
240
|
-
| Many topics in one connection | `realtime.subscribe`
|
|
241
|
-
| Single live stream, simple cleanup | `realtime.subscribeToTopic`
|
|
242
|
-
| Bidirectional (client also sends messages) | `realtime.subscribe`
|
|
243
|
-
| WebSockets blocked by infra | `realtime.subscribeToTopic`
|
|
269
|
+
| Need | Use |
|
|
270
|
+
| ------------------------------------------ | --------------------------- |
|
|
271
|
+
| Many topics in one connection | `realtime.subscribe` |
|
|
272
|
+
| Single live stream, simple cleanup | `realtime.subscribeToTopic` |
|
|
273
|
+
| Bidirectional (client also sends messages) | `realtime.subscribe` |
|
|
274
|
+
| WebSockets blocked by infra | `realtime.subscribeToTopic` |
|
|
244
275
|
|
|
245
276
|
Both auto-clean on the server (the eventHub's `onChannelClosed` hook unsubscribes
|
|
246
277
|
all topics for the dead channel id). Don't write manual cleanup unless you're
|
|
@@ -75,10 +75,12 @@ The `POST /rpc/:rpcName` endpoint that dispatches every `expose: true` function
|
|
|
75
75
|
is **generated, not hand-written**. Turn it on and let codegen own it:
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
|
-
pikku enable rpc # sets scaffold.rpc = true
|
|
79
|
-
pikku enable rpc --noAuth # sets scaffold.rpc = { auth: false } (public)
|
|
78
|
+
pikku enable rpc # sets scaffold.rpc = true
|
|
80
79
|
```
|
|
81
80
|
|
|
81
|
+
The flag says the endpoint exists, not who may call it — each exposed function
|
|
82
|
+
is gated by its own `auth`, permissions and scopes.
|
|
83
|
+
|
|
82
84
|
This writes `rpc-public.gen.ts` with an `rpcCaller` function and its `wireHTTP`
|
|
83
85
|
call already wired. Do not write that wiring yourself — a hand-rolled copy
|
|
84
86
|
collides with the generated route on the same path.
|
|
@@ -18,7 +18,7 @@ Set `dir` **once at the document root** from the active locale, then let the
|
|
|
18
18
|
browser and Mantine mirror everything — _provided_ every custom style is written
|
|
19
19
|
**flow-relative** (start/end), never **physical** (left/right). Get those two
|
|
20
20
|
things right and Arabic, Hebrew, Farsi and Urdu all work with zero per-component
|
|
21
|
-
|
|
21
|
+
_layout_ code — directional icons still need one manual step, covered below.
|
|
22
22
|
|
|
23
23
|
## Agent Operating Procedure
|
|
24
24
|
|
|
@@ -46,7 +46,7 @@ Scenarios live in `srcDirectories` like any other function — by convention `*.
|
|
|
46
46
|
`pikkuScenario` comes from the **generated** workflow types, not `@pikku/core`:
|
|
47
47
|
|
|
48
48
|
```typescript
|
|
49
|
-
import { pikkuScenario } from '#pikku/
|
|
49
|
+
import { pikkuScenario } from '#pikku/scenario'
|
|
50
50
|
|
|
51
51
|
export const orderSupportScenario = pikkuScenario<
|
|
52
52
|
{ value?: number },
|
|
@@ -175,7 +175,7 @@ Hooks are scenario-only. A `before`/`after` on a `pikkuWorkflowFunc` never runs
|
|
|
175
175
|
`pikkuFeature` groups scenarios the way gherkin's `Feature:` groups `Scenario:`. Scenarios are referenced by **imported identifier**, so a renamed or deleted scenario is a compile error rather than a silent skip:
|
|
176
176
|
|
|
177
177
|
```typescript
|
|
178
|
-
import { pikkuFeature } from '#pikku/
|
|
178
|
+
import { pikkuFeature } from '#pikku/scenario'
|
|
179
179
|
import {
|
|
180
180
|
credentialLazyLoadScenario,
|
|
181
181
|
credentialRoundTripScenario,
|
|
@@ -240,7 +240,7 @@ Utilities are **not steps**. They are plain exported functions, they take the br
|
|
|
240
240
|
|
|
241
241
|
```typescript
|
|
242
242
|
// shop.browser.ts — shared actions. Not steps: nothing here is an intent.
|
|
243
|
-
import type { PikkuBrowserWire } from '
|
|
243
|
+
import type { PikkuBrowserWire } from '#pikku/scenario'
|
|
244
244
|
import type {} from '@pikku/playwright'
|
|
245
245
|
|
|
246
246
|
/** Arrive on the shop, from wherever the browser happens to be. */
|
|
@@ -377,6 +377,9 @@ export const seesTheOrderConfirmed = pikkuScenarioStep<
|
|
|
377
377
|
{ status: string }
|
|
378
378
|
>({
|
|
379
379
|
name: 'seesTheOrderConfirmed',
|
|
380
|
+
// Both bindings run as the persona, so the step declares one and the runner
|
|
381
|
+
// injects `wire.actor` — non-optional in every binding.
|
|
382
|
+
actor: true,
|
|
380
383
|
template: 'sees order {orderId} confirmed',
|
|
381
384
|
// Both run on `--run browser`. Each returns what it observed, and the runner
|
|
382
385
|
// compares them — so this fails when the page disagrees with the database.
|
|
@@ -385,8 +388,9 @@ export const seesTheOrderConfirmed = pikkuScenarioStep<
|
|
|
385
388
|
.locate({ testId: 'order-status', where: { 'data-order': orderId } })
|
|
386
389
|
.getAttribute('data-status'),
|
|
387
390
|
}),
|
|
388
|
-
|
|
389
|
-
|
|
391
|
+
// Through the actor, not through a `rpc` service — see "What a step is given".
|
|
392
|
+
default: async (_services, { orderId }, { actor }) => ({
|
|
393
|
+
status: (await actor.invoke('getOrder', { orderId })).status,
|
|
390
394
|
}),
|
|
391
395
|
})
|
|
392
396
|
```
|
|
@@ -408,8 +412,7 @@ Assertions with no possible browser witness are a different thing and should not
|
|
|
408
412
|
`scenario.do` can only name an RPC. A **step** is a named, typed unit of scenario behaviour whose body is an ordinary pikku function — so it can call several RPCs as its actor, assert, or drive a browser.
|
|
409
413
|
|
|
410
414
|
```typescript
|
|
411
|
-
import { pikkuScenarioStep } from '#pikku/
|
|
412
|
-
import { requireActor } from '@pikku/core/workflow'
|
|
415
|
+
import { pikkuScenarioStep } from '#pikku/scenario'
|
|
413
416
|
|
|
414
417
|
export const buysAnApple = pikkuScenarioStep<
|
|
415
418
|
{ qty: number },
|
|
@@ -418,8 +421,9 @@ export const buysAnApple = pikkuScenarioStep<
|
|
|
418
421
|
name: 'buysAnApple',
|
|
419
422
|
description: 'buys an apple',
|
|
420
423
|
template: 'buys {qty} apples',
|
|
421
|
-
|
|
422
|
-
|
|
424
|
+
actor: true,
|
|
425
|
+
default: async (_services, { qty }, { actor }) => {
|
|
426
|
+
return await actor.invoke('placeOrder', { qty })
|
|
423
427
|
},
|
|
424
428
|
})
|
|
425
429
|
```
|
|
@@ -443,12 +447,63 @@ Rules that bite:
|
|
|
443
447
|
- **The step is referenced by its typed string name, not by importing the const** — exactly like `workflow.do`. The name is the step's `pikkuFuncId` and is checked against the generated step map. A non-literal target is a critical error (`PKU678`).
|
|
444
448
|
- **Steps are not RPCs.** They are deliberately never network-callable — a browser-driving step must not be.
|
|
445
449
|
- **`actor.invoke` is typed over the exposed RPC map**, so the name and the payload are checked and the result comes back narrowed — no cast. `actor.invokeRaw(name, data, { headers })` is the same call reporting `{ status, ok, body }` instead of throwing; use it whenever the refusal _is_ the assertion.
|
|
446
|
-
-
|
|
450
|
+
- **A step that runs as somebody declares `actor: true`**, and the runner injects `wire.actor` — non-optional inside every binding, with no guard to write and nothing to unwrap. A `browser` binding implies it, because a window is opened as somebody. Leave it off for a step with no persona to be: an assertion over what an earlier step returned, or one that posts credentials precisely because it must not reuse an actor's session. Dispatching a step that declared it without `{ actor: actors.x }` fails before the body runs (`ScenarioActorRequired`); a step that did not declare it has no `actor` on its wire at all.
|
|
451
|
+
- **`env` is optional on the wire**, because most steps need nothing from it. Narrow it with `requireScenarioEnv(scenarioStep)` from `#pikku/scenario` rather than a local guard — it names the step and says what to pass. `env` is `{ apiUrl, appUrl? }` from the environment the run targets, and is how a raw-HTTP step learns the target's URL: a step runs in the CLI process, where there is no `variables` service and `process.env` is not the answer.
|
|
447
452
|
- **Steps default to `retries: 0`**, unlike ordinary workflow steps. Retrying a failed assertion is wrong; pass `retries` explicitly if a step is genuinely flaky-by-nature.
|
|
448
453
|
- **Step results are persisted**, so return JSON-serialisable data — never a `Locator` or a client object.
|
|
449
454
|
- **`description` documents the step; `template` is what the report renders.** `template`'s `{placeholders}` are filled from the input the step was called with, so one step reads differently for each call — `sees {state} addon {packageName}` reports as "sees available addon @pikku/addon-stripe". Reflect every input field in the template, and type the values so they read as words (`state?: 'installed' | 'available'`, not `installed?: boolean`). A placeholder with no value renders as nothing and the whitespace collapses.
|
|
450
455
|
- Prose precedence is `options.description` → the step's `template` → the step's own `description` → the positional step name. Repeated names get `#1`, `#2` ordinals, so a `for` loop over a data set is how you write a Scenario Outline. A loop-generated step name is not statically known, so it is matched back to its declaration by step function instead — which works as long as that function's call sites agree on their phase, actor and prose. Two call sites that disagree make the loop step report under its bare runtime name.
|
|
451
456
|
|
|
457
|
+
#### What a step is given
|
|
458
|
+
|
|
459
|
+
A step has the signature of an ordinary pikku function, which makes it look as
|
|
460
|
+
though it runs where the application runs. It does not — **it runs in the CLI
|
|
461
|
+
process**, and the services object is built there, by hand:
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
{ logger, workflowService, workflowRunService, agentRunner? }
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
That is the whole list. There is no `kysely`, no `variables`, no `secrets`, and
|
|
468
|
+
none of the project's own singleton or wire services. A step that destructures
|
|
469
|
+
one gets `undefined` and fails on first use — `Cannot read properties of
|
|
470
|
+
undefined (reading 'selectFrom')` — which reads like a broken container and is
|
|
471
|
+
not.
|
|
472
|
+
|
|
473
|
+
`rpc` is the trap worth naming, because it is present and it throws. It is a
|
|
474
|
+
`guardRpc` whose every member refuses:
|
|
475
|
+
|
|
476
|
+
> Scenario tried to run 'getOrder' as an internal step. Every workflow.do in a
|
|
477
|
+
> scenario must carry { actor: actors.x } so it executes against 'local'.
|
|
478
|
+
|
|
479
|
+
The same guard covers `rpc.agent.run/stream/resume/approve/interrupt` and
|
|
480
|
+
`startWorkflow`.
|
|
481
|
+
|
|
482
|
+
This is the design, not a gap: **everything a step touches of the application
|
|
483
|
+
goes over the wire as somebody.** A test that could reach into the database
|
|
484
|
+
would be testing a different program from the one a person uses. So there are
|
|
485
|
+
exactly three ways in, and they are all through the actor:
|
|
486
|
+
|
|
487
|
+
- `actor.invoke(name, data)` — typed over the exposed RPC map, carrying the
|
|
488
|
+
actor's session. Declare `actor: true` and destructure it off the wire.
|
|
489
|
+
- `.invokeRaw(name, data, { headers })` — same call, reporting
|
|
490
|
+
`{ status, ok, body }`, for when the refusal is the assertion.
|
|
491
|
+
- a plain `fetch` against `requireScenarioEnv(scenarioStep).apiUrl`, for
|
|
492
|
+
anything not an RPC — a websocket, a file upload, a webhook.
|
|
493
|
+
|
|
494
|
+
Two consequences follow, and both shape how steps get written:
|
|
495
|
+
|
|
496
|
+
- **A step cannot observe anything the app does not publish.** If a test needs a
|
|
497
|
+
fact the client never sees, the fix is to emit it on the stream or expose it
|
|
498
|
+
as an RPC — which usually improves the product, since a client debugging the
|
|
499
|
+
same problem needed it too.
|
|
500
|
+
- **`agentRunner` is conditional.** It is built only when the project declares
|
|
501
|
+
agents, and `createDevAgentRunner` needs a base URL *and* a key together
|
|
502
|
+
(`OPENAI_BASE_URL` + `OPENAI_API_KEY`, or the LiteLLM pair). With a key alone
|
|
503
|
+
it returns nothing and `agentRunner` is `undefined`, so `actor.converse`
|
|
504
|
+
fails before the persona says anything. A suite that would rather own its own
|
|
505
|
+
model can pass an `llm` to `runConversation` instead of relying on this one.
|
|
506
|
+
|
|
452
507
|
### Browser steps
|
|
453
508
|
|
|
454
509
|
Declaring a `browser` binding is the whole switch: inside that binding `wire.browser` is guaranteed present and non-optional, and a step without one never sees a browser at all. There is nothing to null-check.
|
|
@@ -468,7 +523,9 @@ export const opensTheCart = pikkuScenarioStep<
|
|
|
468
523
|
await browser.goto(path)
|
|
469
524
|
return { url: browser.page.url() }
|
|
470
525
|
},
|
|
471
|
-
default: async ({
|
|
526
|
+
default: async (_services, _data, { actor }) => ({
|
|
527
|
+
url: (await actor.invoke('getCart', {})).url,
|
|
528
|
+
}),
|
|
472
529
|
})
|
|
473
530
|
```
|
|
474
531
|
|
|
@@ -525,11 +582,68 @@ A persona holds only what is true of that kind of person for the app's whole lif
|
|
|
525
582
|
|
|
526
583
|
`kind: "system"` is the app acting on its own — a schedule, a cleanup, a send. It gets **no actor**: there is nobody to sign in. Give it one by hand only if it genuinely has a service account.
|
|
527
584
|
|
|
585
|
+
#### Declaring personas in TypeScript
|
|
586
|
+
|
|
587
|
+
`definePersonas({ … })` is the code form of the block above, and there may be
|
|
588
|
+
**one call in the whole codebase** — one place to read the set from, one place
|
|
589
|
+
to add to it. A second anywhere, including in the same file, is a critical.
|
|
590
|
+
Generated files are exempt and never claim the slot.
|
|
591
|
+
|
|
592
|
+
> [!WARNING]
|
|
593
|
+
> The declaration is **read from source, never evaluated** — the CLI writes it
|
|
594
|
+
> to JSON that a deployed stage carries without the app. So every value has to
|
|
595
|
+
> be statically knowable, and a value that is not comes out as `undefined`
|
|
596
|
+
> rather than as an error. Only `name` is checked, so a computed `personality`,
|
|
597
|
+
> `jobTitle` or `description` is dropped in silence and the persona runs with a
|
|
598
|
+
> blank temperament.
|
|
599
|
+
|
|
600
|
+
What that admits and what it does not:
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
personality: 'Wound up and short with it.' // read
|
|
604
|
+
personality: `Wound up and short with it.
|
|
605
|
+
Says what she wants in a few blunt words.` // read — no ${} in it
|
|
606
|
+
personality: 'Wound up. ' + 'Short with it.' // dropped, silently
|
|
607
|
+
personality: TEMPERAMENTS.impatient // dropped, silently
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
A no-substitution template literal is a string literal as far as the reader is
|
|
611
|
+
concerned, so it is the way to write a long personality across several lines —
|
|
612
|
+
not a concatenation, and not a `prettier-ignore`d single line. Its newlines and
|
|
613
|
+
leading indentation are kept verbatim and reach the model that way, which is
|
|
614
|
+
harmless but worth knowing before you align it to the surrounding code.
|
|
615
|
+
|
|
616
|
+
One more thing worth knowing before writing a rich persona: **`actor.converse`
|
|
617
|
+
builds its prompt from `name`, `jobTitle`, `personality` and the scenario's
|
|
618
|
+
`task` only.** Fields like `disposition`, `goals` and `roles` are read and
|
|
619
|
+
stored, and the console shows them, but they do not reach the conversing
|
|
620
|
+
persona's instructions. Anything that must shape how someone talks belongs in
|
|
621
|
+
`personality` or in the task.
|
|
622
|
+
|
|
528
623
|
An actor with no `persona` is its own persona, so a project that never declares any keeps working unchanged.
|
|
529
624
|
|
|
530
625
|
- `environments.<name>.apiUrl` is required. `signInPath` defaults to `/auth/sign-in/actor`, `rpcPath` to `/rpc`.
|
|
531
626
|
- **`SCENARIO_ACTOR_SECRET` is an environment variable and never goes in `pikku.config.json`.** It signs actors in. `pikku scenario run` throws without it; a server auto-building actors warns and runs without them.
|
|
532
627
|
|
|
628
|
+
### The same actors sign a human in
|
|
629
|
+
|
|
630
|
+
Declared actors are not only for automated runs. `signInPath` is Better Auth's
|
|
631
|
+
`actor` plugin (see `pikku-better-auth`), which any caller can post to — so the
|
|
632
|
+
frontend gets a one-click "Sign in as …" switcher over the **same** list, and an
|
|
633
|
+
app can be reviewed as each kind of user without anyone knowing a seed password.
|
|
634
|
+
|
|
635
|
+
The sandbox dev server bakes both halves into the frontend from the declared
|
|
636
|
+
personas: `VITE_DEV_ACTORS` (the JSON actor list) and
|
|
637
|
+
`VITE_SCENARIO_ACTOR_SECRET`. Neither is set in a production build, so the
|
|
638
|
+
control renders nothing there — but gate the reads on your bundler's dev flag
|
|
639
|
+
anyway (`import.meta.env.DEV ? … : undefined`) so the secret never reaches a
|
|
640
|
+
production bundle in the first place.
|
|
641
|
+
|
|
642
|
+
Do not hand-roll the switcher: `useDevActors()` (`pikku-react`) is the logic and
|
|
643
|
+
`<DevActorSwitcher />` from `@pikku/mantine/dev` is a ready rendering of it.
|
|
644
|
+
`pikku fabric validate` **requires** any frontend with a login screen to ship
|
|
645
|
+
one — without it a reviewer is locked out of their own sandbox.
|
|
646
|
+
|
|
533
647
|
## Running
|
|
534
648
|
|
|
535
649
|
```bash
|
|
@@ -568,14 +682,13 @@ Coverage is attributed by running scenarios against a server that is collecting
|
|
|
568
682
|
Prerequisite in `pikku.config.json`:
|
|
569
683
|
|
|
570
684
|
```bash
|
|
571
|
-
pikku enable scenarios # sets scaffold.scenarios = true
|
|
572
|
-
pikku enable scenarios --noAuth # sets scaffold.scenarios = { "auth": false }
|
|
685
|
+
pikku enable scenarios # sets scaffold.scenarios = true
|
|
573
686
|
```
|
|
574
687
|
|
|
575
|
-
`scaffold.scenarios` is a boolean or `{
|
|
576
|
-
|
|
577
|
-
under a shape where a string could be a path, silently reading
|
|
578
|
-
would be worse than failing.
|
|
688
|
+
`scaffold.scenarios` is a boolean or `{ path? }` — whether the surface exists
|
|
689
|
+
and where it is written. A bare string is **rejected by the config loader**, not
|
|
690
|
+
reinterpreted: under a shape where a string could be a path, silently reading
|
|
691
|
+
one as a flag would be worse than failing.
|
|
579
692
|
|
|
580
693
|
`scaffold.scenarios` generates the coverage and stub RPCs into your project (`pikkuScenarioTakeLiveCoverage`, `pikkuScenarioResetLiveCoverage`, `pikkuScenarioResetStubs`, `pikkuScenarioGetStubCalls`), so scenario runs work against any server. The coverage RPC reads `<outDir>/function/pikku-functions-meta-verbose.gen.json` off disk at request time — codegen always writes it, but it has to be deployed alongside the app or the RPC returns `null`.
|
|
581
694
|
|
|
@@ -592,9 +705,7 @@ The run resets coverage before each scenario and snapshots after, writing **`<ou
|
|
|
592
705
|
"generatedAt": "…",
|
|
593
706
|
"environment": "local",
|
|
594
707
|
"scenarios": {
|
|
595
|
-
"<name>": {
|
|
596
|
-
/* FunctionCoverageReport */
|
|
597
|
-
},
|
|
708
|
+
"<name>": {/* FunctionCoverageReport */},
|
|
598
709
|
},
|
|
599
710
|
}
|
|
600
711
|
```
|
|
@@ -51,7 +51,12 @@ development and a single-instance deployment, wrong for anything else.
|
|
|
51
51
|
### Scheduling a one-off RPC
|
|
52
52
|
|
|
53
53
|
```typescript
|
|
54
|
-
const taskId = await schedulerService.scheduleRPC(
|
|
54
|
+
const taskId = await schedulerService.scheduleRPC(
|
|
55
|
+
'5m',
|
|
56
|
+
'sendReminder',
|
|
57
|
+
data,
|
|
58
|
+
session
|
|
59
|
+
)
|
|
55
60
|
await schedulerService.getTask(taskId) // { rpcName, scheduledFor, status, … } | null
|
|
56
61
|
await schedulerService.getAllTasks() // pending one-offs only
|
|
57
62
|
await schedulerService.unschedule(taskId) // true when it was still pending
|
|
@@ -52,7 +52,7 @@ called `schema` in the source, which reads backwards.
|
|
|
52
52
|
|
|
53
53
|
- **Registration is name-keyed and never re-compiles.** A second
|
|
54
54
|
`compileSchema('X', …)` with a different schema is a no-op; the first one wins
|
|
55
|
-
for the process lifetime. `@pikku/schema-cfworker`
|
|
55
|
+
for the process lifetime. `@pikku/schema-cfworker` _does_ recompile on a
|
|
56
56
|
changed value, so a dev hot-reload after codegen picks up a changed schema
|
|
57
57
|
there but not here — restart the process instead.
|
|
58
58
|
- **AJV is a module-level singleton**, shared by every `AjvSchemaService` you
|
|
@@ -62,7 +62,7 @@ called `schema` in the source, which reads backwards.
|
|
|
62
62
|
become `1` — the wiring layer is what coerces, not this service.
|
|
63
63
|
- `ajv-formats` is registered, so `format` keywords (`email`, `uuid`, `date-time`)
|
|
64
64
|
are enforced.
|
|
65
|
-
- A failed validation throws `UnprocessableContentError` (a 422). A
|
|
65
|
+
- A failed validation throws `UnprocessableContentError` (a 422). A _missing_
|
|
66
66
|
schema throws a bare string, `Missing validator for <name>` — not an `Error`,
|
|
67
67
|
so `catch (e) { e.message }` reads `undefined`. That normally means codegen
|
|
68
68
|
didn't run.
|
|
@@ -63,7 +63,7 @@ surface as behaviour changes rather than compile errors:
|
|
|
63
63
|
underlying cause swallowed — check the schema by hand when you see it.
|
|
64
64
|
|
|
65
65
|
A failed validation throws `UnprocessableContentError` (422) with the validator
|
|
66
|
-
errors joined; a
|
|
66
|
+
errors joined; a _missing_ schema throws a bare string, `Missing validator for
|
|
67
67
|
<name>`, not an `Error`.
|
|
68
68
|
|
|
69
69
|
## Usage Patterns
|
|
@@ -62,7 +62,7 @@ Apply these via `addHTTPMiddleware` in a wirings file:
|
|
|
62
62
|
|
|
63
63
|
```typescript
|
|
64
64
|
import { authBearer, authCookie, authAPIKey } from '@pikku/core/middleware'
|
|
65
|
-
import { addHTTPMiddleware } from '#pikku'
|
|
65
|
+
import { addHTTPMiddleware } from '#pikku/http'
|
|
66
66
|
|
|
67
67
|
// JWT bearer token — reads Authorization header
|
|
68
68
|
addHTTPMiddleware('*', [authBearer()])
|
|
@@ -118,14 +118,18 @@ response.
|
|
|
118
118
|
|
|
119
119
|
```typescript
|
|
120
120
|
// permissions.ts
|
|
121
|
-
import { pikkuAuth, pikkuPermission } from '#pikku'
|
|
121
|
+
import { pikkuAuth, pikkuPermission } from '#pikku/function'
|
|
122
122
|
|
|
123
|
-
export const isAuthenticated = pikkuAuth(
|
|
124
|
-
|
|
123
|
+
export const isAuthenticated = pikkuAuth(
|
|
124
|
+
async (_services, session) => !!session
|
|
125
|
+
)
|
|
126
|
+
export const isVerified = pikkuAuth(
|
|
127
|
+
async (_services, session) => !!session?.emailVerified
|
|
128
|
+
)
|
|
125
129
|
|
|
126
130
|
// wirings/auth.wiring.ts
|
|
127
131
|
import { authCookie } from '@pikku/core/middleware'
|
|
128
|
-
import { addHTTPMiddleware } from '#pikku'
|
|
132
|
+
import { addHTTPMiddleware } from '#pikku/http'
|
|
129
133
|
|
|
130
134
|
addHTTPMiddleware('*', [
|
|
131
135
|
authCookie({
|