Added a create extension skill

This commit is contained in:
Emil
2026-02-27 11:08:53 +01:00
parent 43733092bc
commit ecd95ccb82
8 changed files with 638 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
---
name: create-extension
description: "Generate and implement extensions for erp-base: scaffold files, configure manifests, write event handlers, API routes, services, workspace UIs, settings panels, and testing. Use when creating new extensions, adding surfaces to existing extensions, or understanding the extension architecture. Covers the full lifecycle from scaffolding to registration."
---
# Extension Generator
## Quick Start
**1. Scaffold:**
```bash
npx tsx scripts/create-extension.ts \
--name my-extension --sector general --category operations \
--description "Short description"
```
Sectors: `general`, `restaurant`, `construction`, `hotel`, `tech`, `ecommerce`, `export`
Categories: `import`, `operations`, `reports`, `accounting`
**2. Enable** — add `"my-extension"` to `extensions.config.json`
**3. Regenerate** — `npm run setup:extensions` (auto-runs on `dev`/`build`)
**4. Implement** — edit `index.ts` to add surfaces
---
## Extension Object Template
```typescript
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import { myExtApiRoutes } from './api-routes'
interface MyExtSettings { featureEnabled: boolean; threshold: number }
const DEFAULT_SETTINGS: MyExtSettings = { featureEnabled: true, threshold: 0.8 }
async function handleSomeEvent(
payload: EventPayload<'transaction.synced'>, ctx?: ExtensionContext
): Promise<void> {
const { transactions, userId } = payload
const log = ctx?.log ?? console
const settings = ctx
? { ...DEFAULT_SETTINGS, ...(await ctx.settings.get<Partial<MyExtSettings>>() || {}) }
: DEFAULT_SETTINGS
if (!settings.featureEnabled) return
try {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
// ... business logic ...
} catch (error) { log.error('Handler failed:', error) }
}
export const myExtensionExtension: Extension = {
id: 'my-extension',
name: 'My Extension',
version: '1.0.0',
sector: 'general',
apiRoutes: myExtApiRoutes,
eventHandlers: [
{ eventType: 'transaction.synced', handler: handleSomeEvent },
],
settingsPanel: { label: 'My Extension', path: '/settings/extensions/my-extension' },
async onInstall(ctx) { await ctx.settings.set('settings', DEFAULT_SETTINGS) },
}
```
Export name convention: `{camelCaseId}Extension` (e.g., `myExtensionExtension`).
---
## Manifest (Minimal)
```json
{
"id": "my-extension",
"sector": "general",
"exportName": "myExtensionExtension",
"entryPoint": "@/extensions/general/my-extension",
"workspace": null,
"requiredEnvVars": [],
"optionalEnvVars": [],
"npmDependencies": [],
"definition": {
"name": "My Extension",
"category": "operations",
"icon": "Box",
"dataPattern": "core",
"description": "Short marketplace description",
"longDescription": "Longer detail page description."
}
}
```
For a workspace page, set `"workspace": "@/components/extensions/general/MyExtensionWorkspace"`. See **[Manifest Reference](references/manifest-format.md)**.
## Extension Context
```typescript
interface ExtensionContext {
userId: string; extensionId: string
supabase: SupabaseClient // Pre-authenticated
emit(event: CoreEvent): void // Publish core events
settings: ExtensionSettings // get<T>(key?) / set<T>(key, value) — stored in extension_data table
storage: ExtensionStorage // download / upload / getPublicUrl
log: ExtensionLogger // info / warn / error (scoped)
services: ExtensionServices // Core services (e.g., ingestTransactions)
}
```
## Available Surfaces
| Surface | Reference |
|---------|-----------|
| `eventHandlers` — react to core events | [Event Handlers](references/event-handlers.md) |
| `apiRoutes` — HTTP endpoints | [API Routes](references/api-routes.md) |
| `services` — named functions for core | [Services](references/services-patterns.md) |
| `settingsPanel` / workspace UI | [Workspace & UI](references/workspace-ui.md) |
| `mappingRuleTypes`, `onInstall`/`onUninstall` | [Extension Interface](references/extension-interface.md) |
## Common Mistakes
1. **Forgetting `npm run setup:extensions`** after config change — extension won't load
2. **Editing `_generated/` files** — overwritten on next setup
3. **Not handling `ctx = undefined`** — context is undefined in cron jobs; always fallback
4. **Wrong export name** — must match `exportName` in manifest exactly
5. **Importing from other extensions** — only import from core (`@/lib/`, `@/types`)
6. **Lazy imports in module scope** — static imports for extension code; `await import()` only inside handler functions
@@ -0,0 +1,90 @@
# API Routes Reference
All extension APIs dispatch through `app/api/extensions/ext/[...path]/route.ts`.
**URL scheme:** `/api/extensions/ext/{extensionId}/{routePath}`
## Route Definition
```typescript
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
export const myExtApiRoutes: ApiRouteDefinition[] = [
{
method: 'GET',
path: '/',
handler: async (_req, ctx) => {
const { data, error } = await ctx!.supabase
.from('my_items').select('*')
.eq('user_id', ctx!.userId).order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data })
},
},
]
```
## Path Parameters
Use `:paramName` — dispatcher extracts as `_paramName` search params:
```typescript
{
method: 'GET',
path: '/:id',
handler: async (req, ctx) => {
const id = new URL(req.url).searchParams.get('_id')
const { data } = await ctx!.supabase
.from('my_items').select('*').eq('id', id).eq('user_id', ctx!.userId).single()
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json({ data })
},
}
// Multiple params: path: '/:id/items/:itemId' → _id, _itemId
```
## Dispatcher Flow
1. Extract `extensionId` and `routePath` from URL segments
2. Auth check (401) → toggle check via `isExtensionEnabled()` (403) → match method+path (404)
3. Extract path params → create `ExtensionContext` → call handler
## Settings Route Pattern
```typescript
{ method: 'GET', path: '/settings',
handler: async (_req, ctx) => {
const settings = await ctx!.settings.get<MySettings>()
return NextResponse.json({ data: settings ?? DEFAULT_SETTINGS })
},
},
{ method: 'PUT', path: '/settings',
handler: async (req, ctx) => {
const body = await req.json()
const merged = { ...(await ctx!.settings.get<MySettings>() ?? DEFAULT_SETTINGS), ...body }
await ctx!.settings.set('settings', merged)
return NextResponse.json({ data: merged })
},
},
```
## Response Conventions
```typescript
NextResponse.json({ data: result }) // Success
NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) // Auth error
NextResponse.json({ error: 'Not found' }, { status: 404 }) // Not found
NextResponse.json({ error: error.message }, { status: 500 }) // Server error
```
## Frontend Calls
```typescript
const res = await fetch('/api/extensions/ext/my-extension/')
const res = await fetch(`/api/extensions/ext/my-extension/${id}`)
const res = await fetch('/api/extensions/ext/my-extension/settings', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ featureEnabled: true }),
})
```
@@ -0,0 +1,75 @@
# Event Handlers Reference
Extensions subscribe via `eventHandlers`. The bus dispatches with `Promise.allSettled()` — failures are isolated.
## Signature & Registration
```typescript
async function handleSomeEvent(
payload: EventPayload<'transaction.synced'>, ctx?: ExtensionContext
): Promise<void> { /* ... */ }
export const myExtension: Extension = {
eventHandlers: [
{ eventType: 'transaction.synced', handler: handleSomeEvent },
],
}
```
`ctx` can be `undefined` in cron job contexts — always provide fallbacks.
## All Event Types
Source: `lib/events/types.ts`. Every payload includes `userId`.
| Event | Payload |
|-------|---------|
| `journal_entry.drafted` | `{ entry: JournalEntry, userId }` |
| `journal_entry.committed` | `{ entry: JournalEntry, userId }` |
| `journal_entry.corrected` | `{ original, storno, corrected: JournalEntry, userId }` |
| `document.uploaded` | `{ document: DocumentAttachment, userId }` |
| `invoice.created` | `{ invoice: Invoice, userId }` |
| `invoice.sent` | `{ invoice: Invoice, userId }` |
| `credit_note.created` | `{ creditNote: CreditNote, userId }` |
| `transaction.synced` | `{ transactions: Transaction[], userId }` |
| `transaction.categorized` | `{ transaction, account, taxCode, userId }` |
| `transaction.reconciled` | `{ transaction, journalEntryId, method, userId }` |
| `period.locked` | `{ period: FiscalPeriod, userId }` |
| `period.year_closed` | `{ period: FiscalPeriod, userId }` |
| `customer.created` | `{ customer: Customer, userId }` |
| `receipt.extracted` | `{ receipt, documentId, confidence, userId }` |
| `receipt.matched` | `{ receipt, transaction, confidence, autoMatched, userId }` |
| `receipt.confirmed` | `{ receipt, businessTotal, privateTotal, userId }` |
| `supplier_invoice.received` | `{ inboxItem: InvoiceInboxItem, userId }` |
| `supplier_invoice.extracted` | `{ inboxItem, confidence, userId }` |
| `supplier_invoice.confirmed` | `{ inboxItem, supplierInvoice, userId }` |
## Standard Handler Pattern
```typescript
async function handleTransactionSynced(
payload: EventPayload<'transaction.synced'>, ctx?: ExtensionContext
): Promise<void> {
const { transactions, userId } = payload
const log = ctx?.log ?? console
const settings = ctx
? { ...DEFAULT_SETTINGS, ...(await ctx.settings.get<Partial<MySettings>>() || {}) }
: await getSettings(userId)
if (!settings.featureEnabled) return // Settings gate
try {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
// Business logic...
await ctx?.emit({ type: 'receipt.matched', payload: { /* ... */ userId } })
} catch (error) { log.error('Handler failed:', error) }
}
```
## Key Rules
1. **Handle `ctx = undefined`** — fallback for supabase, logging, settings
2. **Handlers run concurrently** — order undefined, don't depend on other handlers
3. **Use settings gates** — let users control features
4. **Wrap in try/catch** — don't throw unhandled errors
5. **Emit events for cascading pipelines** — e.g., OCR emits `receipt.extracted` → triggers push notifications
@@ -0,0 +1,96 @@
# Extension Interface Reference
Source: `lib/extensions/types.ts`
## Extension Interface
```typescript
interface Extension {
id: string; name: string; version: string; sector?: SectorSlug
routes?: RouteDefinition[]
apiRoutes?: ApiRouteDefinition[]
sidebarItems?: SidebarItem[]
eventHandlers?: ExtensionEventHandler[]
mappingRuleTypes?: MappingRuleTypeDefinition[]
reportTypes?: ReportDefinition[]
settingsPanel?: SettingsPanelDefinition
taxCodes?: TaxCodeDefinition[]
dimensionTypes?: DimensionDefinition[]
services?: Record<string, (...args: any[]) => Promise<any>>
onInstall?(ctx: ExtensionContext): Promise<void>
onUninstall?(ctx: ExtensionContext): Promise<void>
}
```
All surfaces are optional. An extension can provide any combination.
## Key Supporting Types
```typescript
interface ApiRouteDefinition {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
path: string // e.g., "/:id/confirm"
handler: (request: Request, ctx?: ExtensionContext) => Promise<Response>
}
interface ExtensionEventHandler {
eventType: CoreEventType
handler: (payload: any, ctx?: ExtensionContext) => Promise<void> | void
}
interface SidebarItem { label: string; icon?: string; path: string; order?: number }
interface SettingsPanelDefinition { label: string; path: string }
interface MappingRuleTypeDefinition { id: string; name: string; description: string }
interface RouteDefinition { path: string; label: string }
```
## ExtensionContext
```typescript
interface ExtensionContext {
userId: string; extensionId: string; supabase: SupabaseClient
emit(event: CoreEvent): Promise<void>
settings: { get<T>(key?: string): Promise<T | null>; set<T>(key: string, value: T): Promise<void> }
storage: { download(bucket, path); upload(bucket, path, data, options?); getPublicUrl(bucket, path) }
log: { info(msg, ...args); warn(msg, ...args); error(msg, ...args) } // Prefixed ext:{id}
services: { ingestTransactions(supabase, userId, raw): Promise<IngestResult> }
}
```
Settings stored in `extension_data` table with composite key `(user_id, extension_id, key)`.
## Complexity Spectrum
**Level 1 — Pure UI** (no surfaces, workspace reads core data):
```typescript
export const calendarExtension: Extension = { id: 'calendar', name: 'Kalender', version: '1.0.0' }
```
**Level 2 — Event handler only:**
```typescript
export const loggerExtension: Extension = {
id: 'example-logger', name: 'Logger', version: '1.0.0',
eventHandlers: [{ eventType: 'journal_entry.committed', handler: handleCommitted }],
}
```
**Level 3 — Service provider** (registers at module load):
```typescript
registerEmailService(new ResendEmailService())
export const emailExtension: Extension = { id: 'email', name: 'Email', version: '1.0.0' }
```
**Level 4 — Full extension** (events + API + settings + mappingRules + onInstall):
```typescript
export const receiptOcrExtension: Extension = {
id: 'receipt-ocr', name: 'Receipt OCR', version: '1.0.0', sector: 'general',
apiRoutes: receiptOcrApiRoutes,
eventHandlers: [
{ eventType: 'document.uploaded', handler: handleDocumentUploaded },
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
],
mappingRuleTypes: [{ id: 'receipt-ocr-merchant', name: 'OCR Merchant Match', description: '...' }],
settingsPanel: { label: 'Receipt OCR', path: '/settings/extensions/receipt-ocr' },
async onInstall(ctx) { await ctx.settings.set('settings', DEFAULT_SETTINGS) },
}
```
@@ -0,0 +1,62 @@
# Manifest Format Reference
Every extension has a `manifest.json` in `extensions/<sector>/<name>/`.
## Complete Schema
```json
{
"id": "my-extension",
"sector": "general",
"exportName": "myExtensionExtension",
"entryPoint": "@/extensions/general/my-extension",
"workspace": "@/components/extensions/general/MyExtensionWorkspace",
"requiredEnvVars": ["MY_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["some-package"],
"definition": {
"name": "My Extension",
"category": "operations",
"icon": "Box",
"dataPattern": "core",
"description": "Short marketplace card text",
"longDescription": "Longer detail page text.",
"readsCoreTables": ["invoices", "transactions"],
"hasOwnData": true,
"quickAction": { "label": "Do Thing", "description": "Short desc", "icon": "Zap", "href": "/path" },
"subscriptionNotice": "Requires external subscription to X"
}
}
```
## Top-Level Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Kebab-case ID, must match directory name |
| `sector` | string | `"general"` (future: `"restaurant"`, `"construction"`, etc.) |
| `exportName` | string | camelCase export name: `toCamelCase(id) + "Extension"` |
| `entryPoint` | string | Path alias: `"@/extensions/{sector}/{id}"` |
| `workspace` | string \| null | Workspace component path, or `null` |
| `requiredEnvVars` | string[] | Required env vars (empty `[]` if none) |
| `optionalEnvVars` | string[] | Optional env vars (empty `[]` if none) |
| `npmDependencies` | string[] | Package deps for documentation (empty `[]` if none) |
## Definition Fields
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Display name |
| `category` | Yes | `"import"` / `"operations"` / `"reports"` / `"accounting"` |
| `icon` | Yes | Lucide icon name (e.g., `"Camera"`, `"Sparkles"`, `"Bell"`) — falls back to `"Puzzle"` |
| `dataPattern` | Yes | `"core"` (reads core tables) / `"manual"` (own data) / `"both"` |
| `description` | Yes | Short marketplace card text |
| `longDescription` | Yes | Longer detail page text |
| `readsCoreTables` | No | Which core tables it reads |
| `hasOwnData` | No | Whether it stores extension-specific data |
| `quickAction` | No | Dashboard quick action: `{ label, description, icon, href?, event?, order? }` |
| `subscriptionNotice` | No | Warning shown when enabling |
## Naming Convention
`my-extension` → `myExtension` → `myExtensionExtension` (export) → `@/extensions/general/my-extension` (entry point)
@@ -0,0 +1,63 @@
# Service Integration Patterns
Two patterns for extensions to provide services to core. Both ensure core compiles without the extension.
## Pattern A: Interface Registration
Best for single-implementation services (e.g., email). Core defines interface + noop default; extension registers at load time.
```typescript
// Core: lib/email/service.ts
let emailService: EmailService = new NoopEmailService()
export function getEmailService(): EmailService { return emailService }
export function registerEmailService(svc: EmailService): void { emailService = svc }
// Extension: extensions/general/email/index.ts
import { registerEmailService } from '@/lib/email/service'
registerEmailService(new ResendEmailService()) // Registers at module load
export const emailExtension: Extension = { id: 'email', name: 'Email', version: '1.0.0' }
// Core consumption:
const svc = getEmailService()
if (svc.isConfigured()) await svc.sendEmail({ to, subject, html })
```
## Pattern B: Services Record
Best for multiple named functions (e.g., AI categorization). Extension exposes via `services`; core discovers via registry.
```typescript
// Extension:
services: {
findSimilarTemplates: async (...args: unknown[]) => {
const { findSimilarTemplates } = await import('./lib/template-embeddings')
return findSimilarTemplates(args[0] as Transaction, args[1] as EntityType)
},
},
// Core facade (lib/bookkeeping/template-embeddings.ts):
const aiExt = extensionRegistry.get('ai-categorization')
if (aiExt?.services?.findSimilarTemplates) {
return aiExt.services.findSimilarTemplates(transaction, entityType)
}
return findMatchingTemplates(transaction, entityType) // Fallback
```
## Pattern C: Core Services for Extensions
Extensions consume core services via `ctx.services`:
```typescript
await ctx?.services.ingestTransactions(ctx.supabase, ctx.userId, rawTransactions)
```
## Comparison
| Aspect | Interface Registration | Services Record |
|--------|----------------------|-----------------|
| Defined in | Core (`lib/`) | Extension (`index.ts`) |
| Discovery | `get*()` getter | `extensionRegistry.get().services` |
| Functions | Single interface | Multiple named |
| Lazy imports | No | Yes (inside service fns) |
| Fallback | Noop default | Core provides fallback |
| Example | Email | AI categorization |
@@ -0,0 +1,66 @@
# Testing Extensions
Vitest, same patterns as core. Tests colocated in `__tests__/` next to extension files.
## Setup
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() }))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
beforeEach(() => { vi.clearAllMocks(); eventBus.clear() })
```
## Mock Context
```typescript
const mockCtx: ExtensionContext = {
userId: 'user-123', extensionId: 'my-extension',
supabase: createMockSupabase() as any,
emit: vi.fn(),
settings: { get: vi.fn().mockResolvedValue({ featureEnabled: true }), set: vi.fn() },
storage: { download: vi.fn(), upload: vi.fn(), getPublicUrl: vi.fn() },
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
services: { ingestTransactions: vi.fn() },
}
```
## Testing Event Handlers
```typescript
it('should process events', async () => {
const handler = myExt.eventHandlers!.find(h => h.eventType === 'transaction.synced')!.handler
await handler({ transactions: [makeTransaction()], userId: 'user-123' }, mockCtx)
expect(mockCtx.supabase.from).toHaveBeenCalled()
})
it('should skip when disabled', async () => {
mockCtx.settings.get = vi.fn().mockResolvedValue({ featureEnabled: false })
const handler = myExt.eventHandlers!.find(h => h.eventType === 'transaction.synced')!.handler
await handler({ transactions: [], userId: 'user-123' }, mockCtx)
expect(mockCtx.supabase.from).not.toHaveBeenCalled()
})
```
## Testing API Routes
```typescript
const handler = myExtApiRoutes.find(r => r.method === 'GET' && r.path === '/')!.handler
const request = createMockRequest('GET', '/api/extensions/ext/my-extension/')
const response = await handler(request, mockCtx)
expect(response.status).toBe(200)
```
## Test Helpers (`tests/helpers.ts`)
`createMockSupabase()`, `createQueuedMockSupabase()`, `createMockRequest(method, url, body?)`, `parseJsonResponse(response)`, `makeTransaction()`, `makeJournalEntry()`, `makeInvoice()`, `makeCustomer()`, `makeSupplier()`, `makeReceipt()`, `makeDocumentAttachment()`, `makeCompanySettings()`
## Running
```bash
npm test # All tests
npx vitest run extensions/general/my-extension # Specific extension
```
@@ -0,0 +1,59 @@
# Workspace & UI Reference
## Workspace Components
Each extension can have a workspace page at `/e/{sector}/{slug}`.
**1. Create component:**
```typescript
// components/extensions/general/MyExtensionWorkspace.tsx
'use client'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
export default function MyExtensionWorkspace({ userId }: WorkspaceComponentProps) {
return <div className="space-y-6">{/* Extension UI */}</div>
}
```
**2. Set in manifest:** `"workspace": "@/components/extensions/general/MyExtensionWorkspace"`
**3. Regenerate:** `npm run setup:extensions`
Render chain: `/e/general/my-extension` → auth+toggle check → `ExtensionWorkspaceShell` (breadcrumb+header) → your component.
Set `"workspace": null` for extensions without a dedicated page (event-only, service-only, etc.).
## Settings Panels
Declare in extension object:
```typescript
settingsPanel: { label: 'My Extension', path: '/settings/extensions/my-extension' },
```
Register panel in `lib/extensions/settings-panel-registry.tsx`:
```typescript
case 'my-extension':
return dynamic(() => import('@/components/extensions/general/my-extension/MyExtSettings'))
```
Settings component fetches/saves via extension API routes (`GET/PUT /settings`).
## Sidebar
Enabled extensions auto-appear in the "Tillägg" sidebar section as links to `/e/{sector}/{slug}`. Additional nav items via:
```typescript
sidebarItems: [{ label: 'My Tool', icon: 'Wrench', path: '/tools/my-tool', order: 10 }]
```
## Toggle System
Toggle state stored in `extension_toggles` table `(user_id, sector_slug, extension_slug, enabled)`. Checked server-side via `isExtensionEnabled()`. Toggle changes dispatch `extension-toggle-changed` custom event.
## Hooks
```typescript
const { extensions, isLoading, refresh } = useEnabledExtensions() // lib/extensions/hooks
const { enabled, isLoading, toggle } = useExtensionToggle('general', 'my-extension')
const { data, save, remove, getByKey } = useExtensionData('general', 'my-extension')
const { totals, monthly, totalNet } = useAccountTotals({ from, to, monthly: true })
```