@donkeylabs/mcp 0.4.0
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/LICENSE +21 -0
- package/README.md +131 -0
- package/package.json +53 -0
- package/src/server.ts +2524 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,2524 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* @donkeylabs/mcp - MCP Server for AI-assisted development
|
|
4
|
+
*
|
|
5
|
+
* Provides tools and resources for AI assistants to scaffold and manage @donkeylabs/server projects.
|
|
6
|
+
* Helps agents follow best practices and avoid creating unmaintainable code.
|
|
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
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
17
|
+
import { join, dirname, basename, relative } from "path";
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync } from "fs";
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// PROJECT DETECTION & HELPERS
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
function findProjectRoot(startDir: string = process.cwd()): string | null {
|
|
25
|
+
let dir = startDir;
|
|
26
|
+
while (dir !== "/") {
|
|
27
|
+
if (existsSync(join(dir, "donkeylabs.config.ts"))) {
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
const pkgPath = join(dir, "package.json");
|
|
31
|
+
if (existsSync(pkgPath)) {
|
|
32
|
+
try {
|
|
33
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
34
|
+
if (
|
|
35
|
+
pkg.dependencies?.["@donkeylabs/server"] ||
|
|
36
|
+
pkg.devDependencies?.["@donkeylabs/server"]
|
|
37
|
+
) {
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
dir = dirname(dir);
|
|
43
|
+
}
|
|
44
|
+
return process.cwd();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const projectRoot = findProjectRoot() || process.cwd();
|
|
48
|
+
|
|
49
|
+
// =============================================================================
|
|
50
|
+
// PROJECT CONFIG DETECTION
|
|
51
|
+
// =============================================================================
|
|
52
|
+
|
|
53
|
+
interface ProjectConfig {
|
|
54
|
+
adapter?: string;
|
|
55
|
+
pluginsDir: string;
|
|
56
|
+
routesDir: string;
|
|
57
|
+
clientOutput?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function detectProjectConfig(): ProjectConfig {
|
|
61
|
+
const configPath = join(projectRoot, "donkeylabs.config.ts");
|
|
62
|
+
|
|
63
|
+
// Default paths for standalone server
|
|
64
|
+
let config: ProjectConfig = {
|
|
65
|
+
pluginsDir: "src/plugins",
|
|
66
|
+
routesDir: "src/routes",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (existsSync(configPath)) {
|
|
70
|
+
try {
|
|
71
|
+
const content = readFileSync(configPath, "utf-8");
|
|
72
|
+
|
|
73
|
+
// Check for SvelteKit adapter
|
|
74
|
+
if (content.includes("adapter-sveltekit") || content.includes("@donkeylabs/adapter-sveltekit")) {
|
|
75
|
+
config.adapter = "sveltekit";
|
|
76
|
+
config.pluginsDir = "src/server/plugins";
|
|
77
|
+
config.routesDir = "src/server/routes";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check for custom plugins path
|
|
81
|
+
const pluginsMatch = content.match(/plugins:\s*\["([^"]+)"/);
|
|
82
|
+
if (pluginsMatch) {
|
|
83
|
+
const pluginPath = pluginsMatch[1];
|
|
84
|
+
if (pluginPath.includes("src/server/plugins")) {
|
|
85
|
+
config.pluginsDir = "src/server/plugins";
|
|
86
|
+
} else if (pluginPath.includes("src/plugins")) {
|
|
87
|
+
config.pluginsDir = "src/plugins";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check for custom routes path
|
|
92
|
+
const routesMatch = content.match(/routes:\s*"([^"]+)"/);
|
|
93
|
+
if (routesMatch) {
|
|
94
|
+
const routePath = routesMatch[1];
|
|
95
|
+
if (routePath.includes("src/server/routes")) {
|
|
96
|
+
config.routesDir = "src/server/routes";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check for client output
|
|
101
|
+
const clientMatch = content.match(/client:\s*\{[^}]*output:\s*"([^"]+)"/);
|
|
102
|
+
if (clientMatch) {
|
|
103
|
+
config.clientOutput = clientMatch[1];
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// Use defaults on error
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return config;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const projectConfig = detectProjectConfig();
|
|
114
|
+
|
|
115
|
+
function toPascalCase(str: string): string {
|
|
116
|
+
return str
|
|
117
|
+
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => word.toUpperCase())
|
|
118
|
+
.replace(/[\s-_]+/g, "");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toKebabCase(str: string): string {
|
|
122
|
+
return str
|
|
123
|
+
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
124
|
+
.replace(/[\s_]+/g, "-")
|
|
125
|
+
.toLowerCase();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isCamelCase(str: string): boolean {
|
|
129
|
+
return /^[a-z][a-zA-Z0-9]*$/.test(str);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// =============================================================================
|
|
133
|
+
// VALIDATION HELPERS
|
|
134
|
+
// =============================================================================
|
|
135
|
+
|
|
136
|
+
interface ValidationResult {
|
|
137
|
+
valid: boolean;
|
|
138
|
+
error?: string;
|
|
139
|
+
suggestion?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateProjectExists(): ValidationResult {
|
|
143
|
+
const configPath = join(projectRoot, "donkeylabs.config.ts");
|
|
144
|
+
const pkgPath = join(projectRoot, "package.json");
|
|
145
|
+
|
|
146
|
+
if (!existsSync(configPath) && !existsSync(pkgPath)) {
|
|
147
|
+
return {
|
|
148
|
+
valid: false,
|
|
149
|
+
error: "No @donkeylabs/server project found",
|
|
150
|
+
suggestion: "Run 'bunx @donkeylabs/cli init' to create a new project, or navigate to an existing project directory.",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { valid: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function validatePluginName(name: string): ValidationResult {
|
|
157
|
+
if (!isCamelCase(name)) {
|
|
158
|
+
return {
|
|
159
|
+
valid: false,
|
|
160
|
+
error: `Plugin name "${name}" is not in camelCase`,
|
|
161
|
+
suggestion: `Use camelCase naming (e.g., "${name.charAt(0).toLowerCase()}${name.slice(1).replace(/[-_\s]+(.)/g, (_, c) => c.toUpperCase())}")`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return { valid: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function validatePluginExists(name: string): ValidationResult {
|
|
168
|
+
const pluginDir = join(projectRoot, projectConfig.pluginsDir, name);
|
|
169
|
+
if (!existsSync(pluginDir)) {
|
|
170
|
+
const availablePlugins = listAvailablePlugins();
|
|
171
|
+
return {
|
|
172
|
+
valid: false,
|
|
173
|
+
error: `Plugin "${name}" not found at ${projectConfig.pluginsDir}/${name}/`,
|
|
174
|
+
suggestion: availablePlugins.length > 0
|
|
175
|
+
? `Available plugins: ${availablePlugins.join(", ")}`
|
|
176
|
+
: "Create a plugin first using the create_plugin tool.",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return { valid: true };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function validateRouterExists(routerFile: string): ValidationResult {
|
|
183
|
+
const fullPath = join(projectRoot, routerFile);
|
|
184
|
+
if (!existsSync(fullPath)) {
|
|
185
|
+
const availableRouters = listAvailableRouters();
|
|
186
|
+
return {
|
|
187
|
+
valid: false,
|
|
188
|
+
error: `Router file not found: ${routerFile}`,
|
|
189
|
+
suggestion: availableRouters.length > 0
|
|
190
|
+
? `Available routers:\n${availableRouters.map(r => ` - ${r}`).join("\n")}\n\nOr use create_router to create a new one.`
|
|
191
|
+
: "Create a router first using the create_router tool.",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { valid: true };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function listAvailablePlugins(): string[] {
|
|
198
|
+
const pluginsDir = join(projectRoot, projectConfig.pluginsDir);
|
|
199
|
+
if (!existsSync(pluginsDir)) return [];
|
|
200
|
+
|
|
201
|
+
return readdirSync(pluginsDir)
|
|
202
|
+
.filter((d) => statSync(join(pluginsDir, d)).isDirectory())
|
|
203
|
+
.filter((d) => existsSync(join(pluginsDir, d, "index.ts")));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function listAvailableRouters(): string[] {
|
|
207
|
+
const routesDir = join(projectRoot, projectConfig.routesDir);
|
|
208
|
+
if (!existsSync(routesDir)) {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
return findRouterFiles(routesDir, projectConfig.routesDir);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function findRouterFiles(dir: string, prefix: string): string[] {
|
|
215
|
+
const files: string[] = [];
|
|
216
|
+
|
|
217
|
+
function scan(currentDir: string, currentPrefix: string) {
|
|
218
|
+
for (const entry of readdirSync(currentDir)) {
|
|
219
|
+
const fullPath = join(currentDir, entry);
|
|
220
|
+
const relativePath = `${currentPrefix}/${entry}`;
|
|
221
|
+
|
|
222
|
+
if (statSync(fullPath).isDirectory()) {
|
|
223
|
+
const indexPath = join(fullPath, "index.ts");
|
|
224
|
+
if (existsSync(indexPath)) {
|
|
225
|
+
files.push(`${relativePath}/index.ts`);
|
|
226
|
+
}
|
|
227
|
+
scan(fullPath, relativePath);
|
|
228
|
+
} else if (entry.endsWith(".ts") && !entry.includes(".test.") && !entry.includes(".spec.")) {
|
|
229
|
+
files.push(relativePath);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
scan(dir, prefix);
|
|
235
|
+
return files;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function formatError(error: string, context?: string, suggestion?: string, relatedTool?: string): string {
|
|
239
|
+
let message = `## Error: ${error}\n`;
|
|
240
|
+
|
|
241
|
+
if (context) {
|
|
242
|
+
message += `\n### Context\n${context}\n`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (suggestion) {
|
|
246
|
+
message += `\n### How to Fix\n${suggestion}\n`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (relatedTool) {
|
|
250
|
+
message += `\n### Related Tool\nConsider using: \`${relatedTool}\`\n`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return message;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// =============================================================================
|
|
257
|
+
// RESOURCE DEFINITIONS
|
|
258
|
+
// =============================================================================
|
|
259
|
+
|
|
260
|
+
// Resolve docs directory relative to this file's location
|
|
261
|
+
// This handles running from different working directories
|
|
262
|
+
function findDocsDir(): string {
|
|
263
|
+
const possiblePaths = [
|
|
264
|
+
// When running from monorepo
|
|
265
|
+
join(import.meta.dir, "..", "..", "..", "server", "docs"),
|
|
266
|
+
// When installed as package
|
|
267
|
+
join(import.meta.dir, "..", "..", "node_modules", "@donkeylabs", "server", "docs"),
|
|
268
|
+
// Fallback to trying to find it from cwd
|
|
269
|
+
join(process.cwd(), "node_modules", "@donkeylabs", "server", "docs"),
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
for (const p of possiblePaths) {
|
|
273
|
+
if (existsSync(p)) {
|
|
274
|
+
return p;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Return first path even if not found (will show appropriate error later)
|
|
279
|
+
return possiblePaths[0];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const DOCS_DIR = findDocsDir();
|
|
283
|
+
|
|
284
|
+
const RESOURCES = [
|
|
285
|
+
{
|
|
286
|
+
uri: "donkeylabs://docs/project-structure",
|
|
287
|
+
name: "Project Structure",
|
|
288
|
+
description: "Canonical directory layout, naming conventions, and organizational patterns",
|
|
289
|
+
mimeType: "text/markdown",
|
|
290
|
+
docFile: "project-structure.md",
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
uri: "donkeylabs://docs/plugins",
|
|
294
|
+
name: "Plugins Guide",
|
|
295
|
+
description: "Creating plugins with services, database schemas, middleware, and dependencies",
|
|
296
|
+
mimeType: "text/markdown",
|
|
297
|
+
docFile: "plugins.md",
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
uri: "donkeylabs://docs/router",
|
|
301
|
+
name: "Router & Routes",
|
|
302
|
+
description: "Route definitions, handlers (typed, raw, class-based), middleware chaining",
|
|
303
|
+
mimeType: "text/markdown",
|
|
304
|
+
docFile: "router.md",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
uri: "donkeylabs://docs/handlers",
|
|
308
|
+
name: "Custom Handlers",
|
|
309
|
+
description: "Creating custom request handlers for specialized processing",
|
|
310
|
+
mimeType: "text/markdown",
|
|
311
|
+
docFile: "handlers.md",
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
uri: "donkeylabs://docs/middleware",
|
|
315
|
+
name: "Middleware",
|
|
316
|
+
description: "Creating and using middleware for cross-cutting concerns",
|
|
317
|
+
mimeType: "text/markdown",
|
|
318
|
+
docFile: "middleware.md",
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
uri: "donkeylabs://docs/core-services",
|
|
322
|
+
name: "Core Services Overview",
|
|
323
|
+
description: "Overview of all built-in core services",
|
|
324
|
+
mimeType: "text/markdown",
|
|
325
|
+
docFile: "core-services.md",
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
uri: "donkeylabs://docs/logger",
|
|
329
|
+
name: "Logger Service",
|
|
330
|
+
description: "Structured logging with child loggers and context",
|
|
331
|
+
mimeType: "text/markdown",
|
|
332
|
+
docFile: "logger.md",
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
uri: "donkeylabs://docs/cache",
|
|
336
|
+
name: "Cache Service",
|
|
337
|
+
description: "In-memory caching with TTL support",
|
|
338
|
+
mimeType: "text/markdown",
|
|
339
|
+
docFile: "cache.md",
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
uri: "donkeylabs://docs/events",
|
|
343
|
+
name: "Events Service",
|
|
344
|
+
description: "Pub/sub event system with pattern matching",
|
|
345
|
+
mimeType: "text/markdown",
|
|
346
|
+
docFile: "events.md",
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
uri: "donkeylabs://docs/cron",
|
|
350
|
+
name: "Cron Service",
|
|
351
|
+
description: "Scheduled task execution with cron expressions",
|
|
352
|
+
mimeType: "text/markdown",
|
|
353
|
+
docFile: "cron.md",
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
uri: "donkeylabs://docs/jobs",
|
|
357
|
+
name: "Jobs Service",
|
|
358
|
+
description: "Background job queue with retries and scheduling",
|
|
359
|
+
mimeType: "text/markdown",
|
|
360
|
+
docFile: "jobs.md",
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
uri: "donkeylabs://docs/sse",
|
|
364
|
+
name: "SSE Service",
|
|
365
|
+
description: "Server-Sent Events for real-time updates",
|
|
366
|
+
mimeType: "text/markdown",
|
|
367
|
+
docFile: "sse.md",
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
uri: "donkeylabs://docs/rate-limiter",
|
|
371
|
+
name: "Rate Limiter",
|
|
372
|
+
description: "Per-key request rate limiting",
|
|
373
|
+
mimeType: "text/markdown",
|
|
374
|
+
docFile: "rate-limiter.md",
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
uri: "donkeylabs://docs/errors",
|
|
378
|
+
name: "Error Handling",
|
|
379
|
+
description: "HTTP errors and custom error types",
|
|
380
|
+
mimeType: "text/markdown",
|
|
381
|
+
docFile: "errors.md",
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
uri: "donkeylabs://docs/cli",
|
|
385
|
+
name: "CLI Commands",
|
|
386
|
+
description: "Command-line interface for code generation and project management",
|
|
387
|
+
mimeType: "text/markdown",
|
|
388
|
+
docFile: "cli.md",
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
uri: "donkeylabs://docs/api-client",
|
|
392
|
+
name: "API Client Generation",
|
|
393
|
+
description: "Type-safe client generation from route definitions",
|
|
394
|
+
mimeType: "text/markdown",
|
|
395
|
+
docFile: "api-client.md",
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
uri: "donkeylabs://project/current",
|
|
399
|
+
name: "Current Project Analysis",
|
|
400
|
+
description: "Dynamic analysis of the current project structure",
|
|
401
|
+
mimeType: "text/markdown",
|
|
402
|
+
dynamic: true,
|
|
403
|
+
},
|
|
404
|
+
];
|
|
405
|
+
|
|
406
|
+
async function readResource(uri: string): Promise<string> {
|
|
407
|
+
const resource = RESOURCES.find((r) => r.uri === uri);
|
|
408
|
+
if (!resource) {
|
|
409
|
+
return `Resource not found: ${uri}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Dynamic resources
|
|
413
|
+
if (resource.dynamic) {
|
|
414
|
+
if (uri === "donkeylabs://project/current") {
|
|
415
|
+
return await generateProjectAnalysis();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Static doc files
|
|
420
|
+
if (resource.docFile) {
|
|
421
|
+
const docPath = join(DOCS_DIR, resource.docFile);
|
|
422
|
+
if (existsSync(docPath)) {
|
|
423
|
+
return readFileSync(docPath, "utf-8");
|
|
424
|
+
}
|
|
425
|
+
return `Documentation file not found: ${resource.docFile}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return `Resource content not available: ${uri}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function generateProjectAnalysis(): Promise<string> {
|
|
432
|
+
const validation = validateProjectExists();
|
|
433
|
+
if (!validation.valid) {
|
|
434
|
+
return formatError(validation.error!, undefined, validation.suggestion);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let analysis = `# Project Analysis: ${basename(projectRoot)}\n\n`;
|
|
438
|
+
|
|
439
|
+
// Config
|
|
440
|
+
const configPath = join(projectRoot, "donkeylabs.config.ts");
|
|
441
|
+
analysis += `## Configuration\n`;
|
|
442
|
+
if (existsSync(configPath)) {
|
|
443
|
+
analysis += `- Config file: donkeylabs.config.ts\n`;
|
|
444
|
+
if (projectConfig.adapter) {
|
|
445
|
+
analysis += `- Adapter: ${projectConfig.adapter}\n`;
|
|
446
|
+
}
|
|
447
|
+
analysis += `- Plugins directory: ${projectConfig.pluginsDir}/\n`;
|
|
448
|
+
analysis += `- Routes directory: ${projectConfig.routesDir}/\n`;
|
|
449
|
+
if (projectConfig.clientOutput) {
|
|
450
|
+
analysis += `- Client output: ${projectConfig.clientOutput}\n`;
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
analysis += `- No donkeylabs.config.ts found\n`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Plugins
|
|
457
|
+
const plugins = listAvailablePlugins();
|
|
458
|
+
analysis += `\n## Plugins (${plugins.length})\n`;
|
|
459
|
+
if (plugins.length === 0) {
|
|
460
|
+
analysis += `No plugins found. Create one with \`create_plugin\` tool.\n`;
|
|
461
|
+
} else {
|
|
462
|
+
for (const plugin of plugins) {
|
|
463
|
+
const pluginPath = join(projectRoot, projectConfig.pluginsDir, plugin, "index.ts");
|
|
464
|
+
const content = readFileSync(pluginPath, "utf-8");
|
|
465
|
+
|
|
466
|
+
const hasMigrations = existsSync(join(projectRoot, projectConfig.pluginsDir, plugin, "migrations"));
|
|
467
|
+
const hasSchema = existsSync(join(projectRoot, projectConfig.pluginsDir, plugin, "schema.ts"));
|
|
468
|
+
const depsMatch = content.match(/dependencies:\s*\[([^\]]*)\]/);
|
|
469
|
+
|
|
470
|
+
analysis += `\n### ${plugin}\n`;
|
|
471
|
+
analysis += `- Path: ${projectConfig.pluginsDir}/${plugin}/\n`;
|
|
472
|
+
analysis += `- Database: ${hasSchema || hasMigrations ? "yes" : "no"}\n`;
|
|
473
|
+
if (depsMatch && depsMatch[1].trim()) {
|
|
474
|
+
analysis += `- Dependencies: ${depsMatch[1].trim()}\n`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Extract service methods
|
|
478
|
+
const serviceMatch = content.match(/service:\s*async\s*\([^)]*\)\s*=>\s*\({([^}]+)\}/s);
|
|
479
|
+
if (serviceMatch) {
|
|
480
|
+
const methods = [...serviceMatch[1].matchAll(/(\w+):\s*(?:async\s*)?\(/g)].map(m => m[1]);
|
|
481
|
+
if (methods.length > 0) {
|
|
482
|
+
analysis += `- Methods: ${methods.join(", ")}\n`;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Routes
|
|
489
|
+
const routers = listAvailableRouters();
|
|
490
|
+
analysis += `\n## Routes (${routers.length} files)\n`;
|
|
491
|
+
if (routers.length === 0) {
|
|
492
|
+
analysis += `No route files found. Create one with \`create_router\` tool.\n`;
|
|
493
|
+
} else {
|
|
494
|
+
for (const router of routers.slice(0, 10)) {
|
|
495
|
+
analysis += `- ${router}\n`;
|
|
496
|
+
}
|
|
497
|
+
if (routers.length > 10) {
|
|
498
|
+
analysis += `- ... and ${routers.length - 10} more\n`;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Generated types
|
|
503
|
+
const genDir = join(projectRoot, ".@donkeylabs/server");
|
|
504
|
+
analysis += `\n## Generated Types\n`;
|
|
505
|
+
if (existsSync(genDir)) {
|
|
506
|
+
analysis += `- Output: .@donkeylabs/server/\n`;
|
|
507
|
+
analysis += `- Run \`donkeylabs generate\` to regenerate after changes\n`;
|
|
508
|
+
} else {
|
|
509
|
+
analysis += `- Not generated yet. Run \`donkeylabs generate\` after adding plugins/routes.\n`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return analysis;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// =============================================================================
|
|
516
|
+
// TOOL DEFINITIONS
|
|
517
|
+
// =============================================================================
|
|
518
|
+
|
|
519
|
+
const tools = [
|
|
520
|
+
{
|
|
521
|
+
name: "get_project_info",
|
|
522
|
+
description: "Get current project structure, plugins, and routes. Run this first to understand the project.",
|
|
523
|
+
inputSchema: {
|
|
524
|
+
type: "object" as const,
|
|
525
|
+
properties: {},
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
name: "get_architecture_guidance",
|
|
530
|
+
description: "Get step-by-step guidance for implementing a feature. Describes which tools to use in what order.",
|
|
531
|
+
inputSchema: {
|
|
532
|
+
type: "object" as const,
|
|
533
|
+
properties: {
|
|
534
|
+
task: {
|
|
535
|
+
type: "string",
|
|
536
|
+
description: "What you want to accomplish (e.g., 'add user authentication', 'create CRUD for products')",
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
required: ["task"],
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
name: "create_plugin",
|
|
544
|
+
description: `Create a new plugin with correct directory structure.
|
|
545
|
+
|
|
546
|
+
**Plugins can implement:**
|
|
547
|
+
- **service**: Business logic methods (ctx.plugins.name.method())
|
|
548
|
+
- **init hook**: Cron jobs, event listeners, job registration
|
|
549
|
+
- **customErrors**: Typed error definitions
|
|
550
|
+
- **events**: Typed event schemas for SSE/pub-sub
|
|
551
|
+
- **middleware**: Request middleware (auth, rate limiting, etc.)
|
|
552
|
+
- **handlers**: Custom request handlers (beyond typed/raw)
|
|
553
|
+
|
|
554
|
+
**Plugin modifiers:**
|
|
555
|
+
- withSchema<T>(): Add typed database access
|
|
556
|
+
- withConfig<T>(): Make plugin configurable at registration`,
|
|
557
|
+
inputSchema: {
|
|
558
|
+
type: "object" as const,
|
|
559
|
+
properties: {
|
|
560
|
+
name: {
|
|
561
|
+
type: "string",
|
|
562
|
+
description: "Plugin name in camelCase (e.g., 'auth', 'users', 'orders')",
|
|
563
|
+
},
|
|
564
|
+
hasSchema: {
|
|
565
|
+
type: "boolean",
|
|
566
|
+
description: "Whether the plugin needs database schema (creates migrations folder and uses withSchema<>())",
|
|
567
|
+
default: false,
|
|
568
|
+
},
|
|
569
|
+
hasConfig: {
|
|
570
|
+
type: "boolean",
|
|
571
|
+
description: "Whether the plugin accepts configuration at registration (uses withConfig<>())",
|
|
572
|
+
default: false,
|
|
573
|
+
},
|
|
574
|
+
configFields: {
|
|
575
|
+
type: "string",
|
|
576
|
+
description: "If hasConfig=true, TypeScript interface fields for config (e.g., 'apiKey: string; sandbox?: boolean')",
|
|
577
|
+
},
|
|
578
|
+
dependencies: {
|
|
579
|
+
type: "array",
|
|
580
|
+
items: { type: "string" },
|
|
581
|
+
description: "Names of plugins this plugin depends on",
|
|
582
|
+
default: [],
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
required: ["name"],
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
name: "add_service_method",
|
|
590
|
+
description: "Add a method to a plugin's service. Service methods contain business logic.",
|
|
591
|
+
inputSchema: {
|
|
592
|
+
type: "object" as const,
|
|
593
|
+
properties: {
|
|
594
|
+
pluginName: {
|
|
595
|
+
type: "string",
|
|
596
|
+
description: "Name of the plugin",
|
|
597
|
+
},
|
|
598
|
+
methodName: {
|
|
599
|
+
type: "string",
|
|
600
|
+
description: "Name of the method (camelCase)",
|
|
601
|
+
},
|
|
602
|
+
params: {
|
|
603
|
+
type: "string",
|
|
604
|
+
description: "Method parameters (e.g., 'userId: string, data: { name: string }')",
|
|
605
|
+
},
|
|
606
|
+
returnType: {
|
|
607
|
+
type: "string",
|
|
608
|
+
description: "Return type (e.g., 'Promise<User>', '{ id: string }')",
|
|
609
|
+
},
|
|
610
|
+
implementation: {
|
|
611
|
+
type: "string",
|
|
612
|
+
description: "Method implementation code",
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
required: ["pluginName", "methodName", "implementation"],
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
name: "add_migration",
|
|
620
|
+
description: "Create a numbered SQL migration file for a plugin's database schema.",
|
|
621
|
+
inputSchema: {
|
|
622
|
+
type: "object" as const,
|
|
623
|
+
properties: {
|
|
624
|
+
pluginName: {
|
|
625
|
+
type: "string",
|
|
626
|
+
description: "Name of the plugin",
|
|
627
|
+
},
|
|
628
|
+
migrationName: {
|
|
629
|
+
type: "string",
|
|
630
|
+
description: "Descriptive name (e.g., 'create_users', 'add_email_column')",
|
|
631
|
+
},
|
|
632
|
+
upSql: {
|
|
633
|
+
type: "string",
|
|
634
|
+
description: "SQL for the up migration (CREATE TABLE, ALTER TABLE, etc.)",
|
|
635
|
+
},
|
|
636
|
+
downSql: {
|
|
637
|
+
type: "string",
|
|
638
|
+
description: "SQL for the down migration (DROP TABLE, etc.)",
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
required: ["pluginName", "migrationName", "upSql"],
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
name: "create_router",
|
|
646
|
+
description: "Create a new router file with proper structure. Routers group related routes.",
|
|
647
|
+
inputSchema: {
|
|
648
|
+
type: "object" as const,
|
|
649
|
+
properties: {
|
|
650
|
+
routerPath: {
|
|
651
|
+
type: "string",
|
|
652
|
+
description: "Path for new router (e.g., 'src/routes/users/index.ts')",
|
|
653
|
+
},
|
|
654
|
+
routerName: {
|
|
655
|
+
type: "string",
|
|
656
|
+
description: "Export name for router (e.g., 'usersRouter')",
|
|
657
|
+
},
|
|
658
|
+
prefix: {
|
|
659
|
+
type: "string",
|
|
660
|
+
description: "Route prefix - routes will be named 'prefix.routeName' (e.g., 'users')",
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
required: ["routerPath", "routerName", "prefix"],
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
name: "add_route",
|
|
668
|
+
description: "Add a new route to an existing router. Creates a class-based handler by default.",
|
|
669
|
+
inputSchema: {
|
|
670
|
+
type: "object" as const,
|
|
671
|
+
properties: {
|
|
672
|
+
routerFile: {
|
|
673
|
+
type: "string",
|
|
674
|
+
description: "Path to the router file (relative to project root)",
|
|
675
|
+
},
|
|
676
|
+
routeName: {
|
|
677
|
+
type: "string",
|
|
678
|
+
description: "Route name (e.g., 'list', 'get', 'create')",
|
|
679
|
+
},
|
|
680
|
+
inputSchema: {
|
|
681
|
+
type: "string",
|
|
682
|
+
description: "Zod schema for input validation (e.g., 'z.object({ id: z.string() })')",
|
|
683
|
+
},
|
|
684
|
+
outputType: {
|
|
685
|
+
type: "string",
|
|
686
|
+
description: "Zod schema for output validation (optional)",
|
|
687
|
+
},
|
|
688
|
+
handler: {
|
|
689
|
+
type: "string",
|
|
690
|
+
description: "Handler implementation code (the body of the handle method)",
|
|
691
|
+
},
|
|
692
|
+
useClassHandler: {
|
|
693
|
+
type: "boolean",
|
|
694
|
+
description: "Generate a class-based handler in handlers/ directory (recommended)",
|
|
695
|
+
default: true,
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
required: ["routerFile", "routeName", "handler"],
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
name: "add_handler_to_router",
|
|
703
|
+
description: "Register an existing handler class to a router (when handler already exists).",
|
|
704
|
+
inputSchema: {
|
|
705
|
+
type: "object" as const,
|
|
706
|
+
properties: {
|
|
707
|
+
routerFile: {
|
|
708
|
+
type: "string",
|
|
709
|
+
description: "Path to router file",
|
|
710
|
+
},
|
|
711
|
+
handlerName: {
|
|
712
|
+
type: "string",
|
|
713
|
+
description: "Handler class name to import",
|
|
714
|
+
},
|
|
715
|
+
handlerPath: {
|
|
716
|
+
type: "string",
|
|
717
|
+
description: "Relative import path for handler (e.g., './handlers/create-user')",
|
|
718
|
+
},
|
|
719
|
+
routeName: {
|
|
720
|
+
type: "string",
|
|
721
|
+
description: "Route name",
|
|
722
|
+
},
|
|
723
|
+
inputSchema: {
|
|
724
|
+
type: "string",
|
|
725
|
+
description: "Zod input schema",
|
|
726
|
+
},
|
|
727
|
+
outputSchema: {
|
|
728
|
+
type: "string",
|
|
729
|
+
description: "Zod output schema (optional)",
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
required: ["routerFile", "handlerName", "handlerPath", "routeName"],
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
name: "extend_plugin",
|
|
737
|
+
description: `Add custom errors, events, middleware, or handlers to an existing plugin.
|
|
738
|
+
|
|
739
|
+
**Extension types:**
|
|
740
|
+
- **error**: Custom error type (e.g., UserNotFound with status 404)
|
|
741
|
+
- **event**: Typed event for SSE/pub-sub
|
|
742
|
+
- **middleware**: Request middleware (auth, rate limiting, etc.)
|
|
743
|
+
- **handler**: Custom request handler (beyond typed/raw)`,
|
|
744
|
+
inputSchema: {
|
|
745
|
+
type: "object" as const,
|
|
746
|
+
properties: {
|
|
747
|
+
pluginName: {
|
|
748
|
+
type: "string",
|
|
749
|
+
description: "Name of the plugin",
|
|
750
|
+
},
|
|
751
|
+
extensionType: {
|
|
752
|
+
type: "string",
|
|
753
|
+
enum: ["error", "event", "middleware", "handler"],
|
|
754
|
+
description: "Type of extension to add",
|
|
755
|
+
},
|
|
756
|
+
name: {
|
|
757
|
+
type: "string",
|
|
758
|
+
description: "Name of the extension (e.g., 'UserNotFound' for error, 'user.created' for event, 'xml' for handler)",
|
|
759
|
+
},
|
|
760
|
+
params: {
|
|
761
|
+
type: "object",
|
|
762
|
+
description: "Extension-specific parameters",
|
|
763
|
+
properties: {
|
|
764
|
+
code: { type: "string", description: "Error code (for errors)" },
|
|
765
|
+
status: { type: "number", description: "HTTP status code (for errors)" },
|
|
766
|
+
message: { type: "string", description: "Default message (for errors)" },
|
|
767
|
+
schema: { type: "string", description: "Zod schema (for events)" },
|
|
768
|
+
implementation: { type: "string", description: "Middleware or handler implementation code" },
|
|
769
|
+
handlerSignature: { type: "string", description: "TypeScript type for handler function (for handlers)" },
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
required: ["pluginName", "extensionType", "name"],
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
name: "add_cron",
|
|
778
|
+
description: "Schedule a new cron job in a plugin's init hook.",
|
|
779
|
+
inputSchema: {
|
|
780
|
+
type: "object" as const,
|
|
781
|
+
properties: {
|
|
782
|
+
pluginName: {
|
|
783
|
+
type: "string",
|
|
784
|
+
description: "Plugin to add the cron job to",
|
|
785
|
+
},
|
|
786
|
+
name: {
|
|
787
|
+
type: "string",
|
|
788
|
+
description: "Cron job name (for logging/identification)",
|
|
789
|
+
},
|
|
790
|
+
schedule: {
|
|
791
|
+
type: "string",
|
|
792
|
+
description: "Cron schedule expression (e.g., '0 * * * *' for hourly, '0 0 * * *' for daily)",
|
|
793
|
+
},
|
|
794
|
+
implementation: {
|
|
795
|
+
type: "string",
|
|
796
|
+
description: "Task implementation code",
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
required: ["pluginName", "name", "schedule", "implementation"],
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
name: "add_event",
|
|
804
|
+
description: "Register a new event type with its schema in a plugin.",
|
|
805
|
+
inputSchema: {
|
|
806
|
+
type: "object" as const,
|
|
807
|
+
properties: {
|
|
808
|
+
pluginName: {
|
|
809
|
+
type: "string",
|
|
810
|
+
description: "Plugin to register the event in",
|
|
811
|
+
},
|
|
812
|
+
name: {
|
|
813
|
+
type: "string",
|
|
814
|
+
description: "Event name (e.g., 'user.created', 'order.completed')",
|
|
815
|
+
},
|
|
816
|
+
schema: {
|
|
817
|
+
type: "string",
|
|
818
|
+
description: "Zod schema for event data (e.g., 'z.object({ userId: z.string() })')",
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
required: ["pluginName", "name", "schema"],
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
{
|
|
825
|
+
name: "add_async_job",
|
|
826
|
+
description: "Register a new background job handler in a plugin.",
|
|
827
|
+
inputSchema: {
|
|
828
|
+
type: "object" as const,
|
|
829
|
+
properties: {
|
|
830
|
+
pluginName: {
|
|
831
|
+
type: "string",
|
|
832
|
+
description: "Plugin to register the job in",
|
|
833
|
+
},
|
|
834
|
+
name: {
|
|
835
|
+
type: "string",
|
|
836
|
+
description: "Job name (e.g., 'send-email', 'process-payment')",
|
|
837
|
+
},
|
|
838
|
+
implementation: {
|
|
839
|
+
type: "string",
|
|
840
|
+
description: "Job handler implementation code",
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
required: ["pluginName", "name", "implementation"],
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
name: "add_sse_route",
|
|
848
|
+
description: "Add a Server-Sent Events (SSE) route for real-time updates.",
|
|
849
|
+
inputSchema: {
|
|
850
|
+
type: "object" as const,
|
|
851
|
+
properties: {
|
|
852
|
+
routerFile: {
|
|
853
|
+
type: "string",
|
|
854
|
+
description: "Path to router file",
|
|
855
|
+
},
|
|
856
|
+
routeName: {
|
|
857
|
+
type: "string",
|
|
858
|
+
description: "Route name for SSE endpoint",
|
|
859
|
+
},
|
|
860
|
+
channel: {
|
|
861
|
+
type: "string",
|
|
862
|
+
description: "SSE channel name to subscribe to",
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
required: ["routerFile", "routeName", "channel"],
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
name: "list_plugins",
|
|
870
|
+
description: "List all plugins with their service methods and dependencies.",
|
|
871
|
+
inputSchema: {
|
|
872
|
+
type: "object" as const,
|
|
873
|
+
properties: {},
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
{
|
|
877
|
+
name: "generate_types",
|
|
878
|
+
description: "Run type generation to update registry and context types after making changes.",
|
|
879
|
+
inputSchema: {
|
|
880
|
+
type: "object" as const,
|
|
881
|
+
properties: {
|
|
882
|
+
target: {
|
|
883
|
+
type: "string",
|
|
884
|
+
enum: ["all", "registry", "context", "client"],
|
|
885
|
+
description: "What to generate (default: all)",
|
|
886
|
+
default: "all",
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
name: "run_codegen",
|
|
893
|
+
description: "Run CLI codegen commands (generate, generate-client).",
|
|
894
|
+
inputSchema: {
|
|
895
|
+
type: "object" as const,
|
|
896
|
+
properties: {
|
|
897
|
+
command: {
|
|
898
|
+
type: "string",
|
|
899
|
+
enum: ["generate", "generate-client", "generate-types"],
|
|
900
|
+
description: "Which codegen command to run",
|
|
901
|
+
},
|
|
902
|
+
outputPath: {
|
|
903
|
+
type: "string",
|
|
904
|
+
description: "Output path for generated files (optional)",
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
required: ["command"],
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
name: "generate_client",
|
|
912
|
+
description: "Generate a fully-typed API client from routes. For SvelteKit projects, generates a unified client that supports SSR direct calls (no HTTP overhead) and browser HTTP calls.",
|
|
913
|
+
inputSchema: {
|
|
914
|
+
type: "object" as const,
|
|
915
|
+
properties: {
|
|
916
|
+
outputPath: {
|
|
917
|
+
type: "string",
|
|
918
|
+
description: "Output path for generated client (e.g., 'src/lib/api.ts' for SvelteKit, './client' for standalone)",
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
},
|
|
922
|
+
},
|
|
923
|
+
];
|
|
924
|
+
|
|
925
|
+
// =============================================================================
|
|
926
|
+
// TOOL IMPLEMENTATIONS
|
|
927
|
+
// =============================================================================
|
|
928
|
+
|
|
929
|
+
async function getProjectInfo(): Promise<string> {
|
|
930
|
+
return await generateProjectAnalysis();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function getArchitectureGuidance(args: { task: string }): Promise<string> {
|
|
934
|
+
const { task } = args;
|
|
935
|
+
const taskLower = task.toLowerCase();
|
|
936
|
+
|
|
937
|
+
const plugins = listAvailablePlugins();
|
|
938
|
+
const routers = listAvailableRouters();
|
|
939
|
+
|
|
940
|
+
let guidance = `# Architecture Guidance\n\n**Task:** ${task}\n\n`;
|
|
941
|
+
|
|
942
|
+
// Pattern matching for common tasks
|
|
943
|
+
if (taskLower.includes("auth") || taskLower.includes("login") || taskLower.includes("user")) {
|
|
944
|
+
guidance += `## Recommended Approach: Authentication System\n\n`;
|
|
945
|
+
guidance += `### Step 1: Create Auth Plugin\n`;
|
|
946
|
+
guidance += `\`\`\`\nTool: create_plugin\n name: "auth"\n hasSchema: true\n\`\`\`\n\n`;
|
|
947
|
+
|
|
948
|
+
guidance += `### Step 2: Add Database Migration\n`;
|
|
949
|
+
guidance += `\`\`\`\nTool: add_migration\n pluginName: "auth"\n migrationName: "create_users"\n upSql: "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, password_hash TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP)"\n\`\`\`\n\n`;
|
|
950
|
+
|
|
951
|
+
guidance += `### Step 3: Add Service Methods\n`;
|
|
952
|
+
guidance += `Add these methods using \`add_service_method\`:\n`;
|
|
953
|
+
guidance += `- \`createUser(email, password)\` - Create new user\n`;
|
|
954
|
+
guidance += `- \`validateCredentials(email, password)\` - Check login\n`;
|
|
955
|
+
guidance += `- \`generateToken(userId)\` - Create JWT/session\n`;
|
|
956
|
+
guidance += `- \`validateToken(token)\` - Verify token\n\n`;
|
|
957
|
+
|
|
958
|
+
guidance += `### Step 4: Add Auth Middleware\n`;
|
|
959
|
+
guidance += `\`\`\`\nTool: extend_plugin\n pluginName: "auth"\n extensionType: "middleware"\n name: "authRequired"\n\`\`\`\n\n`;
|
|
960
|
+
|
|
961
|
+
guidance += `### Step 5: Create Auth Routes\n`;
|
|
962
|
+
guidance += `\`\`\`\nTool: create_router\n routerPath: "${projectConfig.routesDir}/auth/index.ts"\n routerName: "authRouter"\n prefix: "auth"\n\`\`\`\n\n`;
|
|
963
|
+
guidance += `Then add routes: login, register, logout, me\n\n`;
|
|
964
|
+
|
|
965
|
+
} else if (taskLower.includes("crud") || taskLower.includes("api") || taskLower.includes("resource")) {
|
|
966
|
+
const resourceName = extractResourceName(task);
|
|
967
|
+
|
|
968
|
+
guidance += `## Recommended Approach: CRUD API for "${resourceName}"\n\n`;
|
|
969
|
+
|
|
970
|
+
guidance += `### Step 1: Create Plugin (Business Logic)\n`;
|
|
971
|
+
guidance += `\`\`\`\nTool: create_plugin\n name: "${resourceName}"\n hasSchema: true\n\`\`\`\n\n`;
|
|
972
|
+
|
|
973
|
+
guidance += `### Step 2: Add Database Migration\n`;
|
|
974
|
+
guidance += `\`\`\`\nTool: add_migration\n pluginName: "${resourceName}"\n migrationName: "create_${resourceName}"\n\`\`\`\n\n`;
|
|
975
|
+
|
|
976
|
+
guidance += `### Step 3: Add Service Methods\n`;
|
|
977
|
+
guidance += `Using \`add_service_method\`, add:\n`;
|
|
978
|
+
guidance += `- \`list(options)\` - List with pagination/filtering\n`;
|
|
979
|
+
guidance += `- \`getById(id)\` - Get single item\n`;
|
|
980
|
+
guidance += `- \`create(data)\` - Create new item\n`;
|
|
981
|
+
guidance += `- \`update(id, data)\` - Update existing\n`;
|
|
982
|
+
guidance += `- \`delete(id)\` - Delete item\n\n`;
|
|
983
|
+
|
|
984
|
+
guidance += `### Step 4: Create Router\n`;
|
|
985
|
+
guidance += `\`\`\`\nTool: create_router\n routerPath: "${projectConfig.routesDir}/${toKebabCase(resourceName)}/index.ts"\n routerName: "${resourceName}Router"\n prefix: "${resourceName}"\n\`\`\`\n\n`;
|
|
986
|
+
|
|
987
|
+
guidance += `### Step 5: Add Routes\n`;
|
|
988
|
+
guidance += `Using \`add_route\`, add these routes:\n`;
|
|
989
|
+
guidance += `- list (GET all)\n- get (GET by ID)\n- create (POST)\n- update (PUT)\n- delete (DELETE)\n\n`;
|
|
990
|
+
|
|
991
|
+
} else if (taskLower.includes("realtime") || taskLower.includes("sse") || taskLower.includes("live")) {
|
|
992
|
+
guidance += `## Recommended Approach: Real-time Updates with SSE\n\n`;
|
|
993
|
+
|
|
994
|
+
guidance += `### Step 1: Create Plugin for State Management\n`;
|
|
995
|
+
guidance += `\`\`\`\nTool: create_plugin\n name: "realtime"\n\`\`\`\n\n`;
|
|
996
|
+
|
|
997
|
+
guidance += `### Step 2: Add Event Types\n`;
|
|
998
|
+
guidance += `\`\`\`\nTool: add_event\n pluginName: "realtime"\n name: "update"\n schema: "z.object({ type: z.string(), data: z.any() })"\n\`\`\`\n\n`;
|
|
999
|
+
|
|
1000
|
+
guidance += `### Step 3: Add SSE Route\n`;
|
|
1001
|
+
guidance += `\`\`\`\nTool: add_sse_route\n routerFile: "${projectConfig.routesDir}/realtime/index.ts"\n routeName: "subscribe"\n channel: "updates"\n\`\`\`\n\n`;
|
|
1002
|
+
|
|
1003
|
+
guidance += `### Step 4: Broadcast Updates\n`;
|
|
1004
|
+
guidance += `In your service methods, use:\n`;
|
|
1005
|
+
guidance += `\`\`\`typescript\nctx.core.sse.broadcast("updates", "event-name", { data });\n\`\`\`\n\n`;
|
|
1006
|
+
|
|
1007
|
+
} else if (taskLower.includes("cron") || taskLower.includes("schedule") || taskLower.includes("periodic")) {
|
|
1008
|
+
guidance += `## Recommended Approach: Scheduled Tasks\n\n`;
|
|
1009
|
+
|
|
1010
|
+
guidance += `### Step 1: Add to Existing Plugin or Create New One\n`;
|
|
1011
|
+
guidance += `Cron jobs should belong to a plugin that owns the related business logic.\n\n`;
|
|
1012
|
+
|
|
1013
|
+
guidance += `### Step 2: Add Cron Job\n`;
|
|
1014
|
+
guidance += `\`\`\`\nTool: add_cron\n pluginName: "<your-plugin>"\n name: "daily-cleanup"\n schedule: "0 0 * * *" // Daily at midnight\n implementation: "// Your task code"\n\`\`\`\n\n`;
|
|
1015
|
+
|
|
1016
|
+
guidance += `### Common Cron Schedules:\n`;
|
|
1017
|
+
guidance += `- \`* * * * *\` - Every minute\n`;
|
|
1018
|
+
guidance += `- \`0 * * * *\` - Every hour\n`;
|
|
1019
|
+
guidance += `- \`0 0 * * *\` - Daily at midnight\n`;
|
|
1020
|
+
guidance += `- \`0 0 * * 0\` - Weekly on Sunday\n`;
|
|
1021
|
+
guidance += `- \`0 0 1 * *\` - Monthly on 1st\n\n`;
|
|
1022
|
+
|
|
1023
|
+
} else if (taskLower.includes("job") || taskLower.includes("background") || taskLower.includes("queue")) {
|
|
1024
|
+
guidance += `## Recommended Approach: Background Jobs\n\n`;
|
|
1025
|
+
|
|
1026
|
+
guidance += `### Step 1: Register Job Handler in Plugin\n`;
|
|
1027
|
+
guidance += `\`\`\`\nTool: add_async_job\n pluginName: "<your-plugin>"\n name: "process-order"\n implementation: "// Job handler code"\n\`\`\`\n\n`;
|
|
1028
|
+
|
|
1029
|
+
guidance += `### Step 2: Enqueue Jobs from Service Methods\n`;
|
|
1030
|
+
guidance += `\`\`\`typescript\nawait ctx.core.jobs.enqueue("process-order", { orderId: "123" });\n\`\`\`\n\n`;
|
|
1031
|
+
|
|
1032
|
+
guidance += `### Features:\n`;
|
|
1033
|
+
guidance += `- Automatic retries with configurable attempts\n`;
|
|
1034
|
+
guidance += `- Delayed execution with scheduling\n`;
|
|
1035
|
+
guidance += `- Job status tracking\n`;
|
|
1036
|
+
guidance += `- Event emission on completion/failure\n\n`;
|
|
1037
|
+
|
|
1038
|
+
} else {
|
|
1039
|
+
guidance += `## General Approach\n\n`;
|
|
1040
|
+
guidance += `1. **Identify the domain** - What entity/concept are you working with?\n`;
|
|
1041
|
+
guidance += `2. **Create a plugin** for business logic using \`create_plugin\`\n`;
|
|
1042
|
+
guidance += `3. **Add database schema** if needed using \`add_migration\`\n`;
|
|
1043
|
+
guidance += `4. **Add service methods** for business logic using \`add_service_method\`\n`;
|
|
1044
|
+
guidance += `5. **Create routes** to expose the functionality using \`create_router\` and \`add_route\`\n`;
|
|
1045
|
+
guidance += `6. **Run type generation** using \`generate_types\`\n`;
|
|
1046
|
+
guidance += `7. **Generate API client** using \`generate_client\` for typed frontend calls\n\n`;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Current project state
|
|
1050
|
+
guidance += `## Current Project State\n\n`;
|
|
1051
|
+
guidance += `- **Plugins:** ${plugins.length > 0 ? plugins.join(", ") : "none"}\n`;
|
|
1052
|
+
guidance += `- **Router files:** ${routers.length}\n`;
|
|
1053
|
+
if (projectConfig.adapter) {
|
|
1054
|
+
guidance += `- **Adapter:** ${projectConfig.adapter}\n`;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Warnings
|
|
1058
|
+
if (taskLower.includes("protected") || taskLower.includes("secure")) {
|
|
1059
|
+
if (!plugins.includes("auth")) {
|
|
1060
|
+
guidance += `\n### Warning\n`;
|
|
1061
|
+
guidance += `No auth plugin detected. Create one first if you need protected routes.\n`;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Client generation reminder
|
|
1066
|
+
guidance += `\n## Final Step: Generate API Client\n`;
|
|
1067
|
+
guidance += `After creating routes, generate a typed client:\n`;
|
|
1068
|
+
guidance += `\`\`\`\nTool: generate_client\n\`\`\`\n`;
|
|
1069
|
+
if (projectConfig.adapter === "sveltekit") {
|
|
1070
|
+
guidance += `\n**SvelteKit Benefit:** The generated client supports:\n`;
|
|
1071
|
+
guidance += `- **SSR:** Direct calls via \`locals\` (no HTTP overhead!)\n`;
|
|
1072
|
+
guidance += `- **Browser:** HTTP calls automatically\n`;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
guidance += `\n## Documentation Resources\n`;
|
|
1076
|
+
guidance += `- \`donkeylabs://docs/plugins\` - Plugin patterns\n`;
|
|
1077
|
+
guidance += `- \`donkeylabs://docs/router\` - Route patterns\n`;
|
|
1078
|
+
guidance += `- \`donkeylabs://docs/api-client\` - Client usage & generation\n`;
|
|
1079
|
+
guidance += `- \`donkeylabs://docs/project-structure\` - Best practices\n`;
|
|
1080
|
+
|
|
1081
|
+
return guidance;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function extractResourceName(task: string): string {
|
|
1085
|
+
// Try to extract a resource name from the task description
|
|
1086
|
+
const patterns = [
|
|
1087
|
+
/crud\s+(?:for\s+)?(\w+)/i,
|
|
1088
|
+
/api\s+(?:for\s+)?(\w+)/i,
|
|
1089
|
+
/(\w+)\s+api/i,
|
|
1090
|
+
/(\w+)\s+crud/i,
|
|
1091
|
+
/manage\s+(\w+)/i,
|
|
1092
|
+
/(\w+)\s+management/i,
|
|
1093
|
+
];
|
|
1094
|
+
|
|
1095
|
+
for (const pattern of patterns) {
|
|
1096
|
+
const match = task.match(pattern);
|
|
1097
|
+
if (match) {
|
|
1098
|
+
return match[1].toLowerCase();
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
return "items";
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
async function createPlugin(args: {
|
|
1106
|
+
name: string;
|
|
1107
|
+
hasSchema?: boolean;
|
|
1108
|
+
hasConfig?: boolean;
|
|
1109
|
+
configFields?: string;
|
|
1110
|
+
dependencies?: string[];
|
|
1111
|
+
}): Promise<string> {
|
|
1112
|
+
const { name, hasSchema = false, hasConfig = false, configFields = "", dependencies = [] } = args;
|
|
1113
|
+
|
|
1114
|
+
// Validation
|
|
1115
|
+
const projectValid = validateProjectExists();
|
|
1116
|
+
if (!projectValid.valid) {
|
|
1117
|
+
return formatError(projectValid.error!, undefined, projectValid.suggestion);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const nameValid = validatePluginName(name);
|
|
1121
|
+
if (!nameValid.valid) {
|
|
1122
|
+
return formatError(nameValid.error!, undefined, nameValid.suggestion);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const pluginDir = join(projectRoot, projectConfig.pluginsDir, name);
|
|
1126
|
+
if (existsSync(pluginDir)) {
|
|
1127
|
+
const existingContent = readFileSync(join(pluginDir, "index.ts"), "utf-8");
|
|
1128
|
+
const methods = [...existingContent.matchAll(/(\w+):\s*(?:async\s*)?\(/g)].map(m => m[1]);
|
|
1129
|
+
|
|
1130
|
+
return formatError(
|
|
1131
|
+
`Plugin "${name}" already exists`,
|
|
1132
|
+
`Location: ${projectConfig.pluginsDir}/${name}/\nExisting methods: ${methods.join(", ") || "none"}`,
|
|
1133
|
+
`To add to this plugin:\n- Use \`add_service_method\` to add methods\n- Use \`extend_plugin\` to add errors/events/middleware\n- Use \`add_migration\` to add database changes`,
|
|
1134
|
+
"add_service_method"
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Validate dependencies exist
|
|
1139
|
+
for (const dep of dependencies) {
|
|
1140
|
+
const depValid = validatePluginExists(dep);
|
|
1141
|
+
if (!depValid.valid) {
|
|
1142
|
+
return formatError(
|
|
1143
|
+
`Dependency plugin "${dep}" not found`,
|
|
1144
|
+
undefined,
|
|
1145
|
+
`Create the dependency plugin first, or remove it from dependencies.`,
|
|
1146
|
+
"create_plugin"
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
mkdirSync(pluginDir, { recursive: true });
|
|
1152
|
+
|
|
1153
|
+
// Build the createPlugin chain
|
|
1154
|
+
let createPluginChain = "createPlugin";
|
|
1155
|
+
if (hasSchema) {
|
|
1156
|
+
createPluginChain += `\n .withSchema<${toPascalCase(name)}Schema>()`;
|
|
1157
|
+
}
|
|
1158
|
+
if (hasConfig) {
|
|
1159
|
+
createPluginChain += `\n .withConfig<${toPascalCase(name)}Config>()`;
|
|
1160
|
+
}
|
|
1161
|
+
createPluginChain += "\n .define";
|
|
1162
|
+
|
|
1163
|
+
// Generate imports
|
|
1164
|
+
let imports = `import { createPlugin } from "@donkeylabs/server";\n`;
|
|
1165
|
+
if (dependencies.length > 0) {
|
|
1166
|
+
imports += dependencies.map(d => `import { ${d}Plugin } from "../${d}";`).join("\n") + "\n";
|
|
1167
|
+
}
|
|
1168
|
+
if (hasSchema) {
|
|
1169
|
+
imports += `import type { ${toPascalCase(name)}Schema } from "./schema";\n`;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Generate config interface if needed
|
|
1173
|
+
let configInterface = "";
|
|
1174
|
+
if (hasConfig) {
|
|
1175
|
+
const fields = configFields || " // Add your config fields here\n // apiKey: string;\n // sandbox?: boolean;";
|
|
1176
|
+
configInterface = `\nexport interface ${toPascalCase(name)}Config {\n ${fields}\n}\n`;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const depsArray = dependencies.length > 0
|
|
1180
|
+
? `\n dependencies: [${dependencies.map(d => `${d}Plugin`).join(", ")}] as const,`
|
|
1181
|
+
: "";
|
|
1182
|
+
|
|
1183
|
+
// Build the service context comment based on what's available
|
|
1184
|
+
let ctxComment = " // ctx.plugins - access other plugin services";
|
|
1185
|
+
if (hasSchema) {
|
|
1186
|
+
ctxComment += "\n // ctx.db - typed database access (Kysely)";
|
|
1187
|
+
}
|
|
1188
|
+
if (hasConfig) {
|
|
1189
|
+
ctxComment += "\n // ctx.config - your plugin configuration";
|
|
1190
|
+
}
|
|
1191
|
+
if (dependencies.length > 0) {
|
|
1192
|
+
ctxComment += `\n // ctx.deps - dependency services: ${dependencies.join(", ")}`;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const indexContent = `${imports}${configInterface}
|
|
1196
|
+
export const ${name}Plugin = ${createPluginChain}({
|
|
1197
|
+
name: "${name}",${depsArray}
|
|
1198
|
+
|
|
1199
|
+
service: async (ctx) => {
|
|
1200
|
+
${ctxComment}
|
|
1201
|
+
// ctx.core - logger, cache, events, cron, jobs, sse, rateLimiter
|
|
1202
|
+
|
|
1203
|
+
return {
|
|
1204
|
+
// Service methods are available via ctx.plugins.${name}
|
|
1205
|
+
hello: () => "Hello from ${name} plugin!",
|
|
1206
|
+
};
|
|
1207
|
+
},
|
|
1208
|
+
|
|
1209
|
+
// Uncomment to add initialization logic (crons, event listeners, jobs)
|
|
1210
|
+
// init: (ctx, service) => {
|
|
1211
|
+
// // Register cron jobs
|
|
1212
|
+
// // ctx.core.cron.schedule("0 * * * *", async () => { ... }, { name: "hourly-task" });
|
|
1213
|
+
//
|
|
1214
|
+
// // Register async jobs
|
|
1215
|
+
// // ctx.core.jobs.register("job-name", async (data) => { ... });
|
|
1216
|
+
//
|
|
1217
|
+
// // Listen for events
|
|
1218
|
+
// // ctx.core.events.on("user.created", async (data) => { ... });
|
|
1219
|
+
//
|
|
1220
|
+
// ctx.core.logger.info("${name} plugin initialized");
|
|
1221
|
+
// },
|
|
1222
|
+
});
|
|
1223
|
+
`;
|
|
1224
|
+
|
|
1225
|
+
await Bun.write(join(pluginDir, "index.ts"), indexContent);
|
|
1226
|
+
|
|
1227
|
+
// Create schema and migrations if needed
|
|
1228
|
+
if (hasSchema) {
|
|
1229
|
+
mkdirSync(join(pluginDir, "migrations"), { recursive: true });
|
|
1230
|
+
|
|
1231
|
+
const schemaContent = `// Database schema types for ${name} plugin
|
|
1232
|
+
// Run 'donkeylabs generate' after adding migrations to generate types
|
|
1233
|
+
|
|
1234
|
+
export interface ${toPascalCase(name)}Schema {
|
|
1235
|
+
// Tables will be generated here
|
|
1236
|
+
// Example:
|
|
1237
|
+
// ${name}: {
|
|
1238
|
+
// id: Generated<number>;
|
|
1239
|
+
// name: string;
|
|
1240
|
+
// created_at: string;
|
|
1241
|
+
// };
|
|
1242
|
+
}
|
|
1243
|
+
`;
|
|
1244
|
+
await Bun.write(join(pluginDir, "schema.ts"), schemaContent);
|
|
1245
|
+
|
|
1246
|
+
const migrationContent = `-- Migration: 001_initial
|
|
1247
|
+
-- Created: ${new Date().toISOString()}
|
|
1248
|
+
-- Plugin: ${name}
|
|
1249
|
+
|
|
1250
|
+
-- UP
|
|
1251
|
+
-- Add your CREATE TABLE statements here
|
|
1252
|
+
-- Example:
|
|
1253
|
+
-- CREATE TABLE ${name} (
|
|
1254
|
+
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1255
|
+
-- name TEXT NOT NULL,
|
|
1256
|
+
-- created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
1257
|
+
-- );
|
|
1258
|
+
|
|
1259
|
+
-- DOWN
|
|
1260
|
+
-- Add your DROP TABLE statements here
|
|
1261
|
+
-- DROP TABLE IF EXISTS ${name};
|
|
1262
|
+
`;
|
|
1263
|
+
await Bun.write(join(pluginDir, "migrations", "001_initial.sql"), migrationContent);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Build registration example based on plugin type
|
|
1267
|
+
let registrationExample = "";
|
|
1268
|
+
if (hasConfig) {
|
|
1269
|
+
registrationExample = `\`\`\`typescript
|
|
1270
|
+
// server.ts
|
|
1271
|
+
server.registerPlugin(${name}Plugin({
|
|
1272
|
+
// Your config here
|
|
1273
|
+
}));
|
|
1274
|
+
\`\`\``;
|
|
1275
|
+
} else {
|
|
1276
|
+
registrationExample = `\`\`\`typescript
|
|
1277
|
+
// server.ts
|
|
1278
|
+
server.registerPlugin(${name}Plugin);
|
|
1279
|
+
\`\`\``;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return `## Plugin Created: ${name}
|
|
1283
|
+
|
|
1284
|
+
**Location:** ${projectConfig.pluginsDir}/${name}/
|
|
1285
|
+
**Files created:**
|
|
1286
|
+
- index.ts (plugin definition)${hasSchema ? "\n- schema.ts (database types)\n- migrations/001_initial.sql" : ""}
|
|
1287
|
+
|
|
1288
|
+
**Plugin features:**
|
|
1289
|
+
${hasSchema ? "- ✅ Database schema (withSchema<>)\n" : ""}${hasConfig ? "- ✅ Configurable (withConfig<>)\n" : ""}- Service with ctx.core (logger, cache, events, cron, jobs, sse, rateLimiter)
|
|
1290
|
+
- Init hook for startup logic (crons, jobs, event listeners)
|
|
1291
|
+
|
|
1292
|
+
### Register Plugin
|
|
1293
|
+
|
|
1294
|
+
${registrationExample}
|
|
1295
|
+
|
|
1296
|
+
### Next Steps
|
|
1297
|
+
|
|
1298
|
+
1. ${hasSchema ? "Edit the migration file with your schema" : "Add service methods using `add_service_method`"}
|
|
1299
|
+
2. ${hasSchema ? "Run \`donkeylabs generate\` to create types" : "Register the plugin in your server"}
|
|
1300
|
+
3. Use \`extend_plugin\` to add errors, events, or middleware
|
|
1301
|
+
|
|
1302
|
+
### Example Usage
|
|
1303
|
+
|
|
1304
|
+
\`\`\`typescript
|
|
1305
|
+
// In your route handler:
|
|
1306
|
+
const result = ctx.plugins.${name}.hello();
|
|
1307
|
+
\`\`\`
|
|
1308
|
+
`;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async function addServiceMethod(args: {
|
|
1312
|
+
pluginName: string;
|
|
1313
|
+
methodName: string;
|
|
1314
|
+
params?: string;
|
|
1315
|
+
returnType?: string;
|
|
1316
|
+
implementation: string;
|
|
1317
|
+
}): Promise<string> {
|
|
1318
|
+
const { pluginName, methodName, params = "", returnType = "void", implementation } = args;
|
|
1319
|
+
|
|
1320
|
+
const pluginValid = validatePluginExists(pluginName);
|
|
1321
|
+
if (!pluginValid.valid) {
|
|
1322
|
+
return formatError(pluginValid.error!, undefined, pluginValid.suggestion, "create_plugin");
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const pluginFile = join(projectRoot, projectConfig.pluginsDir, pluginName, "index.ts");
|
|
1326
|
+
const content = await Bun.file(pluginFile).text();
|
|
1327
|
+
|
|
1328
|
+
// Check if method already exists
|
|
1329
|
+
if (content.includes(`${methodName}:`)) {
|
|
1330
|
+
return formatError(
|
|
1331
|
+
`Method "${methodName}" already exists in ${pluginName} plugin`,
|
|
1332
|
+
undefined,
|
|
1333
|
+
"Choose a different method name or edit the existing method directly."
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Find the service return object
|
|
1338
|
+
const serviceMatch = content.match(/service:\s*async\s*\([^)]*\)\s*=>\s*\{[\s\S]*?return\s*\{/);
|
|
1339
|
+
if (!serviceMatch) {
|
|
1340
|
+
// Try simpler pattern for arrow function style
|
|
1341
|
+
const simpleMatch = content.match(/service:\s*async\s*\([^)]*\)\s*=>\s*\(\{/);
|
|
1342
|
+
if (!simpleMatch) {
|
|
1343
|
+
return formatError(
|
|
1344
|
+
"Could not find service definition",
|
|
1345
|
+
`File: ${projectConfig.pluginsDir}/${pluginName}/index.ts`,
|
|
1346
|
+
"Make sure the plugin has a service property with a return statement."
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const insertPoint = simpleMatch.index! + simpleMatch[0].length;
|
|
1351
|
+
const methodDef = `
|
|
1352
|
+
${methodName}: ${params ? `async (${params})` : "async ()"}: ${returnType.includes("Promise") ? returnType : `Promise<${returnType}>`} => {
|
|
1353
|
+
${implementation}
|
|
1354
|
+
},`;
|
|
1355
|
+
|
|
1356
|
+
const newContent = content.slice(0, insertPoint) + methodDef + content.slice(insertPoint);
|
|
1357
|
+
await Bun.write(pluginFile, newContent);
|
|
1358
|
+
} else {
|
|
1359
|
+
const insertPoint = serviceMatch.index! + serviceMatch[0].length;
|
|
1360
|
+
const methodDef = `
|
|
1361
|
+
${methodName}: ${params ? `async (${params})` : "async ()"}: ${returnType.includes("Promise") ? returnType : `Promise<${returnType}>`} => {
|
|
1362
|
+
${implementation}
|
|
1363
|
+
},`;
|
|
1364
|
+
|
|
1365
|
+
const newContent = content.slice(0, insertPoint) + methodDef + content.slice(insertPoint);
|
|
1366
|
+
await Bun.write(pluginFile, newContent);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
return `## Method Added: ${methodName}
|
|
1370
|
+
|
|
1371
|
+
**Plugin:** ${pluginName}
|
|
1372
|
+
**Signature:** \`${methodName}(${params}): ${returnType}\`
|
|
1373
|
+
|
|
1374
|
+
### Usage
|
|
1375
|
+
|
|
1376
|
+
\`\`\`typescript
|
|
1377
|
+
// In route handlers or other plugins:
|
|
1378
|
+
const result = await ctx.plugins.${pluginName}.${methodName}(${params ? "..." : ""});
|
|
1379
|
+
\`\`\`
|
|
1380
|
+
|
|
1381
|
+
**Reminder:** Run \`donkeylabs generate\` to update types.
|
|
1382
|
+
`;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
async function addMigration(args: {
|
|
1386
|
+
pluginName: string;
|
|
1387
|
+
migrationName: string;
|
|
1388
|
+
upSql: string;
|
|
1389
|
+
downSql?: string;
|
|
1390
|
+
}): Promise<string> {
|
|
1391
|
+
const { pluginName, migrationName, upSql, downSql = "" } = args;
|
|
1392
|
+
|
|
1393
|
+
const pluginValid = validatePluginExists(pluginName);
|
|
1394
|
+
if (!pluginValid.valid) {
|
|
1395
|
+
return formatError(pluginValid.error!, undefined, pluginValid.suggestion, "create_plugin");
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const migrationsDir = join(projectRoot, projectConfig.pluginsDir, pluginName, "migrations");
|
|
1399
|
+
|
|
1400
|
+
if (!existsSync(migrationsDir)) {
|
|
1401
|
+
mkdirSync(migrationsDir, { recursive: true });
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Find next migration number
|
|
1405
|
+
const existing = readdirSync(migrationsDir)
|
|
1406
|
+
.filter((f) => f.endsWith(".sql"))
|
|
1407
|
+
.map((f) => parseInt(f.split("_")[0], 10))
|
|
1408
|
+
.filter((n) => !isNaN(n));
|
|
1409
|
+
|
|
1410
|
+
const nextNum = existing.length > 0 ? Math.max(...existing) + 1 : 1;
|
|
1411
|
+
const numStr = String(nextNum).padStart(3, "0");
|
|
1412
|
+
const filename = `${numStr}_${migrationName}.sql`;
|
|
1413
|
+
|
|
1414
|
+
const content = `-- Migration: ${numStr}_${migrationName}
|
|
1415
|
+
-- Created: ${new Date().toISOString()}
|
|
1416
|
+
-- Plugin: ${pluginName}
|
|
1417
|
+
|
|
1418
|
+
-- UP
|
|
1419
|
+
${upSql}
|
|
1420
|
+
|
|
1421
|
+
-- DOWN
|
|
1422
|
+
${downSql || "-- Add rollback SQL here"}
|
|
1423
|
+
`;
|
|
1424
|
+
|
|
1425
|
+
await Bun.write(join(migrationsDir, filename), content);
|
|
1426
|
+
|
|
1427
|
+
return `## Migration Created: ${filename}
|
|
1428
|
+
|
|
1429
|
+
**Plugin:** ${pluginName}
|
|
1430
|
+
**Path:** ${projectConfig.pluginsDir}/${pluginName}/migrations/${filename}
|
|
1431
|
+
|
|
1432
|
+
### Next Steps
|
|
1433
|
+
|
|
1434
|
+
1. Review the migration SQL
|
|
1435
|
+
2. Run \`donkeylabs generate\` to update schema types
|
|
1436
|
+
3. The migration will run automatically on server start
|
|
1437
|
+
|
|
1438
|
+
### Note
|
|
1439
|
+
Make sure to update the plugin's schema.ts file with the new table types.
|
|
1440
|
+
`;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
async function createRouter(args: {
|
|
1444
|
+
routerPath: string;
|
|
1445
|
+
routerName: string;
|
|
1446
|
+
prefix: string;
|
|
1447
|
+
}): Promise<string> {
|
|
1448
|
+
const { routerPath, routerName, prefix } = args;
|
|
1449
|
+
|
|
1450
|
+
const projectValid = validateProjectExists();
|
|
1451
|
+
if (!projectValid.valid) {
|
|
1452
|
+
return formatError(projectValid.error!, undefined, projectValid.suggestion);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// Validate path - must be in the configured routes directory
|
|
1456
|
+
if (!routerPath.includes(projectConfig.routesDir)) {
|
|
1457
|
+
return formatError(
|
|
1458
|
+
"Invalid router path",
|
|
1459
|
+
`Path: ${routerPath}`,
|
|
1460
|
+
`Router files should be in ${projectConfig.routesDir}/ directory.\nSuggested path: ${projectConfig.routesDir}/${prefix}/index.ts`
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const fullPath = join(projectRoot, routerPath);
|
|
1465
|
+
|
|
1466
|
+
if (existsSync(fullPath)) {
|
|
1467
|
+
return formatError(
|
|
1468
|
+
`Router file already exists`,
|
|
1469
|
+
`Path: ${routerPath}`,
|
|
1470
|
+
"Use add_route to add routes to the existing router.",
|
|
1471
|
+
"add_route"
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Create directory if needed
|
|
1476
|
+
const routerDir = dirname(fullPath);
|
|
1477
|
+
if (!existsSync(routerDir)) {
|
|
1478
|
+
mkdirSync(routerDir, { recursive: true });
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const content = `import { createRouter } from "@donkeylabs/server";
|
|
1482
|
+
import { z } from "zod";
|
|
1483
|
+
|
|
1484
|
+
export const ${routerName} = createRouter("${prefix}");
|
|
1485
|
+
|
|
1486
|
+
// Add routes using the add_route tool or manually:
|
|
1487
|
+
// ${routerName}.route("list").typed({
|
|
1488
|
+
// input: z.object({ page: z.number().default(1) }),
|
|
1489
|
+
// handle: async (input, ctx) => {
|
|
1490
|
+
// return { items: [], page: input.page };
|
|
1491
|
+
// },
|
|
1492
|
+
// });
|
|
1493
|
+
`;
|
|
1494
|
+
|
|
1495
|
+
await Bun.write(fullPath, content);
|
|
1496
|
+
|
|
1497
|
+
return `## Router Created: ${routerName}
|
|
1498
|
+
|
|
1499
|
+
**Path:** ${routerPath}
|
|
1500
|
+
**Prefix:** ${prefix} (routes will be named "${prefix}.routeName")
|
|
1501
|
+
|
|
1502
|
+
### Next Steps
|
|
1503
|
+
|
|
1504
|
+
1. Use \`add_route\` to add routes to this router
|
|
1505
|
+
2. Register the router in your server entry point:
|
|
1506
|
+
|
|
1507
|
+
\`\`\`typescript
|
|
1508
|
+
import { ${routerName} } from "./${relative(join(projectRoot, "src"), fullPath).replace(/\.ts$/, "")}";
|
|
1509
|
+
server.use(${routerName});
|
|
1510
|
+
\`\`\`
|
|
1511
|
+
|
|
1512
|
+
### Route Naming
|
|
1513
|
+
|
|
1514
|
+
Routes added to this router will be named \`${prefix}.<routeName>\`:
|
|
1515
|
+
- ${prefix}.list
|
|
1516
|
+
- ${prefix}.get
|
|
1517
|
+
- ${prefix}.create
|
|
1518
|
+
`;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
async function addRoute(args: {
|
|
1522
|
+
routerFile: string;
|
|
1523
|
+
routeName: string;
|
|
1524
|
+
inputSchema?: string;
|
|
1525
|
+
outputType?: string;
|
|
1526
|
+
handler: string;
|
|
1527
|
+
useClassHandler?: boolean;
|
|
1528
|
+
}): Promise<string> {
|
|
1529
|
+
const { routerFile, routeName, inputSchema, outputType, handler, useClassHandler = true } = args;
|
|
1530
|
+
|
|
1531
|
+
const routerValid = validateRouterExists(routerFile);
|
|
1532
|
+
if (!routerValid.valid) {
|
|
1533
|
+
return formatError(routerValid.error!, undefined, routerValid.suggestion, "create_router");
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const fullPath = join(projectRoot, routerFile);
|
|
1537
|
+
const content = await Bun.file(fullPath).text();
|
|
1538
|
+
|
|
1539
|
+
// Check if route already exists
|
|
1540
|
+
if (content.includes(`.route("${routeName}")`)) {
|
|
1541
|
+
return formatError(
|
|
1542
|
+
`Route "${routeName}" already exists`,
|
|
1543
|
+
`File: ${routerFile}`,
|
|
1544
|
+
"Choose a different route name or edit the existing route directly."
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const routerMatch = content.match(/createRouter\([^)]+\)/);
|
|
1549
|
+
if (!routerMatch) {
|
|
1550
|
+
return formatError(
|
|
1551
|
+
"Could not find createRouter() in file",
|
|
1552
|
+
`File: ${routerFile}`,
|
|
1553
|
+
"Make sure this is a valid router file with createRouter()."
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
let finalHandlerCode = "";
|
|
1558
|
+
let importStatement = "";
|
|
1559
|
+
let handlerFilePath = "";
|
|
1560
|
+
|
|
1561
|
+
if (useClassHandler) {
|
|
1562
|
+
const handlerName = toPascalCase(routeName);
|
|
1563
|
+
const handlerClassName = `${handlerName}Handler`;
|
|
1564
|
+
const handlerFileName = toKebabCase(routeName);
|
|
1565
|
+
|
|
1566
|
+
const routerDir = dirname(fullPath);
|
|
1567
|
+
const handlersDir = join(routerDir, "handlers");
|
|
1568
|
+
|
|
1569
|
+
if (!existsSync(handlersDir)) {
|
|
1570
|
+
mkdirSync(handlersDir, { recursive: true });
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Extract router prefix for proper type path
|
|
1574
|
+
const prefixMatch = content.match(/createRouter\(["']([^"']+)["']\)/);
|
|
1575
|
+
const prefix = prefixMatch ? toPascalCase(prefixMatch[1]) : "Api";
|
|
1576
|
+
|
|
1577
|
+
// Determine import path based on project type
|
|
1578
|
+
const apiImportPath = projectConfig.adapter === "sveltekit" ? "$lib/api" : "@/api";
|
|
1579
|
+
|
|
1580
|
+
const handlerFileContent = `import type { Handler, AppContext, Routes } from "${apiImportPath}";
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* Handler for ${routeName} route
|
|
1584
|
+
*/
|
|
1585
|
+
export class ${handlerClassName} implements Handler<Routes.${prefix}.${handlerName}> {
|
|
1586
|
+
ctx: AppContext;
|
|
1587
|
+
|
|
1588
|
+
constructor(ctx: AppContext) {
|
|
1589
|
+
this.ctx = ctx;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
async handle(input: Routes.${prefix}.${handlerName}["input"]): Promise<Routes.${prefix}.${handlerName}["output"]> {
|
|
1593
|
+
${handler}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
`;
|
|
1597
|
+
|
|
1598
|
+
handlerFilePath = join(handlersDir, `${handlerFileName}.ts`);
|
|
1599
|
+
|
|
1600
|
+
if (existsSync(handlerFilePath)) {
|
|
1601
|
+
return formatError(
|
|
1602
|
+
`Handler file already exists`,
|
|
1603
|
+
`Path: handlers/${handlerFileName}.ts`,
|
|
1604
|
+
"Use a different route name or use add_handler_to_router for existing handlers.",
|
|
1605
|
+
"add_handler_to_router"
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
await Bun.write(handlerFilePath, handlerFileContent);
|
|
1610
|
+
|
|
1611
|
+
finalHandlerCode = handlerClassName;
|
|
1612
|
+
importStatement = `import { ${handlerClassName} } from "./handlers/${handlerFileName}";\n`;
|
|
1613
|
+
} else {
|
|
1614
|
+
finalHandlerCode = `async (input, ctx) => {
|
|
1615
|
+
${handler}
|
|
1616
|
+
}`;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const newRoute = inputSchema
|
|
1620
|
+
? `\n .route("${routeName}").typed({
|
|
1621
|
+
input: ${inputSchema},${outputType ? `\n output: ${outputType},` : ""}
|
|
1622
|
+
handle: ${finalHandlerCode},
|
|
1623
|
+
})`
|
|
1624
|
+
: `\n .route("${routeName}").typed({
|
|
1625
|
+
handle: ${finalHandlerCode},
|
|
1626
|
+
})`;
|
|
1627
|
+
|
|
1628
|
+
// Update content
|
|
1629
|
+
let updatedContent = content;
|
|
1630
|
+
|
|
1631
|
+
if (useClassHandler) {
|
|
1632
|
+
const lastImportIdx = updatedContent.lastIndexOf("import ");
|
|
1633
|
+
if (lastImportIdx !== -1) {
|
|
1634
|
+
const endOfLine = updatedContent.indexOf("\n", lastImportIdx);
|
|
1635
|
+
if (endOfLine !== -1) {
|
|
1636
|
+
updatedContent = updatedContent.slice(0, endOfLine + 1) + importStatement + updatedContent.slice(endOfLine + 1);
|
|
1637
|
+
}
|
|
1638
|
+
} else {
|
|
1639
|
+
updatedContent = importStatement + updatedContent;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// Find the last semicolon or closing of router chain
|
|
1644
|
+
const insertPoint = updatedContent.lastIndexOf(";");
|
|
1645
|
+
if (insertPoint !== -1) {
|
|
1646
|
+
updatedContent = updatedContent.slice(0, insertPoint) + newRoute + updatedContent.slice(insertPoint);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
await Bun.write(fullPath, updatedContent);
|
|
1650
|
+
|
|
1651
|
+
return `## Route Added: ${routeName}
|
|
1652
|
+
|
|
1653
|
+
**Router:** ${routerFile}${useClassHandler ? `\n**Handler:** handlers/${toKebabCase(routeName)}.ts` : ""}
|
|
1654
|
+
|
|
1655
|
+
### Usage
|
|
1656
|
+
|
|
1657
|
+
This route is now available at: \`POST /<prefix>.${routeName}\`
|
|
1658
|
+
|
|
1659
|
+
${inputSchema ? `**Input:** ${inputSchema}` : "**Input:** none"}
|
|
1660
|
+
${outputType ? `\n**Output:** ${outputType}` : ""}
|
|
1661
|
+
|
|
1662
|
+
### Next Steps
|
|
1663
|
+
|
|
1664
|
+
1. ${useClassHandler ? "Implement the handler logic in the handler file" : "The route is ready to use"}
|
|
1665
|
+
2. Run \`donkeylabs generate\` to update types
|
|
1666
|
+
3. Test the route
|
|
1667
|
+
`;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
async function addHandlerToRouter(args: {
|
|
1671
|
+
routerFile: string;
|
|
1672
|
+
handlerName: string;
|
|
1673
|
+
handlerPath: string;
|
|
1674
|
+
routeName: string;
|
|
1675
|
+
inputSchema?: string;
|
|
1676
|
+
outputSchema?: string;
|
|
1677
|
+
}): Promise<string> {
|
|
1678
|
+
const { routerFile, handlerName, handlerPath, routeName, inputSchema, outputSchema } = args;
|
|
1679
|
+
|
|
1680
|
+
const routerValid = validateRouterExists(routerFile);
|
|
1681
|
+
if (!routerValid.valid) {
|
|
1682
|
+
return formatError(routerValid.error!, undefined, routerValid.suggestion, "create_router");
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
const fullPath = join(projectRoot, routerFile);
|
|
1686
|
+
let content = await Bun.file(fullPath).text();
|
|
1687
|
+
|
|
1688
|
+
// Add import
|
|
1689
|
+
const importStatement = `import { ${handlerName} } from "${handlerPath}";\n`;
|
|
1690
|
+
|
|
1691
|
+
if (!content.includes(importStatement)) {
|
|
1692
|
+
const lastImportIdx = content.lastIndexOf("import ");
|
|
1693
|
+
if (lastImportIdx !== -1) {
|
|
1694
|
+
const endOfLine = content.indexOf("\n", lastImportIdx);
|
|
1695
|
+
if (endOfLine !== -1) {
|
|
1696
|
+
content = content.slice(0, endOfLine + 1) + importStatement + content.slice(endOfLine + 1);
|
|
1697
|
+
}
|
|
1698
|
+
} else {
|
|
1699
|
+
content = importStatement + content;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// Add route
|
|
1704
|
+
const routeDef = inputSchema
|
|
1705
|
+
? `\n .route("${routeName}").typed({
|
|
1706
|
+
input: ${inputSchema},${outputSchema ? `\n output: ${outputSchema},` : ""}
|
|
1707
|
+
handle: ${handlerName},
|
|
1708
|
+
})`
|
|
1709
|
+
: `\n .route("${routeName}").typed({
|
|
1710
|
+
handle: ${handlerName},
|
|
1711
|
+
})`;
|
|
1712
|
+
|
|
1713
|
+
const insertPoint = content.lastIndexOf(";");
|
|
1714
|
+
if (insertPoint !== -1) {
|
|
1715
|
+
content = content.slice(0, insertPoint) + routeDef + content.slice(insertPoint);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
await Bun.write(fullPath, content);
|
|
1719
|
+
|
|
1720
|
+
return `## Handler Registered: ${handlerName}
|
|
1721
|
+
|
|
1722
|
+
**Route:** ${routeName}
|
|
1723
|
+
**Router:** ${routerFile}
|
|
1724
|
+
**Handler import:** ${handlerPath}
|
|
1725
|
+
|
|
1726
|
+
Run \`donkeylabs generate\` to update types.
|
|
1727
|
+
`;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
async function extendPlugin(args: {
|
|
1731
|
+
pluginName: string;
|
|
1732
|
+
extensionType: "error" | "event" | "middleware" | "handler";
|
|
1733
|
+
name: string;
|
|
1734
|
+
params?: {
|
|
1735
|
+
code?: string;
|
|
1736
|
+
status?: number;
|
|
1737
|
+
message?: string;
|
|
1738
|
+
schema?: string;
|
|
1739
|
+
implementation?: string;
|
|
1740
|
+
handlerSignature?: string;
|
|
1741
|
+
};
|
|
1742
|
+
}): Promise<string> {
|
|
1743
|
+
const { pluginName, extensionType, name, params = {} } = args;
|
|
1744
|
+
|
|
1745
|
+
const pluginValid = validatePluginExists(pluginName);
|
|
1746
|
+
if (!pluginValid.valid) {
|
|
1747
|
+
return formatError(pluginValid.error!, undefined, pluginValid.suggestion, "create_plugin");
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
const pluginFile = join(projectRoot, projectConfig.pluginsDir, pluginName, "index.ts");
|
|
1751
|
+
let content = await Bun.file(pluginFile).text();
|
|
1752
|
+
|
|
1753
|
+
if (extensionType === "error") {
|
|
1754
|
+
const { code = name.toUpperCase().replace(/([a-z])([A-Z])/g, "$1_$2"), status = 400, message } = params;
|
|
1755
|
+
|
|
1756
|
+
// Check if customErrors already exists
|
|
1757
|
+
if (content.includes("customErrors:")) {
|
|
1758
|
+
// Add to existing customErrors
|
|
1759
|
+
const errorsMatch = content.match(/customErrors:\s*\{/);
|
|
1760
|
+
if (errorsMatch) {
|
|
1761
|
+
const insertPoint = errorsMatch.index! + errorsMatch[0].length;
|
|
1762
|
+
const errorDef = `\n ${name}: { status: ${status}, code: "${code}"${message ? `, defaultMessage: "${message}"` : ""} },`;
|
|
1763
|
+
content = content.slice(0, insertPoint) + errorDef + content.slice(insertPoint);
|
|
1764
|
+
}
|
|
1765
|
+
} else {
|
|
1766
|
+
// Add customErrors property
|
|
1767
|
+
const defineMatch = content.match(/\.define\(\{/);
|
|
1768
|
+
if (defineMatch) {
|
|
1769
|
+
const insertPoint = defineMatch.index! + defineMatch[0].length;
|
|
1770
|
+
const errorsDef = `\n customErrors: {\n ${name}: { status: ${status}, code: "${code}"${message ? `, defaultMessage: "${message}"` : ""} },\n },`;
|
|
1771
|
+
content = content.slice(0, insertPoint) + errorsDef + content.slice(insertPoint);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
await Bun.write(pluginFile, content);
|
|
1776
|
+
|
|
1777
|
+
return `## Custom Error Added: ${name}
|
|
1778
|
+
|
|
1779
|
+
**Plugin:** ${pluginName}
|
|
1780
|
+
**Code:** ${code}
|
|
1781
|
+
**Status:** ${status}
|
|
1782
|
+
|
|
1783
|
+
### Usage
|
|
1784
|
+
|
|
1785
|
+
\`\`\`typescript
|
|
1786
|
+
throw ctx.errors.${name}("Error message");
|
|
1787
|
+
\`\`\`
|
|
1788
|
+
|
|
1789
|
+
Run \`donkeylabs generate\` to update types.
|
|
1790
|
+
`;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
if (extensionType === "event") {
|
|
1794
|
+
const { schema = "z.object({})" } = params;
|
|
1795
|
+
|
|
1796
|
+
// Check if events already exists
|
|
1797
|
+
if (content.includes("events:")) {
|
|
1798
|
+
const eventsMatch = content.match(/events:\s*\{/);
|
|
1799
|
+
if (eventsMatch) {
|
|
1800
|
+
const insertPoint = eventsMatch.index! + eventsMatch[0].length;
|
|
1801
|
+
const eventDef = `\n "${name}": ${schema},`;
|
|
1802
|
+
content = content.slice(0, insertPoint) + eventDef + content.slice(insertPoint);
|
|
1803
|
+
}
|
|
1804
|
+
} else {
|
|
1805
|
+
const defineMatch = content.match(/\.define\(\{/);
|
|
1806
|
+
if (defineMatch) {
|
|
1807
|
+
const insertPoint = defineMatch.index! + defineMatch[0].length;
|
|
1808
|
+
const eventsDef = `\n events: {\n "${name}": ${schema},\n },`;
|
|
1809
|
+
content = content.slice(0, insertPoint) + eventsDef + content.slice(insertPoint);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
await Bun.write(pluginFile, content);
|
|
1814
|
+
|
|
1815
|
+
return `## Event Added: ${name}
|
|
1816
|
+
|
|
1817
|
+
**Plugin:** ${pluginName}
|
|
1818
|
+
**Schema:** ${schema}
|
|
1819
|
+
|
|
1820
|
+
### Usage
|
|
1821
|
+
|
|
1822
|
+
\`\`\`typescript
|
|
1823
|
+
// Emit event
|
|
1824
|
+
await ctx.core.events.emit("${name}", { /* data */ });
|
|
1825
|
+
|
|
1826
|
+
// Listen for event (in init hook)
|
|
1827
|
+
ctx.core.events.on("${name}", (data) => { /* handler */ });
|
|
1828
|
+
\`\`\`
|
|
1829
|
+
|
|
1830
|
+
Run \`donkeylabs generate\` to update types.
|
|
1831
|
+
`;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
if (extensionType === "middleware") {
|
|
1835
|
+
const { implementation = "return next();" } = params;
|
|
1836
|
+
|
|
1837
|
+
// Check if middleware function already exists
|
|
1838
|
+
if (content.includes("middleware:")) {
|
|
1839
|
+
const middlewareMatch = content.match(/middleware:\s*\([^)]*\)\s*=>\s*\(\{/);
|
|
1840
|
+
if (middlewareMatch) {
|
|
1841
|
+
const insertPoint = middlewareMatch.index! + middlewareMatch[0].length;
|
|
1842
|
+
const middlewareDef = `\n ${name}: createMiddleware(async (req, ctx, next, config) => {
|
|
1843
|
+
${implementation}
|
|
1844
|
+
}),`;
|
|
1845
|
+
content = content.slice(0, insertPoint) + middlewareDef + content.slice(insertPoint);
|
|
1846
|
+
}
|
|
1847
|
+
} else {
|
|
1848
|
+
// Add middleware property after service
|
|
1849
|
+
const serviceEndMatch = content.match(/service:[\s\S]*?\}\),/);
|
|
1850
|
+
if (serviceEndMatch) {
|
|
1851
|
+
const insertPoint = serviceEndMatch.index! + serviceEndMatch[0].length;
|
|
1852
|
+
const middlewareDef = `\n\n middleware: (ctx, service) => ({
|
|
1853
|
+
${name}: createMiddleware(async (req, reqCtx, next, config) => {
|
|
1854
|
+
${implementation}
|
|
1855
|
+
}),
|
|
1856
|
+
}),`;
|
|
1857
|
+
content = content.slice(0, insertPoint) + middlewareDef + content.slice(insertPoint);
|
|
1858
|
+
|
|
1859
|
+
// Add createMiddleware import if not present
|
|
1860
|
+
if (!content.includes("createMiddleware")) {
|
|
1861
|
+
content = content.replace(
|
|
1862
|
+
/import \{ createPlugin \} from/,
|
|
1863
|
+
"import { createPlugin, createMiddleware } from"
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
await Bun.write(pluginFile, content);
|
|
1870
|
+
|
|
1871
|
+
return `## Middleware Added: ${name}
|
|
1872
|
+
|
|
1873
|
+
**Plugin:** ${pluginName}
|
|
1874
|
+
|
|
1875
|
+
### Usage
|
|
1876
|
+
|
|
1877
|
+
\`\`\`typescript
|
|
1878
|
+
// In router
|
|
1879
|
+
router.middleware.${name}({ /* config */ }).route("protected").typed({ ... });
|
|
1880
|
+
\`\`\`
|
|
1881
|
+
|
|
1882
|
+
Run \`donkeylabs generate\` to update types.
|
|
1883
|
+
`;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
if (extensionType === "handler") {
|
|
1887
|
+
const { implementation = "return new Response('OK');", handlerSignature = "(body: any, ctx: ServerContext) => Promise<Response>" } = params;
|
|
1888
|
+
|
|
1889
|
+
// Check if handlers already exists
|
|
1890
|
+
if (content.includes("handlers:")) {
|
|
1891
|
+
const handlersMatch = content.match(/handlers:\s*\{/);
|
|
1892
|
+
if (handlersMatch) {
|
|
1893
|
+
const insertPoint = handlersMatch.index! + handlersMatch[0].length;
|
|
1894
|
+
content = content.slice(0, insertPoint) + `\n ${name}: createHandler<${handlerSignature}>(async (req, def, handle, ctx) => {\n ${implementation}\n }),` + content.slice(insertPoint);
|
|
1895
|
+
}
|
|
1896
|
+
} else {
|
|
1897
|
+
// Add handlers property before service
|
|
1898
|
+
const serviceMatch = content.match(/service:\s*async/);
|
|
1899
|
+
if (serviceMatch) {
|
|
1900
|
+
const insertPoint = serviceMatch.index!;
|
|
1901
|
+
const handlerDef = `handlers: {\n ${name}: createHandler<${handlerSignature}>(async (req, def, handle, ctx) => {\n ${implementation}\n }),\n },\n\n `;
|
|
1902
|
+
content = content.slice(0, insertPoint) + handlerDef + content.slice(insertPoint);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// Add createHandler import if not present
|
|
1907
|
+
if (!content.includes("createHandler")) {
|
|
1908
|
+
content = content.replace(
|
|
1909
|
+
/import \{ createPlugin/,
|
|
1910
|
+
"import { createPlugin, createHandler"
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
await Bun.write(pluginFile, content);
|
|
1915
|
+
|
|
1916
|
+
return `## Custom Handler Added: ${name}
|
|
1917
|
+
|
|
1918
|
+
**Plugin:** ${pluginName}
|
|
1919
|
+
|
|
1920
|
+
### Usage
|
|
1921
|
+
|
|
1922
|
+
After running \`donkeylabs generate\`, use in routes:
|
|
1923
|
+
\`\`\`typescript
|
|
1924
|
+
router.route("myRoute").${name}({
|
|
1925
|
+
handle: async (body, ctx) => {
|
|
1926
|
+
// Your handler logic
|
|
1927
|
+
return new Response("Result");
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
\`\`\`
|
|
1931
|
+
|
|
1932
|
+
Run \`donkeylabs generate\` to update types.
|
|
1933
|
+
`;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
return `Unknown extension type: ${extensionType}`;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
async function addCron(args: {
|
|
1940
|
+
pluginName: string;
|
|
1941
|
+
name: string;
|
|
1942
|
+
schedule: string;
|
|
1943
|
+
implementation: string;
|
|
1944
|
+
}): Promise<string> {
|
|
1945
|
+
const { pluginName, name, schedule, implementation } = args;
|
|
1946
|
+
|
|
1947
|
+
const pluginValid = validatePluginExists(pluginName);
|
|
1948
|
+
if (!pluginValid.valid) {
|
|
1949
|
+
return formatError(pluginValid.error!, undefined, pluginValid.suggestion, "create_plugin");
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
const pluginFile = join(projectRoot, projectConfig.pluginsDir, pluginName, "index.ts");
|
|
1953
|
+
let content = await Bun.file(pluginFile).text();
|
|
1954
|
+
|
|
1955
|
+
const cronCode = `ctx.core.cron.schedule("${schedule}", async () => {
|
|
1956
|
+
${implementation}
|
|
1957
|
+
}, { name: "${name}" });`;
|
|
1958
|
+
|
|
1959
|
+
// Check if init hook exists
|
|
1960
|
+
if (content.includes("init:")) {
|
|
1961
|
+
// Add to existing init
|
|
1962
|
+
const initMatch = content.match(/init:\s*\([^)]*\)\s*=>\s*\{/);
|
|
1963
|
+
if (initMatch) {
|
|
1964
|
+
const insertPoint = initMatch.index! + initMatch[0].length;
|
|
1965
|
+
content = content.slice(0, insertPoint) + `\n ${cronCode}\n` + content.slice(insertPoint);
|
|
1966
|
+
}
|
|
1967
|
+
} else {
|
|
1968
|
+
// Add init hook
|
|
1969
|
+
const serviceEndMatch = content.match(/service:[\s\S]*?\}\),/);
|
|
1970
|
+
if (serviceEndMatch) {
|
|
1971
|
+
const insertPoint = serviceEndMatch.index! + serviceEndMatch[0].length;
|
|
1972
|
+
const initDef = `\n\n init: (ctx, service) => {\n ${cronCode}\n },`;
|
|
1973
|
+
content = content.slice(0, insertPoint) + initDef + content.slice(insertPoint);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
await Bun.write(pluginFile, content);
|
|
1978
|
+
|
|
1979
|
+
return `## Cron Job Added: ${name}
|
|
1980
|
+
|
|
1981
|
+
**Plugin:** ${pluginName}
|
|
1982
|
+
**Schedule:** ${schedule}
|
|
1983
|
+
|
|
1984
|
+
### Schedule Reference
|
|
1985
|
+
|
|
1986
|
+
- \`* * * * *\` - Every minute
|
|
1987
|
+
- \`0 * * * *\` - Every hour
|
|
1988
|
+
- \`0 0 * * *\` - Daily at midnight
|
|
1989
|
+
- \`0 0 * * 0\` - Weekly on Sunday
|
|
1990
|
+
- \`0 0 1 * *\` - Monthly on 1st
|
|
1991
|
+
|
|
1992
|
+
The cron job will start when the server starts.
|
|
1993
|
+
`;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
async function addEvent(args: {
|
|
1997
|
+
pluginName: string;
|
|
1998
|
+
name: string;
|
|
1999
|
+
schema: string;
|
|
2000
|
+
}): Promise<string> {
|
|
2001
|
+
return await extendPlugin({
|
|
2002
|
+
pluginName: args.pluginName,
|
|
2003
|
+
extensionType: "event",
|
|
2004
|
+
name: args.name,
|
|
2005
|
+
params: { schema: args.schema },
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
async function addAsyncJob(args: {
|
|
2010
|
+
pluginName: string;
|
|
2011
|
+
name: string;
|
|
2012
|
+
implementation: string;
|
|
2013
|
+
}): Promise<string> {
|
|
2014
|
+
const { pluginName, name, implementation } = args;
|
|
2015
|
+
|
|
2016
|
+
const pluginValid = validatePluginExists(pluginName);
|
|
2017
|
+
if (!pluginValid.valid) {
|
|
2018
|
+
return formatError(pluginValid.error!, undefined, pluginValid.suggestion, "create_plugin");
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
const pluginFile = join(projectRoot, projectConfig.pluginsDir, pluginName, "index.ts");
|
|
2022
|
+
let content = await Bun.file(pluginFile).text();
|
|
2023
|
+
|
|
2024
|
+
const jobCode = `ctx.core.jobs.register("${name}", async (data) => {
|
|
2025
|
+
${implementation}
|
|
2026
|
+
});`;
|
|
2027
|
+
|
|
2028
|
+
// Check if init hook exists
|
|
2029
|
+
if (content.includes("init:")) {
|
|
2030
|
+
const initMatch = content.match(/init:\s*\([^)]*\)\s*=>\s*\{/);
|
|
2031
|
+
if (initMatch) {
|
|
2032
|
+
const insertPoint = initMatch.index! + initMatch[0].length;
|
|
2033
|
+
content = content.slice(0, insertPoint) + `\n ${jobCode}\n` + content.slice(insertPoint);
|
|
2034
|
+
}
|
|
2035
|
+
} else {
|
|
2036
|
+
const serviceEndMatch = content.match(/service:[\s\S]*?\}\),/);
|
|
2037
|
+
if (serviceEndMatch) {
|
|
2038
|
+
const insertPoint = serviceEndMatch.index! + serviceEndMatch[0].length;
|
|
2039
|
+
const initDef = `\n\n init: (ctx, service) => {\n ${jobCode}\n },`;
|
|
2040
|
+
content = content.slice(0, insertPoint) + initDef + content.slice(insertPoint);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
await Bun.write(pluginFile, content);
|
|
2045
|
+
|
|
2046
|
+
return `## Background Job Added: ${name}
|
|
2047
|
+
|
|
2048
|
+
**Plugin:** ${pluginName}
|
|
2049
|
+
|
|
2050
|
+
### Usage
|
|
2051
|
+
|
|
2052
|
+
\`\`\`typescript
|
|
2053
|
+
// Enqueue a job
|
|
2054
|
+
await ctx.core.jobs.enqueue("${name}", { /* data */ });
|
|
2055
|
+
|
|
2056
|
+
// Schedule for later
|
|
2057
|
+
await ctx.core.jobs.schedule("${name}", { data }, new Date(Date.now() + 3600000));
|
|
2058
|
+
\`\`\`
|
|
2059
|
+
|
|
2060
|
+
Jobs are processed automatically with retries.
|
|
2061
|
+
`;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
async function addSSERoute(args: {
|
|
2065
|
+
routerFile: string;
|
|
2066
|
+
routeName: string;
|
|
2067
|
+
channel: string;
|
|
2068
|
+
}): Promise<string> {
|
|
2069
|
+
const { routerFile, routeName, channel } = args;
|
|
2070
|
+
|
|
2071
|
+
const routerValid = validateRouterExists(routerFile);
|
|
2072
|
+
if (!routerValid.valid) {
|
|
2073
|
+
return formatError(routerValid.error!, undefined, routerValid.suggestion, "create_router");
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const fullPath = join(projectRoot, routerFile);
|
|
2077
|
+
let content = await Bun.file(fullPath).text();
|
|
2078
|
+
|
|
2079
|
+
const sseDef = `\n .route("${routeName}").raw({
|
|
2080
|
+
handle: async (req, ctx) => {
|
|
2081
|
+
const { client, response } = ctx.core.sse.addClient();
|
|
2082
|
+
ctx.core.sse.subscribe(client.id, "${channel}");
|
|
2083
|
+
return response;
|
|
2084
|
+
},
|
|
2085
|
+
})`;
|
|
2086
|
+
|
|
2087
|
+
const insertPoint = content.lastIndexOf(";");
|
|
2088
|
+
if (insertPoint !== -1) {
|
|
2089
|
+
content = content.slice(0, insertPoint) + sseDef + content.slice(insertPoint);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
await Bun.write(fullPath, content);
|
|
2093
|
+
|
|
2094
|
+
return `## SSE Route Added: ${routeName}
|
|
2095
|
+
|
|
2096
|
+
**Router:** ${routerFile}
|
|
2097
|
+
**Channel:** ${channel}
|
|
2098
|
+
|
|
2099
|
+
### Client Usage
|
|
2100
|
+
|
|
2101
|
+
\`\`\`javascript
|
|
2102
|
+
const eventSource = new EventSource("/<prefix>.${routeName}");
|
|
2103
|
+
eventSource.onmessage = (event) => {
|
|
2104
|
+
console.log("Received:", event.data);
|
|
2105
|
+
};
|
|
2106
|
+
\`\`\`
|
|
2107
|
+
|
|
2108
|
+
### Server Broadcasting
|
|
2109
|
+
|
|
2110
|
+
\`\`\`typescript
|
|
2111
|
+
ctx.core.sse.broadcast("${channel}", "event-name", { data });
|
|
2112
|
+
\`\`\`
|
|
2113
|
+
`;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
async function listPlugins(): Promise<string> {
|
|
2117
|
+
const pluginsDir = join(projectRoot, projectConfig.pluginsDir);
|
|
2118
|
+
|
|
2119
|
+
if (!existsSync(pluginsDir)) {
|
|
2120
|
+
return `No plugins directory found at ${projectConfig.pluginsDir}/. Create a plugin using \`create_plugin\` tool.`;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
const plugins: string[] = [];
|
|
2124
|
+
|
|
2125
|
+
for (const entry of readdirSync(pluginsDir)) {
|
|
2126
|
+
const pluginPath = join(pluginsDir, entry);
|
|
2127
|
+
if (!statSync(pluginPath).isDirectory()) continue;
|
|
2128
|
+
|
|
2129
|
+
const indexPath = join(pluginPath, "index.ts");
|
|
2130
|
+
if (!existsSync(indexPath)) continue;
|
|
2131
|
+
|
|
2132
|
+
const content = await Bun.file(indexPath).text();
|
|
2133
|
+
|
|
2134
|
+
// Extract service methods - look for method patterns like `methodName: async (...` or `methodName: (...`
|
|
2135
|
+
const methods: string[] = [];
|
|
2136
|
+
|
|
2137
|
+
// Find the return statement in service
|
|
2138
|
+
const serviceReturnMatch = content.match(/service:\s*async\s*\([^)]*\)\s*=>\s*\{[\s\S]*?return\s*\{([\s\S]*?)\};?\s*\},/);
|
|
2139
|
+
if (serviceReturnMatch) {
|
|
2140
|
+
// Extract method names from return object
|
|
2141
|
+
const returnBlock = serviceReturnMatch[1];
|
|
2142
|
+
const methodMatches = returnBlock.matchAll(/(\w+):\s*(?:async\s*)?\(/g);
|
|
2143
|
+
for (const m of methodMatches) {
|
|
2144
|
+
if (!methods.includes(m[1])) {
|
|
2145
|
+
methods.push(m[1]);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
} else {
|
|
2149
|
+
// Try arrow function style: service: async (ctx) => ({...})
|
|
2150
|
+
const arrowMatch = content.match(/service:\s*async\s*\([^)]*\)\s*=>\s*\(\{([\s\S]*?)\}\)/);
|
|
2151
|
+
if (arrowMatch) {
|
|
2152
|
+
const returnBlock = arrowMatch[1];
|
|
2153
|
+
const methodMatches = returnBlock.matchAll(/(\w+):\s*(?:async\s*)?\(/g);
|
|
2154
|
+
for (const m of methodMatches) {
|
|
2155
|
+
if (!methods.includes(m[1])) {
|
|
2156
|
+
methods.push(m[1]);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
const depsMatch = content.match(/dependencies:\s*\[([^\]]*)\]/);
|
|
2163
|
+
const deps = depsMatch ? depsMatch[1].trim() : "";
|
|
2164
|
+
|
|
2165
|
+
const hasSchema = existsSync(join(pluginPath, "schema.ts"));
|
|
2166
|
+
const hasMigrations = existsSync(join(pluginPath, "migrations"));
|
|
2167
|
+
|
|
2168
|
+
plugins.push(`### ${entry}
|
|
2169
|
+
- **Methods:** ${methods.length > 0 ? methods.join(", ") : "none"}
|
|
2170
|
+
- **Dependencies:** ${deps || "none"}
|
|
2171
|
+
- **Database:** ${hasSchema || hasMigrations ? "yes" : "no"}
|
|
2172
|
+
- **Path:** ${projectConfig.pluginsDir}/${entry}/`);
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
if (plugins.length === 0) {
|
|
2176
|
+
return "No plugins found. Use `create_plugin` to create one.";
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
return `# Plugins (${plugins.length})\n\n${plugins.join("\n\n")}`;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
async function generateTypes(args: { target?: string }): Promise<string> {
|
|
2183
|
+
try {
|
|
2184
|
+
const proc = Bun.spawn(["bun", "run", "donkeylabs", "generate"], {
|
|
2185
|
+
cwd: projectRoot,
|
|
2186
|
+
stdout: "pipe",
|
|
2187
|
+
stderr: "pipe",
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
const output = await new Response(proc.stdout).text();
|
|
2191
|
+
const error = await new Response(proc.stderr).text();
|
|
2192
|
+
|
|
2193
|
+
await proc.exited;
|
|
2194
|
+
|
|
2195
|
+
if (proc.exitCode !== 0) {
|
|
2196
|
+
return formatError(
|
|
2197
|
+
"Type generation failed",
|
|
2198
|
+
error || output,
|
|
2199
|
+
"Check that your project is properly configured and all plugins are valid."
|
|
2200
|
+
);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
return `## Types Generated Successfully
|
|
2204
|
+
|
|
2205
|
+
${output}
|
|
2206
|
+
|
|
2207
|
+
Types are now up-to-date. Your IDE should recognize the new types.
|
|
2208
|
+
`;
|
|
2209
|
+
} catch (e) {
|
|
2210
|
+
return formatError(
|
|
2211
|
+
"Error running generation",
|
|
2212
|
+
String(e),
|
|
2213
|
+
"Make sure the donkeylabs CLI is installed: `bun add @donkeylabs/cli`"
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
async function runCodegen(args: { command: string; outputPath?: string }): Promise<string> {
|
|
2219
|
+
const { command, outputPath } = args;
|
|
2220
|
+
|
|
2221
|
+
try {
|
|
2222
|
+
const cmdArgs = ["run", "donkeylabs", command];
|
|
2223
|
+
if (outputPath) {
|
|
2224
|
+
cmdArgs.push("--output", outputPath);
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
const proc = Bun.spawn(["bun", ...cmdArgs], {
|
|
2228
|
+
cwd: projectRoot,
|
|
2229
|
+
stdout: "pipe",
|
|
2230
|
+
stderr: "pipe",
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
const output = await new Response(proc.stdout).text();
|
|
2234
|
+
const error = await new Response(proc.stderr).text();
|
|
2235
|
+
|
|
2236
|
+
await proc.exited;
|
|
2237
|
+
|
|
2238
|
+
if (proc.exitCode !== 0) {
|
|
2239
|
+
return formatError(
|
|
2240
|
+
`Command 'donkeylabs ${command}' failed`,
|
|
2241
|
+
error || output,
|
|
2242
|
+
"Check the error message and fix any issues."
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
return `## Command Completed: donkeylabs ${command}
|
|
2247
|
+
|
|
2248
|
+
${output}
|
|
2249
|
+
`;
|
|
2250
|
+
} catch (e) {
|
|
2251
|
+
return formatError(
|
|
2252
|
+
"Error running command",
|
|
2253
|
+
String(e),
|
|
2254
|
+
"Make sure the donkeylabs CLI is installed."
|
|
2255
|
+
);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
async function generateClient(args: { outputPath?: string }): Promise<string> {
|
|
2260
|
+
const { outputPath } = args;
|
|
2261
|
+
|
|
2262
|
+
// Determine default output path based on project type
|
|
2263
|
+
const defaultOutput = projectConfig.adapter === "sveltekit"
|
|
2264
|
+
? projectConfig.clientOutput || "src/lib/api.ts"
|
|
2265
|
+
: projectConfig.clientOutput || "./client/index.ts";
|
|
2266
|
+
|
|
2267
|
+
const finalOutput = outputPath || defaultOutput;
|
|
2268
|
+
|
|
2269
|
+
try {
|
|
2270
|
+
const cmdArgs = ["run", "donkeylabs", "generate"];
|
|
2271
|
+
|
|
2272
|
+
const proc = Bun.spawn(["bun", ...cmdArgs], {
|
|
2273
|
+
cwd: projectRoot,
|
|
2274
|
+
stdout: "pipe",
|
|
2275
|
+
stderr: "pipe",
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
const output = await new Response(proc.stdout).text();
|
|
2279
|
+
const error = await new Response(proc.stderr).text();
|
|
2280
|
+
|
|
2281
|
+
await proc.exited;
|
|
2282
|
+
|
|
2283
|
+
if (proc.exitCode !== 0) {
|
|
2284
|
+
return formatError(
|
|
2285
|
+
"Client generation failed",
|
|
2286
|
+
error || output,
|
|
2287
|
+
"Check that your routes are properly defined and the project is configured."
|
|
2288
|
+
);
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// Build usage instructions based on project type
|
|
2292
|
+
let usage = "";
|
|
2293
|
+
if (projectConfig.adapter === "sveltekit") {
|
|
2294
|
+
const importPath = finalOutput.startsWith("src/lib/") ? "$lib/" + finalOutput.slice(8).replace(/\.ts$/, "") : finalOutput.replace(/\.ts$/, "");
|
|
2295
|
+
usage = `### SvelteKit Usage
|
|
2296
|
+
|
|
2297
|
+
**SSR (server-side, +page.server.ts):**
|
|
2298
|
+
\`\`\`typescript
|
|
2299
|
+
import { createApi } from '${importPath}';
|
|
2300
|
+
|
|
2301
|
+
export const load = async ({ locals }) => {
|
|
2302
|
+
const api = createApi({ locals }); // Direct calls, no HTTP!
|
|
2303
|
+
const data = await api.myRoute.get({});
|
|
2304
|
+
return { data };
|
|
2305
|
+
};
|
|
2306
|
+
\`\`\`
|
|
2307
|
+
|
|
2308
|
+
**Browser (+page.svelte):**
|
|
2309
|
+
\`\`\`svelte
|
|
2310
|
+
<script>
|
|
2311
|
+
import { createApi } from '${importPath}';
|
|
2312
|
+
const api = createApi(); // HTTP calls
|
|
2313
|
+
let data = $state(null);
|
|
2314
|
+
async function load() {
|
|
2315
|
+
data = await api.myRoute.get({});
|
|
2316
|
+
}
|
|
2317
|
+
</script>
|
|
2318
|
+
\`\`\`
|
|
2319
|
+
|
|
2320
|
+
**Key Benefit:** SSR calls go directly to your route handlers without HTTP overhead!
|
|
2321
|
+
|
|
2322
|
+
### Using Route Types
|
|
2323
|
+
|
|
2324
|
+
Import types for forms, validation, or custom components:
|
|
2325
|
+
\`\`\`typescript
|
|
2326
|
+
import { type Routes } from '${importPath}';
|
|
2327
|
+
|
|
2328
|
+
// Use route types directly
|
|
2329
|
+
type UserInput = Routes.Users.Create.Input;
|
|
2330
|
+
type UserOutput = Routes.Users.Create.Output;
|
|
2331
|
+
|
|
2332
|
+
// Example: Form component
|
|
2333
|
+
function UserForm(props: { onSubmit: (data: UserInput) => void }) {
|
|
2334
|
+
// Fully typed input
|
|
2335
|
+
}
|
|
2336
|
+
\`\`\``;
|
|
2337
|
+
} else {
|
|
2338
|
+
usage = `### Usage
|
|
2339
|
+
|
|
2340
|
+
\`\`\`typescript
|
|
2341
|
+
import { createApiClient } from '${finalOutput.replace(/\.ts$/, "")}';
|
|
2342
|
+
|
|
2343
|
+
const api = createApiClient({ baseUrl: 'http://localhost:3000' });
|
|
2344
|
+
|
|
2345
|
+
// Typed route calls
|
|
2346
|
+
const result = await api.myRoute.get({ id: 1 });
|
|
2347
|
+
|
|
2348
|
+
// SSE events
|
|
2349
|
+
api.connect();
|
|
2350
|
+
api.on('events.new', (data) => console.log(data));
|
|
2351
|
+
\`\`\`
|
|
2352
|
+
|
|
2353
|
+
### Using Route Types
|
|
2354
|
+
|
|
2355
|
+
Import types for your frontend code:
|
|
2356
|
+
\`\`\`typescript
|
|
2357
|
+
import { type Routes } from '${finalOutput.replace(/\.ts$/, "")}';
|
|
2358
|
+
|
|
2359
|
+
// Access route input/output types
|
|
2360
|
+
type UserInput = Routes.Users.Create.Input;
|
|
2361
|
+
type UserOutput = Routes.Users.Create.Output;
|
|
2362
|
+
\`\`\``;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
return `## API Client Generated
|
|
2366
|
+
|
|
2367
|
+
**Output:** ${finalOutput}
|
|
2368
|
+
**Type:** ${projectConfig.adapter === "sveltekit" ? "SvelteKit Unified Client (SSR + Browser)" : "Standard HTTP Client"}
|
|
2369
|
+
|
|
2370
|
+
${usage}
|
|
2371
|
+
|
|
2372
|
+
### When to Regenerate
|
|
2373
|
+
Run \`generate_client\` again when you:
|
|
2374
|
+
- Add new routes
|
|
2375
|
+
- Modify route input/output schemas
|
|
2376
|
+
- Add plugin events
|
|
2377
|
+
|
|
2378
|
+
${output}
|
|
2379
|
+
`;
|
|
2380
|
+
} catch (e) {
|
|
2381
|
+
return formatError(
|
|
2382
|
+
"Error generating client",
|
|
2383
|
+
String(e),
|
|
2384
|
+
"Make sure the donkeylabs CLI is installed: `bun add @donkeylabs/cli`"
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// =============================================================================
|
|
2390
|
+
// SERVER SETUP
|
|
2391
|
+
// =============================================================================
|
|
2392
|
+
|
|
2393
|
+
const server = new Server(
|
|
2394
|
+
{
|
|
2395
|
+
name: "donkeylabs-mcp",
|
|
2396
|
+
version: "0.2.0",
|
|
2397
|
+
},
|
|
2398
|
+
{
|
|
2399
|
+
capabilities: {
|
|
2400
|
+
tools: {},
|
|
2401
|
+
resources: {},
|
|
2402
|
+
},
|
|
2403
|
+
}
|
|
2404
|
+
);
|
|
2405
|
+
|
|
2406
|
+
// Resource handlers
|
|
2407
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
2408
|
+
resources: RESOURCES.map(({ uri, name, description, mimeType }) => ({
|
|
2409
|
+
uri,
|
|
2410
|
+
name,
|
|
2411
|
+
description,
|
|
2412
|
+
mimeType,
|
|
2413
|
+
})),
|
|
2414
|
+
}));
|
|
2415
|
+
|
|
2416
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
2417
|
+
const { uri } = request.params;
|
|
2418
|
+
const content = await readResource(uri);
|
|
2419
|
+
|
|
2420
|
+
return {
|
|
2421
|
+
contents: [
|
|
2422
|
+
{
|
|
2423
|
+
uri,
|
|
2424
|
+
mimeType: "text/markdown",
|
|
2425
|
+
text: content,
|
|
2426
|
+
},
|
|
2427
|
+
],
|
|
2428
|
+
};
|
|
2429
|
+
});
|
|
2430
|
+
|
|
2431
|
+
// Tool handlers
|
|
2432
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
2433
|
+
tools,
|
|
2434
|
+
}));
|
|
2435
|
+
|
|
2436
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2437
|
+
const { name, arguments: args } = request.params;
|
|
2438
|
+
|
|
2439
|
+
try {
|
|
2440
|
+
let result: string;
|
|
2441
|
+
|
|
2442
|
+
switch (name) {
|
|
2443
|
+
case "get_project_info":
|
|
2444
|
+
result = await getProjectInfo();
|
|
2445
|
+
break;
|
|
2446
|
+
case "get_architecture_guidance":
|
|
2447
|
+
result = await getArchitectureGuidance(args as { task: string });
|
|
2448
|
+
break;
|
|
2449
|
+
case "create_plugin":
|
|
2450
|
+
result = await createPlugin(args as Parameters<typeof createPlugin>[0]);
|
|
2451
|
+
break;
|
|
2452
|
+
case "add_service_method":
|
|
2453
|
+
result = await addServiceMethod(args as Parameters<typeof addServiceMethod>[0]);
|
|
2454
|
+
break;
|
|
2455
|
+
case "add_migration":
|
|
2456
|
+
result = await addMigration(args as Parameters<typeof addMigration>[0]);
|
|
2457
|
+
break;
|
|
2458
|
+
case "create_router":
|
|
2459
|
+
result = await createRouter(args as Parameters<typeof createRouter>[0]);
|
|
2460
|
+
break;
|
|
2461
|
+
case "add_route":
|
|
2462
|
+
result = await addRoute(args as Parameters<typeof addRoute>[0]);
|
|
2463
|
+
break;
|
|
2464
|
+
case "add_handler_to_router":
|
|
2465
|
+
result = await addHandlerToRouter(args as Parameters<typeof addHandlerToRouter>[0]);
|
|
2466
|
+
break;
|
|
2467
|
+
case "extend_plugin":
|
|
2468
|
+
result = await extendPlugin(args as Parameters<typeof extendPlugin>[0]);
|
|
2469
|
+
break;
|
|
2470
|
+
case "add_cron":
|
|
2471
|
+
result = await addCron(args as Parameters<typeof addCron>[0]);
|
|
2472
|
+
break;
|
|
2473
|
+
case "add_event":
|
|
2474
|
+
result = await addEvent(args as Parameters<typeof addEvent>[0]);
|
|
2475
|
+
break;
|
|
2476
|
+
case "add_async_job":
|
|
2477
|
+
result = await addAsyncJob(args as Parameters<typeof addAsyncJob>[0]);
|
|
2478
|
+
break;
|
|
2479
|
+
case "add_sse_route":
|
|
2480
|
+
result = await addSSERoute(args as Parameters<typeof addSSERoute>[0]);
|
|
2481
|
+
break;
|
|
2482
|
+
case "list_plugins":
|
|
2483
|
+
result = await listPlugins();
|
|
2484
|
+
break;
|
|
2485
|
+
case "generate_types":
|
|
2486
|
+
result = await generateTypes(args as { target?: string });
|
|
2487
|
+
break;
|
|
2488
|
+
case "run_codegen":
|
|
2489
|
+
result = await runCodegen(args as Parameters<typeof runCodegen>[0]);
|
|
2490
|
+
break;
|
|
2491
|
+
case "generate_client":
|
|
2492
|
+
result = await generateClient(args as { outputPath?: string });
|
|
2493
|
+
break;
|
|
2494
|
+
default:
|
|
2495
|
+
result = formatError(
|
|
2496
|
+
`Unknown tool: ${name}`,
|
|
2497
|
+
undefined,
|
|
2498
|
+
`Available tools: ${tools.map(t => t.name).join(", ")}`
|
|
2499
|
+
);
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
return {
|
|
2503
|
+
content: [{ type: "text", text: result }],
|
|
2504
|
+
};
|
|
2505
|
+
} catch (error) {
|
|
2506
|
+
return {
|
|
2507
|
+
content: [
|
|
2508
|
+
{
|
|
2509
|
+
type: "text",
|
|
2510
|
+
text: formatError(
|
|
2511
|
+
error instanceof Error ? error.message : String(error),
|
|
2512
|
+
undefined,
|
|
2513
|
+
"This may be a bug. Please report it."
|
|
2514
|
+
),
|
|
2515
|
+
},
|
|
2516
|
+
],
|
|
2517
|
+
isError: true,
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
});
|
|
2521
|
+
|
|
2522
|
+
// Start server
|
|
2523
|
+
const transport = new StdioServerTransport();
|
|
2524
|
+
await server.connect(transport);
|