@pikku/skills 0.12.35 → 0.12.38
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/README.md +9 -4
- package/dist/index.d.ts +7 -4
- package/dist/index.js +9 -5
- package/dist/skills.gen.d.ts +1 -0
- package/dist/skills.gen.js +5 -3
- package/dist/snippets.d.ts +26 -0
- package/dist/snippets.js +148 -0
- package/package.json +2 -2
- package/skills/pikku-addon/SKILL.md +70 -24
- package/skills/pikku-addon/references/addon-package-manifest.md +9 -4
- package/skills/pikku-addon/references/openapi.md +130 -0
- package/skills/pikku-agent/references/agents.md +3 -1
- package/skills/pikku-auth/references/better-auth.md +33 -2
- package/skills/pikku-build/SKILL.md +94 -6
- package/skills/pikku-build/references/app.md +82 -12
- package/skills/pikku-build/references/design.md +16 -5
- package/skills/pikku-build/references/feature.md +23 -96
- package/skills/pikku-build/references/openapi.md +119 -0
- package/skills/pikku-build/references/platform.md +4 -0
- package/skills/pikku-build/references/quick.md +16 -6
- package/skills/pikku-changes/SKILL.md +172 -0
- package/skills/pikku-concepts/SKILL.md +33 -138
- package/skills/pikku-concepts/references/bootstrap.md +58 -0
- package/skills/pikku-concepts/references/concept-mapping.md +16 -0
- package/skills/pikku-concepts/references/language.md +87 -0
- package/skills/pikku-deploy/SKILL.md +1 -1
- package/skills/pikku-fabric/SKILL.md +13 -13
- package/skills/pikku-guide/SKILL.md +264 -0
- package/skills/pikku-kysely/SKILL.md +1 -1
- package/skills/pikku-n8n-import/SKILL.md +4 -3
- package/skills/pikku-react/references/client.md +12 -0
- package/skills/pikku-realtime/SKILL.md +6 -6
- package/skills/pikku-report/SKILL.md +143 -0
- package/skills/pikku-scenario/SKILL.md +71 -562
- package/skills/pikku-scenario/references/browser.md +59 -0
- package/skills/pikku-scenario/references/coverage.md +70 -0
- package/skills/pikku-scenario/references/personas.md +149 -0
- package/skills/pikku-scenario/references/steps.md +366 -0
- package/skills/pikku-service-backends/SKILL.md +1 -1
- package/skills/pikku-wiring/SKILL.md +1 -1
- package/skills/pikku-workflow/SKILL.md +7 -8
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare class UnclosedSnippetError extends Error {
|
|
2
|
+
constructor(name: string, file: string);
|
|
3
|
+
}
|
|
4
|
+
export declare class DuplicateSnippetError extends Error {
|
|
5
|
+
constructor(name: string, file: string, other: string);
|
|
6
|
+
}
|
|
7
|
+
export declare class MissingSnippetError extends Error {
|
|
8
|
+
constructor(name: string, file: string);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The `// @snippet start <name>` regions in a project's source, so an example
|
|
12
|
+
* in a doc comment can name one instead of restating it. The same mechanism the
|
|
13
|
+
* website uses to pull its code blocks out of the shop template: the code that
|
|
14
|
+
* reaches the reader is the code that compiles, and it cannot drift.
|
|
15
|
+
*/
|
|
16
|
+
export declare const collectSnippets: (projectDir: string, into?: Map<string, string>, origins?: Map<string, string>) => Promise<Map<string, string>>;
|
|
17
|
+
export type Snippets = Record<string, string> | Map<string, string>;
|
|
18
|
+
/** Every region a markdown document shows, in order. */
|
|
19
|
+
export declare const snippetRegionsIn: (markdown: string) => string[];
|
|
20
|
+
/**
|
|
21
|
+
* A skill's text with every marked fence replaced by its region's code. Used
|
|
22
|
+
* twice, deliberately: `scripts/embed.ts` bakes the result into the manifest
|
|
23
|
+
* that ships inside the CLI binary, and `readSkillFile` applies it to a
|
|
24
|
+
* filesystem copy so an installed package shows the same code the binary does.
|
|
25
|
+
*/
|
|
26
|
+
export declare const expandSkillMarkdown: (markdown: string, snippets: Snippets, file?: string) => string;
|
package/dist/snippets.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
const START = /^\s*(?:\/\/|--)\s*@snippet start (\S+)\s*$/;
|
|
4
|
+
const END = /^\s*(?:\/\/|--)\s*@snippet end (\S+)\s*$/;
|
|
5
|
+
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.sql'];
|
|
6
|
+
const SKIPPED_DIRECTORIES = new Set(['node_modules', '.pikku', 'dist', '.git']);
|
|
7
|
+
export class UnclosedSnippetError extends Error {
|
|
8
|
+
constructor(name, file) {
|
|
9
|
+
super(`The snippet "${name}" opens in ${file} and never closes. Add "// @snippet end ${name}".`);
|
|
10
|
+
this.name = 'UnclosedSnippetError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class DuplicateSnippetError extends Error {
|
|
14
|
+
constructor(name, file, other) {
|
|
15
|
+
super(`The snippet "${name}" is defined twice, in ${other} and ${file}. Snippet names are the reference an example uses, so they have to be unique.`);
|
|
16
|
+
this.name = 'DuplicateSnippetError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class MissingSnippetError extends Error {
|
|
20
|
+
constructor(name, file) {
|
|
21
|
+
super(`${file} shows the snippet "${name}", which no @snippet region defines. ` +
|
|
22
|
+
`Add "// @snippet start ${name}" to a compiled example project, or fix the fence.`);
|
|
23
|
+
this.name = 'MissingSnippetError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const dedent = (lines) => {
|
|
27
|
+
const indents = lines
|
|
28
|
+
.filter((line) => line.trim().length > 0)
|
|
29
|
+
.map((line) => line.length - line.trimStart().length);
|
|
30
|
+
const shortest = indents.length > 0 ? Math.min(...indents) : 0;
|
|
31
|
+
return lines
|
|
32
|
+
.map((line) => line.slice(shortest))
|
|
33
|
+
.join('\n')
|
|
34
|
+
.trim();
|
|
35
|
+
};
|
|
36
|
+
const sourceFilesIn = async (directory, found = []) => {
|
|
37
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
38
|
+
const full = join(directory, entry.name);
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
if (SKIPPED_DIRECTORIES.has(entry.name) || entry.name.startsWith('.')) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
await sourceFilesIn(full, found);
|
|
44
|
+
}
|
|
45
|
+
else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
|
|
46
|
+
found.push(full);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return found;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The `// @snippet start <name>` regions in a project's source, so an example
|
|
53
|
+
* in a doc comment can name one instead of restating it. The same mechanism the
|
|
54
|
+
* website uses to pull its code blocks out of the shop template: the code that
|
|
55
|
+
* reaches the reader is the code that compiles, and it cannot drift.
|
|
56
|
+
*/
|
|
57
|
+
export const collectSnippets = async (projectDir, into = new Map(), origins = new Map()) => {
|
|
58
|
+
for (const file of await sourceFilesIn(projectDir)) {
|
|
59
|
+
const where = relative(projectDir, file);
|
|
60
|
+
const lines = (await readFile(file, 'utf8')).split('\n');
|
|
61
|
+
const open = new Map();
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const end = line.match(END);
|
|
64
|
+
if (end?.[1] && open.has(end[1])) {
|
|
65
|
+
const name = end[1];
|
|
66
|
+
const existing = origins.get(name);
|
|
67
|
+
if (existing)
|
|
68
|
+
throw new DuplicateSnippetError(name, where, existing);
|
|
69
|
+
into.set(name, dedent(open.get(name)));
|
|
70
|
+
origins.set(name, where);
|
|
71
|
+
open.delete(name);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const start = line.match(START);
|
|
75
|
+
if (start?.[1]) {
|
|
76
|
+
if (!open.has(start[1]))
|
|
77
|
+
open.set(start[1], []);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (end)
|
|
81
|
+
continue;
|
|
82
|
+
for (const body of open.values())
|
|
83
|
+
body.push(line);
|
|
84
|
+
}
|
|
85
|
+
const [unclosed] = open.keys();
|
|
86
|
+
if (unclosed)
|
|
87
|
+
throw new UnclosedSnippetError(unclosed, where);
|
|
88
|
+
}
|
|
89
|
+
return into;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* A fence that names a region instead of carrying code:
|
|
93
|
+
*
|
|
94
|
+
* ```ts snippet:definePersonas
|
|
95
|
+
* ```
|
|
96
|
+
*
|
|
97
|
+
* The language is optional and kept. Marked fences are replaced whole — both
|
|
98
|
+
* the placeholder form and a filled-in block, so a skill can be migrated
|
|
99
|
+
* without hand-deleting the old copy.
|
|
100
|
+
*/
|
|
101
|
+
const SNIPPET_FENCE = /^(\s*)```(\S*)\s+snippet:([A-Za-z0-9_.-]+)\s*$/;
|
|
102
|
+
const CLOSING_FENCE = /^\s*```\s*$/;
|
|
103
|
+
const replace = (markdown, lookup) => {
|
|
104
|
+
const lines = markdown.split('\n');
|
|
105
|
+
const out = [];
|
|
106
|
+
for (let i = 0; i < lines.length; i++) {
|
|
107
|
+
const line = lines[i];
|
|
108
|
+
const match = line.match(SNIPPET_FENCE);
|
|
109
|
+
if (!match) {
|
|
110
|
+
out.push(line);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const indent = match[1] ?? '';
|
|
114
|
+
const lang = match[2] ?? '';
|
|
115
|
+
const region = match[3];
|
|
116
|
+
const body = lookup(region);
|
|
117
|
+
out.push(`${indent}\`\`\`${lang}`);
|
|
118
|
+
for (const bodyLine of body.split('\n')) {
|
|
119
|
+
out.push(bodyLine.length > 0 ? indent + bodyLine : '');
|
|
120
|
+
}
|
|
121
|
+
out.push(`${indent}\`\`\``);
|
|
122
|
+
while (i + 1 < lines.length && !CLOSING_FENCE.test(lines[i + 1]))
|
|
123
|
+
i++;
|
|
124
|
+
i++;
|
|
125
|
+
}
|
|
126
|
+
return out.join('\n');
|
|
127
|
+
};
|
|
128
|
+
/** Every region a markdown document shows, in order. */
|
|
129
|
+
export const snippetRegionsIn = (markdown) => markdown
|
|
130
|
+
.split('\n')
|
|
131
|
+
.map((line) => line.match(SNIPPET_FENCE))
|
|
132
|
+
.filter((match) => match !== null)
|
|
133
|
+
.map((match) => match[3]);
|
|
134
|
+
/**
|
|
135
|
+
* A skill's text with every marked fence replaced by its region's code. Used
|
|
136
|
+
* twice, deliberately: `scripts/embed.ts` bakes the result into the manifest
|
|
137
|
+
* that ships inside the CLI binary, and `readSkillFile` applies it to a
|
|
138
|
+
* filesystem copy so an installed package shows the same code the binary does.
|
|
139
|
+
*/
|
|
140
|
+
export const expandSkillMarkdown = (markdown, snippets, file = 'skill') => {
|
|
141
|
+
const lookup = (name) => {
|
|
142
|
+
const body = snippets instanceof Map ? snippets.get(name) : snippets[name];
|
|
143
|
+
if (body === undefined)
|
|
144
|
+
throw new MissingSnippetError(name, file);
|
|
145
|
+
return body;
|
|
146
|
+
};
|
|
147
|
+
return replace(markdown, lookup);
|
|
148
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/skills",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.38",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/pikkujs/pikku.git",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"types": "dist/index.d.ts",
|
|
15
15
|
"type": "module",
|
|
16
16
|
"scripts": {
|
|
17
|
-
"embed": "
|
|
17
|
+
"embed": "bun run scripts/embed.ts",
|
|
18
18
|
"tsc": "bun run embed && tsc",
|
|
19
19
|
"build": "bun run embed && tsc -b",
|
|
20
20
|
"ncu": "npx npm-check-updates",
|
|
@@ -3,8 +3,10 @@ name: pikku-addon
|
|
|
3
3
|
description: >-
|
|
4
4
|
Use when creating or consuming reusable function packages (addons) in Pikku. Covers wireAddon,
|
|
5
5
|
ref(), pikkuAddonServices, pikkuAddonWireServices, addon package structure, addons that ship
|
|
6
|
-
database tables (pikku db export),
|
|
7
|
-
|
|
6
|
+
database tables (pikku db export), generating an addon from an OpenAPI/Swagger spec, and
|
|
7
|
+
cross-project function sharing. TRIGGER when: code uses wireAddon/ref()/pikkuAddonServices, user
|
|
8
|
+
asks about addons, reusable function packages, cross-project sharing, or addon package structure,
|
|
9
|
+
or the user hands over an OpenAPI/Swagger spec (file or URL) to build on. DO NOT
|
|
8
10
|
TRIGGER when: user asks about internal function composition (use pikku-wiring) or general function
|
|
9
11
|
definitions (use pikku-concepts).
|
|
10
12
|
installGroups: [core]
|
|
@@ -19,7 +21,7 @@ Use this skill as an execution checklist, not reference material.
|
|
|
19
21
|
1. Discover before editing. Run the relevant `pikku meta ... --json` command and inspect only the focused output you need.
|
|
20
22
|
2. Identify the source files that own the behavior. Do not start by reading generated output, `.pikku`, `node_modules`, vendored packages, or broad build artifacts.
|
|
21
23
|
3. Make the smallest source change that satisfies the task. Keep generated files generated, and avoid hand-editing SDKs, schema output, or typegen.
|
|
22
|
-
4. Validate with the narrowest relevant command first, then run `pikku
|
|
24
|
+
4. Validate with the narrowest relevant command first, then run `pikku all` when functions, wirings, schemas, or generated clients may have changed.
|
|
23
25
|
5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.
|
|
24
26
|
|
|
25
27
|
Addons are reusable Pikku function packages that can be shared across projects. They bundle functions, services, secrets, and variables into a self-contained NPM package.
|
|
@@ -48,6 +50,7 @@ wireAddon({
|
|
|
48
50
|
rpcEndpoint?: string, // Optional remote RPC endpoint for distributed execution
|
|
49
51
|
auth?: boolean, // Require a session for every function in the addon
|
|
50
52
|
mcp?: boolean | string[], // true: every function the addon declared mcp: true; a list: the tools this app offers, typed against the addon's function names
|
|
53
|
+
expose?: boolean | string[], // what rpc.exposed / POST /rpc may reach: unset/true = the addon's own expose: true, false = none, a list = exactly those (typed; PKU343 on unknown names)
|
|
51
54
|
tags?: string[], // Tags applied to all addon functions
|
|
52
55
|
scopes?: string[], // Required of every function, on top of its own
|
|
53
56
|
secretOverrides?: Record<string, string>, // Remap secret names (and grant them)
|
|
@@ -146,7 +149,7 @@ second argument is always present — an addon never falls back to its own logge
|
|
|
146
149
|
variables or secrets; the consuming app supplies them:
|
|
147
150
|
|
|
148
151
|
```typescript
|
|
149
|
-
import { pikkuAddonServices } from '#pikku/setup'
|
|
152
|
+
import { pikkuAddonServices } from '#pikku/addon/setup'
|
|
150
153
|
|
|
151
154
|
export const createSingletonServices = pikkuAddonServices(
|
|
152
155
|
async (config, { secrets, logger }) => {
|
|
@@ -167,7 +170,7 @@ config object.
|
|
|
167
170
|
Define per-request services for an addon package (created fresh per HTTP request, queue job, etc.):
|
|
168
171
|
|
|
169
172
|
```typescript
|
|
170
|
-
import { pikkuAddonWireServices } from '#pikku/setup'
|
|
173
|
+
import { pikkuAddonWireServices } from '#pikku/addon/setup'
|
|
171
174
|
|
|
172
175
|
export const createWireServices = pikkuAddonWireServices(
|
|
173
176
|
async (singletonServices, wire) => {
|
|
@@ -189,13 +192,17 @@ npx pikku new addon <name> # name is a required positional
|
|
|
189
192
|
npx pikku new addon stripe --display-name Stripe --category Payments --dir addons
|
|
190
193
|
```
|
|
191
194
|
|
|
195
|
+
**Handed an OpenAPI or Swagger spec?** Don't hand-write the functions —
|
|
196
|
+
`pikku new addon <name> --openapi <spec>` generates one function per operation,
|
|
197
|
+
with its schemas, service and credential. Read `references/openapi.md`.
|
|
198
|
+
|
|
192
199
|
This generates `package.json` (exports `.pikku/*` + `dist/`), `pikku.config.json` (`addon: true`), `tsconfig.json` (`#pikku` path mapping), `src/services.ts`, `src/functions/`, and `types/application-types.d.ts`. For the full file contents/exports you rarely hand-edit, read `references/addon-package-manifest.md`.
|
|
193
200
|
|
|
194
201
|
### Services
|
|
195
202
|
|
|
196
203
|
```typescript
|
|
197
204
|
// src/services.ts
|
|
198
|
-
import { pikkuAddonServices, pikkuAddonWireServices } from '#pikku/setup'
|
|
205
|
+
import { pikkuAddonServices, pikkuAddonWireServices } from '#pikku/addon/setup'
|
|
199
206
|
import { TodoStore } from './todo-store.service.js'
|
|
200
207
|
|
|
201
208
|
export const createSingletonServices = pikkuAddonServices(async () => {
|
|
@@ -213,18 +220,30 @@ export const createWireServices = pikkuAddonWireServices(
|
|
|
213
220
|
|
|
214
221
|
### Functions
|
|
215
222
|
|
|
216
|
-
An addon's generated tree roots at `.pikku/addon/`,
|
|
217
|
-
|
|
218
|
-
`#pikku/function
|
|
219
|
-
|
|
223
|
+
An addon's generated tree roots at `.pikku/addon/`, and its own source reaches
|
|
224
|
+
it by that path — `#pikku/addon/function`, `#pikku/addon/setup`. An application
|
|
225
|
+
authors against `#pikku/function`; an addon never does, because inside the addon
|
|
226
|
+
`#pikku/function` names a leaf that does not exist.
|
|
227
|
+
|
|
228
|
+
**Declare zod schemas in a file that never imports `#pikku`.** `pikku all` loads
|
|
229
|
+
the file that declares each schema to convert it, and at runtime `#pikku`
|
|
230
|
+
resolves through the package's `imports` — into a `dist` the first build has not
|
|
231
|
+
written yet. Inline, every schema fails with `Could not convert Zod schema …
|
|
232
|
+
Cannot find module …/dist/.pikku/…` and the addon never builds from clean. A
|
|
233
|
+
sibling `<fn>.schemas.ts` is what `--openapi` generates:
|
|
220
234
|
|
|
221
235
|
```typescript
|
|
222
|
-
// src/functions/addTodo.
|
|
236
|
+
// src/functions/addTodo.schemas.ts
|
|
223
237
|
import { z } from 'zod'
|
|
224
|
-
import { pikkuSessionlessFunc } from '#pikku/function'
|
|
225
238
|
|
|
226
|
-
const AddTodoInput = z.object({ title: z.string() })
|
|
227
|
-
const AddTodoOutput = z.object({ id: z.string(), title: z.string() })
|
|
239
|
+
export const AddTodoInput = z.object({ title: z.string() })
|
|
240
|
+
export const AddTodoOutput = z.object({ id: z.string(), title: z.string() })
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
// src/functions/addTodo.function.ts
|
|
245
|
+
import { pikkuSessionlessFunc } from '#pikku/addon/function'
|
|
246
|
+
import { AddTodoInput, AddTodoOutput } from './addTodo.schemas.js'
|
|
228
247
|
|
|
229
248
|
export const addTodo = pikkuSessionlessFunc({
|
|
230
249
|
description: 'Adds a new todo',
|
|
@@ -246,9 +265,7 @@ approvalDescription: async (_services, { title }) => `Add a todo called "${title
|
|
|
246
265
|
### Build
|
|
247
266
|
|
|
248
267
|
```bash
|
|
249
|
-
yarn pikku all
|
|
250
|
-
yarn tsc # Compile TypeScript
|
|
251
|
-
cp -r .pikku types dist/ # Ship the generated files and the types they import
|
|
268
|
+
yarn build # prebuild: pikku all, then tsc && pikku dist
|
|
252
269
|
yarn pikku validate # Check the published file set holds together
|
|
253
270
|
```
|
|
254
271
|
|
|
@@ -257,12 +274,11 @@ devDependency, and building it against a different CLI than it declares is how
|
|
|
257
274
|
generated output ends up disagreeing with the packaged one. `npx pikku new
|
|
258
275
|
addon` above is the exception — it runs before the addon, and its CLI, exist.
|
|
259
276
|
|
|
260
|
-
`
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
which is what `pikku validate` is there to catch before you publish.
|
|
277
|
+
`pikku dist` copies what `tsc` cannot emit — the generated `*.gen.json` meta and
|
|
278
|
+
the hand-written `types/*.d.ts` the generated files import — to where `tsc` put
|
|
279
|
+
everything else. Leave it out and the addon installs fine and fails to typecheck
|
|
280
|
+
in every app that depends on it, which is what `pikku validate` is there to
|
|
281
|
+
catch before you publish.
|
|
266
282
|
|
|
267
283
|
### Database tables
|
|
268
284
|
|
|
@@ -297,7 +313,7 @@ packed, or it never arrives:
|
|
|
297
313
|
**An unresolvable artifact stops `db generate`.** Because the file is
|
|
298
314
|
unconditional, absence means the package cannot say whether it ships tables —
|
|
299
315
|
either it was built with an older CLI, or `exports`/`files` do not carry it. The
|
|
300
|
-
error names both causes. An addon with genuinely no tables is
|
|
316
|
+
error names both causes. An addon with genuinely no tables is _not_ this case:
|
|
301
317
|
it publishes `{}` and is waved through.
|
|
302
318
|
|
|
303
319
|
Two more loud ones: a malformed artifact (missing the SQL for a dialect it
|
|
@@ -343,6 +359,36 @@ export const myFunc = pikkuFunc({
|
|
|
343
359
|
})
|
|
344
360
|
```
|
|
345
361
|
|
|
362
|
+
### Wrap an addon function only to reshape it
|
|
363
|
+
|
|
364
|
+
A screen that shows the addon's data as the addon returns it calls the addon
|
|
365
|
+
function itself: name it in `wireAddon({ expose: ['listTodos'], auth: true })`
|
|
366
|
+
and the frontend calls `rpc.invoke('todos:listTodos', …)` (over HTTP,
|
|
367
|
+
`POST /rpc/todos:listTodos` with `{ "data": … }`), still behind the session.
|
|
368
|
+
Use `ref('todos:listTodos')` on an HTTP or MCP wiring only when the addon needs
|
|
369
|
+
a route of its own. Don't write an app function that calls `rpc.invoke` and
|
|
370
|
+
returns the result unchanged: it's a second name and a second schema for the
|
|
371
|
+
same thing, and it drifts. `expose: true` exposes only what the addon itself
|
|
372
|
+
declared `expose: true` — an OpenAPI-generated addon declares none, so list
|
|
373
|
+
the names.
|
|
374
|
+
|
|
375
|
+
Write your own function when the app needs the data narrowed, typed, or
|
|
376
|
+
combined (a flag the upstream sends as `"0"`, a total summed from several
|
|
377
|
+
calls, one field out of fifty), or when the app adds a permission of its own.
|
|
378
|
+
`wireAddon`'s `scopes` gate every function in the addon at once, and a wiring
|
|
379
|
+
carries middleware, not permissions, so a rule on one addon function lives in
|
|
380
|
+
the `permissions` of an app function that calls it. An addon called with the
|
|
381
|
+
user's own credential is already limited upstream to what that user may do, so
|
|
382
|
+
a data-aware `pikkuPermission` repeating that check (may they read *this*
|
|
383
|
+
invoice?) adds nothing. A session-only `pikkuAuth` (a role, a tier) is still
|
|
384
|
+
worth it: it can be checked before any input exists, so the functions a user
|
|
385
|
+
can't call drop out of the tools an MCP client, an agent or a workflow is
|
|
386
|
+
offered, instead of failing upstream when called. Name it for what the screen means
|
|
387
|
+
(`getMyProfile`), not after the upstream operation (`usersRetrieveInfo`), and
|
|
388
|
+
give it an `output:` schema of only what the app uses. For an OpenAPI-generated
|
|
389
|
+
addon this matters more: its outputs mirror the upstream's loose, oversized
|
|
390
|
+
payloads, and the wrapper is where they become the app's own shape.
|
|
391
|
+
|
|
346
392
|
### Wire to HTTP
|
|
347
393
|
|
|
348
394
|
```typescript
|
|
@@ -22,7 +22,7 @@ An addon's generated tree roots one level down, at `.pikku/addon/`, so its own
|
|
|
22
22
|
leaves are reached as `#pikku/addon/<leaf>` while an application's are
|
|
23
23
|
`#pikku/<leaf>`. `paths` are global to a tsx process rather than scoped to the
|
|
24
24
|
package that declared them, and the extra segment is what stops a linked addon's
|
|
25
|
-
`#pikku/function` from matching the
|
|
25
|
+
`#pikku/function` from matching the _host application's_ flat leaf.
|
|
26
26
|
|
|
27
27
|
## pikku.config.json
|
|
28
28
|
|
|
@@ -68,20 +68,25 @@ package that declared them, and the extra segment is what stops a linked addon's
|
|
|
68
68
|
"scripts": {
|
|
69
69
|
"prebuild": "pikku all",
|
|
70
70
|
"pikku": "pikku all",
|
|
71
|
-
"build": "tsc &&
|
|
71
|
+
"build": "tsc && pikku dist"
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
**`imports` names `dist`, never the source tree.** `files: ["dist"]` is the whole
|
|
77
|
-
published package, and `
|
|
77
|
+
published package, and `pikku dist` copies `.pikku` and `types` into it — so a
|
|
78
78
|
`#pikku/*` target under `./.pikku/` resolves for the author and for nobody else.
|
|
79
79
|
It is a silent break: the addon compiles, packs, installs and then throws
|
|
80
80
|
`Cannot find module '.../.pikku/addon/function/index.ts'` on first import in the
|
|
81
|
-
consuming app, out of a file the consumer never wrote. The addon's own
|
|
81
|
+
consuming app, out of a file the consumer never wrote. The addon's own `tsc`
|
|
82
82
|
does not read `imports` at all — tsconfig `paths` covers it, which is why the
|
|
83
83
|
two maps point at different trees.
|
|
84
84
|
|
|
85
|
+
`pikku all` does read `imports`, though: it loads each file that declares a zod
|
|
86
|
+
schema to convert it to JSON Schema, and at runtime `#pikku` resolves through
|
|
87
|
+
`imports`, into a `dist` the first build has not written yet. So a schema must
|
|
88
|
+
live in a file that never imports `#pikku` — see "Functions" in the skill.
|
|
89
|
+
|
|
85
90
|
**`exports` targets carry the `addon` segment; the subpaths do not.** A consumer
|
|
86
91
|
writes `@my-org/addon-todos/.pikku/rpc/...`, exactly as it would in an
|
|
87
92
|
application, and the leaf stays the package's own business.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# An addon from an OpenAPI spec
|
|
2
|
+
|
|
3
|
+
When you are handed an OpenAPI or Swagger spec — a file, or a URL to one — the
|
|
4
|
+
API it describes becomes an addon: one function per operation, each with its
|
|
5
|
+
input and output schemas, behind one service that makes the HTTP calls. Say so
|
|
6
|
+
before starting ("This is an OpenAPI spec — I'll turn it into an addon first"),
|
|
7
|
+
then generate it. Don't hand-write the functions, and don't ask which
|
|
8
|
+
operations to keep: generate the whole spec, however large.
|
|
9
|
+
|
|
10
|
+
## Recognising one
|
|
11
|
+
|
|
12
|
+
A JSON or YAML document with a top-level `openapi` key (3.x) or `swagger` key
|
|
13
|
+
(2.0), and a `paths` object. A URL ending in `openapi.json`, `swagger.json` or
|
|
14
|
+
`.yaml` is almost always one; open it and check the key before generating.
|
|
15
|
+
|
|
16
|
+
## 1 — Look at the spec first
|
|
17
|
+
|
|
18
|
+
`--openapi` takes a path or a URL. A spec published only to signed-in callers
|
|
19
|
+
takes the key the way the API reads it: `--openapi-header "NAME: value"`
|
|
20
|
+
(repeatable), or in the URL's query string when the API reads it there
|
|
21
|
+
(Dolibarr's explorer takes `?DOLAPIKEY=`). A 401 while fetching says which.
|
|
22
|
+
|
|
23
|
+
The generator warns loudly when the spec has fewer than five operations, or only
|
|
24
|
+
auth routes. That is almost always the public half of a spec that shows more
|
|
25
|
+
to an authenticated caller — fetch it again with the key, don't build on it.
|
|
26
|
+
|
|
27
|
+
Keep a copy in `specs/` as the record of what the addon was generated from.
|
|
28
|
+
|
|
29
|
+
## 2 — Generate, from the app's root
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bunx --bun pikku new addon <name> --openapi <path-or-url>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
One command. Inside an app it writes `packages/addon-<name>` as
|
|
36
|
+
`@pikku/addon-<name>`, then installs it into the app:
|
|
37
|
+
|
|
38
|
+
- the dependency in the root and the functions `package.json`
|
|
39
|
+
- `src/addons/<name>.addon.ts` — `wireAddon` with `auth: true` and an explicit
|
|
40
|
+
`expose` list (every operation in per-user modes, only the `GET`s behind a
|
|
41
|
+
shared secret)
|
|
42
|
+
- the auth wiring in `src/auth.ts` for the chosen mode
|
|
43
|
+
- `<NAME>_BASE_URL` in `.env`
|
|
44
|
+
|
|
45
|
+
and then runs install and the addon's build. `--no-install` generates the
|
|
46
|
+
package alone.
|
|
47
|
+
|
|
48
|
+
| Flag | When |
|
|
49
|
+
| -------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
50
|
+
| `--auth user` (default) | Each user brings their own credential |
|
|
51
|
+
| `--auth shared` | One secret behind every user; locally it goes in `.env` |
|
|
52
|
+
| `--auth none` | The API really takes no auth |
|
|
53
|
+
| `--credential apikey\|bearer\|basic\|oauth2` | Override what the spec's `securitySchemes` declares |
|
|
54
|
+
| `--auth-config <file>` | Users sign in with their upstream login, or the spec gets auth wrong |
|
|
55
|
+
| `--tags a,b` / `--include` / `--exclude` | Keep part of a huge spec: tags, or globs on operationId, `/path`, `METHOD /path` |
|
|
56
|
+
| `--mcp` | The operations should also be MCP tools |
|
|
57
|
+
| `--camel-case` | The API's property names are snake_case and the app's are not |
|
|
58
|
+
|
|
59
|
+
The mode comes from the spec unless a flag says otherwise. A spec with no
|
|
60
|
+
machine-readable auth is refused rather than guessed: pass one of the flags the
|
|
61
|
+
error names. Which mode fits, and the auth-config format, are in the
|
|
62
|
+
`pikku-build` skill's `references/openapi.md`.
|
|
63
|
+
|
|
64
|
+
## 3 — Check what was generated
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
packages/addon-<name>/
|
|
68
|
+
├── <name>.svg # placeholder icon; replace with the real one
|
|
69
|
+
├── src/<name>-api.service.ts # one fetch wrapper, reads <NAME>_BASE_URL
|
|
70
|
+
├── src/<name>.variable.ts # <NAME>_BASE_URL: z.string().url(), the first server as default
|
|
71
|
+
├── src/functions/<op>.function.ts
|
|
72
|
+
├── src/functions/<op>.schemas.ts # the op's zod schemas — never import #pikku here
|
|
73
|
+
└── src/index.ts # re-exports every function
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
An operation whose spec gives no response, or one too vague to validate
|
|
77
|
+
against, outputs `z.unknown()`. Tighten it in `<op>.schemas.ts` once §6 shows
|
|
78
|
+
what the API really returns.
|
|
79
|
+
|
|
80
|
+
An upstream 401 on a per-user credential throws `CredentialRejectedError`
|
|
81
|
+
(403, `reauth: 'sign-in' | 'connect'`); a UI shows the matching screen again
|
|
82
|
+
rather than a generic error.
|
|
83
|
+
|
|
84
|
+
## 4 — Call it from the app
|
|
85
|
+
|
|
86
|
+
Operations are reached by reference — `ref('<name>:<operationFn>')` in a
|
|
87
|
+
workflow, agent tool or HTTP wiring — the same way as any other addon. See
|
|
88
|
+
"Consuming an Addon" in the skill. An exposed operation is also callable from
|
|
89
|
+
the frontend at `POST /rpc/<name>:<operationFn>` with a body of
|
|
90
|
+
`{ "data": { … } }`, as the signed-in user.
|
|
91
|
+
|
|
92
|
+
## 5 — Verify
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
bunx --bun pikku all
|
|
96
|
+
bunx --bun pikku validate
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`Could not convert Zod schema … Cannot find module …/dist/.pikku/…` means a
|
|
100
|
+
schema is declared in a file that imports `#pikku`. The generator never does
|
|
101
|
+
that, so a hand edit moved it — put it back in `<op>.schemas.ts`.
|
|
102
|
+
|
|
103
|
+
`pikku validate` reports a `skewed-type-identity` error (and `pikku all` warns
|
|
104
|
+
`[PKU719]`) when the CLI and the app resolve different copies of `zod` or
|
|
105
|
+
another shared package. Codegen then reads the app's
|
|
106
|
+
schemas with the wrong copy and fails on schemas that are correct. Pin one
|
|
107
|
+
version for the whole install, as the finding says, and reinstall.
|
|
108
|
+
|
|
109
|
+
## 6 — Check the spec against the real API
|
|
110
|
+
|
|
111
|
+
Specs are often wrong, and the generated schemas repeat every mistake. Before
|
|
112
|
+
building on the addon:
|
|
113
|
+
|
|
114
|
+
- **Call the reads you can.** The `GET`s the credential can reach, following ids
|
|
115
|
+
from lists into retrieves. Writes only if the user opts in.
|
|
116
|
+
- **Fix the addon, not the app**: the schema in the op's `<op>.schemas.ts`, or the
|
|
117
|
+
request shape in `src/<name>-api.service.ts`. Then rebuild it.
|
|
118
|
+
- **List each mismatch in `packages/addon-<name>/SPEC-ISSUES.md`**: a title and a
|
|
119
|
+
short description. No credentials or customer data.
|
|
120
|
+
|
|
121
|
+
Then tell the user in one line: "FYI, the spec deviates from the real API in
|
|
122
|
+
N ways: [SPEC-ISSUES.md](…)". Add to the file whenever a later call disagrees
|
|
123
|
+
with its schema. Ask before sending it to the API's maintainers, because an issue
|
|
124
|
+
on their tracker is a public post.
|
|
125
|
+
|
|
126
|
+
## Then
|
|
127
|
+
|
|
128
|
+
Go back to the mode you were building in (`pikku-build`). The addon is a
|
|
129
|
+
dependency of the app, not the app: plan milestones around what the user wants
|
|
130
|
+
to do with the API, and reach the operations through `ref()`.
|
|
@@ -35,7 +35,9 @@ pikkuAgent({
|
|
|
35
35
|
temperature?: number,
|
|
36
36
|
providerOptions?: { // passed through untouched, keyed by provider
|
|
37
37
|
openai?: { reasoningEffort?: 'minimal' | ... },
|
|
38
|
-
},
|
|
38
|
+
}, // inline literals only — a computed value
|
|
39
|
+
// cannot be read into the generated metadata
|
|
40
|
+
// and is reported as PKU156
|
|
39
41
|
|
|
40
42
|
// --- capabilities: all three take ref() handles, not imported values ---
|
|
41
43
|
tools?: unknown[], // ref('todos:addTodo'), ref('graph:sleep'), …
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Pikku Better Auth Integration
|
|
2
2
|
|
|
3
|
+
Every section, in the order it is usually needed:
|
|
4
|
+
|
|
5
|
+
- [⚠️ MANDATORY RULE — READ FIRST](#mandatory-rule--read-first)
|
|
6
|
+
- [Installation](#installation)
|
|
7
|
+
- [Core Concepts](#core-concepts)
|
|
8
|
+
- [Standard Setup](#standard-setup)
|
|
9
|
+
- [⚠️ Stateless session — ALWAYS enable `cookieCache` for deployed apps](#stateless-session--always-enable-cookiecache-for-deployed-apps)
|
|
10
|
+
- [Social Providers needing extra config](#social-providers-needing-extra-config)
|
|
11
|
+
- [Auth-Protected Functions](#auth-protected-functions)
|
|
12
|
+
- [HTTP surface (call the real endpoints)](#http-surface-call-the-real-endpoints)
|
|
13
|
+
- [Secret Management](#secret-management)
|
|
14
|
+
- [`pikkuBetterAuth` API](#pikkubetterauth-api)
|
|
15
|
+
- [Post-signup side effects](#post-signup-side-effects)
|
|
16
|
+
- [Two-factor (2FA / MFA)](#two-factor-2fa--mfa)
|
|
17
|
+
- [Security hardening](#security-hardening)
|
|
18
|
+
|
|
3
19
|
## ⚠️ MANDATORY RULE — READ FIRST
|
|
4
20
|
|
|
5
21
|
**ALL authentication in Pikku apps MUST use `@pikku/better-auth`. No exceptions.**
|
|
@@ -291,7 +307,7 @@ singleton a 403 that leaves no platform user behind.
|
|
|
291
307
|
|
|
292
308
|
```typescript
|
|
293
309
|
pikkuDelegatedAuth({
|
|
294
|
-
authenticate: async ({ email, password, apiKey }) => upstream.login(...),
|
|
310
|
+
authenticate: async ({ login, email, password, apiKey }) => upstream.login(...),
|
|
295
311
|
storeCredential: (userId, identity) =>
|
|
296
312
|
credentialService.set('acme', identity.credential, userId),
|
|
297
313
|
defaultRole: 'member',
|
|
@@ -302,7 +318,10 @@ pikkuDelegatedAuth({
|
|
|
302
318
|
```
|
|
303
319
|
|
|
304
320
|
`POST /sign-in/delegated` forwards the credentials the user already has to
|
|
305
|
-
`authenticate`.
|
|
321
|
+
`authenticate`. The body takes `email`, `login` or `username` with `password`
|
|
322
|
+
(or `apiKey`); whichever identifier was sent reaches `authenticate` as
|
|
323
|
+
`credentials.login`, and `email` as well when it was one. Upstreams that sign in
|
|
324
|
+
with a username — most ERPs — need nothing more. On success it JIT-provisions a real user row (email-keyed and
|
|
306
325
|
`emailVerified` — the upstream just verified them), links it via an `account`
|
|
307
326
|
row (`providerId: 'delegated'`, `accountId: externalId`), persists the upstream
|
|
308
327
|
token **before** minting the session, and returns a normal session cookie.
|
|
@@ -317,6 +336,18 @@ a warning and the user still gets in.
|
|
|
317
336
|
`storeCredential` failing, by contrast, **fails the sign-in**: every proxied
|
|
318
337
|
call would be dead anyway.
|
|
319
338
|
|
|
339
|
+
An upstream user with no email gets one made up from the login, and the
|
|
340
|
+
identity says so with `syntheticEmail: true`. A made-up address never links to
|
|
341
|
+
an existing user row, so it cannot take over someone else's account.
|
|
342
|
+
|
|
343
|
+
For an addon generated from an OpenAPI spec, none of this is written by hand:
|
|
344
|
+
`pikku new addon --openapi … --auth-config <file>` generates
|
|
345
|
+
`authenticate<Name>Upstream` in the addon and wires this plugin, the stored
|
|
346
|
+
credential and the actor credentials into `src/auth.ts`. The config format is
|
|
347
|
+
in the `pikku-build` skill's `references/openapi.md`. When the upstream later
|
|
348
|
+
refuses the stored token, the addon throws `CredentialRejectedError` (403,
|
|
349
|
+
`reauth: 'sign-in'`): the UI shows the sign-in again.
|
|
350
|
+
|
|
320
351
|
#### `pikkuFabric()` — control-plane operator sign-in
|
|
321
352
|
|
|
322
353
|
```typescript
|