everything-dev 1.42.1 → 1.42.2

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.
@@ -0,0 +1,478 @@
1
+ ---
2
+ name: plugin-development
3
+ description: Build, register, and deploy plugins within everything.dev. Covers the _template scaffold, contract/service/index pattern, database setup with Drizzle, bos.config.json registration, plugin UI/sidebar, and CLI workflow. Use when creating new plugins, adding database-backed routes, or deploying plugins to production.
4
+ metadata:
5
+ sources: "plugins/_template/src/index.ts,plugins/_template/src/contract.ts,plugins/_template/src/service.ts,plugins/_template/rspack.config.js,plugins/_template/plugin.dev.ts,plugins/projects/src/db/schema.ts,plugins/projects/src/db/layer.ts,api/src/db/index.ts,api/src/db/migrator.ts,packages/every-plugin/src/plugin.ts"
6
+ ---
7
+
8
+ # Plugin Development
9
+
10
+ ## Plugin Structure
11
+
12
+ ```
13
+ plugins/your-plugin/
14
+ ├── src/
15
+ │ ├── contract.ts # oRPC route definitions + Zod schemas
16
+ │ ├── service.ts # Business logic (class or plain functions)
17
+ │ ├── index.ts # createPlugin() wiring
18
+ │ ├── db/ # Optional: database schema + migrations
19
+ │ │ ├── schema.ts
20
+ │ │ ├── layer.ts
21
+ │ │ ├── migrator.ts
22
+ │ │ └── migrations/
23
+ │ ├── plugins-client.gen.ts # Generated — in-process plugin client types
24
+ │ └── __tests__/ # Tests
25
+ ├── package.json
26
+ ├── rspack.config.js # Build config
27
+ ├── plugin.dev.ts # Dev server (port, variables, secrets)
28
+ └── tsconfig.json
29
+ ```
30
+
31
+ ## Step 1: Copy from `_template`
32
+
33
+ The quickest start is to copy the template:
34
+
35
+ ```bash
36
+ cp -r plugins/_template plugins/your-plugin
37
+ ```
38
+
39
+ Then rename in `package.json`, `plugin.dev.ts`, and `rspack.config.js`.
40
+
41
+ Or use the CLI (no automated command yet — copy template manually).
42
+
43
+ ## Step 2: Define the Contract (`src/contract.ts`)
44
+
45
+ Use `oc.router()` to define typed routes. Each route has a method, path, optional input/output schemas, and optional error declarations:
46
+
47
+ ```ts
48
+ import { eventIterator, oc } from "every-plugin/orpc";
49
+ import { z } from "every-plugin/zod";
50
+
51
+ // Define errors this plugin throws
52
+ const Errors = {
53
+ NOT_FOUND: { status: 404, message: "Item not found" },
54
+ UNAUTHORIZED: { status: 401, message: "User ID required" },
55
+ };
56
+
57
+ export const ItemSchema = z.object({
58
+ id: z.string(),
59
+ title: z.string(),
60
+ createdAt: z.string().datetime(),
61
+ });
62
+
63
+ export const contract = oc.router({
64
+ getById: oc
65
+ .route({ method: "GET", path: "/items/{id}" })
66
+ .input(z.object({ id: z.string().min(1) }))
67
+ .output(z.object({ item: ItemSchema, userId: z.string() }))
68
+ .errors(Errors),
69
+
70
+ ping: oc
71
+ .route({ method: "GET", path: "/ping" })
72
+ .output(z.object({ status: z.literal("ok"), timestamp: z.string().datetime() })),
73
+
74
+ search: oc
75
+ .route({ method: "GET", path: "/search" })
76
+ .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
77
+ .output(eventIterator(SearchResultSchema)), // SSE streaming
78
+
79
+ enqueueBackground: oc
80
+ .route({ method: "POST", path: "/background/events" })
81
+ .input(z.object({ id: z.string().optional() }))
82
+ .output(z.object({ ok: z.boolean() })),
83
+ });
84
+
85
+ export type ContractType = typeof contract;
86
+ ```
87
+
88
+ Key rules:
89
+ - Import from `every-plugin/zod`, `every-plugin/orpc`, `every-plugin/errors` — never directly from `zod` or `@orpc/*`
90
+ - Path params (`{id}`) automatically bind to Zod input keys
91
+ - `.output(eventIterator(Schema))` enables SSE streaming routes
92
+ - `.errors(Errors)` enables typed error handling in generated clients
93
+
94
+ ## Step 3: Build Services (`src/service.ts`)
95
+
96
+ Services are plain classes or functions, optionally using Effect:
97
+
98
+ ```ts
99
+ import { Effect } from "every-plugin/effect";
100
+
101
+ export class TemplateService {
102
+ constructor(
103
+ private baseUrl: URL,
104
+ private apiKey: string,
105
+ private timeout: number,
106
+ ) {}
107
+
108
+ ping(): Effect.Effect<{ status: string; timestamp: string }> {
109
+ return Effect.succeed({ status: "ok", timestamp: new Date().toISOString() });
110
+ }
111
+
112
+ getById(id: string): Effect.Effect<{ id: string; title: string; createdAt: string }> {
113
+ return Effect.tryPromise(async () => {
114
+ const res = await fetch(`${this.baseUrl}/items/${id}`, {
115
+ headers: { Authorization: `Bearer ${this.apiKey}` },
116
+ signal: AbortSignal.timeout(this.timeout),
117
+ });
118
+ if (!res.ok) throw new Error("Item not found");
119
+ return res.json();
120
+ });
121
+ }
122
+ }
123
+ ```
124
+
125
+ Use `Effect.runPromise(service.method())` to bridge Effect and async handlers in `createRouter`.
126
+
127
+ ## Step 4: Wire with `createPlugin` (`src/index.ts`)
128
+
129
+ ```ts
130
+ import { createPlugin } from "every-plugin";
131
+ import { Effect } from "every-plugin/effect";
132
+ import { MemoryPublisher, ORPCError } from "every-plugin/orpc";
133
+ import { z } from "every-plugin/zod";
134
+ import { contract } from "./contract";
135
+ import type { PluginsClient } from "./plugins-client.gen";
136
+ import { TemplateService } from "./service";
137
+
138
+ export default createPlugin.withPlugins<PluginsClient>()({
139
+ variables: z.object({
140
+ baseUrl: z.url().default("https://api.example.com"),
141
+ timeout: z.number().min(1000).max(60000).default(10000),
142
+ }),
143
+
144
+ secrets: z.object({
145
+ apiKey: z.string().min(1).default("dev-key"),
146
+ }),
147
+
148
+ context: z.object({
149
+ userId: z.string().optional(),
150
+ user: z.object({
151
+ id: z.string(),
152
+ role: z.string().optional(),
153
+ email: z.string().optional(),
154
+ name: z.string().optional(),
155
+ }).optional(),
156
+ reqHeaders: z.custom<Headers>().optional(),
157
+ getRawBody: z.custom<() => Promise<string>>().optional(),
158
+ }),
159
+
160
+ contract,
161
+
162
+ initialize: (config) =>
163
+ Effect.gen(function* () {
164
+ const service = new TemplateService(config.variables.baseUrl, config.secrets.apiKey, config.variables.timeout);
165
+ yield* service.ping(); // health check on startup
166
+
167
+ const publisher = new MemoryPublisher({ resumeRetentionSeconds: 120 });
168
+
169
+ return { service, publisher };
170
+ }),
171
+
172
+ shutdown: () => Effect.void,
173
+
174
+ createRouter: (context, builder) => {
175
+ const { service, publisher } = context;
176
+ const requireAuth = builder.middleware(async ({ context, next }) => {
177
+ if (!context.userId) throw new ORPCError("UNAUTHORIZED", { message: "User ID required" });
178
+ return next({ context: { ...context, userId: context.userId } });
179
+ });
180
+
181
+ return {
182
+ getById: builder.getById.use(requireAuth).handler(async ({ input, context }) => {
183
+ const item = await Effect.runPromise(service.getById(input.id));
184
+ return { item, userId: context.userId };
185
+ }),
186
+
187
+ ping: builder.ping.handler(async () => {
188
+ return await Effect.runPromise(service.ping());
189
+ }),
190
+
191
+ search: builder.search.handler(async function* ({ input }) {
192
+ const generator = await Effect.runPromise(service.search(input.query, input.limit));
193
+ for await (const result of generator) yield result;
194
+ }),
195
+ };
196
+ },
197
+ });
198
+ ```
199
+
200
+ ### Thing Provider Pattern
201
+
202
+ When the API owns the registry but the plugin owns the payload, add a small provider surface to your contract:
203
+
204
+ ```ts
205
+ createThing: oc
206
+ .route({ method: "POST", path: "/things" })
207
+ .input(z.object({ thingId: z.string(), payload: z.unknown() }))
208
+ .output(z.object({ type: z.string(), payload: z.unknown(), action: z.string().optional() })),
209
+
210
+ getThing: oc
211
+ .route({ method: "GET", path: "/things/{thingId}" })
212
+ .input(z.object({ thingId: z.string() }))
213
+ .output(z.object({ type: z.string(), payload: z.unknown() })),
214
+ ```
215
+
216
+ In `service.ts`, keep the plugin-specific type derivation close to the data source:
217
+
218
+ ```ts
219
+ createThing(thingId: string, payload: unknown) {
220
+ return Effect.sync(() => {
221
+ const type = this.resolveThingType(payload);
222
+ this.things.set(thingId, { thingId, type, payload, createdAt: now, updatedAt: now });
223
+ return { type, payload, action: `${type}.created` };
224
+ });
225
+ }
226
+ ```
227
+
228
+ Good practice:
229
+ - Let the API store only `thingId`, `pluginId`, and timestamps.
230
+ - Let the plugin decide the public `type` string and payload shape.
231
+ - Use `pluginId` as the API routing key, not as a UI concern.
232
+ - Keep `plugins-client.gen.ts` local to the plugin scaffold so `createPlugin.withPlugins<PluginsClient>()` stays typed.
233
+
234
+ ### Key Patterns
235
+
236
+ **`variables`** — Public config exposed in `bos.config.json` (typed, with defaults).
237
+ **`secrets`** — Private values from `process.env` (typed, dev defaults).
238
+ **`context`** — Per-request context injected by the host. See "Request Context Reference" below for all available fields.
239
+ **`initialize`** — Effect-based startup. Create services, publishers, DB connections. Return value is passed as `context` to `createRouter`.
240
+ **`createRouter`** — Maps contract procedures to handlers. Receives the value returned by `initialize` plus a pre-configured `builder`.
241
+
242
+ ## Request Context Reference
243
+
244
+ The host injects a per-request context object into every plugin. The plugin **must declare the fields it uses** in its context zod schema. Only declared fields are available to route handlers and middleware:
245
+
246
+ ```ts
247
+ context: z.object({
248
+ userId: z.string().optional(),
249
+ user: z.object({
250
+ id: z.string(),
251
+ role: z.string().optional(), // "admin", "member", or null for anon
252
+ email: z.string().optional(),
253
+ name: z.string().optional(),
254
+ }).optional(),
255
+ organizationId: z.string().optional(), // Active organization UUID
256
+ organization: z.object({
257
+ activeOrganizationId: z.string().nullable().optional(),
258
+ organization: z.object({
259
+ id: z.string(),
260
+ name: z.string(),
261
+ slug: z.string(),
262
+ logo: z.string().nullable().optional(),
263
+ metadata: z.record(z.string(), z.unknown()).optional(), // daoAccountId lives here
264
+ }).nullable().optional(),
265
+ member: z.object({
266
+ id: z.string(),
267
+ role: z.string(), // User's role within this org ("admin", "member")
268
+ }).nullable().optional(),
269
+ isPersonal: z.boolean(),
270
+ hasOrganization: z.boolean(),
271
+ }).optional(),
272
+ near: z.object({
273
+ primaryAccountId: z.string().nullable(),
274
+ linkedAccounts: z.array(z.object({
275
+ accountId: z.string(),
276
+ network: z.string(),
277
+ publicKey: z.string(),
278
+ isPrimary: z.boolean(),
279
+ })),
280
+ hasNearAccount: z.boolean(),
281
+ }).optional(),
282
+ walletAddress: z.string().optional(),
283
+ apiKey: z.object({
284
+ id: z.string(),
285
+ name: z.string().nullable().optional(),
286
+ permissions: z.record(z.string(), z.array(z.string())).nullable().optional(),
287
+ }).optional(),
288
+ reqHeaders: z.custom<Headers>().optional(),
289
+ getRawBody: z.custom<() => Promise<string>>().optional(),
290
+ }),
291
+ ```
292
+
293
+ The zod schema is a filter — the host passes all fields, but your plugin only sees what you declare. For pre-built auth/organization middleware, see the `api-and-auth` skill.
294
+
295
+ ## Step 5: Register in `bos.config.json`
296
+
297
+ Add a plugin entry:
298
+
299
+ ```json
300
+ {
301
+ "plugins": {
302
+ "your-plugin": {
303
+ "name": "your-plugin",
304
+ "development": "local:plugins/your-plugin",
305
+ "production": "https://cdn.example.com/your-plugin/remoteEntry.js",
306
+ "ssr": "https://cdn.example.com/your-plugin/ssr/remoteEntry.js",
307
+ "variables": {
308
+ "baseUrl": "https://api.example.com",
309
+ "timeout": 5000
310
+ },
311
+ "secrets": ["YOURPLUGIN_API_KEY"],
312
+ "sidebar": [
313
+ {
314
+ "label": "Your Plugin",
315
+ "icon": "Activity",
316
+ "to": "/your-plugin",
317
+ "roleRequired": "member"
318
+ }
319
+ ],
320
+ "routes": ["/your-plugin/*"],
321
+ "shared": {
322
+ "react": { "version": "^19.0.0", "singleton": true },
323
+ "react-dom": { "version": "^19.0.0", "singleton": true }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ ```
329
+
330
+ The CLI updates `bos.config.json` automatically when you run `bos plugin add` / `bos plugin remove`.
331
+
332
+ ## Step 6: `plugin.dev.ts`
333
+
334
+ Dev server config for local development:
335
+
336
+ ```ts
337
+ import { defineConfig } from "every-plugin/dev";
338
+
339
+ export default defineConfig({
340
+ port: 3015,
341
+ variables: {
342
+ baseUrl: "https://api.example.com",
343
+ timeout: 10000,
344
+ },
345
+ secrets: {
346
+ apiKey: "dev-key",
347
+ },
348
+ });
349
+ ```
350
+
351
+ ## Step 7: Build Config (`rspack.config.js`)
352
+
353
+ ```js
354
+ const { createRspackConfig } = require("every-plugin/rspack");
355
+
356
+ module.exports = createRspackConfig({
357
+ name: "your-plugin",
358
+ exposes: {
359
+ ".": "./src/index.ts",
360
+ "./contract": "./src/contract.ts",
361
+ },
362
+ plugins: [],
363
+ });
364
+ ```
365
+
366
+ Key exports:
367
+ - `"."` — the plugin entry point (`createPlugin` export)
368
+ - `"./contract"` — the contract module (for type generation from remote plugins)
369
+
370
+ ## Database Setup
371
+
372
+ ### Schema (`src/db/schema.ts`)
373
+
374
+ ```ts
375
+ import { pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
376
+
377
+ export const items = pgTable("items", {
378
+ id: text("id").primaryKey(),
379
+ title: text("title").notNull(),
380
+ createdAt: timestamp("created_at").defaultNow().notNull(),
381
+ }, (table) => ({
382
+ titleIdx: uniqueIndex("items_title_idx").on(table.title),
383
+ }));
384
+ ```
385
+
386
+ ### Driver (`src/db/index.ts` or use `api/src/db/index.ts`)
387
+
388
+ ```ts
389
+ export async function createDatabaseDriver(url: string) {
390
+ // Supports:
391
+ // "pglite:.bos/your-plugin/:memory:" — in-memory PGlite (dev)
392
+ // "pglite:.bos/your-plugin/data" — persistent PGlite (dev)
393
+ // "<postgres connection string>" — real PostgreSQL (prod)
394
+ // Returns { db: drizzle client, close(): Promise<void> }
395
+ }
396
+ ```
397
+
398
+ ### Layer Pattern
399
+
400
+ ```ts
401
+ // src/db/layer.ts
402
+ import { Effect, Layer } from "every-plugin/effect";
403
+ import { createDatabaseDriver } from "./index";
404
+
405
+ export class DatabaseTag extends Context.Tag("plugins/your-plugin/Database")<
406
+ DatabaseTag,
407
+ { db: any; close: () => Promise<void> }
408
+ >() {}
409
+
410
+ export function DatabaseLive(url: string) {
411
+ return Layer.scoped(
412
+ DatabaseTag,
413
+ Effect.acquireRelease(
414
+ Effect.promise(async () => {
415
+ const driver = await createDatabaseDriver(url);
416
+ return driver;
417
+ }),
418
+ (driver) => Effect.promise(() => driver.close()),
419
+ ),
420
+ );
421
+ }
422
+ ```
423
+
424
+ ### Migration Generation
425
+
426
+ 1. Create `drizzle.config.ts` in your plugin directory
427
+ 2. Run `drizzle-kit generate` to produce SQL migration files
428
+ 3. Store migrations in `src/db/migrations/`
429
+ 4. Use the custom `migrate()` function during `initialize`:
430
+
431
+ ```ts
432
+ initialize: (config) =>
433
+ Effect.gen(function* () {
434
+ const driver = yield* DatabaseLive(config.secrets.DATABASE_URL);
435
+ // Or load and apply migrations directly:
436
+ const { loadMigrations } = yield* Effect.promise(() => import("./db/load-migrations"));
437
+ const { migrate } = yield* Effect.promise(() => import("./db/migrator"));
438
+ const migrations = yield* loadMigrations();
439
+ yield* migrate(driver.db, migrations);
440
+
441
+ return { db: driver.db, ... };
442
+ }),
443
+ ```
444
+
445
+ ### Per-Plugin Isolation
446
+
447
+ Each plugin manages its own database or schema. Connection strings are passed via secrets (`YOURPLUGIN_DATABASE_URL`). The convention:
448
+
449
+ ```
450
+ YOURPLUGIN_DATABASE_URL=pglite:.bos/your-plugin/:memory:
451
+ ```
452
+
453
+ In production, set this to a real PostgreSQL connection string in the host's environment.
454
+
455
+ ## Calling Other Plugins
456
+
457
+ Use `createPlugin.withPlugins<PluginsClient>()` to get typed access to other plugin factories. The `plugins-client.gen.ts` file provides the `PluginsClient` type. Regenerate with `bos types gen`.
458
+
459
+ ## Plugin UI
460
+
461
+ Add a UI remote via the `ui` field in `bos.config.json`. Sidebar entries use `icon` from `lucide-react` and `roleRequired` from `anon`, `member`, `admin`. These merge into `plugin-sidebar.gen.ts` at `ui/src/lib/`.
462
+
463
+ ## Deploy
464
+
465
+ ```bash
466
+ bos plugin publish # Build and publish just this plugin
467
+ bos publish --deploy # Build ALL packages + deploy + publish config
468
+ ```
469
+
470
+ Deploy builds via `rspack.config.js`, uploads to Zephyr CDN, updates `bos.config.json` with production URL + integrity hash, and publishes to FastKV registry. Restart the host after publishing.
471
+
472
+ ### CLI Lifecycle
473
+
474
+ 1. `cp -r plugins/_template plugins/your-plugin` then edit names
475
+ 2. Register in `bos.config.json` (or `bos plugin add`)
476
+ 3. `bos types gen`
477
+ 4. `bos dev` to develop
478
+ 5. `bos plugin publish` to deploy