@pikku/skills 0.12.15 → 0.12.17
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 +69 -0
- package/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-better-auth/SKILL.md +210 -24
- package/skills/pikku-build-app/SKILL.md +65 -2
- package/skills/pikku-build-platform/SKILL.md +7 -0
- package/skills/pikku-build-quick/SKILL.md +12 -1
- package/skills/pikku-concepts/SKILL.md +91 -1
- package/skills/pikku-fabric/SKILL.md +21 -6
- package/skills/pikku-feature/SKILL.md +7 -0
- package/skills/pikku-i18n/SKILL.md +77 -2
- package/skills/pikku-middleware/SKILL.md +76 -1
- package/skills/pikku-permissions/SKILL.md +4 -0
- package/skills/pikku-scenario/SKILL.md +51 -0
- package/skills/pikku-schedule/SKILL.md +23 -0
package/package.json
CHANGED
|
@@ -137,21 +137,21 @@ resolves the caller's scopes through the registered `ScopeService` and checks th
|
|
|
137
137
|
`admin:*` tree (`ADMIN_SCOPES` exports the ids so you never spell them as bare
|
|
138
138
|
strings):
|
|
139
139
|
|
|
140
|
-
| Gate | Scope required
|
|
141
|
-
| -------------------------------------------------------------------- |
|
|
142
|
-
| `impersonation` (`betterAuthSession` / `betterAuthStatelessSession`) | `admin:impersonate`
|
|
143
|
-
| `credentialOAuth`'s `canLinkSingleton` | `admin:credentials:link`
|
|
144
|
-
| the console's user directory | `admin:users:list`
|
|
145
|
-
| create a user out of band | `admin:users:create`
|
|
146
|
-
| ban / unban | `admin:users:ban`
|
|
147
|
-
| delete a user and their data | `admin:users:remove`
|
|
148
|
-
| revoke a user's sessions | `admin:users:sessions`
|
|
149
|
-
| set a user's password | `admin:users:password`
|
|
150
|
-
| read credential values and who holds them | `admin:credentials:read`
|
|
140
|
+
| Gate | Scope required |
|
|
141
|
+
| -------------------------------------------------------------------- | -------------------------- |
|
|
142
|
+
| `impersonation` (`betterAuthSession` / `betterAuthStatelessSession`) | `admin:impersonate` |
|
|
143
|
+
| `credentialOAuth`'s `canLinkSingleton` | `admin:credentials:link` |
|
|
144
|
+
| the console's user directory | `admin:users:list` |
|
|
145
|
+
| create a user out of band | `admin:users:create` |
|
|
146
|
+
| ban / unban | `admin:users:ban` |
|
|
147
|
+
| delete a user and their data | `admin:users:remove` |
|
|
148
|
+
| revoke a user's sessions | `admin:users:sessions` |
|
|
149
|
+
| set a user's password | `admin:users:password` |
|
|
150
|
+
| read credential values and who holds them | `admin:credentials:read` |
|
|
151
151
|
| set and delete credentials | `admin:credentials:manage` |
|
|
152
|
-
| view declared scopes, roles, and who holds them | `admin:scopes:read`
|
|
153
|
-
| create roles, change their scopes, grant them | `admin:scopes:manage`
|
|
154
|
-
| read the audit trail | `admin:audit:read`
|
|
152
|
+
| view declared scopes, roles, and who holds them | `admin:scopes:read` |
|
|
153
|
+
| create roles, change their scopes, grant them | `admin:scopes:manage` |
|
|
154
|
+
| read the audit trail | `admin:audit:read` |
|
|
155
155
|
|
|
156
156
|
Holding the bare `admin` scope satisfies all of them — a parent grant covers
|
|
157
157
|
everything nested beneath it — so `admin` is the direct replacement for the old
|
|
@@ -247,6 +247,112 @@ create a session for a banned user, lapsing an expired ban as it goes. It makes
|
|
|
247
247
|
no authorization decision — who may ban is decided by `admin:users:ban` — so it
|
|
248
248
|
never needs to know about scopes or roles.
|
|
249
249
|
|
|
250
|
+
### The plugins `@pikku/better-auth` ships
|
|
251
|
+
|
|
252
|
+
Five, all imported from the package root and passed to `betterAuth({ plugins })`
|
|
253
|
+
like any other. None is automatic — an app wires the ones it needs.
|
|
254
|
+
|
|
255
|
+
| Plugin | Plugin `id` | Adds | Use it when |
|
|
256
|
+
| ------------------- | ------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
257
|
+
| `ban()` | `pikku-ban` | `user.banned/banReason/banExpires` | You ban users (the schema + enforcement half of the above) |
|
|
258
|
+
| `actor()` | `actor` | `POST /sign-in/actor`, `user.actor` | Scenarios or a dev switcher sign in as a persona |
|
|
259
|
+
| `credentialOAuth()` | `credential-oauth` | `POST /credential-oauth/link`, `/credential-oauth/callback/:providerId` | An app links OAuth2 **API credentials** for a user |
|
|
260
|
+
| `delegatedAuth()` | `delegated-auth` | `POST /sign-in/delegated` | An imported upstream API is the system of record for identity |
|
|
261
|
+
| `fabric()` | `fabric` | `POST /sign-in/fabric` | A Fabric-deployed app lets a control-plane operator in |
|
|
262
|
+
|
|
263
|
+
The plugin's `id` is what better-auth stores; the **export name** is what the
|
|
264
|
+
inspector reads off your `plugins` array and what generated metadata is keyed
|
|
265
|
+
by, so the two differ for `ban` and `delegatedAuth`.
|
|
266
|
+
|
|
267
|
+
#### `credentialOAuth()` — link API credentials, not identities
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
credentialOAuth({
|
|
271
|
+
config: [
|
|
272
|
+
{
|
|
273
|
+
providerId: 'github',
|
|
274
|
+
type: 'wire',
|
|
275
|
+
clientId,
|
|
276
|
+
clientSecret,
|
|
277
|
+
authorizationUrl,
|
|
278
|
+
tokenUrl,
|
|
279
|
+
scopes: ['repo'],
|
|
280
|
+
},
|
|
281
|
+
{ providerId: 'slack', type: 'singleton' /* … */ },
|
|
282
|
+
],
|
|
283
|
+
scopeService,
|
|
284
|
+
logger,
|
|
285
|
+
})
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Wraps better-auth's `genericOAuth` to keep its token exchange and refresh, and
|
|
289
|
+
replaces only the two identity-bound endpoints. `genericOAuth`'s own
|
|
290
|
+
`/oauth2/link` models an identity **provider**: it demands a userinfo response
|
|
291
|
+
and refuses to link when the provider's email differs from the user's. A
|
|
292
|
+
credential is not an identity — most credential providers expose only
|
|
293
|
+
`/authorize` and `/token` — so the account row is keyed on _whose_ credential it
|
|
294
|
+
is (`accountId` = the linking user's id), making `(providerId, userId)` unique
|
|
295
|
+
by construction. Tokens land in better-auth's `account` table, so
|
|
296
|
+
`auth.api.getAccessToken()` refreshes them on read.
|
|
297
|
+
|
|
298
|
+
`type` decides the blast radius:
|
|
299
|
+
|
|
300
|
+
- **`wire`** — every user links their own, and the credential is read on
|
|
301
|
+
the wire that runs as them. Signed in is enough.
|
|
302
|
+
- **`singleton`** — one token the whole app shares, owned by a reserved
|
|
303
|
+
`pikku-platform` user row created on demand. Rebinding it changes the
|
|
304
|
+
credential for _everyone_, so it is gated on `admin:credentials:link` (or the
|
|
305
|
+
`admin` root above it), and **fails closed** with no `ScopeService`. Override
|
|
306
|
+
the whole gate with `canLinkSingleton`.
|
|
307
|
+
|
|
308
|
+
An undeclared `providerId` is a 404; an anonymous caller a 401; a refused
|
|
309
|
+
singleton a 403 that leaves no platform user behind.
|
|
310
|
+
|
|
311
|
+
#### `delegatedAuth()` — the upstream API is the identity provider
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
delegatedAuth({
|
|
315
|
+
authenticate: async ({ email, password, apiKey }) => upstream.login(...),
|
|
316
|
+
storeCredential: (userId, identity) =>
|
|
317
|
+
credentialService.set('acme', identity.credential, userId),
|
|
318
|
+
defaultRole: 'member',
|
|
319
|
+
mapRole: (upstreamRole) => ROLE_MAP[upstreamRole],
|
|
320
|
+
scopeService,
|
|
321
|
+
logger,
|
|
322
|
+
})
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`POST /sign-in/delegated` forwards the credentials the user already has to
|
|
326
|
+
`authenticate`. On success it JIT-provisions a real user row (email-keyed and
|
|
327
|
+
`emailVerified` — the upstream just verified them), links it via an `account`
|
|
328
|
+
row (`providerId: 'delegated'`, `accountId: externalId`), persists the upstream
|
|
329
|
+
token **before** minting the session, and returns a normal session cookie.
|
|
330
|
+
Passwords are never stored. Exactly one upstream per app: additional imported
|
|
331
|
+
APIs are linked integrations (`credentialOAuth`), not extra login methods.
|
|
332
|
+
|
|
333
|
+
A resolved role is granted through the `ScopeService` as a pikku role, so it
|
|
334
|
+
lands in `pikku_user_role` rather than on a column. A role the app never
|
|
335
|
+
defined is a provisioning gap, not a sign-in failure — the grant is dropped with
|
|
336
|
+
a warning and the user still gets in.
|
|
337
|
+
|
|
338
|
+
`storeCredential` failing, by contrast, **fails the sign-in**: every proxied
|
|
339
|
+
call would be dead anyway.
|
|
340
|
+
|
|
341
|
+
#### `fabric()` — control-plane operator sign-in
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
fabric({ publicKey: FABRIC_AUTH_PUBLIC_KEY, scopeService, logger })
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
`POST /sign-in/fabric` verifies a short-lived RS256 token that the Fabric
|
|
348
|
+
control plane signed for an operator session, then signs them into a synthetic
|
|
349
|
+
`fabric-<id>@fabric.internal` row holding the `admin` scope. Asymmetric on
|
|
350
|
+
purpose: the app holds only the public key, so it can never forge an operator
|
|
351
|
+
login, and the same `FABRIC_AUTH_PUBLIC_KEY` is distributed to every stage with
|
|
352
|
+
no per-environment secret. A missing or empty key disables the endpoint, and a
|
|
353
|
+
token whose `purpose` claim is not `fabric-admin` is rejected. Without a
|
|
354
|
+
`ScopeService` the operator signs in holding nothing.
|
|
355
|
+
|
|
250
356
|
### 2. Production database adapter
|
|
251
357
|
|
|
252
358
|
For real deployments swap `memoryAdapter` for the Kysely adapter backed by an injected DB. Better Auth owns its own tables (`user`, `session`, `account`, `verification`, plus plugin tables) — generate its schema with `npx @better-auth/cli generate` and apply it as a migration.
|
|
@@ -371,14 +477,35 @@ plugins: [actor({ secret: SCENARIO_ACTOR_SECRET })]
|
|
|
371
477
|
session cookie. `secret` may also be a (possibly async) function, so it can come
|
|
372
478
|
off the secrets service instead of a captured value.
|
|
373
479
|
|
|
480
|
+
**Which command is running decides whether it works, not whether a secret is
|
|
481
|
+
set.** `pikku dev` sets `PIKKU_DEV_ACTOR_SIGN_IN` and mints an ephemeral
|
|
482
|
+
`SCENARIO_ACTOR_SECRET` for the run, so local development needs no configuration
|
|
483
|
+
at all. Everywhere else the endpoint refuses (`Actor sign-in is disabled outside
|
|
484
|
+
\`pikku dev\``) — `pikku serve` clears the marker outright, so a secret that
|
|
485
|
+
leaked into a production environment enables nothing and gets a warning naming
|
|
486
|
+
itself instead.
|
|
487
|
+
|
|
488
|
+
A stage that genuinely must run scenarios opts in on purpose, with
|
|
489
|
+
`PIKKU_ALLOW_ACTOR_SIGN_IN=passwordless-actor-sign-in`. Any other value is
|
|
490
|
+
ignored and warned about, so the hatch cannot be opened by copying a `true` from
|
|
491
|
+
the line above, and it is the only hatch — there is no build-time option, because
|
|
492
|
+
an option compiled into the bundle cannot be audited from the environment it
|
|
493
|
+
runs in.
|
|
494
|
+
|
|
495
|
+
**Signing in and provisioning are separate powers.** An unknown address becomes
|
|
496
|
+
an `actor: true` row only under `pikku dev`. With the opt-in set, a stage signs
|
|
497
|
+
in as the personas the deployment provisioned when it started and refuses
|
|
498
|
+
everything else (`No actor account exists for that address`), so holding the
|
|
499
|
+
secret on such a stage does not let anyone invent identities. Those rows are
|
|
500
|
+
written by `provisionPersonas` from `@pikku/better-auth`, called in the server's
|
|
501
|
+
own lifecycle, so provisioning needs no actor secret and works on a stage whose
|
|
502
|
+
endpoint is shut.
|
|
503
|
+
|
|
374
504
|
**`SCENARIO_ACTOR_SECRET` is a credential as powerful as the most privileged
|
|
375
|
-
persona.**
|
|
376
|
-
`admin` persona is an actor holding real admin — anyone with the secret
|
|
377
|
-
a session as one.
|
|
378
|
-
|
|
379
|
-
which is the supported switch (`Actor sign-in is not configured`), rather than
|
|
380
|
-
conditionally registering the plugin. Do not treat "actors only" as a licence to
|
|
381
|
-
enable it in production.
|
|
505
|
+
persona.** Provisioning grants declared roles to actor accounts, so an
|
|
506
|
+
`admin` persona is an actor holding real admin — anyone with the secret _and_ the
|
|
507
|
+
opt-in can take a session as one. Do not treat "actors only" as a licence to open
|
|
508
|
+
the hatch in production.
|
|
382
509
|
|
|
383
510
|
Within that boundary, three properties bound the damage:
|
|
384
511
|
|
|
@@ -386,9 +513,9 @@ Within that boundary, three properties bound the damage:
|
|
|
386
513
|
column; an email matching a row without it is refused with `User is not an
|
|
387
514
|
actor`. So the secret cannot take over a **real user's** account — the blast
|
|
388
515
|
radius is the actor accounts and whatever roles they were granted.
|
|
389
|
-
- **Unknown emails are created
|
|
390
|
-
|
|
391
|
-
|
|
516
|
+
- **Unknown emails are created only under `pikku dev`**, flagged `actor: true`,
|
|
517
|
+
so a local scenario declaring a new persona needs no seed step. Anywhere else
|
|
518
|
+
the account has to have been provisioned at boot first.
|
|
392
519
|
- **The comparison is constant-time and length-hiding**, so a wrong secret leaks
|
|
393
520
|
neither the length nor a prefix of the right one.
|
|
394
521
|
|
|
@@ -396,6 +523,65 @@ This is the endpoint `pikku scenario` signs its actors in through, and the one
|
|
|
396
523
|
the frontend dev switcher posts to — see `pikku-scenario` for declaring the
|
|
397
524
|
actors and `pikku-react` for `useDevActors()`.
|
|
398
525
|
|
|
526
|
+
### Provisioning personas (`provisionPersonas`)
|
|
527
|
+
|
|
528
|
+
Anywhere but `pikku dev`, the accounts have to exist before anyone signs in.
|
|
529
|
+
The deployment creates them itself, from its own lifecycle:
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
import { provisionPersonas } from '@pikku/better-auth'
|
|
533
|
+
import {
|
|
534
|
+
personaConfigs,
|
|
535
|
+
personaEnvironments,
|
|
536
|
+
} from '#pikku/pikku-personas.gen.js'
|
|
537
|
+
|
|
538
|
+
await provisionPersonas(singletonServices, {
|
|
539
|
+
personas: personaConfigs,
|
|
540
|
+
environments: personaEnvironments,
|
|
541
|
+
})
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
It runs where the database already is, which is the point: the CLI has no
|
|
545
|
+
connection to a deployed environment's database — it resolves one from the local
|
|
546
|
+
project config — so a `pikku persona sync staging` that wrote rows would write
|
|
547
|
+
them to whatever database the checkout happened to point at. `pikku persona sync
|
|
548
|
+
<environment>` still exists, and reports who that environment will provision and
|
|
549
|
+
why anyone was skipped, which is what you run _before_ the deploy.
|
|
550
|
+
|
|
551
|
+
It creates missing accounts as `actor: true`, applies the roles each persona
|
|
552
|
+
declares, and is additive — it never revokes. `PIKKU_ENV` (or an explicit
|
|
553
|
+
`environment`) selects who is eligible, through the same rule that decides who
|
|
554
|
+
may run there; an address already held by a real, non-actor user throws rather
|
|
555
|
+
than being granted the persona's roles.
|
|
556
|
+
|
|
557
|
+
**Deleting a persona does not delete its account.** Being additive leaves a hole:
|
|
558
|
+
the account keeps every role it was granted, and the actor endpoint authenticates
|
|
559
|
+
on the `actor` column alone without consulting the declaration — so an `admin`
|
|
560
|
+
persona nobody declares any more is still a live way in wherever that endpoint is
|
|
561
|
+
open. By default provisioning warns about those accounts and changes nothing.
|
|
562
|
+
`orphans: 'ban'` shuts them:
|
|
563
|
+
|
|
564
|
+
```ts
|
|
565
|
+
await provisionPersonas(singletonServices, {
|
|
566
|
+
personas: personaConfigs,
|
|
567
|
+
environments: personaEnvironments,
|
|
568
|
+
orphans: 'ban',
|
|
569
|
+
})
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
It writes the same `banned` column the console's ban RPC writes (so it needs the
|
|
573
|
+
`ban()` plugin wired, and says so if it isn't), revokes the account's sessions,
|
|
574
|
+
and leaves the row, its grants and its history intact — provisioning lifts the
|
|
575
|
+
ban again by itself if the persona comes back. Deleting is deliberately not
|
|
576
|
+
offered: an actor row is referenced by whatever those scenarios did while it
|
|
577
|
+
existed.
|
|
578
|
+
|
|
579
|
+
`report` is the default because a rolling deploy runs the new replica's
|
|
580
|
+
provisioning while the old replica is still serving, so for the length of that
|
|
581
|
+
overlap "no persona claims this" is a statement about the newer declaration only.
|
|
582
|
+
A persona pinned to another environment counts as unclaimed here — it has no
|
|
583
|
+
business holding a signable account in an environment its own rule refuses it.
|
|
584
|
+
|
|
399
585
|
---
|
|
400
586
|
|
|
401
587
|
## Secret Management
|
|
@@ -28,7 +28,10 @@ project shaped so `pikku fabric init` later adopts it with zero rework.
|
|
|
28
28
|
## Agent Operating Procedure
|
|
29
29
|
|
|
30
30
|
1. Discover before editing. Run `pikku info functions --verbose --silent` and
|
|
31
|
-
read `AGENTS.md` before your first change.
|
|
31
|
+
read `AGENTS.md` before your first change. Read `locale` in
|
|
32
|
+
`pikku.config.json` too: it is the language every `description`, `title` and
|
|
33
|
+
step `template` you write must be in (§1a). Identifiers stay English whatever
|
|
34
|
+
it says.
|
|
32
35
|
2. Make the smallest source change that satisfies the task. Keep generated files
|
|
33
36
|
generated — never hand-edit `.pikku/`, `*.gen.*`, or the SDK.
|
|
34
37
|
3. Validate with the narrowest relevant command, then `pikku all` when functions,
|
|
@@ -72,10 +75,68 @@ in one message. Then stop; do not interview the user.
|
|
|
72
75
|
Neutral (fine for an internal tool, but say so out loud); a direction in words;
|
|
73
76
|
a reference (brand guide, screenshots, a site whose register they want); or
|
|
74
77
|
their own design agent/prompt, whose output you take as the direction.
|
|
78
|
+
- **What language should the app speak, and what language does the team work
|
|
79
|
+
in?** Two answers, not one — see §1a, which is where they go. Ask only if the
|
|
80
|
+
request is not obviously English; a brief written in English about an English
|
|
81
|
+
product answers both.
|
|
75
82
|
|
|
76
83
|
Skip anything you can decide yourself. If nobody answers, assume one app with
|
|
77
84
|
paths, the roles implied by the request, Neutral, English — and say so.
|
|
78
85
|
|
|
86
|
+
## 1a. Three languages, and you must not collapse them
|
|
87
|
+
|
|
88
|
+
A brief saying "the entire UI is German" is about **one** of these. Getting this
|
|
89
|
+
wrong has already shipped a project that can never add a second language, so
|
|
90
|
+
settle all three explicitly before you write code.
|
|
91
|
+
|
|
92
|
+
| Axis | What it covers | Where it goes |
|
|
93
|
+
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
94
|
+
| **Identifiers** | Function, component, type, variable and file names. Database tables and columns. Commit messages. | Nowhere — **always English**, no setting, not negotiable |
|
|
95
|
+
| **Meta** | `description` on functions and steps, `name`/`title` on features and scenarios, step `template`, role and persona descriptions | `locale` in `pikku.config.json`, default `en` |
|
|
96
|
+
| **Product UI** | Every string the app shows a user | `messages/<locale>.json`, and `defaultLocale` for what a first-time visitor opens in |
|
|
97
|
+
|
|
98
|
+
**Identifiers are English.** The product's market does not change this and
|
|
99
|
+
neither does `locale`. Identifiers are the surface the generated `#pikku/*`
|
|
100
|
+
clients, `pikku info`, the typed RPC map and the Kysely types all bind to, and
|
|
101
|
+
unlike a string an identifier cannot be translated later — renaming one is a
|
|
102
|
+
migration. A German practice management tool gets `getWorklist`, `case`,
|
|
103
|
+
`event`, not `getUebersicht`, `vorgang`, `ereignis`.
|
|
104
|
+
|
|
105
|
+
**Meta follows `locale`.** Write the team's answer into `pikku.config.json` in
|
|
106
|
+
this phase, before there is any meta to be wrong:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{ "locale": "de" }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
It exists for the Pikku Console. Meta is the one part of a project the Console
|
|
113
|
+
renders back to a human, so a team working in German reads their own functions,
|
|
114
|
+
features and scenario reports in German. Default `en` and do not ask when the
|
|
115
|
+
project is obviously English. **On every later run, read this field first and
|
|
116
|
+
author descriptions, titles and step templates in it** — a project whose
|
|
117
|
+
`locale` you ignored reports half in one language and half in another.
|
|
118
|
+
|
|
119
|
+
**Product UI is the message catalogue.** `messages/<locale>.json` via
|
|
120
|
+
`pikku-i18n`, with `defaultLocale` deciding what a visitor opens in.
|
|
121
|
+
`baseLocale` in `project.inlang/settings.json` **stays `en`**: it names the
|
|
122
|
+
message source, the catalogue every other language is cloned from, so a project
|
|
123
|
+
that repoints it has nothing to translate from and `--add-locale` is broken
|
|
124
|
+
forever.
|
|
125
|
+
|
|
126
|
+
A German medical portal, correctly:
|
|
127
|
+
|
|
128
|
+
```jsonc
|
|
129
|
+
// project.inlang/settings.json
|
|
130
|
+
{ "baseLocale": "en", "locales": ["en", "de"] }
|
|
131
|
+
// apps/app/src/i18n/active.json (or: fabric i18n --default-locale de)
|
|
132
|
+
{ "defaultLocale": "de" }
|
|
133
|
+
// pikku.config.json
|
|
134
|
+
{ "locale": "de" }
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Record the two non-obvious answers as a `decisions/` note in §2 — neither is
|
|
138
|
+
discoverable from code, and the next agent will otherwise re-derive them wrong.
|
|
139
|
+
|
|
79
140
|
---
|
|
80
141
|
|
|
81
142
|
## PHASE 1 — What the app is
|
|
@@ -325,7 +386,9 @@ or not the note says `built`.
|
|
|
325
386
|
for working on an empty state the test data would hide). Because reset always
|
|
326
387
|
arrives at a database it just wiped, **the seed file is plain `INSERT`s** — no
|
|
327
388
|
`ON CONFLICT DO NOTHING`, no `INSERT OR IGNORE`. Nothing ever applies it
|
|
328
|
-
twice, so it never has to defend itself.
|
|
389
|
+
twice, so it never has to defend itself. It is also **local only** — no deploy
|
|
390
|
+
applies it, so anything the app would be broken without in production is
|
|
391
|
+
configuration and belongs in a migration, not here.
|
|
329
392
|
Do this generously and do it now: an empty app demos badly and critiques
|
|
330
393
|
badly, and you cannot judge a screen's hierarchy, overflow, or truncation
|
|
331
394
|
against zero rows. Seed rows each persona sees differently — with an ownership
|
|
@@ -154,6 +154,13 @@ icons, hardcoded `marginLeft`, a nav that opens on the wrong side.
|
|
|
154
154
|
Adding a language means adding a locale file. If it means touching components,
|
|
155
155
|
that is the finding.
|
|
156
156
|
|
|
157
|
+
`baseLocale` stays `en` through all of this — it names the message source that
|
|
158
|
+
every added locale is cloned from, so three locales is `locales: ["en", …]` and
|
|
159
|
+
never a repointed base. Shipping locales is also not a reason for anything in
|
|
160
|
+
the code to stop being English: identifiers are English in every project, and
|
|
161
|
+
the language of `description`/`title`/`template` is `locale` in
|
|
162
|
+
`pikku.config.json`. See `pikku-build-app` §1a.
|
|
163
|
+
|
|
157
164
|
### Emails — `pikku-emails`
|
|
158
165
|
|
|
159
166
|
Templates in `emails/`, rendered and sent through the injected `email` service,
|
|
@@ -52,6 +52,15 @@ it — one kind of person, or several?** Everything else you decide yourself.
|
|
|
52
52
|
Do not ask about design, deployment, or scope. This is the quick mode; the
|
|
53
53
|
defaults are the point.
|
|
54
54
|
|
|
55
|
+
Do not ask about language either — take the defaults and note them in §6.
|
|
56
|
+
Identifiers are English in every project, whatever the product's market;
|
|
57
|
+
`locale` in `pikku.config.json` (the language of `description`/`title`/step
|
|
58
|
+
`template`, which the Console renders) stays `en` unless the user already told
|
|
59
|
+
you otherwise. If the request says the app's UI is not English, that is the
|
|
60
|
+
message catalogue only: add the locale and set `defaultLocale`, and leave
|
|
61
|
+
`baseLocale` at `en`. `pikku-build-app` §1a has the three axes in full; getting
|
|
62
|
+
them confused is how a project ends up unable to add a second language.
|
|
63
|
+
|
|
55
64
|
## 2. Personas — 60 seconds, not optional
|
|
56
65
|
|
|
57
66
|
`packages/functions/src/personas.ts` ships with a `visitor`. Add one persona per
|
|
@@ -115,7 +124,9 @@ Then, in this order — it is the order codegen depends on:
|
|
|
115
124
|
2. **Seed** — rows in `db/sqlite-dev-seed.sql`. There is no seed command:
|
|
116
125
|
`bunx --bun pikku db reset` wipes, migrates and seeds in one go, and is the
|
|
117
126
|
only thing that applies the file. It always starts from a wiped database, so
|
|
118
|
-
the file is plain `INSERT`s — no `ON CONFLICT DO NOTHING`.
|
|
127
|
+
the file is plain `INSERT`s — no `ON CONFLICT DO NOTHING`. It is local only:
|
|
128
|
+
no deploy applies it, so anything the app cannot run without belongs in a
|
|
129
|
+
migration instead. **Be generous, and
|
|
119
130
|
seed rows for both personas.** An empty app demos badly, and you cannot see a
|
|
120
131
|
layout break against zero rows.
|
|
121
132
|
3. **Functions** — one `pikkuFunc` per `*.function.ts`, `expose: true`. Pikku
|
|
@@ -138,7 +138,9 @@ Services can be destructured inline in the `func` signature (e.g. `async ({ logg
|
|
|
138
138
|
|
|
139
139
|
```typescript
|
|
140
140
|
pikkuFunc({
|
|
141
|
-
// Identity and documentation
|
|
141
|
+
// Identity and documentation — prose, so it follows `locale` in
|
|
142
|
+
// pikku.config.json (default `en`). The identifier does not; see
|
|
143
|
+
// "What Language You Write In".
|
|
142
144
|
title?: string, // Human-readable name
|
|
143
145
|
description?: string, // What the function does
|
|
144
146
|
version?: number, // Contract version (see pikku-versioning)
|
|
@@ -321,6 +323,94 @@ src/
|
|
|
321
323
|
└── pikku-bootstrap.gen.ts
|
|
322
324
|
```
|
|
323
325
|
|
|
326
|
+
## What Language You Write In
|
|
327
|
+
|
|
328
|
+
Three different things in a Pikku project have a human language, and they are
|
|
329
|
+
**not** the same language. Collapsing them is the mistake this section exists to
|
|
330
|
+
prevent, and it has already shipped in a real product — the failure is at the
|
|
331
|
+
bottom.
|
|
332
|
+
|
|
333
|
+
| Axis | What it covers | What decides it |
|
|
334
|
+
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
335
|
+
| **Identifiers** | Function, component, type, variable and file names. Database tables and columns. Branch names and commit messages. | Nothing. **Always English.** There is no setting. |
|
|
336
|
+
| **Meta** | The prose authored _inside_ the code: `description` on functions and steps, `name`/`title` on features and scenarios, step `template`, role/persona descriptions. | `locale` in `pikku.config.json`. Defaults to `en`. |
|
|
337
|
+
| **Product UI** | Every string the app shows a user. | `messages/<locale>.json`, with `active.json`'s `defaultLocale` choosing what a first-time visitor opens in. |
|
|
338
|
+
|
|
339
|
+
### Identifiers are English, and nothing changes that
|
|
340
|
+
|
|
341
|
+
Not the product's market, not the team's working language, and **not `locale`**.
|
|
342
|
+
A German medical practice, an Arabic marketplace and a Japanese logistics tool
|
|
343
|
+
all get `getOverview`, `AttentionStripe`, `case`, `event`.
|
|
344
|
+
|
|
345
|
+
This is not linguistic preference, it is mechanics. Identifiers are the surface
|
|
346
|
+
every other tool binds to: the generated `#pikku/*` clients, `pikku info` and
|
|
347
|
+
`pikku meta`, the RPC map a scenario's `actor.invoke` is typed over, the
|
|
348
|
+
generated SQL types, every skill and every agent that ever picks the project up.
|
|
349
|
+
A `vorgang` table types as `Vorgang` in Kysely and reads as noise to everyone who
|
|
350
|
+
did not name it, and unlike a string it cannot be translated later — renaming an
|
|
351
|
+
identifier is a migration, not an edit.
|
|
352
|
+
|
|
353
|
+
### Meta follows `locale`, and that is what the field is for
|
|
354
|
+
|
|
355
|
+
```json
|
|
356
|
+
{ "locale": "de" }
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Meta is the one part of a project the **Pikku Console** renders back to a human.
|
|
360
|
+
A team reviewing their own functions, features and scenario reports in the
|
|
361
|
+
Console is reading meta and nothing else, so a team whose working language is
|
|
362
|
+
German should be able to read their Console in German. That is the entire reason
|
|
363
|
+
the field exists.
|
|
364
|
+
|
|
365
|
+
Read it before you author meta, and write descriptions, titles and templates in
|
|
366
|
+
it. Absent, it is `en`. It is a BCP-47 tag (`en`, `de`, `pt-BR` — a hyphen, not
|
|
367
|
+
an underscore), and the CLI rejects anything else by name.
|
|
368
|
+
|
|
369
|
+
`locale` is **not** licence to rename anything. `locale: "de"` buys a German
|
|
370
|
+
`description: 'Zeigt die Arbeitsliste'` on a function still called
|
|
371
|
+
`getWorklist`.
|
|
372
|
+
|
|
373
|
+
### Product UI language lives in the catalogue, and only there
|
|
374
|
+
|
|
375
|
+
What the app says to its users is a translation concern, not a code concern. It
|
|
376
|
+
belongs in `messages/<locale>.json`; `pikku-i18n` owns the details. The one rule
|
|
377
|
+
worth repeating here: **`baseLocale` in `project.inlang/settings.json` stays
|
|
378
|
+
`en`.** It names the message _source_ — the catalogue every other language is
|
|
379
|
+
cloned from and translated against — so a project that sets it to anything else
|
|
380
|
+
has no English catalogue to translate from and can never gain a second language
|
|
381
|
+
without re-authoring every key.
|
|
382
|
+
|
|
383
|
+
### The failure this comes from
|
|
384
|
+
|
|
385
|
+
An agent was asked to build a doctor's portal for a German practice. The brief
|
|
386
|
+
said "the entire UI is German, no English strings visible anywhere". The agent
|
|
387
|
+
read one sentence about the product's users as an instruction about the
|
|
388
|
+
codebase, and produced:
|
|
389
|
+
|
|
390
|
+
- `project.inlang/settings.json` with `baseLocale: "de"` and `locales: ["de"]`,
|
|
391
|
+
no `en.json` at all — which silently broke `--add-locale` forever
|
|
392
|
+
- RPC functions `getUebersicht` and `getPatientendetail`
|
|
393
|
+
- React components `Zeitstrahl` and `AufmerksamkeitStreifen`
|
|
394
|
+
- database tables `vorgang` and `ereignis`, with German columns
|
|
395
|
+
|
|
396
|
+
Every one of those is wrong, and the brief was satisfied by none of them: a
|
|
397
|
+
German UI needs German _messages_. What that project actually wanted was three
|
|
398
|
+
settings, each on its own axis:
|
|
399
|
+
|
|
400
|
+
```jsonc
|
|
401
|
+
// project.inlang/settings.json — the message source stays English
|
|
402
|
+
{ "baseLocale": "en", "locales": ["en", "de"] }
|
|
403
|
+
|
|
404
|
+
// apps/app/src/i18n/active.json — what a first-time visitor opens in
|
|
405
|
+
{ "defaultLocale": "de" }
|
|
406
|
+
|
|
407
|
+
// pikku.config.json — the language the team reads their Console in
|
|
408
|
+
{ "locale": "de" }
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Identifiers stay English throughout. When a brief tells you the product speaks a
|
|
412
|
+
language, it is telling you about axis three and nothing else.
|
|
413
|
+
|
|
324
414
|
## Environment Variables
|
|
325
415
|
|
|
326
416
|
Never use `process.env` inside Pikku functions. Use the `variables` service (see `pikku-config`):
|
|
@@ -109,11 +109,25 @@ plain `INSERT`s** — no `INSERT OR IGNORE`, no `ON CONFLICT DO NOTHING`, no
|
|
|
109
109
|
you find yourself reaching for an idempotent form, that's a sign the data wants
|
|
110
110
|
to be a migration instead.
|
|
111
111
|
|
|
112
|
-
This is **
|
|
113
|
-
app.
|
|
114
|
-
and
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
This is **local dev data only**: enough rows that a fresh dev database isn't an
|
|
113
|
+
empty app. Nothing else ever runs it. A deployed stage applies `db/<engine>/*.sql`
|
|
114
|
+
and stops there — reset refuses `NODE_ENV=production` and refuses a database
|
|
115
|
+
outside the runtime directory, and no deploy step reaches for the seed file.
|
|
116
|
+
|
|
117
|
+
So the test is not "is this row realistic?", it is **"would the app be broken
|
|
118
|
+
without it in production?"** If yes, it is configuration and belongs in a
|
|
119
|
+
migration, however much it looks like sample data. A venue and its rooms, a
|
|
120
|
+
product catalogue, a tenant, a country list, the organization the whole
|
|
121
|
+
deployment hangs off — all configuration. Accounts and role grants are
|
|
122
|
+
provisioning: `provisionPersonas` at deployment, or a migration. What is left
|
|
123
|
+
over is the seed's job — the bookings, orders and messages a demo needs and a real
|
|
124
|
+
environment starts without.
|
|
125
|
+
|
|
126
|
+
Get this wrong and it hides: the app is perfect locally, where reset has just
|
|
127
|
+
run, and every deployed environment comes up with empty tables. The signature is
|
|
128
|
+
a stage whose pages return 200 — the shell renders fine — while its first data
|
|
129
|
+
read throws `no result` or a foreign-key violation on a row the seed was
|
|
130
|
+
silently supplying.
|
|
117
131
|
|
|
118
132
|
A Better Auth app has a second constraint: the plugins you enable (`ban()`,
|
|
119
133
|
`actor()`, …) each declare columns, and `pikku db migrate` refuses to run while
|
|
@@ -397,6 +411,7 @@ These apply in every Fabric app:
|
|
|
397
411
|
- **No hand-editing `.pikku/db/schema.gen.ts`** — write a migration and re-run `pikku db migrate`.
|
|
398
412
|
- **One runtime unit per file** — never define multiple functions/workflows in a single source file.
|
|
399
413
|
- **Workflow steps don't need manual wiring** — `pikkuSessionlessFunc` step functions in `*.steps.ts` files are auto-discovered by codegen.
|
|
414
|
+
- **Identifiers are English** — functions, components, types, files, database tables and columns, in every app whatever market it serves. The team's language is `locale` in `pikku.config.json` and reaches `description`/`title`/`template` only; the app's language is the message catalogue, where `baseLocale` stays `en` and `defaultLocale` decides what a visitor opens in. `pikku fabric validate` warns (`app-base-locale-not-english-<app>`) when an app repoints its base.
|
|
400
415
|
|
|
401
416
|
## Converting an existing app to Fabric format
|
|
402
417
|
|
|
@@ -412,7 +427,7 @@ Fix every `error` and `warn` in the output before continuing. Then:
|
|
|
412
427
|
2. **Replace route handlers with pikkuFuncs**: extract business logic into `pikkuFunc`/`pikkuSessionlessFunc`, add `wireHTTP` or `expose: true` for transport.
|
|
413
428
|
3. **Replace DI/IoC with pikkuServices**: move service construction to `createSingletonServices` in `services.ts`.
|
|
414
429
|
4. **Replace `process.env` calls**: plain config becomes `defineVariable` + `variables.get()`, anything sensitive becomes `defineSecret` + `secrets.getSecret()`.
|
|
415
|
-
5. **Add `pikku.config.json`** at project root with `srcDirectories`, `outDir`, and `clientFiles
|
|
430
|
+
5. **Add `pikku.config.json`** at project root with `srcDirectories`, `outDir`, and `clientFiles` — plus `locale` if the team does not work in English, which is the language every `description`, `title` and step `template` is then authored in.
|
|
416
431
|
6. **Add `pikkufabric.config.json`** at project root with `projectId`, `production.domain`, and `frontends` (production is always `main`, so there is no `production.branch`).
|
|
417
432
|
7. **Run `pikku all`** — verify codegen succeeds and there are no type errors.
|
|
418
433
|
8. **Run `pikku fabric validate`** once more to confirm no structural issues remain.
|
|
@@ -41,6 +41,13 @@ schemas (`yarn pikku meta functions get <id>`) or workflow steps
|
|
|
41
41
|
**Capability rule:** do not introduce new wires of a type whose
|
|
42
42
|
`capabilities.<type>` is `false` unless the user explicitly asked for it.
|
|
43
43
|
|
|
44
|
+
**Language rule:** read `locale` in `pikku.config.json` (default `en`). It is
|
|
45
|
+
the language of every `description`, `title` and step `template` you author —
|
|
46
|
+
the meta the Pikku Console renders back to the team. It renames nothing:
|
|
47
|
+
functions, components, types, files, tables and columns are English in every
|
|
48
|
+
project, and what the app says to its users is the message catalogue. See
|
|
49
|
+
`pikku-concepts` → _What Language You Write In_.
|
|
50
|
+
|
|
44
51
|
## Stage 2 — State intent in plain English (BEFORE writing code)
|
|
45
52
|
|
|
46
53
|
Before touching any files, give the user one paragraph stating exactly what
|