@donkeylabs/server 0.1.3 → 0.1.4
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/examples/starter/node_modules/@donkeylabs/server/README.md +15 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/commands/generate.ts +461 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/commands/init.ts +476 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/commands/interactive.ts +223 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/commands/plugin.ts +192 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/donkeylabs +106 -0
- package/examples/starter/node_modules/@donkeylabs/server/cli/index.ts +100 -0
- package/examples/starter/node_modules/@donkeylabs/server/context.d.ts +17 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/api-client.md +520 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/cache.md +437 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/cli.md +353 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/core-services.md +338 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/cron.md +465 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/errors.md +303 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/events.md +460 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/handlers.md +549 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/jobs.md +556 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/logger.md +316 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/middleware.md +682 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/plugins.md +524 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/project-structure.md +493 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/rate-limiter.md +525 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/router.md +566 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/sse.md +542 -0
- package/examples/starter/node_modules/@donkeylabs/server/docs/svelte-frontend.md +324 -0
- package/examples/starter/node_modules/@donkeylabs/server/mcp/donkeylabs-mcp +3238 -0
- package/examples/starter/node_modules/@donkeylabs/server/mcp/server.ts +3238 -0
- package/examples/starter/node_modules/@donkeylabs/server/package.json +77 -0
- package/examples/starter/node_modules/@donkeylabs/server/registry.d.ts +11 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/client/base.ts +481 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/client/index.ts +150 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/cache.ts +183 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/cron.ts +255 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/errors.ts +320 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/events.ts +163 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/index.ts +94 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/jobs.ts +334 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/logger.ts +131 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/rate-limiter.ts +193 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core/sse.ts +210 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/core.ts +428 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/handlers.ts +87 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/harness.ts +70 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/index.ts +38 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/middleware.ts +34 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/registry.ts +13 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/router.ts +155 -0
- package/examples/starter/node_modules/@donkeylabs/server/src/server.ts +234 -0
- package/examples/starter/node_modules/@donkeylabs/server/templates/init/donkeylabs.config.ts.template +14 -0
- package/examples/starter/node_modules/@donkeylabs/server/templates/init/index.ts.template +41 -0
- package/examples/starter/node_modules/@donkeylabs/server/templates/plugin/index.ts.template +25 -0
- package/examples/starter/src/routes/health/ping/models/model.ts +11 -7
- package/package.json +3 -3
- package/examples/starter/node_modules/.svelte2tsx-language-server-files/svelte-native-jsx.d.ts +0 -32
- package/examples/starter/node_modules/.svelte2tsx-language-server-files/svelte-shims-v4.d.ts +0 -290
|
@@ -0,0 +1,3238 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* @donkeylabs/server MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Provides tools for AI assistants to create and manage plugins,
|
|
6
|
+
* routes, and server code following project conventions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import {
|
|
12
|
+
CallToolRequestSchema,
|
|
13
|
+
ListToolsRequestSchema,
|
|
14
|
+
ListResourcesRequestSchema,
|
|
15
|
+
ReadResourceRequestSchema,
|
|
16
|
+
type Tool,
|
|
17
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
18
|
+
import { mkdir, writeFile, readFile, readdir } from "node:fs/promises";
|
|
19
|
+
import { join, relative } from "node:path";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
|
|
22
|
+
// ============================================
|
|
23
|
+
// Tool Definitions
|
|
24
|
+
// ============================================
|
|
25
|
+
|
|
26
|
+
const tools: Tool[] = [
|
|
27
|
+
{
|
|
28
|
+
name: "init_project",
|
|
29
|
+
description: `Initialize a new @donkeylabs/server project. Creates the basic structure with:
|
|
30
|
+
- src/index.ts (server entry point)
|
|
31
|
+
- src/db.ts (SQLite database setup)
|
|
32
|
+
- donkeylabs.config.ts (project config)
|
|
33
|
+
- package.json with dependencies
|
|
34
|
+
- tsconfig.json
|
|
35
|
+
|
|
36
|
+
Run this first before creating plugins or routes.`,
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
projectName: {
|
|
41
|
+
type: "string",
|
|
42
|
+
description: "Name for the project (used in package.json)",
|
|
43
|
+
},
|
|
44
|
+
useDatabase: {
|
|
45
|
+
type: "boolean",
|
|
46
|
+
description: "Set up SQLite database (default: true)",
|
|
47
|
+
default: true,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "create_plugin",
|
|
54
|
+
description: `Create a new plugin with correct directory structure.
|
|
55
|
+
|
|
56
|
+
AVAILABLE IN PLUGIN SERVICE CONTEXT (ctx):
|
|
57
|
+
- ctx.db: Kysely database with your schema types
|
|
58
|
+
- ctx.deps: Access to dependency plugin services (e.g., ctx.deps.auth)
|
|
59
|
+
- ctx.core.logger: Structured logging (info, warn, error, debug)
|
|
60
|
+
- ctx.core.cache: In-memory caching with TTL
|
|
61
|
+
- ctx.core.events: Pub/sub event system
|
|
62
|
+
- ctx.core.jobs: Background job processing
|
|
63
|
+
- ctx.core.cron: Scheduled tasks (cron expressions)
|
|
64
|
+
- ctx.core.sse: Server-sent events for real-time updates
|
|
65
|
+
- ctx.core.rateLimiter: Rate limiting per IP/key
|
|
66
|
+
- ctx.errors: Error factories (BadRequest, Unauthorized, NotFound, etc.)
|
|
67
|
+
|
|
68
|
+
Use these services to build robust, production-ready plugins.`,
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
name: {
|
|
73
|
+
type: "string",
|
|
74
|
+
description: "Plugin name (lowercase, alphanumeric with dashes). Example: 'notifications' or 'user-preferences'",
|
|
75
|
+
},
|
|
76
|
+
hasSchema: {
|
|
77
|
+
type: "boolean",
|
|
78
|
+
description: "Whether this plugin needs a database schema and migrations",
|
|
79
|
+
default: false,
|
|
80
|
+
},
|
|
81
|
+
dependencies: {
|
|
82
|
+
type: "array",
|
|
83
|
+
items: { type: "string" },
|
|
84
|
+
description: "Names of other plugins this depends on. Example: ['auth', 'notifications']",
|
|
85
|
+
default: [],
|
|
86
|
+
},
|
|
87
|
+
serviceMethods: {
|
|
88
|
+
type: "array",
|
|
89
|
+
items: { type: "string" },
|
|
90
|
+
description: "Names of service methods to scaffold. Example: ['sendEmail', 'getNotifications']",
|
|
91
|
+
default: [],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
required: ["name"],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "add_route",
|
|
99
|
+
description:
|
|
100
|
+
"Add a new route to an existing router file. Generates properly typed route with input/output schemas.",
|
|
101
|
+
inputSchema: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
routerFile: {
|
|
105
|
+
type: "string",
|
|
106
|
+
description: "Path to the router file (relative to project root). Example: 'src/index.ts'",
|
|
107
|
+
},
|
|
108
|
+
routeName: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description: "Name of the route (will be prefixed with router namespace). Example: 'getUser'",
|
|
111
|
+
},
|
|
112
|
+
handlerType: {
|
|
113
|
+
type: "string",
|
|
114
|
+
enum: ["typed", "raw"],
|
|
115
|
+
description: "Handler type: 'typed' for JSON-RPC style, 'raw' for direct Request/Response",
|
|
116
|
+
default: "typed",
|
|
117
|
+
},
|
|
118
|
+
inputFields: {
|
|
119
|
+
type: "array",
|
|
120
|
+
items: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
name: { type: "string" },
|
|
124
|
+
type: { type: "string", enum: ["string", "number", "boolean", "object", "array"] },
|
|
125
|
+
optional: { type: "boolean", default: false },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
description: "Input schema fields for typed handlers",
|
|
129
|
+
},
|
|
130
|
+
outputFields: {
|
|
131
|
+
type: "array",
|
|
132
|
+
items: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
name: { type: "string" },
|
|
136
|
+
type: { type: "string", enum: ["string", "number", "boolean", "object", "array"] },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
description: "Output schema fields for typed handlers",
|
|
140
|
+
},
|
|
141
|
+
description: {
|
|
142
|
+
type: "string",
|
|
143
|
+
description: "Description of what this route does",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
required: ["routerFile", "routeName"],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: "add_migration",
|
|
151
|
+
description:
|
|
152
|
+
"Create a new migration file for a plugin with proper sequential numbering.",
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: {
|
|
156
|
+
pluginName: {
|
|
157
|
+
type: "string",
|
|
158
|
+
description: "Name of the plugin to add migration to",
|
|
159
|
+
},
|
|
160
|
+
migrationName: {
|
|
161
|
+
type: "string",
|
|
162
|
+
description: "Name for the migration (snake_case). Example: 'add_email_column'",
|
|
163
|
+
},
|
|
164
|
+
upSql: {
|
|
165
|
+
type: "string",
|
|
166
|
+
description: "SQL or Kysely code for the 'up' migration",
|
|
167
|
+
},
|
|
168
|
+
downSql: {
|
|
169
|
+
type: "string",
|
|
170
|
+
description: "SQL or Kysely code for the 'down' migration",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
required: ["pluginName", "migrationName"],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: "generate_types",
|
|
178
|
+
description:
|
|
179
|
+
"Run type generation to update registry.d.ts and context.d.ts after making changes to plugins or handlers.",
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: "object",
|
|
182
|
+
properties: {},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "list_plugins",
|
|
187
|
+
description:
|
|
188
|
+
"List all plugins in the project with their dependencies and service methods.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: "object",
|
|
191
|
+
properties: {},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: "get_project_info",
|
|
196
|
+
description:
|
|
197
|
+
"Get information about the project structure, available handlers, middleware, and configuration.",
|
|
198
|
+
inputSchema: {
|
|
199
|
+
type: "object",
|
|
200
|
+
properties: {},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "add_service_method",
|
|
205
|
+
description:
|
|
206
|
+
"Add a new method to an existing plugin's service interface and implementation.",
|
|
207
|
+
inputSchema: {
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: {
|
|
210
|
+
pluginName: {
|
|
211
|
+
type: "string",
|
|
212
|
+
description: "Name of the plugin to add the method to",
|
|
213
|
+
},
|
|
214
|
+
methodName: {
|
|
215
|
+
type: "string",
|
|
216
|
+
description: "Name of the method (camelCase). Example: 'sendNotification'",
|
|
217
|
+
},
|
|
218
|
+
params: {
|
|
219
|
+
type: "array",
|
|
220
|
+
items: {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: {
|
|
223
|
+
name: { type: "string" },
|
|
224
|
+
type: { type: "string" },
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
description: "Method parameters",
|
|
228
|
+
},
|
|
229
|
+
returnType: {
|
|
230
|
+
type: "string",
|
|
231
|
+
description: "Return type. Example: 'Promise<boolean>'",
|
|
232
|
+
},
|
|
233
|
+
implementation: {
|
|
234
|
+
type: "string",
|
|
235
|
+
description: "Method implementation code (optional, will scaffold if not provided)",
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
required: ["pluginName", "methodName"],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: "create_router",
|
|
243
|
+
description:
|
|
244
|
+
"Create a new router file with a namespace. Use this when you need to organize routes into separate files.",
|
|
245
|
+
inputSchema: {
|
|
246
|
+
type: "object",
|
|
247
|
+
properties: {
|
|
248
|
+
fileName: {
|
|
249
|
+
type: "string",
|
|
250
|
+
description: "Name for the router file (without extension). Example: 'users' creates 'src/routes/users.ts'",
|
|
251
|
+
},
|
|
252
|
+
namespace: {
|
|
253
|
+
type: "string",
|
|
254
|
+
description: "Router namespace (prefix for all routes). Example: 'users' means routes are 'users.list', 'users.get', etc.",
|
|
255
|
+
},
|
|
256
|
+
initialRoutes: {
|
|
257
|
+
type: "array",
|
|
258
|
+
items: { type: "string" },
|
|
259
|
+
description: "Names of initial routes to create. Example: ['list', 'get', 'create']",
|
|
260
|
+
default: [],
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
required: ["fileName", "namespace"],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "validate_project",
|
|
268
|
+
description:
|
|
269
|
+
"Check the project for common issues: missing dependencies, invalid plugin structure, import errors, etc.",
|
|
270
|
+
inputSchema: {
|
|
271
|
+
type: "object",
|
|
272
|
+
properties: {},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: "register_plugin",
|
|
277
|
+
description:
|
|
278
|
+
"Register a plugin in the server's index.ts file. Adds the import and registerPlugin call.",
|
|
279
|
+
inputSchema: {
|
|
280
|
+
type: "object",
|
|
281
|
+
properties: {
|
|
282
|
+
pluginName: {
|
|
283
|
+
type: "string",
|
|
284
|
+
description: "Name of the plugin to register (must exist in src/plugins/)",
|
|
285
|
+
},
|
|
286
|
+
configOptions: {
|
|
287
|
+
type: "object",
|
|
288
|
+
description: "Optional configuration object to pass to the plugin",
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
required: ["pluginName"],
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
name: "run_migrations",
|
|
296
|
+
description:
|
|
297
|
+
"Run database migrations for one or all plugins. Creates tables and applies schema changes.",
|
|
298
|
+
inputSchema: {
|
|
299
|
+
type: "object",
|
|
300
|
+
properties: {
|
|
301
|
+
pluginName: {
|
|
302
|
+
type: "string",
|
|
303
|
+
description: "Plugin name to run migrations for (optional - runs all if not specified)",
|
|
304
|
+
},
|
|
305
|
+
direction: {
|
|
306
|
+
type: "string",
|
|
307
|
+
enum: ["up", "down"],
|
|
308
|
+
description: "Migration direction: 'up' to apply, 'down' to rollback (default: up)",
|
|
309
|
+
default: "up",
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
name: "setup_database",
|
|
316
|
+
description:
|
|
317
|
+
"Initialize or check database setup. Creates the database file and verifies the connection.",
|
|
318
|
+
inputSchema: {
|
|
319
|
+
type: "object",
|
|
320
|
+
properties: {
|
|
321
|
+
dbPath: {
|
|
322
|
+
type: "string",
|
|
323
|
+
description: "Path to SQLite database file (default: app.db)",
|
|
324
|
+
default: "app.db",
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
name: "list_routes",
|
|
331
|
+
description:
|
|
332
|
+
"List all routes in the project. Shows namespace, route name, handler type, and file path. Use to avoid duplicates.",
|
|
333
|
+
inputSchema: {
|
|
334
|
+
type: "object",
|
|
335
|
+
properties: {
|
|
336
|
+
namespace: {
|
|
337
|
+
type: "string",
|
|
338
|
+
description: "Filter by namespace (optional)",
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: "add_route_file",
|
|
345
|
+
description: `Create a new route with proper directory structure.
|
|
346
|
+
|
|
347
|
+
ROUTE HANDLER CONTEXT (ctx) - Available in all handlers:
|
|
348
|
+
- ctx.db: Kysely database with type-safe queries
|
|
349
|
+
- ctx.plugins: All registered plugin services (e.g., ctx.plugins.auth, ctx.plugins.users)
|
|
350
|
+
- ctx.core.logger: Structured logging
|
|
351
|
+
- ctx.core.cache: Caching with TTL
|
|
352
|
+
- ctx.core.events: Pub/sub events
|
|
353
|
+
- ctx.core.jobs: Background job processing
|
|
354
|
+
- ctx.core.cron: Scheduled tasks
|
|
355
|
+
- ctx.core.sse: Server-sent events
|
|
356
|
+
- ctx.core.rateLimiter: Rate limiting
|
|
357
|
+
- ctx.errors: Error factories (BadRequest, NotFound, etc.)
|
|
358
|
+
- ctx.ip: Client IP address
|
|
359
|
+
- ctx.requestId: Request tracking ID
|
|
360
|
+
- ctx.user: Authenticated user (if set by auth middleware)
|
|
361
|
+
|
|
362
|
+
Creates /src/routes/<namespace>/<route>/handler.ts and optional test.ts.`,
|
|
363
|
+
inputSchema: {
|
|
364
|
+
type: "object",
|
|
365
|
+
properties: {
|
|
366
|
+
namespace: {
|
|
367
|
+
type: "string",
|
|
368
|
+
description: "Route namespace (e.g., 'users', 'posts'). Creates /src/routes/<namespace>/",
|
|
369
|
+
},
|
|
370
|
+
routeName: {
|
|
371
|
+
type: "string",
|
|
372
|
+
description: "Route name (e.g., 'get-by-id', 'create'). Creates /src/routes/<namespace>/<routeName>/",
|
|
373
|
+
},
|
|
374
|
+
handlerType: {
|
|
375
|
+
type: "string",
|
|
376
|
+
enum: ["typed", "raw"],
|
|
377
|
+
description: "Handler type: 'typed' for JSON-RPC, 'raw' for Request/Response",
|
|
378
|
+
default: "typed",
|
|
379
|
+
},
|
|
380
|
+
inputFields: {
|
|
381
|
+
type: "array",
|
|
382
|
+
items: {
|
|
383
|
+
type: "object",
|
|
384
|
+
properties: {
|
|
385
|
+
name: { type: "string" },
|
|
386
|
+
type: { type: "string", enum: ["string", "number", "boolean", "object", "array"] },
|
|
387
|
+
optional: { type: "boolean", default: false },
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
description: "Input schema fields (for typed handlers)",
|
|
391
|
+
},
|
|
392
|
+
outputFields: {
|
|
393
|
+
type: "array",
|
|
394
|
+
items: {
|
|
395
|
+
type: "object",
|
|
396
|
+
properties: {
|
|
397
|
+
name: { type: "string" },
|
|
398
|
+
type: { type: "string", enum: ["string", "number", "boolean", "object", "array"] },
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
description: "Output schema fields (for typed handlers)",
|
|
402
|
+
},
|
|
403
|
+
description: {
|
|
404
|
+
type: "string",
|
|
405
|
+
description: "Description of what this route does",
|
|
406
|
+
},
|
|
407
|
+
createTest: {
|
|
408
|
+
type: "boolean",
|
|
409
|
+
description: "Create a test file alongside the handler (default: true)",
|
|
410
|
+
default: true,
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
required: ["namespace", "routeName"],
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: "create_model",
|
|
418
|
+
description:
|
|
419
|
+
"Create a model class in /src/models/ for business logic separation. Models receive GlobalContext for testability.",
|
|
420
|
+
inputSchema: {
|
|
421
|
+
type: "object",
|
|
422
|
+
properties: {
|
|
423
|
+
name: {
|
|
424
|
+
type: "string",
|
|
425
|
+
description: "Model name (PascalCase). Example: 'UserModel' creates /src/models/UserModel.ts",
|
|
426
|
+
},
|
|
427
|
+
methods: {
|
|
428
|
+
type: "array",
|
|
429
|
+
items: {
|
|
430
|
+
type: "object",
|
|
431
|
+
properties: {
|
|
432
|
+
name: { type: "string" },
|
|
433
|
+
params: { type: "string" },
|
|
434
|
+
returnType: { type: "string" },
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
description: "Methods to scaffold on the model",
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
required: ["name"],
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
name: "generate_client",
|
|
445
|
+
description:
|
|
446
|
+
"Generate a typed API client from routes. The client provides type-safe methods instead of raw fetch() calls. ALWAYS use this for frontend code.",
|
|
447
|
+
inputSchema: {
|
|
448
|
+
type: "object",
|
|
449
|
+
properties: {
|
|
450
|
+
output: {
|
|
451
|
+
type: "string",
|
|
452
|
+
description: "Output path for generated client (default: src/client/api.ts)",
|
|
453
|
+
default: "src/client/api.ts",
|
|
454
|
+
},
|
|
455
|
+
baseUrl: {
|
|
456
|
+
type: "string",
|
|
457
|
+
description: "Default base URL for the client (default: empty, set at runtime)",
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
name: "add_unit_test",
|
|
464
|
+
description:
|
|
465
|
+
"Create a unit test file for a model or utility. Uses Bun test runner with mock context.",
|
|
466
|
+
inputSchema: {
|
|
467
|
+
type: "object",
|
|
468
|
+
properties: {
|
|
469
|
+
targetFile: {
|
|
470
|
+
type: "string",
|
|
471
|
+
description: "Path to the file being tested (e.g., 'src/models/UserModel.ts')",
|
|
472
|
+
},
|
|
473
|
+
testCases: {
|
|
474
|
+
type: "array",
|
|
475
|
+
items: {
|
|
476
|
+
type: "object",
|
|
477
|
+
properties: {
|
|
478
|
+
name: { type: "string", description: "Test case name" },
|
|
479
|
+
method: { type: "string", description: "Method being tested" },
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
description: "Test cases to scaffold",
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
required: ["targetFile"],
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
name: "add_integration_test",
|
|
490
|
+
description:
|
|
491
|
+
"Create an integration test for a route using the test harness. Tests the full request/response cycle with real plugins.",
|
|
492
|
+
inputSchema: {
|
|
493
|
+
type: "object",
|
|
494
|
+
properties: {
|
|
495
|
+
namespace: {
|
|
496
|
+
type: "string",
|
|
497
|
+
description: "Route namespace (e.g., 'users')",
|
|
498
|
+
},
|
|
499
|
+
routeName: {
|
|
500
|
+
type: "string",
|
|
501
|
+
description: "Route name (e.g., 'create')",
|
|
502
|
+
},
|
|
503
|
+
testCases: {
|
|
504
|
+
type: "array",
|
|
505
|
+
items: {
|
|
506
|
+
type: "object",
|
|
507
|
+
properties: {
|
|
508
|
+
name: { type: "string", description: "Test case name" },
|
|
509
|
+
input: { type: "object", description: "Input data for the test" },
|
|
510
|
+
expectedStatus: { type: "number", description: "Expected HTTP status" },
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
description: "Test cases to scaffold",
|
|
514
|
+
},
|
|
515
|
+
plugins: {
|
|
516
|
+
type: "array",
|
|
517
|
+
items: { type: "string" },
|
|
518
|
+
description: "Plugins required for this test (e.g., ['auth', 'users'])",
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
required: ["namespace", "routeName"],
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
name: "add_middleware",
|
|
526
|
+
description:
|
|
527
|
+
"Add middleware to a plugin. Middleware runs before route handlers for auth, logging, rate limiting, etc.",
|
|
528
|
+
inputSchema: {
|
|
529
|
+
type: "object",
|
|
530
|
+
properties: {
|
|
531
|
+
pluginName: {
|
|
532
|
+
type: "string",
|
|
533
|
+
description: "Plugin to add middleware to",
|
|
534
|
+
},
|
|
535
|
+
middlewareName: {
|
|
536
|
+
type: "string",
|
|
537
|
+
description: "Name of the middleware (camelCase). Example: 'requireAuth'",
|
|
538
|
+
},
|
|
539
|
+
description: {
|
|
540
|
+
type: "string",
|
|
541
|
+
description: "What this middleware does",
|
|
542
|
+
},
|
|
543
|
+
modifiesContext: {
|
|
544
|
+
type: "boolean",
|
|
545
|
+
description: "Whether this middleware adds to context (e.g., ctx.user)",
|
|
546
|
+
default: false,
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
required: ["pluginName", "middlewareName"],
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
name: "add_custom_handler",
|
|
554
|
+
description:
|
|
555
|
+
"Add a custom handler type to a plugin. Custom handlers extend beyond 'typed' and 'raw' for specialized use cases.",
|
|
556
|
+
inputSchema: {
|
|
557
|
+
type: "object",
|
|
558
|
+
properties: {
|
|
559
|
+
pluginName: {
|
|
560
|
+
type: "string",
|
|
561
|
+
description: "Plugin to add the handler to",
|
|
562
|
+
},
|
|
563
|
+
handlerName: {
|
|
564
|
+
type: "string",
|
|
565
|
+
description: "Name of the handler (camelCase). Example: 'streaming'",
|
|
566
|
+
},
|
|
567
|
+
description: {
|
|
568
|
+
type: "string",
|
|
569
|
+
description: "What this handler type does",
|
|
570
|
+
},
|
|
571
|
+
signatureParams: {
|
|
572
|
+
type: "string",
|
|
573
|
+
description: "Handler function parameters. Example: 'data: T, ctx: ServerContext'",
|
|
574
|
+
},
|
|
575
|
+
signatureReturn: {
|
|
576
|
+
type: "string",
|
|
577
|
+
description: "Handler function return type. Example: 'Promise<Response>'",
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
required: ["pluginName", "handlerName"],
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
name: "create_custom_error",
|
|
585
|
+
description:
|
|
586
|
+
"Create a custom error type for a plugin. Custom errors extend HttpError with specific status codes and machine-readable error codes. Use for domain-specific errors like 'InsufficientFunds' or 'QuotaExceeded'.",
|
|
587
|
+
inputSchema: {
|
|
588
|
+
type: "object",
|
|
589
|
+
properties: {
|
|
590
|
+
pluginName: {
|
|
591
|
+
type: "string",
|
|
592
|
+
description: "Plugin to add the error to",
|
|
593
|
+
},
|
|
594
|
+
errorName: {
|
|
595
|
+
type: "string",
|
|
596
|
+
description: "Error name (PascalCase, no 'Error' suffix). E.g., 'InsufficientFunds'",
|
|
597
|
+
},
|
|
598
|
+
httpStatus: {
|
|
599
|
+
type: "number",
|
|
600
|
+
description: "HTTP status code (default: 400)",
|
|
601
|
+
default: 400,
|
|
602
|
+
},
|
|
603
|
+
defaultMessage: {
|
|
604
|
+
type: "string",
|
|
605
|
+
description: "Default error message shown to users",
|
|
606
|
+
},
|
|
607
|
+
errorCode: {
|
|
608
|
+
type: "string",
|
|
609
|
+
description: "Machine-readable code (UPPER_SNAKE_CASE). E.g., 'INSUFFICIENT_FUNDS'",
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
required: ["pluginName", "errorName"],
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
name: "validate_code",
|
|
617
|
+
description:
|
|
618
|
+
"Validate code against @donkeylabs/server conventions. Checks for common mistakes, missing patterns, and best practices. Use after writing code to ensure quality.",
|
|
619
|
+
inputSchema: {
|
|
620
|
+
type: "object",
|
|
621
|
+
properties: {
|
|
622
|
+
filePath: {
|
|
623
|
+
type: "string",
|
|
624
|
+
description: "Path to the file to validate (relative to project root)",
|
|
625
|
+
},
|
|
626
|
+
checkType: {
|
|
627
|
+
type: "string",
|
|
628
|
+
enum: ["plugin", "route", "model", "auto"],
|
|
629
|
+
description: "Type of validation: 'plugin', 'route', 'model', or 'auto' (detect from path). Default: auto",
|
|
630
|
+
default: "auto",
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
required: ["filePath"],
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
];
|
|
637
|
+
|
|
638
|
+
// ============================================
|
|
639
|
+
// Tool Implementations
|
|
640
|
+
// ============================================
|
|
641
|
+
|
|
642
|
+
async function initProject(args: {
|
|
643
|
+
projectName?: string;
|
|
644
|
+
useDatabase?: boolean;
|
|
645
|
+
}): Promise<string> {
|
|
646
|
+
const { projectName = "my-server", useDatabase = true } = args;
|
|
647
|
+
const root = process.cwd();
|
|
648
|
+
|
|
649
|
+
const createdFiles: string[] = [];
|
|
650
|
+
|
|
651
|
+
// Create directories
|
|
652
|
+
await mkdir(join(root, "src/plugins"), { recursive: true });
|
|
653
|
+
await mkdir(join(root, "src/routes"), { recursive: true });
|
|
654
|
+
await mkdir(join(root, ".@donkeylabs/server"), { recursive: true });
|
|
655
|
+
|
|
656
|
+
// Create donkeylabs.config.ts
|
|
657
|
+
const configContent = `import { defineConfig } from "@donkeylabs/server";
|
|
658
|
+
|
|
659
|
+
export default defineConfig({
|
|
660
|
+
plugins: ["./src/plugins/**/index.ts"],
|
|
661
|
+
outDir: ".@donkeylabs/server",
|
|
662
|
+
routes: "./src/routes/**/handler.ts",
|
|
663
|
+
});
|
|
664
|
+
`;
|
|
665
|
+
await writeFile(join(root, "donkeylabs.config.ts"), configContent);
|
|
666
|
+
createdFiles.push("donkeylabs.config.ts");
|
|
667
|
+
|
|
668
|
+
// Create database setup if requested
|
|
669
|
+
if (useDatabase) {
|
|
670
|
+
const dbContent = `import { Kysely } from "kysely";
|
|
671
|
+
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
|
672
|
+
import { Database } from "bun:sqlite";
|
|
673
|
+
|
|
674
|
+
const dbPath = process.env.DATABASE_URL || "app.db";
|
|
675
|
+
|
|
676
|
+
export const db = new Kysely<any>({
|
|
677
|
+
dialect: new BunSqliteDialect({
|
|
678
|
+
database: new Database(dbPath),
|
|
679
|
+
}),
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
export type DB = typeof db;
|
|
683
|
+
`;
|
|
684
|
+
await writeFile(join(root, "src/db.ts"), dbContent);
|
|
685
|
+
createdFiles.push("src/db.ts");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Create main index.ts
|
|
689
|
+
const indexContent = useDatabase
|
|
690
|
+
? `import { db } from "./db";
|
|
691
|
+
import { AppServer, createRouter } from "@donkeylabs/server";
|
|
692
|
+
import { z } from "zod";
|
|
693
|
+
|
|
694
|
+
// Create Server
|
|
695
|
+
const server = new AppServer({
|
|
696
|
+
port: Number(process.env.PORT) || 3000,
|
|
697
|
+
db,
|
|
698
|
+
config: { env: process.env.NODE_ENV || "development" },
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// Health check route
|
|
702
|
+
const router = createRouter("api")
|
|
703
|
+
.route("health").raw({
|
|
704
|
+
handle: async () => {
|
|
705
|
+
return Response.json({ status: "ok", timestamp: new Date().toISOString() });
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
server.use(router);
|
|
710
|
+
|
|
711
|
+
// Start Server
|
|
712
|
+
await server.start();
|
|
713
|
+
console.log("Server running on http://localhost:3000");
|
|
714
|
+
`
|
|
715
|
+
: `import { AppServer, createRouter } from "@donkeylabs/server";
|
|
716
|
+
import { Kysely, DummyDriver, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "kysely";
|
|
717
|
+
import { z } from "zod";
|
|
718
|
+
|
|
719
|
+
// Dummy database (no persistence)
|
|
720
|
+
const db = new Kysely<any>({
|
|
721
|
+
dialect: {
|
|
722
|
+
createAdapter: () => new SqliteAdapter(),
|
|
723
|
+
createDriver: () => new DummyDriver(),
|
|
724
|
+
createIntrospector: (db) => new SqliteIntrospector(db),
|
|
725
|
+
createQueryCompiler: () => new SqliteQueryCompiler(),
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// Create Server
|
|
730
|
+
const server = new AppServer({
|
|
731
|
+
port: Number(process.env.PORT) || 3000,
|
|
732
|
+
db,
|
|
733
|
+
config: { env: process.env.NODE_ENV || "development" },
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
// Health check route
|
|
737
|
+
const router = createRouter("api")
|
|
738
|
+
.route("health").raw({
|
|
739
|
+
handle: async () => {
|
|
740
|
+
return Response.json({ status: "ok", timestamp: new Date().toISOString() });
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
server.use(router);
|
|
745
|
+
|
|
746
|
+
// Start Server
|
|
747
|
+
await server.start();
|
|
748
|
+
console.log("Server running on http://localhost:3000");
|
|
749
|
+
`;
|
|
750
|
+
await writeFile(join(root, "src/index.ts"), indexContent);
|
|
751
|
+
createdFiles.push("src/index.ts");
|
|
752
|
+
|
|
753
|
+
// Create/update package.json
|
|
754
|
+
const pkgPath = join(root, "package.json");
|
|
755
|
+
let pkg: any = { name: projectName, version: "1.0.0", type: "module", scripts: {} };
|
|
756
|
+
|
|
757
|
+
if (existsSync(pkgPath)) {
|
|
758
|
+
try {
|
|
759
|
+
pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
760
|
+
pkg.scripts = pkg.scripts || {};
|
|
761
|
+
} catch {}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
pkg.name = pkg.name || projectName;
|
|
765
|
+
pkg.type = "module";
|
|
766
|
+
pkg.scripts.dev = "bun --watch src/index.ts";
|
|
767
|
+
pkg.scripts.start = "bun src/index.ts";
|
|
768
|
+
pkg.scripts["gen:types"] = "donkeylabs generate";
|
|
769
|
+
|
|
770
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\\n");
|
|
771
|
+
createdFiles.push("package.json");
|
|
772
|
+
|
|
773
|
+
// Create tsconfig.json
|
|
774
|
+
const tsconfigContent = `{
|
|
775
|
+
"compilerOptions": {
|
|
776
|
+
"lib": ["ESNext"],
|
|
777
|
+
"target": "ESNext",
|
|
778
|
+
"module": "Preserve",
|
|
779
|
+
"moduleDetection": "force",
|
|
780
|
+
"jsx": "react-jsx",
|
|
781
|
+
"allowJs": true,
|
|
782
|
+
"moduleResolution": "bundler",
|
|
783
|
+
"allowImportingTsExtensions": true,
|
|
784
|
+
"verbatimModuleSyntax": true,
|
|
785
|
+
"noEmit": true,
|
|
786
|
+
"strict": true,
|
|
787
|
+
"skipLibCheck": true
|
|
788
|
+
},
|
|
789
|
+
"include": ["src/**/*", "*.ts", ".@donkeylabs/**/*"]
|
|
790
|
+
}
|
|
791
|
+
`;
|
|
792
|
+
if (!existsSync(join(root, "tsconfig.json"))) {
|
|
793
|
+
await writeFile(join(root, "tsconfig.json"), tsconfigContent);
|
|
794
|
+
createdFiles.push("tsconfig.json");
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Create .gitignore
|
|
798
|
+
const gitignoreContent = `node_modules/
|
|
799
|
+
.@donkeylabs/
|
|
800
|
+
*.db
|
|
801
|
+
*.db-journal
|
|
802
|
+
.env
|
|
803
|
+
.env.local
|
|
804
|
+
`;
|
|
805
|
+
if (!existsSync(join(root, ".gitignore"))) {
|
|
806
|
+
await writeFile(join(root, ".gitignore"), gitignoreContent);
|
|
807
|
+
createdFiles.push(".gitignore");
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return `Project initialized successfully!
|
|
811
|
+
|
|
812
|
+
Created files:
|
|
813
|
+
${createdFiles.map((f) => " - " + f).join("\\n")}
|
|
814
|
+
|
|
815
|
+
Next steps:
|
|
816
|
+
1. Install dependencies: bun install @donkeylabs/server kysely kysely-bun-sqlite zod
|
|
817
|
+
2. Create a plugin: use create_plugin tool
|
|
818
|
+
3. Add routes: use add_route_file tool
|
|
819
|
+
4. Generate types: use generate_types tool
|
|
820
|
+
5. Start server: bun run dev`;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function findProjectRoot(): Promise<string> {
|
|
824
|
+
let dir = process.cwd();
|
|
825
|
+
while (dir !== "/") {
|
|
826
|
+
if (existsSync(join(dir, "donkeylabs.config.ts")) || existsSync(join(dir, "package.json"))) {
|
|
827
|
+
return dir;
|
|
828
|
+
}
|
|
829
|
+
dir = join(dir, "..");
|
|
830
|
+
}
|
|
831
|
+
return process.cwd();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function getPluginsDir(): Promise<string> {
|
|
835
|
+
const root = await findProjectRoot();
|
|
836
|
+
// Check for src/plugins (user project) or examples/basic-server/src/plugins (dev)
|
|
837
|
+
const srcPlugins = join(root, "src/plugins");
|
|
838
|
+
if (existsSync(srcPlugins)) return srcPlugins;
|
|
839
|
+
return join(root, "src/plugins"); // Default to creating here
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function createPlugin(args: {
|
|
843
|
+
name: string;
|
|
844
|
+
hasSchema?: boolean;
|
|
845
|
+
dependencies?: string[];
|
|
846
|
+
serviceMethods?: string[];
|
|
847
|
+
}): Promise<string> {
|
|
848
|
+
const { name, hasSchema = false, dependencies = [], serviceMethods = [] } = args;
|
|
849
|
+
|
|
850
|
+
// Validate name
|
|
851
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
852
|
+
throw new Error("Plugin name must be lowercase alphanumeric with dashes, starting with a letter");
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const pluginsDir = await getPluginsDir();
|
|
856
|
+
const pluginDir = join(pluginsDir, name);
|
|
857
|
+
|
|
858
|
+
if (existsSync(pluginDir)) {
|
|
859
|
+
throw new Error(`Plugin '${name}' already exists at ${pluginDir}`);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
await mkdir(pluginDir, { recursive: true });
|
|
863
|
+
|
|
864
|
+
// Generate names
|
|
865
|
+
const camelName = name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
866
|
+
const pascalName = camelName.charAt(0).toUpperCase() + camelName.slice(1);
|
|
867
|
+
|
|
868
|
+
// Generate service interface
|
|
869
|
+
const serviceInterface = serviceMethods.length > 0
|
|
870
|
+
? serviceMethods.map((m) => ` ${m}(): Promise<void>;`).join("\n")
|
|
871
|
+
: " getData(): Promise<string>;";
|
|
872
|
+
|
|
873
|
+
// Generate service implementation
|
|
874
|
+
const serviceImpl = serviceMethods.length > 0
|
|
875
|
+
? serviceMethods
|
|
876
|
+
.map(
|
|
877
|
+
(m) => ` ${m}: async () => {
|
|
878
|
+
// TODO: Implement ${m}
|
|
879
|
+
},`
|
|
880
|
+
)
|
|
881
|
+
.join("\n")
|
|
882
|
+
: ` getData: async () => {
|
|
883
|
+
return "Hello from ${name} plugin!";
|
|
884
|
+
},`;
|
|
885
|
+
|
|
886
|
+
// Generate dependencies line
|
|
887
|
+
const depsLine = dependencies.length > 0
|
|
888
|
+
? ` dependencies: [${dependencies.map((d) => `"${d}"`).join(", ")}] as const,\n`
|
|
889
|
+
: "";
|
|
890
|
+
|
|
891
|
+
// Generate deps access comments
|
|
892
|
+
const depsComments = dependencies.length > 0
|
|
893
|
+
? dependencies.map((d) => ` // Access ${d}: ctx.deps.${d}`).join("\n") + "\n"
|
|
894
|
+
: "";
|
|
895
|
+
|
|
896
|
+
const pluginContent = `import { createPlugin } from "@donkeylabs/server";
|
|
897
|
+
${hasSchema ? `import type { DB as ${pascalName}Schema } from "./schema";\n` : ""}
|
|
898
|
+
export interface ${pascalName}Service {
|
|
899
|
+
${serviceInterface}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export const ${camelName}Plugin = createPlugin${hasSchema ? `\n .withSchema<${pascalName}Schema>()` : ""}
|
|
903
|
+
.define({
|
|
904
|
+
name: "${name}",
|
|
905
|
+
version: "1.0.0",
|
|
906
|
+
${depsLine} service: async (ctx): Promise<${pascalName}Service> => {
|
|
907
|
+
// Available core services:
|
|
908
|
+
// - ctx.core.logger.info("message", { data }) - Structured logging
|
|
909
|
+
// - ctx.core.cache.set("key", value, { ttl: 60 }) - Caching with TTL
|
|
910
|
+
// - ctx.core.events.emit("event", data) - Pub/sub events
|
|
911
|
+
// - ctx.core.jobs.enqueue("job", payload) - Background jobs
|
|
912
|
+
// - ctx.core.cron.schedule("0 * * * *", handler) - Scheduled tasks
|
|
913
|
+
// - ctx.core.sse.broadcast("channel", data) - Real-time updates
|
|
914
|
+
// - ctx.core.rateLimiter.limit("key") - Rate limiting
|
|
915
|
+
//
|
|
916
|
+
// Error factories (throw these):
|
|
917
|
+
// - ctx.errors.BadRequest("message") - 400
|
|
918
|
+
// - ctx.errors.Unauthorized("message") - 401
|
|
919
|
+
// - ctx.errors.Forbidden("message") - 403
|
|
920
|
+
// - ctx.errors.NotFound("message") - 404
|
|
921
|
+
// - ctx.errors.Conflict("message") - 409
|
|
922
|
+
// - ctx.errors.TooManyRequests("message") - 429
|
|
923
|
+
${depsComments}
|
|
924
|
+
return {
|
|
925
|
+
${serviceImpl}
|
|
926
|
+
};
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
`;
|
|
930
|
+
|
|
931
|
+
await writeFile(join(pluginDir, "index.ts"), pluginContent);
|
|
932
|
+
|
|
933
|
+
const createdFiles = [`src/plugins/${name}/index.ts`];
|
|
934
|
+
|
|
935
|
+
if (hasSchema) {
|
|
936
|
+
const schemaContent = `// Database schema types for ${name} plugin
|
|
937
|
+
// Run type generation after adding migrations
|
|
938
|
+
|
|
939
|
+
export interface DB {
|
|
940
|
+
// Define your table interfaces here
|
|
941
|
+
// ${name}: {
|
|
942
|
+
// id: Generated<number>;
|
|
943
|
+
// name: string;
|
|
944
|
+
// created_at: Generated<string>;
|
|
945
|
+
// };
|
|
946
|
+
}
|
|
947
|
+
`;
|
|
948
|
+
await writeFile(join(pluginDir, "schema.ts"), schemaContent);
|
|
949
|
+
createdFiles.push(`src/plugins/${name}/schema.ts`);
|
|
950
|
+
|
|
951
|
+
await mkdir(join(pluginDir, "migrations"), { recursive: true });
|
|
952
|
+
|
|
953
|
+
const migrationContent = `import { Kysely, sql } from "kysely";
|
|
954
|
+
|
|
955
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
956
|
+
// Create your tables here
|
|
957
|
+
// await db.schema
|
|
958
|
+
// .createTable("${name}")
|
|
959
|
+
// .ifNotExists()
|
|
960
|
+
// .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
961
|
+
// .addColumn("created_at", "text", (col) => col.defaultTo(sql\`CURRENT_TIMESTAMP\`))
|
|
962
|
+
// .execute();
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
966
|
+
// await db.schema.dropTable("${name}").ifExists().execute();
|
|
967
|
+
}
|
|
968
|
+
`;
|
|
969
|
+
await writeFile(join(pluginDir, "migrations", "001_initial.ts"), migrationContent);
|
|
970
|
+
createdFiles.push(`src/plugins/${name}/migrations/001_initial.ts`);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return `Created plugin '${name}' with files:\n${createdFiles.map((f) => ` - ${f}`).join("\n")}\n\nNext steps:\n1. Implement your service methods\n${hasSchema ? "2. Define your database schema in migrations\n3. " : "2. "}Run 'donkeylabs generate' to update types`;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function addMigration(args: {
|
|
977
|
+
pluginName: string;
|
|
978
|
+
migrationName: string;
|
|
979
|
+
upSql?: string;
|
|
980
|
+
downSql?: string;
|
|
981
|
+
}): Promise<string> {
|
|
982
|
+
const { pluginName, migrationName, upSql, downSql } = args;
|
|
983
|
+
|
|
984
|
+
if (!/^[a-z0-9_]+$/.test(migrationName)) {
|
|
985
|
+
throw new Error("Migration name must be snake_case (lowercase with underscores)");
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const pluginsDir = await getPluginsDir();
|
|
989
|
+
const migrationsDir = join(pluginsDir, pluginName, "migrations");
|
|
990
|
+
|
|
991
|
+
if (!existsSync(join(pluginsDir, pluginName))) {
|
|
992
|
+
throw new Error(`Plugin '${pluginName}' not found`);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
await mkdir(migrationsDir, { recursive: true });
|
|
996
|
+
|
|
997
|
+
// Get next migration number
|
|
998
|
+
let nextNum = 1;
|
|
999
|
+
try {
|
|
1000
|
+
const files = await readdir(migrationsDir);
|
|
1001
|
+
const nums = files
|
|
1002
|
+
.map((f) => parseInt(f.split("_")[0] || "", 10))
|
|
1003
|
+
.filter((n) => !isNaN(n));
|
|
1004
|
+
if (nums.length > 0) {
|
|
1005
|
+
nextNum = Math.max(...nums) + 1;
|
|
1006
|
+
}
|
|
1007
|
+
} catch {}
|
|
1008
|
+
|
|
1009
|
+
const filename = `${String(nextNum).padStart(3, "0")}_${migrationName}.ts`;
|
|
1010
|
+
|
|
1011
|
+
const content = `import { Kysely, sql } from "kysely";
|
|
1012
|
+
|
|
1013
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
1014
|
+
${upSql || "// Add your migration code here"}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
1018
|
+
${downSql || "// Add your rollback code here"}
|
|
1019
|
+
}
|
|
1020
|
+
`;
|
|
1021
|
+
|
|
1022
|
+
await writeFile(join(migrationsDir, filename), content);
|
|
1023
|
+
|
|
1024
|
+
return `Created migration: src/plugins/${pluginName}/migrations/${filename}`;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async function listPlugins(): Promise<string> {
|
|
1028
|
+
const pluginsDir = await getPluginsDir();
|
|
1029
|
+
|
|
1030
|
+
if (!existsSync(pluginsDir)) {
|
|
1031
|
+
return "No plugins directory found. Run 'donkeylabs init' to set up a project.";
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const entries = await readdir(pluginsDir, { withFileTypes: true });
|
|
1035
|
+
const plugins: string[] = [];
|
|
1036
|
+
|
|
1037
|
+
for (const entry of entries) {
|
|
1038
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
1039
|
+
const indexPath = join(pluginsDir, entry.name, "index.ts");
|
|
1040
|
+
if (existsSync(indexPath)) {
|
|
1041
|
+
const content = await readFile(indexPath, "utf-8");
|
|
1042
|
+
|
|
1043
|
+
// Extract dependencies
|
|
1044
|
+
const depsMatch = content.match(/dependencies:\s*\[([^\]]*)\]/);
|
|
1045
|
+
const deps = depsMatch?.[1]
|
|
1046
|
+
? depsMatch[1].match(/"([^"]+)"/g)?.map((d) => d.replace(/"/g, "")) || []
|
|
1047
|
+
: [];
|
|
1048
|
+
|
|
1049
|
+
// Extract service methods
|
|
1050
|
+
const serviceMatch = content.match(/interface\s+\w+Service\s*\{([^}]+)\}/);
|
|
1051
|
+
const methods = serviceMatch?.[1]
|
|
1052
|
+
? [...serviceMatch[1].matchAll(/(\w+)\s*\(/g)].map((m) => m[1])
|
|
1053
|
+
: [];
|
|
1054
|
+
|
|
1055
|
+
// Check for schema
|
|
1056
|
+
const hasSchema = existsSync(join(pluginsDir, entry.name, "schema.ts"));
|
|
1057
|
+
|
|
1058
|
+
plugins.push(
|
|
1059
|
+
`${entry.name}${hasSchema ? " [has schema]" : ""}\n` +
|
|
1060
|
+
(deps.length > 0 ? ` Dependencies: ${deps.join(", ")}\n` : "") +
|
|
1061
|
+
(methods.length > 0 ? ` Methods: ${methods.join(", ")}` : "")
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (plugins.length === 0) {
|
|
1068
|
+
return "No plugins found.";
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return `Found ${plugins.length} plugin(s):\n\n${plugins.join("\n\n")}`;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
async function getProjectInfo(): Promise<string> {
|
|
1075
|
+
const root = await findProjectRoot();
|
|
1076
|
+
|
|
1077
|
+
const info: string[] = [`Project root: ${root}`];
|
|
1078
|
+
|
|
1079
|
+
// Check for config
|
|
1080
|
+
const configPath = join(root, "donkeylabs.config.ts");
|
|
1081
|
+
if (existsSync(configPath)) {
|
|
1082
|
+
info.push("Config: donkeylabs.config.ts found");
|
|
1083
|
+
} else {
|
|
1084
|
+
info.push("Config: Not found (run 'donkeylabs init')");
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Check package.json
|
|
1088
|
+
const pkgPath = join(root, "package.json");
|
|
1089
|
+
if (existsSync(pkgPath)) {
|
|
1090
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
1091
|
+
info.push(`Package: ${pkg.name}@${pkg.version || "0.0.0"}`);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// List plugins
|
|
1095
|
+
const pluginsDir = join(root, "src/plugins");
|
|
1096
|
+
if (existsSync(pluginsDir)) {
|
|
1097
|
+
const entries = await readdir(pluginsDir, { withFileTypes: true });
|
|
1098
|
+
const pluginNames = entries
|
|
1099
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
1100
|
+
.map((e) => e.name);
|
|
1101
|
+
info.push(`Plugins: ${pluginNames.length > 0 ? pluginNames.join(", ") : "none"}`);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Available handlers
|
|
1105
|
+
info.push("\nBuilt-in handlers: typed, raw");
|
|
1106
|
+
|
|
1107
|
+
// Structure reminder
|
|
1108
|
+
info.push(`
|
|
1109
|
+
Project structure:
|
|
1110
|
+
src/
|
|
1111
|
+
index.ts # Server entry point
|
|
1112
|
+
plugins/ # Plugin modules
|
|
1113
|
+
<name>/
|
|
1114
|
+
index.ts # Plugin definition
|
|
1115
|
+
schema.ts # DB types (if needed)
|
|
1116
|
+
migrations/ # SQL migrations
|
|
1117
|
+
donkeylabs.config.ts`);
|
|
1118
|
+
|
|
1119
|
+
return info.join("\n");
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
async function generateTypes(): Promise<string> {
|
|
1123
|
+
const root = await findProjectRoot();
|
|
1124
|
+
|
|
1125
|
+
// Try to run the generate command
|
|
1126
|
+
const proc = Bun.spawn(["bun", "cli/index.ts", "generate"], {
|
|
1127
|
+
cwd: root,
|
|
1128
|
+
stdout: "pipe",
|
|
1129
|
+
stderr: "pipe",
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
const output = await new Response(proc.stdout).text();
|
|
1133
|
+
const error = await new Response(proc.stderr).text();
|
|
1134
|
+
await proc.exited;
|
|
1135
|
+
|
|
1136
|
+
if (proc.exitCode !== 0) {
|
|
1137
|
+
return `Type generation failed:\n${error || output}`;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
return `Types generated successfully!\n${output}`;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
async function addRoute(args: {
|
|
1144
|
+
routerFile: string;
|
|
1145
|
+
routeName: string;
|
|
1146
|
+
handlerType?: string;
|
|
1147
|
+
inputFields?: { name: string; type: string; optional?: boolean }[];
|
|
1148
|
+
outputFields?: { name: string; type: string }[];
|
|
1149
|
+
description?: string;
|
|
1150
|
+
}): Promise<string> {
|
|
1151
|
+
const {
|
|
1152
|
+
routerFile,
|
|
1153
|
+
routeName,
|
|
1154
|
+
handlerType = "typed",
|
|
1155
|
+
inputFields = [],
|
|
1156
|
+
outputFields = [],
|
|
1157
|
+
description,
|
|
1158
|
+
} = args;
|
|
1159
|
+
|
|
1160
|
+
const root = await findProjectRoot();
|
|
1161
|
+
const filePath = join(root, routerFile);
|
|
1162
|
+
|
|
1163
|
+
if (!existsSync(filePath)) {
|
|
1164
|
+
// Provide helpful guidance if file doesn't exist
|
|
1165
|
+
const suggestion = routerFile.includes("routes/")
|
|
1166
|
+
? `Use 'create_router' tool first to create the router file.`
|
|
1167
|
+
: `Check that the file path is correct. For a new router, use 'create_router' tool.`;
|
|
1168
|
+
throw new Error(`File not found: ${routerFile}\n\n${suggestion}`);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const content = await readFile(filePath, "utf-8");
|
|
1172
|
+
|
|
1173
|
+
// Generate Zod schema for input
|
|
1174
|
+
const inputSchema =
|
|
1175
|
+
inputFields.length > 0
|
|
1176
|
+
? `z.object({\n ${inputFields
|
|
1177
|
+
.map((f) => `${f.name}: z.${f.type}()${f.optional ? ".optional()" : ""}`)
|
|
1178
|
+
.join(",\n ")}\n })`
|
|
1179
|
+
: "z.object({})";
|
|
1180
|
+
|
|
1181
|
+
// Generate Zod schema for output
|
|
1182
|
+
const outputSchema =
|
|
1183
|
+
outputFields.length > 0
|
|
1184
|
+
? `z.object({\n ${outputFields.map((f) => `${f.name}: z.${f.type}()`).join(",\n ")}\n })`
|
|
1185
|
+
: "z.object({ success: z.boolean() })";
|
|
1186
|
+
|
|
1187
|
+
// Generate route code
|
|
1188
|
+
const routeCode =
|
|
1189
|
+
handlerType === "typed"
|
|
1190
|
+
? `
|
|
1191
|
+
.route("${routeName}").typed({${description ? `\n // ${description}` : ""}
|
|
1192
|
+
input: ${inputSchema},
|
|
1193
|
+
output: ${outputSchema},
|
|
1194
|
+
handle: async (input, ctx) => {
|
|
1195
|
+
// TODO: Implement ${routeName}
|
|
1196
|
+
return { success: true };
|
|
1197
|
+
},
|
|
1198
|
+
})`
|
|
1199
|
+
: `
|
|
1200
|
+
.route("${routeName}").raw({${description ? `\n // ${description}` : ""}
|
|
1201
|
+
handle: async (req, ctx) => {
|
|
1202
|
+
// TODO: Implement ${routeName}
|
|
1203
|
+
return new Response("OK");
|
|
1204
|
+
},
|
|
1205
|
+
})`;
|
|
1206
|
+
|
|
1207
|
+
// Find where to insert (before server.use or at end of router chain)
|
|
1208
|
+
const insertPoint = content.lastIndexOf("server.use(");
|
|
1209
|
+
if (insertPoint === -1) {
|
|
1210
|
+
throw new Error("Could not find insertion point. Make sure the file has a router.");
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const newContent = content.slice(0, insertPoint) + routeCode + "\n\n" + content.slice(insertPoint);
|
|
1214
|
+
|
|
1215
|
+
await writeFile(filePath, newContent);
|
|
1216
|
+
|
|
1217
|
+
return `Added route '${routeName}' to ${routerFile}\n\nRemember to implement the handler logic!`;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
async function addServiceMethod(args: {
|
|
1221
|
+
pluginName: string;
|
|
1222
|
+
methodName: string;
|
|
1223
|
+
params?: { name: string; type: string }[];
|
|
1224
|
+
returnType?: string;
|
|
1225
|
+
implementation?: string;
|
|
1226
|
+
}): Promise<string> {
|
|
1227
|
+
const {
|
|
1228
|
+
pluginName,
|
|
1229
|
+
methodName,
|
|
1230
|
+
params = [],
|
|
1231
|
+
returnType = "Promise<void>",
|
|
1232
|
+
implementation,
|
|
1233
|
+
} = args;
|
|
1234
|
+
|
|
1235
|
+
const pluginsDir = await getPluginsDir();
|
|
1236
|
+
const indexPath = join(pluginsDir, pluginName, "index.ts");
|
|
1237
|
+
|
|
1238
|
+
if (!existsSync(indexPath)) {
|
|
1239
|
+
throw new Error(`Plugin '${pluginName}' not found`);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
let content = await readFile(indexPath, "utf-8");
|
|
1243
|
+
|
|
1244
|
+
// Generate param string
|
|
1245
|
+
const paramStr = params.map((p) => `${p.name}: ${p.type}`).join(", ");
|
|
1246
|
+
|
|
1247
|
+
// Add to interface
|
|
1248
|
+
const interfaceMatch = content.match(/(interface\s+\w+Service\s*\{)([^}]*)(})/);
|
|
1249
|
+
if (!interfaceMatch) {
|
|
1250
|
+
throw new Error("Could not find service interface in plugin");
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const newInterfaceMethod = `\n ${methodName}(${paramStr}): ${returnType};`;
|
|
1254
|
+
content = content.replace(
|
|
1255
|
+
interfaceMatch[0],
|
|
1256
|
+
interfaceMatch[1] + interfaceMatch[2] + newInterfaceMethod + "\n" + interfaceMatch[3]
|
|
1257
|
+
);
|
|
1258
|
+
|
|
1259
|
+
// Add to implementation
|
|
1260
|
+
const returnMatch = content.match(/(return\s*\{)([^}]*)(};?\s*},?\s*}\s*\);?\s*$)/s);
|
|
1261
|
+
if (!returnMatch) {
|
|
1262
|
+
throw new Error("Could not find service implementation in plugin");
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const impl = implementation || `// TODO: Implement ${methodName}`;
|
|
1266
|
+
const newImplMethod = `\n ${methodName}: async (${paramStr}) => {\n ${impl}\n },`;
|
|
1267
|
+
content = content.replace(
|
|
1268
|
+
returnMatch[0],
|
|
1269
|
+
returnMatch[1] + returnMatch[2] + newImplMethod + "\n " + returnMatch[3]
|
|
1270
|
+
);
|
|
1271
|
+
|
|
1272
|
+
await writeFile(indexPath, content);
|
|
1273
|
+
|
|
1274
|
+
return `Added method '${methodName}' to ${pluginName} plugin`;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
async function createRouter(args: {
|
|
1278
|
+
fileName: string;
|
|
1279
|
+
namespace: string;
|
|
1280
|
+
initialRoutes?: string[];
|
|
1281
|
+
}): Promise<string> {
|
|
1282
|
+
const { fileName, namespace, initialRoutes = [] } = args;
|
|
1283
|
+
|
|
1284
|
+
const root = await findProjectRoot();
|
|
1285
|
+
const routesDir = join(root, "src/routes");
|
|
1286
|
+
await mkdir(routesDir, { recursive: true });
|
|
1287
|
+
|
|
1288
|
+
const filePath = join(routesDir, `${fileName}.ts`);
|
|
1289
|
+
|
|
1290
|
+
if (existsSync(filePath)) {
|
|
1291
|
+
throw new Error(`Router file already exists: src/routes/${fileName}.ts`);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Generate initial routes
|
|
1295
|
+
const routeDefinitions = initialRoutes.length > 0
|
|
1296
|
+
? initialRoutes.map((route) => `
|
|
1297
|
+
.route("${route}").typed({
|
|
1298
|
+
input: z.object({}),
|
|
1299
|
+
output: z.object({ success: z.boolean() }),
|
|
1300
|
+
handle: async (input, ctx) => {
|
|
1301
|
+
// TODO: Implement ${route}
|
|
1302
|
+
return { success: true };
|
|
1303
|
+
},
|
|
1304
|
+
})`).join("")
|
|
1305
|
+
: `
|
|
1306
|
+
.route("hello").typed({
|
|
1307
|
+
input: z.object({ name: z.string().optional() }),
|
|
1308
|
+
output: z.object({ message: z.string() }),
|
|
1309
|
+
handle: async (input) => {
|
|
1310
|
+
return { message: \`Hello from ${namespace}!\` };
|
|
1311
|
+
},
|
|
1312
|
+
})`;
|
|
1313
|
+
|
|
1314
|
+
const content = `import { createRouter } from "@donkeylabs/server";
|
|
1315
|
+
import { z } from "zod";
|
|
1316
|
+
|
|
1317
|
+
export const ${fileName}Router = createRouter("${namespace}")${routeDefinitions};
|
|
1318
|
+
`;
|
|
1319
|
+
|
|
1320
|
+
await writeFile(filePath, content);
|
|
1321
|
+
|
|
1322
|
+
return `Created router at src/routes/${fileName}.ts
|
|
1323
|
+
|
|
1324
|
+
To use this router, add to your src/index.ts:
|
|
1325
|
+
|
|
1326
|
+
import { ${fileName}Router } from "./routes/${fileName}";
|
|
1327
|
+
// ... after server.registerPlugin calls
|
|
1328
|
+
server.use(${fileName}Router);`;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
async function validateProject(): Promise<string> {
|
|
1332
|
+
const root = await findProjectRoot();
|
|
1333
|
+
const issues: string[] = [];
|
|
1334
|
+
const warnings: string[] = [];
|
|
1335
|
+
|
|
1336
|
+
// Check for config file
|
|
1337
|
+
if (!existsSync(join(root, "donkeylabs.config.ts"))) {
|
|
1338
|
+
issues.push("Missing donkeylabs.config.ts - run 'donkeylabs init'");
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Check for package.json
|
|
1342
|
+
const pkgPath = join(root, "package.json");
|
|
1343
|
+
if (existsSync(pkgPath)) {
|
|
1344
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
1345
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1346
|
+
|
|
1347
|
+
// Check required dependencies
|
|
1348
|
+
if (!deps["@donkeylabs/server"]) {
|
|
1349
|
+
issues.push("Missing dependency: @donkeylabs/server");
|
|
1350
|
+
}
|
|
1351
|
+
if (!deps["kysely"]) {
|
|
1352
|
+
issues.push("Missing dependency: kysely");
|
|
1353
|
+
}
|
|
1354
|
+
if (!deps["zod"]) {
|
|
1355
|
+
issues.push("Missing dependency: zod");
|
|
1356
|
+
}
|
|
1357
|
+
} else {
|
|
1358
|
+
issues.push("Missing package.json");
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Check for src/index.ts
|
|
1362
|
+
const indexPath = join(root, "src/index.ts");
|
|
1363
|
+
if (existsSync(indexPath)) {
|
|
1364
|
+
const indexContent = await readFile(indexPath, "utf-8");
|
|
1365
|
+
|
|
1366
|
+
// Check for DummyDriver (bad)
|
|
1367
|
+
if (indexContent.includes("DummyDriver")) {
|
|
1368
|
+
warnings.push("src/index.ts uses DummyDriver - this is a placeholder, not a real database");
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Check for AppServer
|
|
1372
|
+
if (!indexContent.includes("AppServer")) {
|
|
1373
|
+
warnings.push("src/index.ts doesn't import AppServer");
|
|
1374
|
+
}
|
|
1375
|
+
} else {
|
|
1376
|
+
issues.push("Missing src/index.ts - run 'donkeylabs init'");
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Check plugins directory
|
|
1380
|
+
const pluginsDir = join(root, "src/plugins");
|
|
1381
|
+
if (existsSync(pluginsDir)) {
|
|
1382
|
+
const entries = await readdir(pluginsDir, { withFileTypes: true });
|
|
1383
|
+
|
|
1384
|
+
for (const entry of entries) {
|
|
1385
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
1386
|
+
const pluginIndex = join(pluginsDir, entry.name, "index.ts");
|
|
1387
|
+
|
|
1388
|
+
if (!existsSync(pluginIndex)) {
|
|
1389
|
+
issues.push(`Plugin '${entry.name}' is missing index.ts`);
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const content = await readFile(pluginIndex, "utf-8");
|
|
1394
|
+
|
|
1395
|
+
// Check for proper export
|
|
1396
|
+
if (!content.includes("createPlugin")) {
|
|
1397
|
+
warnings.push(`Plugin '${entry.name}' doesn't use createPlugin`);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Check if plugin is registered in index.ts
|
|
1401
|
+
if (existsSync(indexPath)) {
|
|
1402
|
+
const indexContent = await readFile(indexPath, "utf-8");
|
|
1403
|
+
if (!indexContent.includes(`${entry.name}Plugin`) && !indexContent.includes(`'./plugins/${entry.name}'`)) {
|
|
1404
|
+
warnings.push(`Plugin '${entry.name}' exists but may not be registered in src/index.ts`);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Check for generated types
|
|
1412
|
+
const genDir = join(root, ".@donkeylabs/server");
|
|
1413
|
+
if (!existsSync(genDir) || !existsSync(join(genDir, "registry.d.ts"))) {
|
|
1414
|
+
warnings.push("Generated types not found - run 'donkeylabs generate'");
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Format output
|
|
1418
|
+
if (issues.length === 0 && warnings.length === 0) {
|
|
1419
|
+
return "Project validation passed. No issues found.";
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
let output = "";
|
|
1423
|
+
|
|
1424
|
+
if (issues.length > 0) {
|
|
1425
|
+
output += `ERRORS (${issues.length}):\n${issues.map((i) => ` - ${i}`).join("\n")}\n\n`;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if (warnings.length > 0) {
|
|
1429
|
+
output += `WARNINGS (${warnings.length}):\n${warnings.map((w) => ` - ${w}`).join("\n")}`;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
return output;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
async function registerPlugin(args: {
|
|
1436
|
+
pluginName: string;
|
|
1437
|
+
configOptions?: Record<string, any>;
|
|
1438
|
+
}): Promise<string> {
|
|
1439
|
+
const { pluginName, configOptions } = args;
|
|
1440
|
+
|
|
1441
|
+
const root = await findProjectRoot();
|
|
1442
|
+
const pluginsDir = join(root, "src/plugins");
|
|
1443
|
+
const indexPath = join(root, "src/index.ts");
|
|
1444
|
+
|
|
1445
|
+
// Verify plugin exists
|
|
1446
|
+
const pluginDir = join(pluginsDir, pluginName);
|
|
1447
|
+
if (!existsSync(pluginDir)) {
|
|
1448
|
+
throw new Error(`Plugin '${pluginName}' not found in src/plugins/`);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// Read plugin to get export name
|
|
1452
|
+
const pluginIndexPath = join(pluginDir, "index.ts");
|
|
1453
|
+
const pluginContent = await readFile(pluginIndexPath, "utf-8");
|
|
1454
|
+
const exportMatch = pluginContent.match(/export\s+const\s+(\w+Plugin)\s*=/);
|
|
1455
|
+
const exportName = exportMatch?.[1];
|
|
1456
|
+
|
|
1457
|
+
if (!exportName) {
|
|
1458
|
+
throw new Error(`Could not find plugin export in ${pluginName}/index.ts`);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Read index.ts
|
|
1462
|
+
if (!existsSync(indexPath)) {
|
|
1463
|
+
throw new Error("src/index.ts not found");
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
let content = await readFile(indexPath, "utf-8");
|
|
1467
|
+
|
|
1468
|
+
// Check if already imported
|
|
1469
|
+
if (content.includes(`from "./plugins/${pluginName}"`) || content.includes(`from './plugins/${pluginName}'`)) {
|
|
1470
|
+
return `Plugin '${pluginName}' is already imported in src/index.ts`;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Add import after other plugin imports or after @donkeylabs/server import
|
|
1474
|
+
const importLine = `import { ${exportName} } from "./plugins/${pluginName}";\n`;
|
|
1475
|
+
|
|
1476
|
+
// Find a good place to add the import
|
|
1477
|
+
const lastPluginImport = content.lastIndexOf('from "./plugins/');
|
|
1478
|
+
const serverImport = content.indexOf('from "@donkeylabs/server"');
|
|
1479
|
+
|
|
1480
|
+
if (lastPluginImport !== -1) {
|
|
1481
|
+
// Add after last plugin import
|
|
1482
|
+
const lineEnd = content.indexOf("\n", lastPluginImport);
|
|
1483
|
+
content = content.slice(0, lineEnd + 1) + importLine + content.slice(lineEnd + 1);
|
|
1484
|
+
} else if (serverImport !== -1) {
|
|
1485
|
+
// Add after server import
|
|
1486
|
+
const lineEnd = content.indexOf("\n", serverImport);
|
|
1487
|
+
content = content.slice(0, lineEnd + 1) + importLine + content.slice(lineEnd + 1);
|
|
1488
|
+
} else {
|
|
1489
|
+
// Add at top
|
|
1490
|
+
content = importLine + content;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// Add registerPlugin call
|
|
1494
|
+
const configStr = configOptions ? JSON.stringify(configOptions, null, 2) : "";
|
|
1495
|
+
const registerCall = configStr
|
|
1496
|
+
? `server.registerPlugin(${exportName}(${configStr}));\n`
|
|
1497
|
+
: `server.registerPlugin(${exportName});\n`;
|
|
1498
|
+
|
|
1499
|
+
// Find where to add registration (after other registerPlugin calls or after new AppServer)
|
|
1500
|
+
const lastRegister = content.lastIndexOf("server.registerPlugin(");
|
|
1501
|
+
const serverCreate = content.indexOf("new AppServer(");
|
|
1502
|
+
|
|
1503
|
+
if (lastRegister !== -1) {
|
|
1504
|
+
const lineEnd = content.indexOf("\n", lastRegister);
|
|
1505
|
+
content = content.slice(0, lineEnd + 1) + registerCall + content.slice(lineEnd + 1);
|
|
1506
|
+
} else if (serverCreate !== -1) {
|
|
1507
|
+
// Find the end of the AppServer construction (closing brace and semicolon)
|
|
1508
|
+
let braceCount = 0;
|
|
1509
|
+
let pos = serverCreate;
|
|
1510
|
+
while (pos < content.length) {
|
|
1511
|
+
if (content[pos] === "{") braceCount++;
|
|
1512
|
+
if (content[pos] === "}") braceCount--;
|
|
1513
|
+
if (braceCount === 0 && content[pos] === ";") {
|
|
1514
|
+
content = content.slice(0, pos + 1) + "\n\n" + registerCall + content.slice(pos + 1);
|
|
1515
|
+
break;
|
|
1516
|
+
}
|
|
1517
|
+
pos++;
|
|
1518
|
+
}
|
|
1519
|
+
} else {
|
|
1520
|
+
throw new Error("Could not find place to add registerPlugin call");
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
await writeFile(indexPath, content);
|
|
1524
|
+
|
|
1525
|
+
return `Registered plugin '${pluginName}' in src/index.ts\n\nAdded:\n - import { ${exportName} } from "./plugins/${pluginName}"\n - server.registerPlugin(${exportName}${configStr ? "(...)" : ""})`;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
async function runMigrations(args: {
|
|
1529
|
+
pluginName?: string;
|
|
1530
|
+
direction?: string;
|
|
1531
|
+
}): Promise<string> {
|
|
1532
|
+
const { pluginName, direction = "up" } = args;
|
|
1533
|
+
|
|
1534
|
+
const root = await findProjectRoot();
|
|
1535
|
+
const pluginsDir = join(root, "src/plugins");
|
|
1536
|
+
|
|
1537
|
+
if (!existsSync(pluginsDir)) {
|
|
1538
|
+
throw new Error("No plugins directory found");
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const results: string[] = [];
|
|
1542
|
+
|
|
1543
|
+
// Get list of plugins to migrate
|
|
1544
|
+
const pluginsToMigrate: string[] = [];
|
|
1545
|
+
if (pluginName) {
|
|
1546
|
+
if (!existsSync(join(pluginsDir, pluginName))) {
|
|
1547
|
+
throw new Error(`Plugin '${pluginName}' not found`);
|
|
1548
|
+
}
|
|
1549
|
+
pluginsToMigrate.push(pluginName);
|
|
1550
|
+
} else {
|
|
1551
|
+
const entries = await readdir(pluginsDir, { withFileTypes: true });
|
|
1552
|
+
for (const entry of entries) {
|
|
1553
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
1554
|
+
pluginsToMigrate.push(entry.name);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
for (const plugin of pluginsToMigrate) {
|
|
1560
|
+
const migrationsDir = join(pluginsDir, plugin, "migrations");
|
|
1561
|
+
|
|
1562
|
+
if (!existsSync(migrationsDir)) {
|
|
1563
|
+
results.push(`${plugin}: No migrations directory`);
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const files = await readdir(migrationsDir);
|
|
1568
|
+
const migrationFiles = files
|
|
1569
|
+
.filter((f) => f.endsWith(".ts") && !f.startsWith("."))
|
|
1570
|
+
.sort();
|
|
1571
|
+
|
|
1572
|
+
if (migrationFiles.length === 0) {
|
|
1573
|
+
results.push(`${plugin}: No migrations found`);
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// If going down, reverse the order
|
|
1578
|
+
if (direction === "down") {
|
|
1579
|
+
migrationFiles.reverse();
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
results.push(`${plugin}: Found ${migrationFiles.length} migration(s)`);
|
|
1583
|
+
|
|
1584
|
+
for (const file of migrationFiles) {
|
|
1585
|
+
const migrationPath = join(migrationsDir, file);
|
|
1586
|
+
try {
|
|
1587
|
+
const migration = await import(migrationPath);
|
|
1588
|
+
|
|
1589
|
+
if (direction === "up" && typeof migration.up === "function") {
|
|
1590
|
+
results.push(` - ${file}: Would run 'up' migration`);
|
|
1591
|
+
} else if (direction === "down" && typeof migration.down === "function") {
|
|
1592
|
+
results.push(` - ${file}: Would run 'down' migration`);
|
|
1593
|
+
} else {
|
|
1594
|
+
results.push(` - ${file}: No '${direction}' function found`);
|
|
1595
|
+
}
|
|
1596
|
+
} catch (err: any) {
|
|
1597
|
+
results.push(` - ${file}: Error loading - ${err.message}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
results.push("");
|
|
1603
|
+
results.push("Note: This tool shows what migrations are available.");
|
|
1604
|
+
results.push("To actually run migrations, execute them in your server code:");
|
|
1605
|
+
results.push(" await migration.up(db); // or migration.down(db)");
|
|
1606
|
+
|
|
1607
|
+
return results.join("\n");
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
async function setupDatabase(args: {
|
|
1611
|
+
dbPath?: string;
|
|
1612
|
+
}): Promise<string> {
|
|
1613
|
+
const { dbPath = "app.db" } = args;
|
|
1614
|
+
const root = await findProjectRoot();
|
|
1615
|
+
const fullPath = join(root, dbPath);
|
|
1616
|
+
|
|
1617
|
+
const results: string[] = [];
|
|
1618
|
+
|
|
1619
|
+
// Check if src/db.ts exists
|
|
1620
|
+
const dbSetupPath = join(root, "src/db.ts");
|
|
1621
|
+
if (!existsSync(dbSetupPath)) {
|
|
1622
|
+
results.push("WARNING: src/db.ts not found");
|
|
1623
|
+
results.push("Run 'donkeylabs init' with SQLite enabled to create database setup.");
|
|
1624
|
+
return results.join("\n");
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
results.push(`Database setup file: src/db.ts`);
|
|
1628
|
+
|
|
1629
|
+
// Check if database file exists
|
|
1630
|
+
if (existsSync(fullPath)) {
|
|
1631
|
+
results.push(`Database file exists: ${dbPath}`);
|
|
1632
|
+
|
|
1633
|
+
// Try to get some info about the database
|
|
1634
|
+
try {
|
|
1635
|
+
const { Database } = await import("bun:sqlite");
|
|
1636
|
+
const db = new Database(fullPath, { readonly: true });
|
|
1637
|
+
const tables = db.query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as { name: string }[];
|
|
1638
|
+
db.close();
|
|
1639
|
+
|
|
1640
|
+
if (tables.length > 0) {
|
|
1641
|
+
results.push(`Tables: ${tables.map((t) => t.name).join(", ")}`);
|
|
1642
|
+
} else {
|
|
1643
|
+
results.push("No tables yet - run migrations to create them");
|
|
1644
|
+
}
|
|
1645
|
+
} catch (err: any) {
|
|
1646
|
+
results.push(`Could not inspect database: ${err.message}`);
|
|
1647
|
+
}
|
|
1648
|
+
} else {
|
|
1649
|
+
results.push(`Database file will be created at: ${dbPath}`);
|
|
1650
|
+
results.push("The database is created automatically when your server starts.");
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
results.push("");
|
|
1654
|
+
results.push("Database setup looks good!");
|
|
1655
|
+
|
|
1656
|
+
return results.join("\n");
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
async function listRoutes(args: { namespace?: string }): Promise<string> {
|
|
1660
|
+
const root = await findProjectRoot();
|
|
1661
|
+
const routesDir = join(root, "src/routes");
|
|
1662
|
+
|
|
1663
|
+
if (!existsSync(routesDir)) {
|
|
1664
|
+
return "No routes directory found. Routes should be in src/routes/<namespace>/<route>/handler.ts";
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
const results: string[] = ["Routes found:\n"];
|
|
1668
|
+
let routeCount = 0;
|
|
1669
|
+
|
|
1670
|
+
const namespaces = await readdir(routesDir, { withFileTypes: true });
|
|
1671
|
+
|
|
1672
|
+
for (const ns of namespaces) {
|
|
1673
|
+
if (!ns.isDirectory() || ns.name.startsWith(".")) continue;
|
|
1674
|
+
if (args.namespace && ns.name !== args.namespace) continue;
|
|
1675
|
+
|
|
1676
|
+
const nsDir = join(routesDir, ns.name);
|
|
1677
|
+
const routeDirs = await readdir(nsDir, { withFileTypes: true });
|
|
1678
|
+
|
|
1679
|
+
results.push(`${ns.name}/`);
|
|
1680
|
+
|
|
1681
|
+
for (const routeDir of routeDirs) {
|
|
1682
|
+
if (!routeDir.isDirectory() || routeDir.name.startsWith(".")) continue;
|
|
1683
|
+
|
|
1684
|
+
const handlerPath = join(nsDir, routeDir.name, "handler.ts");
|
|
1685
|
+
const testPath = join(nsDir, routeDir.name, "test.ts");
|
|
1686
|
+
|
|
1687
|
+
if (!existsSync(handlerPath)) continue;
|
|
1688
|
+
|
|
1689
|
+
const content = await readFile(handlerPath, "utf-8");
|
|
1690
|
+
const handlerType = content.includes(".raw(") ? "raw" : "typed";
|
|
1691
|
+
const hasTest = existsSync(testPath);
|
|
1692
|
+
|
|
1693
|
+
results.push(` ${routeDir.name}/ (${handlerType})${hasTest ? " [tested]" : ""}`);
|
|
1694
|
+
routeCount++;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
if (routeCount === 0) {
|
|
1699
|
+
return "No routes found. Use 'add_route_file' to create routes.";
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
results.push(`\nTotal: ${routeCount} route(s)`);
|
|
1703
|
+
return results.join("\n");
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
async function addRouteFile(args: {
|
|
1707
|
+
namespace: string;
|
|
1708
|
+
routeName: string;
|
|
1709
|
+
handlerType?: string;
|
|
1710
|
+
inputFields?: { name: string; type: string; optional?: boolean }[];
|
|
1711
|
+
outputFields?: { name: string; type: string }[];
|
|
1712
|
+
description?: string;
|
|
1713
|
+
createTest?: boolean;
|
|
1714
|
+
}): Promise<string> {
|
|
1715
|
+
const {
|
|
1716
|
+
namespace,
|
|
1717
|
+
routeName,
|
|
1718
|
+
handlerType = "typed",
|
|
1719
|
+
inputFields = [],
|
|
1720
|
+
outputFields = [],
|
|
1721
|
+
description,
|
|
1722
|
+
createTest = true,
|
|
1723
|
+
} = args;
|
|
1724
|
+
|
|
1725
|
+
const root = await findProjectRoot();
|
|
1726
|
+
const routeDir = join(root, "src/routes", namespace, routeName);
|
|
1727
|
+
|
|
1728
|
+
if (existsSync(routeDir)) {
|
|
1729
|
+
throw new Error(`Route already exists: src/routes/${namespace}/${routeName}/`);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
await mkdir(routeDir, { recursive: true });
|
|
1733
|
+
|
|
1734
|
+
// Generate input schema
|
|
1735
|
+
const inputSchema = inputFields.length > 0
|
|
1736
|
+
? `z.object({\n ${inputFields.map((f) => `${f.name}: z.${f.type}()${f.optional ? ".optional()" : ""}`).join(",\n ")}\n })`
|
|
1737
|
+
: "z.object({})";
|
|
1738
|
+
|
|
1739
|
+
// Generate output schema
|
|
1740
|
+
const outputSchema = outputFields.length > 0
|
|
1741
|
+
? `z.object({\n ${outputFields.map((f) => `${f.name}: z.${f.type}()`).join(",\n ")}\n })`
|
|
1742
|
+
: "z.object({ success: z.boolean() })";
|
|
1743
|
+
|
|
1744
|
+
// Generate handler file
|
|
1745
|
+
const handlerContent = handlerType === "typed"
|
|
1746
|
+
? `/**
|
|
1747
|
+
* ${namespace}.${routeName}
|
|
1748
|
+
* ${description || "TODO: Add description"}
|
|
1749
|
+
*/
|
|
1750
|
+
import { z } from "zod";
|
|
1751
|
+
import type { GlobalContext } from "../../../.@donkeylabs/server/context";
|
|
1752
|
+
|
|
1753
|
+
export const input = ${inputSchema};
|
|
1754
|
+
export const output = ${outputSchema};
|
|
1755
|
+
|
|
1756
|
+
export async function handler(
|
|
1757
|
+
data: z.infer<typeof input>,
|
|
1758
|
+
ctx: GlobalContext
|
|
1759
|
+
): Promise<z.infer<typeof output>> {
|
|
1760
|
+
// Available in ctx:
|
|
1761
|
+
// - ctx.db: Database queries (ctx.db.selectFrom("table")...)
|
|
1762
|
+
// - ctx.plugins: Plugin services (ctx.plugins.auth, ctx.plugins.users, etc.)
|
|
1763
|
+
// - ctx.core.logger: Logging (ctx.core.logger.info("message", { data }))
|
|
1764
|
+
// - ctx.core.cache: Caching (ctx.core.cache.get("key"), ctx.core.cache.set("key", value))
|
|
1765
|
+
// - ctx.errors: Error factories (throw ctx.errors.NotFound("User not found"))
|
|
1766
|
+
// - ctx.user: Authenticated user (if auth middleware applied)
|
|
1767
|
+
|
|
1768
|
+
// TODO: Implement handler logic
|
|
1769
|
+
return { success: true };
|
|
1770
|
+
}
|
|
1771
|
+
`
|
|
1772
|
+
: `/**
|
|
1773
|
+
* ${namespace}.${routeName}
|
|
1774
|
+
* ${description || "TODO: Add description"}
|
|
1775
|
+
*/
|
|
1776
|
+
import type { GlobalContext } from "../../../.@donkeylabs/server/context";
|
|
1777
|
+
|
|
1778
|
+
export async function handler(
|
|
1779
|
+
req: Request,
|
|
1780
|
+
ctx: GlobalContext
|
|
1781
|
+
): Promise<Response> {
|
|
1782
|
+
// Available in ctx:
|
|
1783
|
+
// - ctx.db: Database queries
|
|
1784
|
+
// - ctx.plugins: Plugin services (ctx.plugins.auth, etc.)
|
|
1785
|
+
// - ctx.core.logger/cache/events/jobs/cron/sse/rateLimiter
|
|
1786
|
+
// - ctx.errors: Error factories
|
|
1787
|
+
// - ctx.user: Authenticated user (if auth middleware applied)
|
|
1788
|
+
|
|
1789
|
+
// TODO: Implement handler logic
|
|
1790
|
+
return new Response("OK");
|
|
1791
|
+
}
|
|
1792
|
+
`;
|
|
1793
|
+
|
|
1794
|
+
await writeFile(join(routeDir, "handler.ts"), handlerContent);
|
|
1795
|
+
|
|
1796
|
+
const createdFiles = [`src/routes/${namespace}/${routeName}/handler.ts`];
|
|
1797
|
+
|
|
1798
|
+
// Generate test file
|
|
1799
|
+
if (createTest) {
|
|
1800
|
+
const testContent = handlerType === "typed"
|
|
1801
|
+
? `import { describe, test, expect } from "bun:test";
|
|
1802
|
+
import { handler, input, output } from "./handler";
|
|
1803
|
+
|
|
1804
|
+
describe("${namespace}.${routeName}", () => {
|
|
1805
|
+
test("should return success", async () => {
|
|
1806
|
+
// TODO: Create mock context
|
|
1807
|
+
const mockCtx = {} as any;
|
|
1808
|
+
const result = await handler({}, mockCtx);
|
|
1809
|
+
expect(result.success).toBe(true);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
test("should validate input", () => {
|
|
1813
|
+
expect(() => input.parse({})).not.toThrow();
|
|
1814
|
+
});
|
|
1815
|
+
});
|
|
1816
|
+
`
|
|
1817
|
+
: `import { describe, test, expect } from "bun:test";
|
|
1818
|
+
import { handler } from "./handler";
|
|
1819
|
+
|
|
1820
|
+
describe("${namespace}.${routeName}", () => {
|
|
1821
|
+
test("should return OK", async () => {
|
|
1822
|
+
const mockCtx = {} as any;
|
|
1823
|
+
const req = new Request("http://localhost/${namespace}.${routeName}");
|
|
1824
|
+
const response = await handler(req, mockCtx);
|
|
1825
|
+
expect(response.status).toBe(200);
|
|
1826
|
+
});
|
|
1827
|
+
});
|
|
1828
|
+
`;
|
|
1829
|
+
|
|
1830
|
+
await writeFile(join(routeDir, "test.ts"), testContent);
|
|
1831
|
+
createdFiles.push(`src/routes/${namespace}/${routeName}/test.ts`);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
return `Created route ${namespace}.${routeName}:\n${createdFiles.map((f) => ` - ${f}`).join("\n")}\n\nRun 'donkeylabs generate' to update types.`;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
async function createModel(args: {
|
|
1838
|
+
name: string;
|
|
1839
|
+
methods?: { name: string; params: string; returnType: string }[];
|
|
1840
|
+
}): Promise<string> {
|
|
1841
|
+
const { name, methods = [] } = args;
|
|
1842
|
+
|
|
1843
|
+
const root = await findProjectRoot();
|
|
1844
|
+
const modelsDir = join(root, "src/models");
|
|
1845
|
+
await mkdir(modelsDir, { recursive: true });
|
|
1846
|
+
|
|
1847
|
+
const filePath = join(modelsDir, `${name}.ts`);
|
|
1848
|
+
|
|
1849
|
+
if (existsSync(filePath)) {
|
|
1850
|
+
throw new Error(`Model already exists: src/models/${name}.ts`);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// Generate method stubs
|
|
1854
|
+
const methodStubs = methods.length > 0
|
|
1855
|
+
? methods.map((m) => `
|
|
1856
|
+
async ${m.name}(${m.params}): ${m.returnType} {
|
|
1857
|
+
// TODO: Implement ${m.name}
|
|
1858
|
+
throw new Error("Not implemented");
|
|
1859
|
+
}`).join("\n")
|
|
1860
|
+
: `
|
|
1861
|
+
async getData(): Promise<any> {
|
|
1862
|
+
// TODO: Implement getData
|
|
1863
|
+
return null;
|
|
1864
|
+
}`;
|
|
1865
|
+
|
|
1866
|
+
const content = `/**
|
|
1867
|
+
* ${name}
|
|
1868
|
+
*
|
|
1869
|
+
* Business logic model with dependency injection for testability.
|
|
1870
|
+
*/
|
|
1871
|
+
import type { GlobalContext } from "../.@donkeylabs/server/context";
|
|
1872
|
+
|
|
1873
|
+
export class ${name} {
|
|
1874
|
+
constructor(private ctx: GlobalContext) {}
|
|
1875
|
+
${methodStubs}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/**
|
|
1879
|
+
* Factory function for creating ${name} instances.
|
|
1880
|
+
* Use this in route handlers for clean dependency injection.
|
|
1881
|
+
*/
|
|
1882
|
+
export function create${name}(ctx: GlobalContext): ${name} {
|
|
1883
|
+
return new ${name}(ctx);
|
|
1884
|
+
}
|
|
1885
|
+
`;
|
|
1886
|
+
|
|
1887
|
+
await writeFile(filePath, content);
|
|
1888
|
+
|
|
1889
|
+
return `Created model: src/models/${name}.ts
|
|
1890
|
+
|
|
1891
|
+
Usage in route handler:
|
|
1892
|
+
import { create${name} } from "../../models/${name}";
|
|
1893
|
+
|
|
1894
|
+
export async function handler(data, ctx) {
|
|
1895
|
+
const model = create${name}(ctx);
|
|
1896
|
+
return model.getData();
|
|
1897
|
+
}`;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
async function addUnitTest(args: {
|
|
1901
|
+
targetFile: string;
|
|
1902
|
+
testCases?: { name: string; method: string }[];
|
|
1903
|
+
}): Promise<string> {
|
|
1904
|
+
const { targetFile, testCases = [] } = args;
|
|
1905
|
+
|
|
1906
|
+
const root = await findProjectRoot();
|
|
1907
|
+
const fullPath = join(root, targetFile);
|
|
1908
|
+
|
|
1909
|
+
if (!existsSync(fullPath)) {
|
|
1910
|
+
throw new Error(`File not found: ${targetFile}`);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// Determine test file path (sibling .test.ts file)
|
|
1914
|
+
const testPath = targetFile.replace(/\.ts$/, ".test.ts");
|
|
1915
|
+
const fullTestPath = join(root, testPath);
|
|
1916
|
+
|
|
1917
|
+
// Read target file to extract exports
|
|
1918
|
+
const content = await readFile(fullPath, "utf-8");
|
|
1919
|
+
const classMatch = content.match(/export\s+class\s+(\w+)/);
|
|
1920
|
+
const functionMatch = content.match(/export\s+function\s+(\w+)/);
|
|
1921
|
+
|
|
1922
|
+
const exportName = classMatch?.[1] || functionMatch?.[1] || "module";
|
|
1923
|
+
const isClass = !!classMatch;
|
|
1924
|
+
|
|
1925
|
+
// Generate test cases
|
|
1926
|
+
const testCaseCode = testCases.length > 0
|
|
1927
|
+
? testCases.map((tc) => `
|
|
1928
|
+
test("${tc.name}", async () => {
|
|
1929
|
+
${isClass ? `// const instance = new ${exportName}(mockCtx);` : ""}
|
|
1930
|
+
// const result = ${isClass ? "instance" : exportName}.${tc.method}();
|
|
1931
|
+
// expect(result).toBeDefined();
|
|
1932
|
+
expect(true).toBe(true); // TODO: Implement test
|
|
1933
|
+
});`).join("\n")
|
|
1934
|
+
: `
|
|
1935
|
+
test("should work correctly", async () => {
|
|
1936
|
+
// TODO: Implement test
|
|
1937
|
+
expect(true).toBe(true);
|
|
1938
|
+
});`;
|
|
1939
|
+
|
|
1940
|
+
const testContent = `import { describe, test, expect, mock } from "bun:test";
|
|
1941
|
+
import { ${exportName} } from "./${targetFile.split("/").pop()?.replace(/\.ts$/, "")}";
|
|
1942
|
+
|
|
1943
|
+
describe("${exportName}", () => {
|
|
1944
|
+
// Mock GlobalContext for unit testing
|
|
1945
|
+
const mockCtx = {
|
|
1946
|
+
db: {} as any,
|
|
1947
|
+
plugins: {} as any,
|
|
1948
|
+
core: {} as any,
|
|
1949
|
+
errors: {} as any,
|
|
1950
|
+
ip: "127.0.0.1",
|
|
1951
|
+
};
|
|
1952
|
+
${testCaseCode}
|
|
1953
|
+
});
|
|
1954
|
+
`;
|
|
1955
|
+
|
|
1956
|
+
await writeFile(fullTestPath, testContent);
|
|
1957
|
+
|
|
1958
|
+
return `Created unit test: ${testPath}
|
|
1959
|
+
|
|
1960
|
+
Run tests with: bun test ${testPath}`;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
async function addIntegrationTest(args: {
|
|
1964
|
+
namespace: string;
|
|
1965
|
+
routeName: string;
|
|
1966
|
+
testCases?: { name: string; input?: any; expectedStatus?: number }[];
|
|
1967
|
+
plugins?: string[];
|
|
1968
|
+
}): Promise<string> {
|
|
1969
|
+
const { namespace, routeName, testCases = [], plugins = [] } = args;
|
|
1970
|
+
|
|
1971
|
+
const root = await findProjectRoot();
|
|
1972
|
+
const testDir = join(root, "test/integration");
|
|
1973
|
+
await mkdir(testDir, { recursive: true });
|
|
1974
|
+
|
|
1975
|
+
const testPath = join(testDir, `${namespace}.${routeName}.test.ts`);
|
|
1976
|
+
|
|
1977
|
+
// Generate plugin imports
|
|
1978
|
+
const pluginImports = plugins.length > 0
|
|
1979
|
+
? plugins.map((p) => `import { ${p}Plugin } from "../src/plugins/${p}";`).join("\n")
|
|
1980
|
+
: "// Import plugins as needed";
|
|
1981
|
+
|
|
1982
|
+
const pluginList = plugins.length > 0
|
|
1983
|
+
? plugins.map((p) => `${p}Plugin`).join(", ")
|
|
1984
|
+
: "/* plugins */";
|
|
1985
|
+
|
|
1986
|
+
// Generate test cases
|
|
1987
|
+
const testCaseCode = testCases.length > 0
|
|
1988
|
+
? testCases.map((tc) => `
|
|
1989
|
+
test("${tc.name}", async () => {
|
|
1990
|
+
const response = await harness.request("${namespace}.${routeName}", ${JSON.stringify(tc.input || {})});
|
|
1991
|
+
expect(response.status).toBe(${tc.expectedStatus || 200});
|
|
1992
|
+
});`).join("\n")
|
|
1993
|
+
: `
|
|
1994
|
+
test("should handle request successfully", async () => {
|
|
1995
|
+
const response = await harness.request("${namespace}.${routeName}", {});
|
|
1996
|
+
expect(response.status).toBe(200);
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
test("should handle invalid input", async () => {
|
|
2000
|
+
// TODO: Test with invalid input
|
|
2001
|
+
expect(true).toBe(true);
|
|
2002
|
+
});`;
|
|
2003
|
+
|
|
2004
|
+
const content = `import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2005
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
2006
|
+
${pluginImports}
|
|
2007
|
+
|
|
2008
|
+
describe("${namespace}.${routeName} (Integration)", () => {
|
|
2009
|
+
let harness: Awaited<ReturnType<typeof createTestHarness>>;
|
|
2010
|
+
|
|
2011
|
+
beforeAll(async () => {
|
|
2012
|
+
harness = await createTestHarness(${pluginList});
|
|
2013
|
+
await harness.manager.initialize();
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
afterAll(async () => {
|
|
2017
|
+
// Cleanup if needed
|
|
2018
|
+
});
|
|
2019
|
+
${testCaseCode}
|
|
2020
|
+
});
|
|
2021
|
+
`;
|
|
2022
|
+
|
|
2023
|
+
await writeFile(testPath, content);
|
|
2024
|
+
|
|
2025
|
+
return `Created integration test: test/integration/${namespace}.${routeName}.test.ts
|
|
2026
|
+
|
|
2027
|
+
Run tests with: bun test test/integration/${namespace}.${routeName}.test.ts`;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
async function generateClientTool(args: {
|
|
2031
|
+
output?: string;
|
|
2032
|
+
baseUrl?: string;
|
|
2033
|
+
}): Promise<string> {
|
|
2034
|
+
const { output = "src/client/api.ts", baseUrl = "" } = args;
|
|
2035
|
+
|
|
2036
|
+
const root = await findProjectRoot();
|
|
2037
|
+
const routesDir = join(root, "src/routes");
|
|
2038
|
+
const outputPath = join(root, output);
|
|
2039
|
+
|
|
2040
|
+
// Collect routes
|
|
2041
|
+
interface RouteInfo {
|
|
2042
|
+
namespace: string;
|
|
2043
|
+
name: string;
|
|
2044
|
+
handlerType: "typed" | "raw";
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
const routes: RouteInfo[] = [];
|
|
2048
|
+
|
|
2049
|
+
if (existsSync(routesDir)) {
|
|
2050
|
+
const namespaces = await readdir(routesDir, { withFileTypes: true });
|
|
2051
|
+
|
|
2052
|
+
for (const ns of namespaces) {
|
|
2053
|
+
if (!ns.isDirectory() || ns.name.startsWith(".")) continue;
|
|
2054
|
+
|
|
2055
|
+
const nsDir = join(routesDir, ns.name);
|
|
2056
|
+
const routeDirs = await readdir(nsDir, { withFileTypes: true });
|
|
2057
|
+
|
|
2058
|
+
for (const routeDir of routeDirs) {
|
|
2059
|
+
if (!routeDir.isDirectory() || routeDir.name.startsWith(".")) continue;
|
|
2060
|
+
|
|
2061
|
+
const handlerPath = join(nsDir, routeDir.name, "handler.ts");
|
|
2062
|
+
if (!existsSync(handlerPath)) continue;
|
|
2063
|
+
|
|
2064
|
+
const content = await readFile(handlerPath, "utf-8");
|
|
2065
|
+
const handlerType = content.includes(".raw(") || content.includes("req: Request") ? "raw" : "typed";
|
|
2066
|
+
|
|
2067
|
+
routes.push({
|
|
2068
|
+
namespace: ns.name,
|
|
2069
|
+
name: routeDir.name,
|
|
2070
|
+
handlerType,
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
if (routes.length === 0) {
|
|
2077
|
+
return "No routes found. Create routes first with 'add_route_file' tool.";
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// Group by namespace
|
|
2081
|
+
const byNamespace = new Map<string, RouteInfo[]>();
|
|
2082
|
+
for (const route of routes) {
|
|
2083
|
+
if (!byNamespace.has(route.namespace)) {
|
|
2084
|
+
byNamespace.set(route.namespace, []);
|
|
2085
|
+
}
|
|
2086
|
+
byNamespace.get(route.namespace)!.push(route);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// Generate namespace methods
|
|
2090
|
+
const namespaceBlocks: string[] = [];
|
|
2091
|
+
|
|
2092
|
+
for (const [namespace, nsRoutes] of byNamespace) {
|
|
2093
|
+
const methods = nsRoutes.map((r) => {
|
|
2094
|
+
const methodName = r.name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
2095
|
+
if (r.handlerType === "typed") {
|
|
2096
|
+
return ` /** Call ${namespace}.${r.name} */
|
|
2097
|
+
${methodName}: <TInput, TOutput>(input: TInput): Promise<TOutput> =>
|
|
2098
|
+
this.request("${namespace}.${r.name}", input)`;
|
|
2099
|
+
} else {
|
|
2100
|
+
return ` /** Call ${namespace}.${r.name} (raw) */
|
|
2101
|
+
${methodName}: (init?: RequestInit): Promise<Response> =>
|
|
2102
|
+
this.rawRequest("${namespace}.${r.name}", init)`;
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
namespaceBlocks.push(` ${namespace} = {\n${methods.join(",\n\n")}\n };`);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const content = `/**
|
|
2110
|
+
* Auto-generated API Client
|
|
2111
|
+
* Generated by: donkeylabs generate_client
|
|
2112
|
+
*
|
|
2113
|
+
* Usage:
|
|
2114
|
+
* import { api } from "./client/api";
|
|
2115
|
+
* const result = await api.users.create({ email: "test@example.com" });
|
|
2116
|
+
*
|
|
2117
|
+
* DO NOT use raw fetch() - use this typed client instead!
|
|
2118
|
+
*/
|
|
2119
|
+
|
|
2120
|
+
export interface ApiClientOptions {
|
|
2121
|
+
baseUrl: string;
|
|
2122
|
+
credentials?: RequestCredentials;
|
|
2123
|
+
headers?: HeadersInit;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
export class ApiClient {
|
|
2127
|
+
constructor(private options: ApiClientOptions) {}
|
|
2128
|
+
|
|
2129
|
+
private async request<TInput, TOutput>(
|
|
2130
|
+
route: string,
|
|
2131
|
+
input: TInput
|
|
2132
|
+
): Promise<TOutput> {
|
|
2133
|
+
const response = await fetch(\`\${this.options.baseUrl}/\${route}\`, {
|
|
2134
|
+
method: "POST",
|
|
2135
|
+
headers: {
|
|
2136
|
+
"Content-Type": "application/json",
|
|
2137
|
+
...this.options.headers,
|
|
2138
|
+
},
|
|
2139
|
+
credentials: this.options.credentials || "include",
|
|
2140
|
+
body: JSON.stringify(input),
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
if (!response.ok) {
|
|
2144
|
+
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
2145
|
+
throw new ApiError(response.status, error.message || "Request failed", error);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
return response.json();
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
private async rawRequest(route: string, init?: RequestInit): Promise<Response> {
|
|
2152
|
+
return fetch(\`\${this.options.baseUrl}/\${route}\`, {
|
|
2153
|
+
credentials: this.options.credentials || "include",
|
|
2154
|
+
...init,
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
${namespaceBlocks.join("\n\n")}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
export class ApiError extends Error {
|
|
2162
|
+
constructor(
|
|
2163
|
+
public status: number,
|
|
2164
|
+
message: string,
|
|
2165
|
+
public data?: any
|
|
2166
|
+
) {
|
|
2167
|
+
super(message);
|
|
2168
|
+
this.name = "ApiError";
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* Create API client instance
|
|
2174
|
+
*
|
|
2175
|
+
* @example
|
|
2176
|
+
* const api = createApiClient({ baseUrl: "http://localhost:3000" });
|
|
2177
|
+
* const user = await api.users.create({ email: "test@example.com" });
|
|
2178
|
+
*/
|
|
2179
|
+
export function createApiClient(options: ApiClientOptions): ApiClient {
|
|
2180
|
+
return new ApiClient(options);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
${baseUrl ? `// Pre-configured client instance\nexport const api = createApiClient({ baseUrl: "${baseUrl}" });\n` : "// Configure and export: export const api = createApiClient({ baseUrl: 'http://localhost:3000' });"}
|
|
2184
|
+
`;
|
|
2185
|
+
|
|
2186
|
+
await mkdir(join(root, "src/client"), { recursive: true });
|
|
2187
|
+
await writeFile(outputPath, content);
|
|
2188
|
+
|
|
2189
|
+
return `Generated API client: ${output}
|
|
2190
|
+
|
|
2191
|
+
Routes included:
|
|
2192
|
+
${routes.map((r) => ` - ${r.namespace}.${r.name} (${r.handlerType})`).join("\n")}
|
|
2193
|
+
|
|
2194
|
+
Usage:
|
|
2195
|
+
import { createApiClient } from "./${output.replace(/\.ts$/, "")}";
|
|
2196
|
+
|
|
2197
|
+
const api = createApiClient({ baseUrl: "http://localhost:3000" });
|
|
2198
|
+
|
|
2199
|
+
// Type-safe API calls (NOT raw fetch!)
|
|
2200
|
+
const result = await api.${routes[0]?.namespace || "namespace"}.${routes[0]?.name.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) || "method"}({ ... });`;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
async function addMiddleware(args: {
|
|
2204
|
+
pluginName: string;
|
|
2205
|
+
middlewareName: string;
|
|
2206
|
+
description?: string;
|
|
2207
|
+
modifiesContext?: boolean;
|
|
2208
|
+
}): Promise<string> {
|
|
2209
|
+
const { pluginName, middlewareName, description, modifiesContext = false } = args;
|
|
2210
|
+
|
|
2211
|
+
const pluginsDir = await getPluginsDir();
|
|
2212
|
+
const indexPath = join(pluginsDir, pluginName, "index.ts");
|
|
2213
|
+
|
|
2214
|
+
if (!existsSync(indexPath)) {
|
|
2215
|
+
throw new Error(`Plugin '${pluginName}' not found`);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
let content = await readFile(indexPath, "utf-8");
|
|
2219
|
+
|
|
2220
|
+
// Check if middleware already exists
|
|
2221
|
+
if (content.includes(`${middlewareName}:`)) {
|
|
2222
|
+
throw new Error(`Middleware '${middlewareName}' already exists in plugin`);
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// Create middleware definition
|
|
2226
|
+
const middlewareCode = `
|
|
2227
|
+
// ${description || `${middlewareName} middleware`}
|
|
2228
|
+
export const ${middlewareName}Middleware = createMiddleware({
|
|
2229
|
+
name: "${middlewareName}",
|
|
2230
|
+
handler: async (ctx, next) => {
|
|
2231
|
+
// TODO: Implement middleware logic
|
|
2232
|
+
${modifiesContext ? "// Example: ctx.user = await validateToken(ctx);" : ""}
|
|
2233
|
+
return next();
|
|
2234
|
+
},
|
|
2235
|
+
});
|
|
2236
|
+
`;
|
|
2237
|
+
|
|
2238
|
+
// Check if createMiddleware is imported
|
|
2239
|
+
if (!content.includes("createMiddleware")) {
|
|
2240
|
+
content = content.replace(
|
|
2241
|
+
/from "@donkeylabs\/server"/,
|
|
2242
|
+
'from "@donkeylabs/server";\nimport { createMiddleware } from "@donkeylabs/server"'
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// Add to plugin definition's middleware object
|
|
2247
|
+
const middlewareMatch = content.match(/middleware:\s*\{([^}]*)\}/);
|
|
2248
|
+
if (middlewareMatch) {
|
|
2249
|
+
// Add to existing middleware object
|
|
2250
|
+
const insertPos = middlewareMatch.index! + middlewareMatch[0].length - 1;
|
|
2251
|
+
const existingMiddleware = middlewareMatch[1].trim();
|
|
2252
|
+
const separator = existingMiddleware ? ",\n " : "\n ";
|
|
2253
|
+
content = content.slice(0, insertPos) + separator + `${middlewareName}: ${middlewareName}Middleware` + content.slice(insertPos);
|
|
2254
|
+
} else {
|
|
2255
|
+
// Add middleware object to plugin definition
|
|
2256
|
+
const defineMatch = content.match(/\.define\s*\(\s*\{/);
|
|
2257
|
+
if (defineMatch) {
|
|
2258
|
+
const insertPos = defineMatch.index! + defineMatch[0].length;
|
|
2259
|
+
content = content.slice(0, insertPos) + `\n middleware: {\n ${middlewareName}: ${middlewareName}Middleware,\n },` + content.slice(insertPos);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// Add middleware definition before the plugin export
|
|
2264
|
+
const exportMatch = content.match(/export const \w+Plugin/);
|
|
2265
|
+
if (exportMatch) {
|
|
2266
|
+
content = content.slice(0, exportMatch.index) + middlewareCode + "\n" + content.slice(exportMatch.index);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
await writeFile(indexPath, content);
|
|
2270
|
+
|
|
2271
|
+
return `Added middleware '${middlewareName}' to ${pluginName} plugin.
|
|
2272
|
+
|
|
2273
|
+
Usage in routes:
|
|
2274
|
+
router.route("protected").${middlewareName}().typed({ ... })
|
|
2275
|
+
|
|
2276
|
+
Remember to run 'donkeylabs generate' to update types!`;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
async function addCustomHandler(args: {
|
|
2280
|
+
pluginName: string;
|
|
2281
|
+
handlerName: string;
|
|
2282
|
+
description?: string;
|
|
2283
|
+
signatureParams?: string;
|
|
2284
|
+
signatureReturn?: string;
|
|
2285
|
+
}): Promise<string> {
|
|
2286
|
+
const {
|
|
2287
|
+
pluginName,
|
|
2288
|
+
handlerName,
|
|
2289
|
+
description,
|
|
2290
|
+
signatureParams = "data: any, ctx: ServerContext",
|
|
2291
|
+
signatureReturn = "Promise<any>",
|
|
2292
|
+
} = args;
|
|
2293
|
+
|
|
2294
|
+
const pluginsDir = await getPluginsDir();
|
|
2295
|
+
const indexPath = join(pluginsDir, pluginName, "index.ts");
|
|
2296
|
+
|
|
2297
|
+
if (!existsSync(indexPath)) {
|
|
2298
|
+
throw new Error(`Plugin '${pluginName}' not found`);
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
let content = await readFile(indexPath, "utf-8");
|
|
2302
|
+
|
|
2303
|
+
// Check if handler already exists
|
|
2304
|
+
if (content.includes(`${handlerName}:`)) {
|
|
2305
|
+
throw new Error(`Handler '${handlerName}' already exists in plugin`);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
// Create handler type and definition
|
|
2309
|
+
const handlerCode = `
|
|
2310
|
+
// ${description || `${handlerName} handler`}
|
|
2311
|
+
type ${handlerName.charAt(0).toUpperCase() + handlerName.slice(1)}Fn = (${signatureParams}) => ${signatureReturn};
|
|
2312
|
+
|
|
2313
|
+
export const ${handlerName.charAt(0).toUpperCase() + handlerName.slice(1)}Handler = createHandler<${handlerName.charAt(0).toUpperCase() + handlerName.slice(1)}Fn>(
|
|
2314
|
+
async (req, def, handle, ctx) => {
|
|
2315
|
+
// TODO: Implement handler wrapper
|
|
2316
|
+
const data = await req.json();
|
|
2317
|
+
const result = await handle(data, ctx);
|
|
2318
|
+
return Response.json(result);
|
|
2319
|
+
}
|
|
2320
|
+
);
|
|
2321
|
+
`;
|
|
2322
|
+
|
|
2323
|
+
// Check if createHandler is imported
|
|
2324
|
+
if (!content.includes("createHandler")) {
|
|
2325
|
+
content = content.replace(
|
|
2326
|
+
/from "@donkeylabs\/server"/,
|
|
2327
|
+
', createHandler } from "@donkeylabs/server"'
|
|
2328
|
+
);
|
|
2329
|
+
content = content.replace("{ createPlugin, createHandler }", "{ createPlugin, createHandler }");
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// Add to plugin definition's handlers object
|
|
2333
|
+
const handlersMatch = content.match(/handlers:\s*\{([^}]*)\}/);
|
|
2334
|
+
if (handlersMatch) {
|
|
2335
|
+
// Add to existing handlers object
|
|
2336
|
+
const insertPos = handlersMatch.index! + handlersMatch[0].length - 1;
|
|
2337
|
+
const existingHandlers = handlersMatch[1].trim();
|
|
2338
|
+
const separator = existingHandlers ? ",\n " : "\n ";
|
|
2339
|
+
content = content.slice(0, insertPos) + separator + `${handlerName}: ${handlerName.charAt(0).toUpperCase() + handlerName.slice(1)}Handler` + content.slice(insertPos);
|
|
2340
|
+
} else {
|
|
2341
|
+
// Add handlers object to plugin definition
|
|
2342
|
+
const defineMatch = content.match(/\.define\s*\(\s*\{/);
|
|
2343
|
+
if (defineMatch) {
|
|
2344
|
+
const insertPos = defineMatch.index! + defineMatch[0].length;
|
|
2345
|
+
content = content.slice(0, insertPos) + `\n handlers: {\n ${handlerName}: ${handlerName.charAt(0).toUpperCase() + handlerName.slice(1)}Handler,\n },` + content.slice(insertPos);
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
// Add handler definition before the plugin export
|
|
2350
|
+
const exportMatch = content.match(/export const \w+Plugin/);
|
|
2351
|
+
if (exportMatch) {
|
|
2352
|
+
content = content.slice(0, exportMatch.index) + handlerCode + "\n" + content.slice(exportMatch.index);
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
await writeFile(indexPath, content);
|
|
2356
|
+
|
|
2357
|
+
return `Added custom handler '${handlerName}' to ${pluginName} plugin.
|
|
2358
|
+
|
|
2359
|
+
Usage in routes:
|
|
2360
|
+
router.route("data").${handlerName}({
|
|
2361
|
+
handle: async (${signatureParams.split(",")[0]}) => {
|
|
2362
|
+
// Return type: ${signatureReturn}
|
|
2363
|
+
}
|
|
2364
|
+
})
|
|
2365
|
+
|
|
2366
|
+
Remember to run 'donkeylabs generate' to update types!`;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
async function createCustomError(args: {
|
|
2370
|
+
pluginName: string;
|
|
2371
|
+
errorName: string;
|
|
2372
|
+
httpStatus?: number;
|
|
2373
|
+
defaultMessage?: string;
|
|
2374
|
+
errorCode?: string;
|
|
2375
|
+
}): Promise<string> {
|
|
2376
|
+
const {
|
|
2377
|
+
pluginName,
|
|
2378
|
+
errorName,
|
|
2379
|
+
httpStatus = 400,
|
|
2380
|
+
defaultMessage,
|
|
2381
|
+
errorCode,
|
|
2382
|
+
} = args;
|
|
2383
|
+
|
|
2384
|
+
const pluginsDir = await getPluginsDir();
|
|
2385
|
+
const pluginDir = join(pluginsDir, pluginName);
|
|
2386
|
+
|
|
2387
|
+
if (!existsSync(pluginDir)) {
|
|
2388
|
+
throw new Error(`Plugin '${pluginName}' not found`);
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// Generate error code from name if not provided
|
|
2392
|
+
const code = errorCode || errorName.replace(/([A-Z])/g, "_$1").toUpperCase().slice(1);
|
|
2393
|
+
const message = defaultMessage || errorName.replace(/([A-Z])/g, " $1").trim();
|
|
2394
|
+
const className = `${errorName}Error`;
|
|
2395
|
+
|
|
2396
|
+
const errorsPath = join(pluginDir, "errors.ts");
|
|
2397
|
+
let content: string;
|
|
2398
|
+
|
|
2399
|
+
if (existsSync(errorsPath)) {
|
|
2400
|
+
// Append to existing errors file
|
|
2401
|
+
const existing = await readFile(errorsPath, "utf-8");
|
|
2402
|
+
|
|
2403
|
+
if (existing.includes(`class ${className}`)) {
|
|
2404
|
+
throw new Error(`Error '${className}' already exists in ${pluginName}/errors.ts`);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
const newError = `
|
|
2408
|
+
export class ${className} extends HttpError {
|
|
2409
|
+
constructor(message = "${message}") {
|
|
2410
|
+
super(${httpStatus}, message, "${code}");
|
|
2411
|
+
this.name = "${className}";
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
`;
|
|
2415
|
+
content = existing + newError;
|
|
2416
|
+
} else {
|
|
2417
|
+
// Create new errors file
|
|
2418
|
+
content = `import { HttpError } from "@donkeylabs/server";
|
|
2419
|
+
|
|
2420
|
+
export class ${className} extends HttpError {
|
|
2421
|
+
constructor(message = "${message}") {
|
|
2422
|
+
super(${httpStatus}, message, "${code}");
|
|
2423
|
+
this.name = "${className}";
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
`;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
await writeFile(errorsPath, content);
|
|
2430
|
+
|
|
2431
|
+
return `Created custom error '${className}' in ${pluginName}/errors.ts
|
|
2432
|
+
|
|
2433
|
+
Usage:
|
|
2434
|
+
import { ${className} } from "./errors";
|
|
2435
|
+
|
|
2436
|
+
// In service or handler:
|
|
2437
|
+
throw new ${className}();
|
|
2438
|
+
// or with custom message:
|
|
2439
|
+
throw new ${className}("Custom message here");
|
|
2440
|
+
|
|
2441
|
+
HTTP Response:
|
|
2442
|
+
Status: ${httpStatus}
|
|
2443
|
+
Body: { "error": "${code}", "message": "${message}" }`;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
async function validateCode(args: {
|
|
2447
|
+
filePath: string;
|
|
2448
|
+
checkType?: string;
|
|
2449
|
+
}): Promise<string> {
|
|
2450
|
+
const { filePath, checkType = "auto" } = args;
|
|
2451
|
+
|
|
2452
|
+
const root = await findProjectRoot();
|
|
2453
|
+
const fullPath = join(root, filePath);
|
|
2454
|
+
|
|
2455
|
+
if (!existsSync(fullPath)) {
|
|
2456
|
+
throw new Error(`File not found: ${filePath}`);
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
const content = await readFile(fullPath, "utf-8");
|
|
2460
|
+
const issues: string[] = [];
|
|
2461
|
+
const warnings: string[] = [];
|
|
2462
|
+
const suggestions: string[] = [];
|
|
2463
|
+
|
|
2464
|
+
// Detect check type from path if auto
|
|
2465
|
+
let type = checkType;
|
|
2466
|
+
if (type === "auto") {
|
|
2467
|
+
if (filePath.includes("/plugins/")) type = "plugin";
|
|
2468
|
+
else if (filePath.includes("/routes/")) type = "route";
|
|
2469
|
+
else if (filePath.includes("/models/")) type = "model";
|
|
2470
|
+
else type = "general";
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
// Common checks for all files
|
|
2474
|
+
if (content.includes("fetch(") && !filePath.includes("client")) {
|
|
2475
|
+
warnings.push("Uses raw fetch() - consider using generated API client instead");
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
if (content.includes("console.log(") && !content.includes("// debug")) {
|
|
2479
|
+
warnings.push("Contains console.log() - use ctx.core.logger instead");
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
// Plugin-specific checks
|
|
2483
|
+
if (type === "plugin") {
|
|
2484
|
+
if (!content.includes("createPlugin")) {
|
|
2485
|
+
issues.push("Missing createPlugin import - required for plugin definition");
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
if (!content.includes("export const") || !content.includes("Plugin")) {
|
|
2489
|
+
issues.push("No plugin export found - export should be named <name>Plugin");
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
if (content.includes("service:") && !content.includes("async (ctx)") && !content.includes("async(ctx)")) {
|
|
2493
|
+
warnings.push("Service function should be async and receive ctx parameter");
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
if (content.includes("dependencies:") && !content.includes("as const")) {
|
|
2497
|
+
warnings.push("Dependencies array should use 'as const' for type safety");
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
if (!content.includes("version:")) {
|
|
2501
|
+
suggestions.push("Consider adding 'version' field to plugin definition");
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// Route-specific checks
|
|
2506
|
+
if (type === "route") {
|
|
2507
|
+
if (!content.includes("z.object") && !content.includes(".raw(")) {
|
|
2508
|
+
warnings.push("Typed routes should have Zod input/output schemas");
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
if (!content.includes("async function handler") && !content.includes("async (") && !content.includes("handle:")) {
|
|
2512
|
+
issues.push("Handler function should be async");
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
if (!content.includes("GlobalContext") && !content.includes("ServerContext")) {
|
|
2516
|
+
warnings.push("Handler should use GlobalContext or ServerContext type for ctx parameter");
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
const hasInputSchema = content.includes("export const input") || content.includes("input:");
|
|
2520
|
+
const hasOutputSchema = content.includes("export const output") || content.includes("output:");
|
|
2521
|
+
|
|
2522
|
+
if (content.includes(".typed(") && (!hasInputSchema || !hasOutputSchema)) {
|
|
2523
|
+
warnings.push("Typed handlers should define both input and output schemas");
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
// Model-specific checks
|
|
2528
|
+
if (type === "model") {
|
|
2529
|
+
if (!content.includes("constructor(") || !content.includes("ctx")) {
|
|
2530
|
+
warnings.push("Model constructor should accept GlobalContext for dependency injection");
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
if (!content.includes("private ctx") && !content.includes("readonly ctx")) {
|
|
2534
|
+
suggestions.push("Consider storing ctx as private/readonly class property");
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
if (!content.includes("export function create") && !content.includes("export const create")) {
|
|
2538
|
+
suggestions.push("Consider adding a factory function (e.g., createUserModel) for convenience");
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
if (content.includes("this.db") || content.includes("this.plugins")) {
|
|
2542
|
+
warnings.push("Access db/plugins via this.ctx.db and this.ctx.plugins instead");
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
// Format output
|
|
2547
|
+
const results: string[] = [`Validation results for ${filePath} (${type}):\n`];
|
|
2548
|
+
|
|
2549
|
+
if (issues.length === 0 && warnings.length === 0 && suggestions.length === 0) {
|
|
2550
|
+
results.push("All checks passed. Code follows conventions.");
|
|
2551
|
+
return results.join("\n");
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
if (issues.length > 0) {
|
|
2555
|
+
results.push(`ERRORS (${issues.length}):`);
|
|
2556
|
+
issues.forEach((i) => results.push(` - ${i}`));
|
|
2557
|
+
results.push("");
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
if (warnings.length > 0) {
|
|
2561
|
+
results.push(`WARNINGS (${warnings.length}):`);
|
|
2562
|
+
warnings.forEach((w) => results.push(` - ${w}`));
|
|
2563
|
+
results.push("");
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
if (suggestions.length > 0) {
|
|
2567
|
+
results.push(`SUGGESTIONS (${suggestions.length}):`);
|
|
2568
|
+
suggestions.forEach((s) => results.push(` - ${s}`));
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
return results.join("\n");
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
// ============================================
|
|
2575
|
+
// Server Setup
|
|
2576
|
+
// ============================================
|
|
2577
|
+
|
|
2578
|
+
// ============================================
|
|
2579
|
+
// Resource: Project Context (like CLAUDE.md)
|
|
2580
|
+
// ============================================
|
|
2581
|
+
|
|
2582
|
+
const PROJECT_CONTEXT = `# @donkeylabs/server - AI Development Guide
|
|
2583
|
+
|
|
2584
|
+
## Project Structure
|
|
2585
|
+
|
|
2586
|
+
\`\`\`
|
|
2587
|
+
src/
|
|
2588
|
+
index.ts # Server entry point
|
|
2589
|
+
db.ts # Database setup (Kysely + SQLite)
|
|
2590
|
+
routes/ # Route handlers (preferred structure)
|
|
2591
|
+
<namespace>/
|
|
2592
|
+
<route-name>/
|
|
2593
|
+
handler.ts # Route handler
|
|
2594
|
+
test.ts # Unit tests
|
|
2595
|
+
models/ # Business logic models
|
|
2596
|
+
UserModel.ts # Model with GlobalContext injection
|
|
2597
|
+
plugins/ # Plugin modules
|
|
2598
|
+
<name>/
|
|
2599
|
+
index.ts # Plugin definition
|
|
2600
|
+
schema.ts # DB types (optional)
|
|
2601
|
+
errors.ts # Custom errors (optional)
|
|
2602
|
+
migrations/ # SQL migrations
|
|
2603
|
+
test/
|
|
2604
|
+
integration/ # Integration tests
|
|
2605
|
+
.@donkeylabs/server/ # Generated types (gitignored)
|
|
2606
|
+
registry.d.ts
|
|
2607
|
+
context.d.ts
|
|
2608
|
+
routes.d.ts
|
|
2609
|
+
\`\`\`
|
|
2610
|
+
|
|
2611
|
+
## GlobalContext (CRITICAL - Use This!)
|
|
2612
|
+
|
|
2613
|
+
The \`GlobalContext\` type provides access to ALL services. ALWAYS use ctx instead of creating your own instances!
|
|
2614
|
+
|
|
2615
|
+
\`\`\`typescript
|
|
2616
|
+
import type { GlobalContext } from "./@donkeylabs/server/context";
|
|
2617
|
+
|
|
2618
|
+
interface GlobalContext {
|
|
2619
|
+
db: Kysely<DatabaseSchema>; // Type-safe database
|
|
2620
|
+
plugins: { // Plugin services
|
|
2621
|
+
auth: AuthService;
|
|
2622
|
+
users: UsersService;
|
|
2623
|
+
// ... all registered plugins
|
|
2624
|
+
};
|
|
2625
|
+
core: CoreServices; // Logger, cache, events, jobs, cron, sse, rateLimiter
|
|
2626
|
+
errors: Errors; // Error factories
|
|
2627
|
+
ip: string; // Client IP address
|
|
2628
|
+
requestId?: string; // Request tracking ID
|
|
2629
|
+
user?: any; // Authenticated user (set by auth middleware)
|
|
2630
|
+
}
|
|
2631
|
+
\`\`\`
|
|
2632
|
+
|
|
2633
|
+
## Core Services (ctx.core) - Full Reference
|
|
2634
|
+
|
|
2635
|
+
### Logger
|
|
2636
|
+
\`\`\`typescript
|
|
2637
|
+
// Structured logging with levels
|
|
2638
|
+
ctx.core.logger.info("User created", { userId: "123", email: "..." });
|
|
2639
|
+
ctx.core.logger.warn("Rate limit approaching", { remaining: 5 });
|
|
2640
|
+
ctx.core.logger.error("Payment failed", { error, orderId });
|
|
2641
|
+
ctx.core.logger.debug("Cache hit", { key, ttl });
|
|
2642
|
+
\`\`\`
|
|
2643
|
+
|
|
2644
|
+
### Cache
|
|
2645
|
+
\`\`\`typescript
|
|
2646
|
+
// Set with TTL (seconds)
|
|
2647
|
+
await ctx.core.cache.set("user:123", userData, { ttl: 300 });
|
|
2648
|
+
|
|
2649
|
+
// Get (returns undefined if expired/missing)
|
|
2650
|
+
const user = await ctx.core.cache.get("user:123");
|
|
2651
|
+
|
|
2652
|
+
// Delete
|
|
2653
|
+
await ctx.core.cache.delete("user:123");
|
|
2654
|
+
|
|
2655
|
+
// Check existence
|
|
2656
|
+
const exists = await ctx.core.cache.has("user:123");
|
|
2657
|
+
\`\`\`
|
|
2658
|
+
|
|
2659
|
+
### Events (Pub/Sub)
|
|
2660
|
+
\`\`\`typescript
|
|
2661
|
+
// Emit event (fire-and-forget)
|
|
2662
|
+
ctx.core.events.emit("user.created", { userId: "123", email: "..." });
|
|
2663
|
+
ctx.core.events.emit("order.completed", { orderId, total });
|
|
2664
|
+
|
|
2665
|
+
// Subscribe to events (in plugin service)
|
|
2666
|
+
ctx.core.events.on("user.created", async (data) => {
|
|
2667
|
+
await sendWelcomeEmail(data.email);
|
|
2668
|
+
});
|
|
2669
|
+
|
|
2670
|
+
// One-time listener
|
|
2671
|
+
ctx.core.events.once("server.ready", () => console.log("Server started"));
|
|
2672
|
+
\`\`\`
|
|
2673
|
+
|
|
2674
|
+
### Jobs (Background Processing)
|
|
2675
|
+
\`\`\`typescript
|
|
2676
|
+
// Register job handler (in plugin initialization)
|
|
2677
|
+
ctx.core.jobs.register("sendEmail", async (payload) => {
|
|
2678
|
+
await mailer.send(payload.to, payload.subject, payload.body);
|
|
2679
|
+
});
|
|
2680
|
+
|
|
2681
|
+
// Enqueue job from anywhere
|
|
2682
|
+
await ctx.core.jobs.enqueue("sendEmail", {
|
|
2683
|
+
to: "user@example.com",
|
|
2684
|
+
subject: "Welcome!",
|
|
2685
|
+
body: "Welcome to our service!",
|
|
2686
|
+
});
|
|
2687
|
+
|
|
2688
|
+
// Enqueue with delay
|
|
2689
|
+
await ctx.core.jobs.enqueue("reminder", data, { delay: 60 * 60 * 1000 }); // 1 hour
|
|
2690
|
+
\`\`\`
|
|
2691
|
+
|
|
2692
|
+
### Cron (Scheduled Tasks)
|
|
2693
|
+
\`\`\`typescript
|
|
2694
|
+
// Schedule recurring task (cron expression)
|
|
2695
|
+
ctx.core.cron.schedule("0 0 * * *", async () => {
|
|
2696
|
+
// Runs daily at midnight
|
|
2697
|
+
await cleanupExpiredSessions();
|
|
2698
|
+
});
|
|
2699
|
+
|
|
2700
|
+
// Schedule with options
|
|
2701
|
+
ctx.core.cron.schedule("*/5 * * * *", handler, {
|
|
2702
|
+
name: "cleanup",
|
|
2703
|
+
runOnStart: false,
|
|
2704
|
+
});
|
|
2705
|
+
|
|
2706
|
+
// Common patterns:
|
|
2707
|
+
// "0 * * * *" - Every hour
|
|
2708
|
+
// "*/5 * * * *" - Every 5 minutes
|
|
2709
|
+
// "0 0 * * *" - Daily at midnight
|
|
2710
|
+
// "0 0 * * 0" - Weekly on Sunday
|
|
2711
|
+
// "0 0 1 * *" - Monthly on 1st
|
|
2712
|
+
\`\`\`
|
|
2713
|
+
|
|
2714
|
+
### SSE (Server-Sent Events)
|
|
2715
|
+
\`\`\`typescript
|
|
2716
|
+
// In raw handler - create SSE connection
|
|
2717
|
+
const client = ctx.core.sse.addClient(userId);
|
|
2718
|
+
return client.response; // Return this Response directly
|
|
2719
|
+
|
|
2720
|
+
// Broadcast to all connected clients
|
|
2721
|
+
ctx.core.sse.broadcast("notifications", { message: "New update!" });
|
|
2722
|
+
|
|
2723
|
+
// Send to specific client
|
|
2724
|
+
ctx.core.sse.send(userId, "private", { data: "Personal message" });
|
|
2725
|
+
|
|
2726
|
+
// Remove client on disconnect
|
|
2727
|
+
ctx.core.sse.removeClient(userId);
|
|
2728
|
+
\`\`\`
|
|
2729
|
+
|
|
2730
|
+
### Rate Limiter
|
|
2731
|
+
\`\`\`typescript
|
|
2732
|
+
// Check rate limit
|
|
2733
|
+
const result = await ctx.core.rateLimiter.limit(\`api:\${ctx.ip}\`, {
|
|
2734
|
+
window: "1m", // Time window
|
|
2735
|
+
max: 100, // Max requests in window
|
|
2736
|
+
});
|
|
2737
|
+
|
|
2738
|
+
if (!result.allowed) {
|
|
2739
|
+
throw ctx.errors.TooManyRequests(\`Try again in \${result.retryAfter}s\`);
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// Rate limit by user
|
|
2743
|
+
const userLimit = await ctx.core.rateLimiter.limit(\`user:\${ctx.user.id}\`, {
|
|
2744
|
+
window: "1h",
|
|
2745
|
+
max: 1000,
|
|
2746
|
+
});
|
|
2747
|
+
\`\`\`
|
|
2748
|
+
|
|
2749
|
+
## Error Factories (ctx.errors)
|
|
2750
|
+
|
|
2751
|
+
| Factory | HTTP Status | Use Case |
|
|
2752
|
+
|---------|-------------|----------|
|
|
2753
|
+
| \`BadRequest(msg)\` | 400 | Invalid input data |
|
|
2754
|
+
| \`Unauthorized(msg)\` | 401 | Not logged in |
|
|
2755
|
+
| \`Forbidden(msg)\` | 403 | No permission |
|
|
2756
|
+
| \`NotFound(msg)\` | 404 | Resource doesn't exist |
|
|
2757
|
+
| \`MethodNotAllowed(msg)\` | 405 | Wrong HTTP method |
|
|
2758
|
+
| \`Conflict(msg)\` | 409 | Resource already exists |
|
|
2759
|
+
| \`Gone(msg)\` | 410 | Resource permanently deleted |
|
|
2760
|
+
| \`UnprocessableEntity(msg)\` | 422 | Semantic/business logic error |
|
|
2761
|
+
| \`TooManyRequests(msg)\` | 429 | Rate limit exceeded |
|
|
2762
|
+
| \`InternalServerError(msg)\` | 500 | Unexpected server error |
|
|
2763
|
+
| \`NotImplemented(msg)\` | 501 | Feature not ready |
|
|
2764
|
+
| \`ServiceUnavailable(msg)\` | 503 | Maintenance mode |
|
|
2765
|
+
|
|
2766
|
+
Usage:
|
|
2767
|
+
\`\`\`typescript
|
|
2768
|
+
// Throw errors in handlers
|
|
2769
|
+
if (!user) throw ctx.errors.NotFound("User not found");
|
|
2770
|
+
if (!canAccess) throw ctx.errors.Forbidden("Access denied");
|
|
2771
|
+
if (exists) throw ctx.errors.Conflict("Email already registered");
|
|
2772
|
+
\`\`\`
|
|
2773
|
+
|
|
2774
|
+
## Custom Errors
|
|
2775
|
+
|
|
2776
|
+
Create domain-specific errors using the \`create_custom_error\` tool:
|
|
2777
|
+
\`\`\`typescript
|
|
2778
|
+
// plugins/payments/errors.ts
|
|
2779
|
+
import { HttpError } from "@donkeylabs/server";
|
|
2780
|
+
|
|
2781
|
+
export class InsufficientFundsError extends HttpError {
|
|
2782
|
+
constructor(message = "Insufficient funds") {
|
|
2783
|
+
super(400, message, "INSUFFICIENT_FUNDS");
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
\`\`\`
|
|
2787
|
+
|
|
2788
|
+
## Route Structure (Preferred)
|
|
2789
|
+
|
|
2790
|
+
Routes go in \`src/routes/<namespace>/<route-name>/handler.ts\`:
|
|
2791
|
+
|
|
2792
|
+
\`\`\`typescript
|
|
2793
|
+
// src/routes/users/create/handler.ts
|
|
2794
|
+
import { z } from "zod";
|
|
2795
|
+
import type { GlobalContext } from "../../../.@donkeylabs/server/context";
|
|
2796
|
+
|
|
2797
|
+
export const input = z.object({
|
|
2798
|
+
email: z.string().email(),
|
|
2799
|
+
name: z.string(),
|
|
2800
|
+
});
|
|
2801
|
+
|
|
2802
|
+
export const output = z.object({
|
|
2803
|
+
user: z.object({ id: z.string() }),
|
|
2804
|
+
});
|
|
2805
|
+
|
|
2806
|
+
export async function handler(
|
|
2807
|
+
data: z.infer<typeof input>,
|
|
2808
|
+
ctx: GlobalContext
|
|
2809
|
+
): Promise<z.infer<typeof output>> {
|
|
2810
|
+
const user = await ctx.plugins.users.create(data);
|
|
2811
|
+
return { user };
|
|
2812
|
+
}
|
|
2813
|
+
\`\`\`
|
|
2814
|
+
|
|
2815
|
+
## Models for Testability
|
|
2816
|
+
|
|
2817
|
+
Create models in \`src/models/\` for business logic separation:
|
|
2818
|
+
|
|
2819
|
+
\`\`\`typescript
|
|
2820
|
+
// src/models/UserModel.ts
|
|
2821
|
+
import type { GlobalContext } from "../.@donkeylabs/server/context";
|
|
2822
|
+
|
|
2823
|
+
export class UserModel {
|
|
2824
|
+
constructor(private ctx: GlobalContext) {}
|
|
2825
|
+
|
|
2826
|
+
async create(data: { email: string; name: string }) {
|
|
2827
|
+
// Access ctx.db, ctx.plugins, ctx.core, ctx.errors
|
|
2828
|
+
return this.ctx.db.insertInto("users")
|
|
2829
|
+
.values(data)
|
|
2830
|
+
.returningAll()
|
|
2831
|
+
.executeTakeFirstOrThrow();
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
export function createUserModel(ctx: GlobalContext) {
|
|
2836
|
+
return new UserModel(ctx);
|
|
2837
|
+
}
|
|
2838
|
+
\`\`\`
|
|
2839
|
+
|
|
2840
|
+
Usage in route:
|
|
2841
|
+
\`\`\`typescript
|
|
2842
|
+
import { createUserModel } from "../../models/UserModel";
|
|
2843
|
+
|
|
2844
|
+
export async function handler(data, ctx) {
|
|
2845
|
+
const model = createUserModel(ctx);
|
|
2846
|
+
return model.create(data);
|
|
2847
|
+
}
|
|
2848
|
+
\`\`\`
|
|
2849
|
+
|
|
2850
|
+
## Testing
|
|
2851
|
+
|
|
2852
|
+
### Unit Tests (with mock context)
|
|
2853
|
+
Place test files next to handlers: \`handler.ts\` + \`test.ts\`
|
|
2854
|
+
|
|
2855
|
+
\`\`\`typescript
|
|
2856
|
+
import { describe, test, expect } from "bun:test";
|
|
2857
|
+
import { handler } from "./handler";
|
|
2858
|
+
|
|
2859
|
+
describe("users.create", () => {
|
|
2860
|
+
const mockCtx = {
|
|
2861
|
+
db: { insertInto: () => ({ values: () => ({ returningAll: () => ({ executeTakeFirstOrThrow: async () => ({ id: "123" }) }) }) }) } as any,
|
|
2862
|
+
plugins: {} as any,
|
|
2863
|
+
core: {} as any,
|
|
2864
|
+
errors: {} as any,
|
|
2865
|
+
ip: "127.0.0.1",
|
|
2866
|
+
};
|
|
2867
|
+
|
|
2868
|
+
test("creates user", async () => {
|
|
2869
|
+
const result = await handler({ email: "test@example.com", name: "Test" }, mockCtx);
|
|
2870
|
+
expect(result.user.id).toBe("123");
|
|
2871
|
+
});
|
|
2872
|
+
});
|
|
2873
|
+
\`\`\`
|
|
2874
|
+
|
|
2875
|
+
### Integration Tests (with real plugins)
|
|
2876
|
+
Use \`createTestHarness\` for full integration tests:
|
|
2877
|
+
|
|
2878
|
+
\`\`\`typescript
|
|
2879
|
+
import { describe, test, expect, beforeAll } from "bun:test";
|
|
2880
|
+
import { createTestHarness } from "@donkeylabs/server/harness";
|
|
2881
|
+
import { authPlugin } from "../src/plugins/auth";
|
|
2882
|
+
import { usersPlugin } from "../src/plugins/users";
|
|
2883
|
+
|
|
2884
|
+
describe("users.create (Integration)", () => {
|
|
2885
|
+
let harness: Awaited<ReturnType<typeof createTestHarness>>;
|
|
2886
|
+
|
|
2887
|
+
beforeAll(async () => {
|
|
2888
|
+
harness = await createTestHarness(authPlugin, usersPlugin);
|
|
2889
|
+
await harness.manager.initialize();
|
|
2890
|
+
});
|
|
2891
|
+
|
|
2892
|
+
test("creates user with real database", async () => {
|
|
2893
|
+
const response = await harness.request("users.create", {
|
|
2894
|
+
email: "test@example.com",
|
|
2895
|
+
name: "Test User",
|
|
2896
|
+
});
|
|
2897
|
+
expect(response.status).toBe(200);
|
|
2898
|
+
const data = await response.json();
|
|
2899
|
+
expect(data.user.id).toBeDefined();
|
|
2900
|
+
});
|
|
2901
|
+
});
|
|
2902
|
+
\`\`\`
|
|
2903
|
+
|
|
2904
|
+
## Database
|
|
2905
|
+
- Uses Kysely with BunSqliteDialect for SQLite
|
|
2906
|
+
- Each plugin manages its own tables via migrations
|
|
2907
|
+
- Migrations are numbered: \`001_create_users.ts\`, \`002_add_email.ts\`
|
|
2908
|
+
|
|
2909
|
+
## Error Handling
|
|
2910
|
+
Use \`ctx.errors\` factories:
|
|
2911
|
+
- \`ctx.errors.BadRequest(message)\`
|
|
2912
|
+
- \`ctx.errors.Unauthorized(message)\`
|
|
2913
|
+
- \`ctx.errors.Forbidden(message)\`
|
|
2914
|
+
- \`ctx.errors.NotFound(message)\`
|
|
2915
|
+
- \`ctx.errors.Conflict(message)\`
|
|
2916
|
+
|
|
2917
|
+
## API Client (Frontend)
|
|
2918
|
+
|
|
2919
|
+
ALWAYS use the generated API client instead of raw \`fetch()\` calls!
|
|
2920
|
+
|
|
2921
|
+
Generate the client:
|
|
2922
|
+
\`\`\`bash
|
|
2923
|
+
donkeylabs generate # or use generate_client MCP tool
|
|
2924
|
+
\`\`\`
|
|
2925
|
+
|
|
2926
|
+
Usage:
|
|
2927
|
+
\`\`\`typescript
|
|
2928
|
+
// DON'T do this:
|
|
2929
|
+
const res = await fetch("http://localhost:3000/users.create", { ... });
|
|
2930
|
+
|
|
2931
|
+
// DO this instead:
|
|
2932
|
+
import { createApiClient } from "./client/api";
|
|
2933
|
+
|
|
2934
|
+
const api = createApiClient({ baseUrl: "http://localhost:3000" });
|
|
2935
|
+
const user = await api.users.create({ email: "test@example.com" });
|
|
2936
|
+
\`\`\`
|
|
2937
|
+
|
|
2938
|
+
## Middleware
|
|
2939
|
+
|
|
2940
|
+
Add middleware to plugins for cross-cutting concerns (auth, logging, rate limiting):
|
|
2941
|
+
|
|
2942
|
+
\`\`\`typescript
|
|
2943
|
+
// In plugin index.ts
|
|
2944
|
+
import { createMiddleware } from "@donkeylabs/server";
|
|
2945
|
+
|
|
2946
|
+
const requireAuthMiddleware = createMiddleware({
|
|
2947
|
+
name: "requireAuth",
|
|
2948
|
+
handler: async (ctx, next) => {
|
|
2949
|
+
const token = ctx.headers.get("Authorization");
|
|
2950
|
+
if (!token) throw ctx.errors.Unauthorized("No token");
|
|
2951
|
+
ctx.user = await validateToken(token);
|
|
2952
|
+
return next();
|
|
2953
|
+
},
|
|
2954
|
+
});
|
|
2955
|
+
|
|
2956
|
+
export const authPlugin = createPlugin.define({
|
|
2957
|
+
name: "auth",
|
|
2958
|
+
middleware: {
|
|
2959
|
+
requireAuth: requireAuthMiddleware,
|
|
2960
|
+
},
|
|
2961
|
+
// ...
|
|
2962
|
+
});
|
|
2963
|
+
\`\`\`
|
|
2964
|
+
|
|
2965
|
+
Usage in routes:
|
|
2966
|
+
\`\`\`typescript
|
|
2967
|
+
router.route("profile")
|
|
2968
|
+
.requireAuth() // Apply middleware
|
|
2969
|
+
.typed({
|
|
2970
|
+
handle: async (input, ctx) => {
|
|
2971
|
+
return ctx.user; // User is now available
|
|
2972
|
+
},
|
|
2973
|
+
});
|
|
2974
|
+
\`\`\`
|
|
2975
|
+
|
|
2976
|
+
## Custom Handlers
|
|
2977
|
+
|
|
2978
|
+
Create custom handler types beyond \`typed\` and \`raw\`:
|
|
2979
|
+
|
|
2980
|
+
\`\`\`typescript
|
|
2981
|
+
import { createHandler } from "@donkeylabs/server";
|
|
2982
|
+
|
|
2983
|
+
type StreamingFn = (data: any, ctx: ServerContext) => AsyncGenerator<string>;
|
|
2984
|
+
|
|
2985
|
+
const StreamingHandler = createHandler<StreamingFn>(
|
|
2986
|
+
async (req, def, handle, ctx) => {
|
|
2987
|
+
const data = await req.json();
|
|
2988
|
+
const stream = handle(data, ctx);
|
|
2989
|
+
|
|
2990
|
+
return new Response(
|
|
2991
|
+
new ReadableStream({
|
|
2992
|
+
async start(controller) {
|
|
2993
|
+
for await (const chunk of stream) {
|
|
2994
|
+
controller.enqueue(new TextEncoder().encode(chunk));
|
|
2995
|
+
}
|
|
2996
|
+
controller.close();
|
|
2997
|
+
},
|
|
2998
|
+
})
|
|
2999
|
+
);
|
|
3000
|
+
}
|
|
3001
|
+
);
|
|
3002
|
+
|
|
3003
|
+
export const myPlugin = createPlugin.define({
|
|
3004
|
+
handlers: {
|
|
3005
|
+
streaming: StreamingHandler,
|
|
3006
|
+
},
|
|
3007
|
+
// ...
|
|
3008
|
+
});
|
|
3009
|
+
\`\`\`
|
|
3010
|
+
|
|
3011
|
+
Usage:
|
|
3012
|
+
\`\`\`typescript
|
|
3013
|
+
router.route("stream").streaming({
|
|
3014
|
+
handle: async function* (data, ctx) {
|
|
3015
|
+
yield "Starting...";
|
|
3016
|
+
yield "Processing...";
|
|
3017
|
+
yield "Done!";
|
|
3018
|
+
},
|
|
3019
|
+
});
|
|
3020
|
+
\`\`\`
|
|
3021
|
+
|
|
3022
|
+
## After Making Changes
|
|
3023
|
+
Always run \`donkeylabs generate\` to update types after:
|
|
3024
|
+
- Adding/modifying plugins
|
|
3025
|
+
- Adding routes in src/routes/
|
|
3026
|
+
- Adding custom handlers
|
|
3027
|
+
- Adding middleware
|
|
3028
|
+
|
|
3029
|
+
## Common Patterns
|
|
3030
|
+
|
|
3031
|
+
### Creating a Plugin with Database
|
|
3032
|
+
\`\`\`typescript
|
|
3033
|
+
import { createPlugin } from "@donkeylabs/server";
|
|
3034
|
+
import type { DB as MySchema } from "./schema";
|
|
3035
|
+
|
|
3036
|
+
export const myPlugin = createPlugin
|
|
3037
|
+
.withSchema<MySchema>()
|
|
3038
|
+
.define({
|
|
3039
|
+
name: "my-plugin",
|
|
3040
|
+
version: "1.0.0",
|
|
3041
|
+
service: async (ctx) => ({
|
|
3042
|
+
getData: async () => {
|
|
3043
|
+
return ctx.db.selectFrom("my_table").selectAll().execute();
|
|
3044
|
+
}
|
|
3045
|
+
}),
|
|
3046
|
+
});
|
|
3047
|
+
\`\`\`
|
|
3048
|
+
|
|
3049
|
+
### Creating a Typed Route
|
|
3050
|
+
\`\`\`typescript
|
|
3051
|
+
router.route("getUser").typed({
|
|
3052
|
+
input: z.object({ id: z.string() }),
|
|
3053
|
+
output: z.object({ user: userSchema }),
|
|
3054
|
+
handle: async (input, ctx) => {
|
|
3055
|
+
const user = await ctx.plugins.users.getById(input.id);
|
|
3056
|
+
if (!user) throw ctx.errors.NotFound("User not found");
|
|
3057
|
+
return { user };
|
|
3058
|
+
},
|
|
3059
|
+
});
|
|
3060
|
+
\`\`\`
|
|
3061
|
+
|
|
3062
|
+
### Plugin Dependencies
|
|
3063
|
+
\`\`\`typescript
|
|
3064
|
+
export const statsPlugin = createPlugin.define({
|
|
3065
|
+
name: "stats",
|
|
3066
|
+
dependencies: ["auth", "counter"] as const,
|
|
3067
|
+
service: async (ctx) => ({
|
|
3068
|
+
getStats: async () => {
|
|
3069
|
+
// Access dependencies via ctx.deps
|
|
3070
|
+
const user = ctx.deps.auth.getCurrentUser();
|
|
3071
|
+
const count = ctx.deps.counter.get("visits");
|
|
3072
|
+
return { user, count };
|
|
3073
|
+
}
|
|
3074
|
+
}),
|
|
3075
|
+
});
|
|
3076
|
+
\`\`\`
|
|
3077
|
+
|
|
3078
|
+
## Error Handling
|
|
3079
|
+
Use \`ctx.errors\` factories:
|
|
3080
|
+
- \`ctx.errors.BadRequest(message)\`
|
|
3081
|
+
- \`ctx.errors.Unauthorized(message)\`
|
|
3082
|
+
- \`ctx.errors.Forbidden(message)\`
|
|
3083
|
+
- \`ctx.errors.NotFound(message)\`
|
|
3084
|
+
- \`ctx.errors.Conflict(message)\`
|
|
3085
|
+
|
|
3086
|
+
## After Making Changes
|
|
3087
|
+
Always run \`donkeylabs generate\` to update types after:
|
|
3088
|
+
- Adding/modifying plugins
|
|
3089
|
+
- Adding custom handlers
|
|
3090
|
+
- Adding middleware
|
|
3091
|
+
`;
|
|
3092
|
+
|
|
3093
|
+
const server = new Server(
|
|
3094
|
+
{
|
|
3095
|
+
name: "donkeylabs-server",
|
|
3096
|
+
version: "0.1.0",
|
|
3097
|
+
},
|
|
3098
|
+
{
|
|
3099
|
+
capabilities: {
|
|
3100
|
+
tools: {},
|
|
3101
|
+
resources: {},
|
|
3102
|
+
},
|
|
3103
|
+
}
|
|
3104
|
+
);
|
|
3105
|
+
|
|
3106
|
+
// Resource handlers
|
|
3107
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
3108
|
+
resources: [
|
|
3109
|
+
{
|
|
3110
|
+
uri: "donkeylabs://context",
|
|
3111
|
+
name: "Project Development Guide",
|
|
3112
|
+
description: "Conventions, patterns, and best practices for @donkeylabs/server development",
|
|
3113
|
+
mimeType: "text/markdown",
|
|
3114
|
+
},
|
|
3115
|
+
],
|
|
3116
|
+
}));
|
|
3117
|
+
|
|
3118
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
3119
|
+
const { uri } = request.params;
|
|
3120
|
+
|
|
3121
|
+
if (uri === "donkeylabs://context") {
|
|
3122
|
+
return {
|
|
3123
|
+
contents: [
|
|
3124
|
+
{
|
|
3125
|
+
uri,
|
|
3126
|
+
mimeType: "text/markdown",
|
|
3127
|
+
text: PROJECT_CONTEXT,
|
|
3128
|
+
},
|
|
3129
|
+
],
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
throw new Error(`Unknown resource: ${uri}`);
|
|
3134
|
+
});
|
|
3135
|
+
|
|
3136
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
3137
|
+
tools,
|
|
3138
|
+
}));
|
|
3139
|
+
|
|
3140
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3141
|
+
const { name, arguments: args } = request.params;
|
|
3142
|
+
|
|
3143
|
+
try {
|
|
3144
|
+
let result: string;
|
|
3145
|
+
|
|
3146
|
+
switch (name) {
|
|
3147
|
+
case "init_project":
|
|
3148
|
+
result = await initProject(args as any);
|
|
3149
|
+
break;
|
|
3150
|
+
case "create_plugin":
|
|
3151
|
+
result = await createPlugin(args as any);
|
|
3152
|
+
break;
|
|
3153
|
+
case "add_route":
|
|
3154
|
+
result = await addRoute(args as any);
|
|
3155
|
+
break;
|
|
3156
|
+
case "add_migration":
|
|
3157
|
+
result = await addMigration(args as any);
|
|
3158
|
+
break;
|
|
3159
|
+
case "generate_types":
|
|
3160
|
+
result = await generateTypes();
|
|
3161
|
+
break;
|
|
3162
|
+
case "list_plugins":
|
|
3163
|
+
result = await listPlugins();
|
|
3164
|
+
break;
|
|
3165
|
+
case "get_project_info":
|
|
3166
|
+
result = await getProjectInfo();
|
|
3167
|
+
break;
|
|
3168
|
+
case "add_service_method":
|
|
3169
|
+
result = await addServiceMethod(args as any);
|
|
3170
|
+
break;
|
|
3171
|
+
case "create_router":
|
|
3172
|
+
result = await createRouter(args as any);
|
|
3173
|
+
break;
|
|
3174
|
+
case "validate_project":
|
|
3175
|
+
result = await validateProject();
|
|
3176
|
+
break;
|
|
3177
|
+
case "register_plugin":
|
|
3178
|
+
result = await registerPlugin(args as any);
|
|
3179
|
+
break;
|
|
3180
|
+
case "run_migrations":
|
|
3181
|
+
result = await runMigrations(args as any);
|
|
3182
|
+
break;
|
|
3183
|
+
case "setup_database":
|
|
3184
|
+
result = await setupDatabase(args as any);
|
|
3185
|
+
break;
|
|
3186
|
+
case "list_routes":
|
|
3187
|
+
result = await listRoutes(args as any);
|
|
3188
|
+
break;
|
|
3189
|
+
case "add_route_file":
|
|
3190
|
+
result = await addRouteFile(args as any);
|
|
3191
|
+
break;
|
|
3192
|
+
case "create_model":
|
|
3193
|
+
result = await createModel(args as any);
|
|
3194
|
+
break;
|
|
3195
|
+
case "add_unit_test":
|
|
3196
|
+
result = await addUnitTest(args as any);
|
|
3197
|
+
break;
|
|
3198
|
+
case "add_integration_test":
|
|
3199
|
+
result = await addIntegrationTest(args as any);
|
|
3200
|
+
break;
|
|
3201
|
+
case "generate_client":
|
|
3202
|
+
result = await generateClientTool(args as any);
|
|
3203
|
+
break;
|
|
3204
|
+
case "add_middleware":
|
|
3205
|
+
result = await addMiddleware(args as any);
|
|
3206
|
+
break;
|
|
3207
|
+
case "add_custom_handler":
|
|
3208
|
+
result = await addCustomHandler(args as any);
|
|
3209
|
+
break;
|
|
3210
|
+
case "create_custom_error":
|
|
3211
|
+
result = await createCustomError(args as any);
|
|
3212
|
+
break;
|
|
3213
|
+
case "validate_code":
|
|
3214
|
+
result = await validateCode(args as any);
|
|
3215
|
+
break;
|
|
3216
|
+
default:
|
|
3217
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
return {
|
|
3221
|
+
content: [{ type: "text", text: result }],
|
|
3222
|
+
};
|
|
3223
|
+
} catch (error: any) {
|
|
3224
|
+
return {
|
|
3225
|
+
content: [{ type: "text", text: `Error: ${error.message}` }],
|
|
3226
|
+
isError: true,
|
|
3227
|
+
};
|
|
3228
|
+
}
|
|
3229
|
+
});
|
|
3230
|
+
|
|
3231
|
+
// Start the server
|
|
3232
|
+
async function main() {
|
|
3233
|
+
const transport = new StdioServerTransport();
|
|
3234
|
+
await server.connect(transport);
|
|
3235
|
+
console.error("@donkeylabs/server MCP server running on stdio");
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
main().catch(console.error);
|