@donkeylabs/cli 0.1.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 +141 -0
- package/package.json +51 -0
- package/src/commands/generate.ts +585 -0
- package/src/commands/init.ts +201 -0
- package/src/commands/interactive.ts +223 -0
- package/src/commands/plugin.ts +205 -0
- package/src/index.ts +108 -0
- package/templates/starter/.env.example +3 -0
- package/templates/starter/.gitignore.template +4 -0
- package/templates/starter/CLAUDE.md +144 -0
- package/templates/starter/donkeylabs.config.ts +6 -0
- package/templates/starter/package.json +21 -0
- package/templates/starter/src/client.test.ts +7 -0
- package/templates/starter/src/db.ts +9 -0
- package/templates/starter/src/index.ts +48 -0
- package/templates/starter/src/plugins/stats/index.ts +105 -0
- package/templates/starter/src/routes/health/index.ts +5 -0
- package/templates/starter/src/routes/health/ping/index.ts +13 -0
- package/templates/starter/src/routes/health/ping/models/model.ts +23 -0
- package/templates/starter/src/routes/health/ping/schema.ts +14 -0
- package/templates/starter/src/routes/health/ping/tests/integ.test.ts +20 -0
- package/templates/starter/src/routes/health/ping/tests/unit.test.ts +21 -0
- package/templates/starter/src/test-ctx.ts +24 -0
- package/templates/starter/tsconfig.json +27 -0
- package/templates/sveltekit-app/.env.example +3 -0
- package/templates/sveltekit-app/README.md +103 -0
- package/templates/sveltekit-app/donkeylabs.config.ts +10 -0
- package/templates/sveltekit-app/package.json +36 -0
- package/templates/sveltekit-app/src/app.css +40 -0
- package/templates/sveltekit-app/src/app.html +12 -0
- package/templates/sveltekit-app/src/hooks.server.ts +4 -0
- package/templates/sveltekit-app/src/lib/api.ts +134 -0
- package/templates/sveltekit-app/src/lib/components/ui/badge/badge.svelte +30 -0
- package/templates/sveltekit-app/src/lib/components/ui/badge/index.ts +3 -0
- package/templates/sveltekit-app/src/lib/components/ui/button/button.svelte +48 -0
- package/templates/sveltekit-app/src/lib/components/ui/button/index.ts +9 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card-content.svelte +18 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card-description.svelte +18 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card-footer.svelte +18 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card-header.svelte +18 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card-title.svelte +18 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/card.svelte +21 -0
- package/templates/sveltekit-app/src/lib/components/ui/card/index.ts +21 -0
- package/templates/sveltekit-app/src/lib/components/ui/index.ts +4 -0
- package/templates/sveltekit-app/src/lib/components/ui/input/index.ts +2 -0
- package/templates/sveltekit-app/src/lib/components/ui/input/input.svelte +20 -0
- package/templates/sveltekit-app/src/lib/utils/index.ts +6 -0
- package/templates/sveltekit-app/src/routes/+layout.svelte +8 -0
- package/templates/sveltekit-app/src/routes/+page.server.ts +25 -0
- package/templates/sveltekit-app/src/routes/+page.svelte +401 -0
- package/templates/sveltekit-app/src/server/index.ts +263 -0
- package/templates/sveltekit-app/static/robots.txt +3 -0
- package/templates/sveltekit-app/svelte.config.js +18 -0
- package/templates/sveltekit-app/tsconfig.json +25 -0
- package/templates/sveltekit-app/vite.config.ts +7 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import { readdir, writeFile, readFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join, relative, dirname, basename } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
|
|
7
|
+
interface DonkeylabsConfig {
|
|
8
|
+
plugins: string[];
|
|
9
|
+
outDir?: string;
|
|
10
|
+
client?: {
|
|
11
|
+
output: string;
|
|
12
|
+
};
|
|
13
|
+
routes?: string; // Route files pattern, default: "./src/routes/**/handler.ts"
|
|
14
|
+
entry?: string; // Server entry file for extracting routes, default: "./src/index.ts"
|
|
15
|
+
adapter?: string; // Adapter package for framework-specific generation, e.g., "@donkeylabs/adapter-sveltekit"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function loadConfig(): Promise<DonkeylabsConfig> {
|
|
19
|
+
const configPath = join(process.cwd(), "donkeylabs.config.ts");
|
|
20
|
+
|
|
21
|
+
if (!existsSync(configPath)) {
|
|
22
|
+
throw new Error("donkeylabs.config.ts not found. Run 'donkeylabs init' first.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const config = await import(configPath);
|
|
26
|
+
return config.default;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function getPluginExportName(pluginPath: string): Promise<string | null> {
|
|
30
|
+
try {
|
|
31
|
+
const content = await readFile(pluginPath, "utf-8");
|
|
32
|
+
const match = content.match(/export\s+const\s+(\w+Plugin)\s*=/);
|
|
33
|
+
return match?.[1] ?? null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function getPluginDefinedName(pluginPath: string): Promise<string | null> {
|
|
40
|
+
try {
|
|
41
|
+
const content = await readFile(pluginPath, "utf-8");
|
|
42
|
+
// Match name: "pluginName" or name: 'pluginName' in createPlugin.define({ name: "..." })
|
|
43
|
+
const match = content.match(/createPlugin(?:\.[\w<>(),\s]+)*\.define\s*\(\s*\{[^}]*name:\s*["'](\w+)["']/s);
|
|
44
|
+
return match?.[1] ?? null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function extractHandlerNames(pluginPath: string): Promise<string[]> {
|
|
51
|
+
try {
|
|
52
|
+
const content = await readFile(pluginPath, "utf-8");
|
|
53
|
+
const handlersMatch = content.match(/handlers:\s*\{([^}]+)\}/);
|
|
54
|
+
if (!handlersMatch?.[1]) return [];
|
|
55
|
+
|
|
56
|
+
const handlersBlock = handlersMatch[1];
|
|
57
|
+
return [...handlersBlock.matchAll(/(\w+)\s*:/g)]
|
|
58
|
+
.map((m) => m[1])
|
|
59
|
+
.filter((name): name is string => !!name);
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function extractMiddlewareNames(pluginPath: string): Promise<string[]> {
|
|
66
|
+
try {
|
|
67
|
+
const content = await readFile(pluginPath, "utf-8");
|
|
68
|
+
|
|
69
|
+
// Look for middleware definitions: `name: createMiddleware(...)`
|
|
70
|
+
// This works for both old `middleware: { timing: createMiddleware(...) }`
|
|
71
|
+
// and new `middleware: (ctx) => ({ timing: createMiddleware(...) })`
|
|
72
|
+
const middlewareNames = [...content.matchAll(/(\w+)\s*:\s*createMiddleware\s*\(/g)]
|
|
73
|
+
.map((m) => m[1])
|
|
74
|
+
.filter((name): name is string => !!name);
|
|
75
|
+
|
|
76
|
+
return middlewareNames;
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ExtractedRoute {
|
|
83
|
+
name: string;
|
|
84
|
+
handler: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function extractRoutesFromServer(entryPath: string): Promise<ExtractedRoute[]> {
|
|
88
|
+
const fullPath = join(process.cwd(), entryPath);
|
|
89
|
+
|
|
90
|
+
if (!existsSync(fullPath)) {
|
|
91
|
+
console.warn(pc.yellow(`Entry file not found: ${entryPath}, skipping route extraction`));
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const child = spawn("bun", [fullPath], {
|
|
97
|
+
env: { ...process.env, DONKEYLABS_GENERATE: "1" },
|
|
98
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
99
|
+
cwd: process.cwd(),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
let stdout = "";
|
|
103
|
+
let stderr = "";
|
|
104
|
+
|
|
105
|
+
child.stdout?.on("data", (data) => {
|
|
106
|
+
stdout += data.toString();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
child.stderr?.on("data", (data) => {
|
|
110
|
+
stderr += data.toString();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
child.on("close", (code) => {
|
|
114
|
+
if (code !== 0) {
|
|
115
|
+
console.warn(pc.yellow(`Failed to extract routes from server (exit code ${code})`));
|
|
116
|
+
if (stderr) console.warn(pc.dim(stderr));
|
|
117
|
+
resolve([]);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Parse the JSON output from server
|
|
123
|
+
const result = JSON.parse(stdout.trim());
|
|
124
|
+
resolve(result.routes || []);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.warn(pc.yellow("Failed to parse route data from server"));
|
|
127
|
+
resolve([]);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
child.on("error", (err) => {
|
|
132
|
+
console.warn(pc.yellow(`Failed to run entry file: ${err.message}`));
|
|
133
|
+
resolve([]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async function findPlugins(
|
|
140
|
+
patterns: string[]
|
|
141
|
+
): Promise<{ name: string; path: string; exportName: string }[]> {
|
|
142
|
+
const plugins: { name: string; path: string; exportName: string }[] = [];
|
|
143
|
+
|
|
144
|
+
for (const pattern of patterns) {
|
|
145
|
+
const baseDir = pattern.includes("**")
|
|
146
|
+
? pattern.split("**")[0] || "."
|
|
147
|
+
: dirname(pattern);
|
|
148
|
+
|
|
149
|
+
const targetDir = join(process.cwd(), baseDir);
|
|
150
|
+
if (!existsSync(targetDir)) continue;
|
|
151
|
+
|
|
152
|
+
async function scanDir(dir: string): Promise<void> {
|
|
153
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
154
|
+
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const fullPath = join(dir, entry.name);
|
|
157
|
+
|
|
158
|
+
if (entry.isDirectory()) {
|
|
159
|
+
await scanDir(fullPath);
|
|
160
|
+
} else if (entry.name === "index.ts") {
|
|
161
|
+
const exportName = await getPluginExportName(fullPath);
|
|
162
|
+
if (exportName) {
|
|
163
|
+
// Get the actual plugin name from the define() call, fall back to directory name
|
|
164
|
+
const definedName = await getPluginDefinedName(fullPath);
|
|
165
|
+
const pluginName = definedName || dirname(fullPath).split("/").pop()!;
|
|
166
|
+
plugins.push({
|
|
167
|
+
name: pluginName,
|
|
168
|
+
path: relative(process.cwd(), fullPath),
|
|
169
|
+
exportName,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await scanDir(targetDir);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return plugins;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function generateCommand(_args: string[]): Promise<void> {
|
|
183
|
+
const config = await loadConfig();
|
|
184
|
+
const outDir = config.outDir || ".@donkeylabs/server";
|
|
185
|
+
const outPath = join(process.cwd(), outDir);
|
|
186
|
+
|
|
187
|
+
await mkdir(outPath, { recursive: true });
|
|
188
|
+
|
|
189
|
+
const plugins = await findPlugins(config.plugins);
|
|
190
|
+
const fileRoutes = await findRoutes(config.routes || "./src/routes/**/schema.ts");
|
|
191
|
+
|
|
192
|
+
// Extract routes from server entry file
|
|
193
|
+
const entryPath = config.entry || "./src/index.ts";
|
|
194
|
+
const serverRoutes = await extractRoutesFromServer(entryPath);
|
|
195
|
+
|
|
196
|
+
// Generate all files
|
|
197
|
+
await generateRegistry(plugins, outPath);
|
|
198
|
+
await generateContext(plugins, outPath);
|
|
199
|
+
await generateRouteTypes(fileRoutes, outPath);
|
|
200
|
+
|
|
201
|
+
const generated = ["registry", "context", "routes"];
|
|
202
|
+
|
|
203
|
+
// Determine client output path
|
|
204
|
+
const clientOutput = config.client?.output || join(outPath, "client.ts");
|
|
205
|
+
|
|
206
|
+
// Check if adapter provides a custom generator
|
|
207
|
+
if (config.adapter) {
|
|
208
|
+
try {
|
|
209
|
+
// Dynamically import adapter's generator
|
|
210
|
+
const adapterModule = await import(`${config.adapter}/generator`);
|
|
211
|
+
if (adapterModule.generateClient) {
|
|
212
|
+
await adapterModule.generateClient(config, serverRoutes, clientOutput);
|
|
213
|
+
generated.push(`client (${config.adapter})`);
|
|
214
|
+
console.log(pc.green("Generated:"), generated.map(g => pc.dim(g)).join(", "));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
} catch (e) {
|
|
218
|
+
// Adapter doesn't provide generator or import failed, fall back to default
|
|
219
|
+
console.log(pc.dim(`Note: Adapter ${config.adapter} has no custom generator, using default`));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Default client generation
|
|
224
|
+
await generateClientFromRoutes(serverRoutes, clientOutput);
|
|
225
|
+
generated.push("client");
|
|
226
|
+
|
|
227
|
+
console.log(pc.green("Generated:"), generated.map(g => pc.dim(g)).join(", "));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function generateRegistry(
|
|
231
|
+
plugins: { name: string; path: string; exportName: string }[],
|
|
232
|
+
outPath: string
|
|
233
|
+
) {
|
|
234
|
+
const importLines = plugins
|
|
235
|
+
.map(
|
|
236
|
+
(p) =>
|
|
237
|
+
`import { ${p.exportName} } from "${join(process.cwd(), p.path).replace(/\.ts$/, "")}";`
|
|
238
|
+
)
|
|
239
|
+
.join("\n");
|
|
240
|
+
|
|
241
|
+
const pluginRegistryEntries = plugins
|
|
242
|
+
.map(
|
|
243
|
+
(p) =>
|
|
244
|
+
` ${p.name}: Register<InferService<typeof ${p.exportName}>, InferSchema<typeof ${p.exportName}>, InferHandlers<typeof ${p.exportName}>, InferDependencies<typeof ${p.exportName}>, InferMiddleware<typeof ${p.exportName}>>;`
|
|
245
|
+
)
|
|
246
|
+
.join("\n");
|
|
247
|
+
|
|
248
|
+
const handlerExtensions =
|
|
249
|
+
plugins.map((p) => `InferHandlers<typeof ${p.exportName}>`).join(",\n ") ||
|
|
250
|
+
"{}";
|
|
251
|
+
|
|
252
|
+
const middlewareExtensions =
|
|
253
|
+
plugins
|
|
254
|
+
.map((p) => `InferMiddleware<typeof ${p.exportName}>`)
|
|
255
|
+
.join(",\n ") || "{}";
|
|
256
|
+
|
|
257
|
+
// Collect handlers and middleware from each plugin
|
|
258
|
+
const allHandlers: { plugin: string; handler: string }[] = [];
|
|
259
|
+
const allMiddleware: { plugin: string; middleware: string }[] = [];
|
|
260
|
+
|
|
261
|
+
for (const p of plugins) {
|
|
262
|
+
const handlers = await extractHandlerNames(join(process.cwd(), p.path));
|
|
263
|
+
const middleware = await extractMiddlewareNames(join(process.cwd(), p.path));
|
|
264
|
+
|
|
265
|
+
for (const h of handlers) {
|
|
266
|
+
allHandlers.push({ plugin: p.exportName, handler: h });
|
|
267
|
+
}
|
|
268
|
+
for (const m of middleware) {
|
|
269
|
+
allMiddleware.push({ plugin: p.exportName, middleware: m });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const routeBuilderMethods = allHandlers
|
|
274
|
+
.map(
|
|
275
|
+
({ plugin, handler }) => ` /** Custom handler from ${plugin} */
|
|
276
|
+
${handler}(config: {
|
|
277
|
+
handle: InferHandlers<typeof ${plugin}>["${handler}"]["__signature"];
|
|
278
|
+
}): TRouter;`
|
|
279
|
+
)
|
|
280
|
+
.join("\n");
|
|
281
|
+
|
|
282
|
+
const handlerUnion =
|
|
283
|
+
allHandlers.length > 0
|
|
284
|
+
? `"typed" | "raw" | ${allHandlers.map((h) => `"${h.handler}"`).join(" | ")}`
|
|
285
|
+
: '"typed" | "raw"';
|
|
286
|
+
|
|
287
|
+
const middlewareUnion =
|
|
288
|
+
allMiddleware.length > 0
|
|
289
|
+
? allMiddleware.map((m) => `"${m.middleware}"`).join(" | ")
|
|
290
|
+
: "never";
|
|
291
|
+
|
|
292
|
+
// Router middleware methods (returns IRouter for chaining)
|
|
293
|
+
const middlewareBuilderMethods = allMiddleware
|
|
294
|
+
.map(
|
|
295
|
+
({ plugin, middleware }) => ` /** Middleware from ${plugin} */
|
|
296
|
+
${middleware}(config?: InferMiddleware<typeof ${plugin}>["${middleware}"]["__config"]): TRouter;`
|
|
297
|
+
)
|
|
298
|
+
.join("\n");
|
|
299
|
+
|
|
300
|
+
const content = `// Auto-generated by donkeylabs generate
|
|
301
|
+
import { type Register, type InferService, type InferSchema, type InferHandlers, type InferMiddleware, type InferDependencies } from "@donkeylabs/server";
|
|
302
|
+
${importLines}
|
|
303
|
+
|
|
304
|
+
declare module "@donkeylabs/server" {
|
|
305
|
+
export interface PluginRegistry {
|
|
306
|
+
${pluginRegistryEntries}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export interface PluginHandlerRegistry extends
|
|
310
|
+
${handlerExtensions}
|
|
311
|
+
{}
|
|
312
|
+
|
|
313
|
+
export interface PluginMiddlewareRegistry extends
|
|
314
|
+
${middlewareExtensions}
|
|
315
|
+
{}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export type AvailableHandlers = ${handlerUnion};
|
|
319
|
+
export type AvailableMiddleware = ${middlewareUnion};
|
|
320
|
+
|
|
321
|
+
declare module "@donkeylabs/server" {
|
|
322
|
+
export interface IRouteBuilder<TRouter> {
|
|
323
|
+
${routeBuilderMethods}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface IMiddlewareBuilder<TRouter> {
|
|
327
|
+
${middlewareBuilderMethods}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
`;
|
|
331
|
+
|
|
332
|
+
await writeFile(join(outPath, "registry.d.ts"), content);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function generateContext(
|
|
336
|
+
plugins: { name: string; path: string; exportName: string }[],
|
|
337
|
+
outPath: string
|
|
338
|
+
) {
|
|
339
|
+
const schemaIntersection =
|
|
340
|
+
plugins.map((p) => `PluginRegistry["${p.name}"]["schema"]`).join(" & ") ||
|
|
341
|
+
"{}";
|
|
342
|
+
|
|
343
|
+
const content = `// Auto-generated by donkeylabs generate
|
|
344
|
+
// App context - import as: import type { AppContext } from ".@donkeylabs/server/context";
|
|
345
|
+
|
|
346
|
+
/// <reference path="./registry.d.ts" />
|
|
347
|
+
import type { PluginRegistry, CoreServices, Errors } from "@donkeylabs/server";
|
|
348
|
+
import type { Kysely } from "kysely";
|
|
349
|
+
|
|
350
|
+
/** Merged database schema from all plugins */
|
|
351
|
+
export type DatabaseSchema = ${schemaIntersection};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Fully typed application context.
|
|
355
|
+
* Use this instead of ServerContext for typed plugin access.
|
|
356
|
+
*/
|
|
357
|
+
export interface AppContext {
|
|
358
|
+
/** Database with merged schema from all plugins */
|
|
359
|
+
db: Kysely<DatabaseSchema>;
|
|
360
|
+
/** Typed plugin services */
|
|
361
|
+
plugins: {
|
|
362
|
+
[K in keyof PluginRegistry]: PluginRegistry[K]["service"];
|
|
363
|
+
};
|
|
364
|
+
/** Core services (logger, cache, events, etc.) */
|
|
365
|
+
core: Omit<CoreServices, "db" | "config" | "errors">;
|
|
366
|
+
/** Error factories (BadRequest, NotFound, etc.) */
|
|
367
|
+
errors: Errors;
|
|
368
|
+
/** Application config */
|
|
369
|
+
config: Record<string, any>;
|
|
370
|
+
/** Client IP address */
|
|
371
|
+
ip: string;
|
|
372
|
+
/** Unique request ID */
|
|
373
|
+
requestId: string;
|
|
374
|
+
/** Authenticated user (set by auth middleware) */
|
|
375
|
+
user?: any;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Re-export as GlobalContext for backwards compatibility
|
|
379
|
+
export type GlobalContext = AppContext;
|
|
380
|
+
`;
|
|
381
|
+
|
|
382
|
+
await writeFile(join(outPath, "context.d.ts"), content);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Route file structure: /src/routes/<namespace>/<route-name>/schema.ts
|
|
386
|
+
interface RouteInfo {
|
|
387
|
+
namespace: string;
|
|
388
|
+
name: string;
|
|
389
|
+
schemaPath: string;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function findRoutes(_pattern: string): Promise<RouteInfo[]> {
|
|
393
|
+
const routes: RouteInfo[] = [];
|
|
394
|
+
const routesDir = join(process.cwd(), "src/routes");
|
|
395
|
+
|
|
396
|
+
if (!existsSync(routesDir)) {
|
|
397
|
+
return routes;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Scan routes directory structure: /routes/<namespace>/<route>/schema.ts
|
|
401
|
+
const namespaces = await readdir(routesDir, { withFileTypes: true });
|
|
402
|
+
|
|
403
|
+
for (const ns of namespaces) {
|
|
404
|
+
if (!ns.isDirectory() || ns.name.startsWith(".")) continue;
|
|
405
|
+
|
|
406
|
+
const namespaceDir = join(routesDir, ns.name);
|
|
407
|
+
const routeDirs = await readdir(namespaceDir, { withFileTypes: true });
|
|
408
|
+
|
|
409
|
+
for (const routeDir of routeDirs) {
|
|
410
|
+
if (!routeDir.isDirectory() || routeDir.name.startsWith(".")) continue;
|
|
411
|
+
|
|
412
|
+
const schemaPath = join(namespaceDir, routeDir.name, "schema.ts");
|
|
413
|
+
|
|
414
|
+
if (!existsSync(schemaPath)) continue;
|
|
415
|
+
|
|
416
|
+
routes.push({
|
|
417
|
+
namespace: ns.name,
|
|
418
|
+
name: routeDir.name,
|
|
419
|
+
schemaPath: relative(process.cwd(), schemaPath),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return routes;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function toPascalCase(str: string): string {
|
|
428
|
+
return str
|
|
429
|
+
.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
|
430
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function generateRouteTypes(routes: RouteInfo[], outPath: string): Promise<void> {
|
|
434
|
+
// Group routes by namespace
|
|
435
|
+
const byNamespace = new Map<string, RouteInfo[]>();
|
|
436
|
+
for (const route of routes) {
|
|
437
|
+
if (!byNamespace.has(route.namespace)) {
|
|
438
|
+
byNamespace.set(route.namespace, []);
|
|
439
|
+
}
|
|
440
|
+
byNamespace.get(route.namespace)!.push(route);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Generate imports for each route's schema (relative to outPath)
|
|
444
|
+
const imports: string[] = [];
|
|
445
|
+
for (const route of routes) {
|
|
446
|
+
// Calculate relative path from .@donkeylabs/server/ to src/routes/.../schema
|
|
447
|
+
const schemaAbsPath = join(process.cwd(), route.schemaPath).replace(/\.ts$/, "");
|
|
448
|
+
const outAbsPath = outPath;
|
|
449
|
+
const relativePath = relative(outAbsPath, schemaAbsPath);
|
|
450
|
+
const alias = `${toPascalCase(route.namespace)}_${toPascalCase(route.name)}`;
|
|
451
|
+
imports.push(`import { Input as ${alias}_Input, Output as ${alias}_Output } from "${relativePath}";`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Generate namespace declarations
|
|
455
|
+
const namespaceBlocks: string[] = [];
|
|
456
|
+
for (const [namespace, nsRoutes] of byNamespace) {
|
|
457
|
+
const pascalNamespace = toPascalCase(namespace);
|
|
458
|
+
|
|
459
|
+
const routeExports = nsRoutes.map((r) => {
|
|
460
|
+
const pascalRoute = toPascalCase(r.name);
|
|
461
|
+
const alias = `${pascalNamespace}_${pascalRoute}`;
|
|
462
|
+
return ` export namespace ${pascalRoute} {
|
|
463
|
+
/** Zod schema for input validation */
|
|
464
|
+
export const Input = ${alias}_Input;
|
|
465
|
+
/** Zod schema for output validation */
|
|
466
|
+
export const Output = ${alias}_Output;
|
|
467
|
+
/** TypeScript type for input data */
|
|
468
|
+
export type Input = z.infer<typeof ${alias}_Input>;
|
|
469
|
+
/** TypeScript type for output data */
|
|
470
|
+
export type Output = z.infer<typeof ${alias}_Output>;
|
|
471
|
+
}
|
|
472
|
+
/** Route contract type - use with Route<${pascalNamespace}.${pascalRoute}> and Handler<${pascalNamespace}.${pascalRoute}> */
|
|
473
|
+
export type ${pascalRoute} = { input: ${pascalRoute}.Input; output: ${pascalRoute}.Output };`;
|
|
474
|
+
}).join("\n\n");
|
|
475
|
+
|
|
476
|
+
namespaceBlocks.push(`export namespace ${pascalNamespace} {\n${routeExports}\n}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const content = `// Auto-generated by donkeylabs generate
|
|
480
|
+
// Route Input/Output types - import as: import { Health } from ".@donkeylabs/server/routes";
|
|
481
|
+
|
|
482
|
+
import { z } from "zod";
|
|
483
|
+
import type { AppContext } from "./context";
|
|
484
|
+
${imports.join("\n")}
|
|
485
|
+
|
|
486
|
+
${namespaceBlocks.join("\n\n")}
|
|
487
|
+
`;
|
|
488
|
+
|
|
489
|
+
await writeFile(join(outPath, "routes.ts"), content);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function generateClientFromRoutes(
|
|
493
|
+
routes: ExtractedRoute[],
|
|
494
|
+
outputPath: string
|
|
495
|
+
): Promise<void> {
|
|
496
|
+
// Group routes by namespace (first part of route name)
|
|
497
|
+
// e.g., "api.hello.test" -> namespace "api", sub "hello", method "test"
|
|
498
|
+
const tree = new Map<string, Map<string, { method: string; fullName: string }[]>>();
|
|
499
|
+
|
|
500
|
+
for (const route of routes) {
|
|
501
|
+
const parts = route.name.split(".");
|
|
502
|
+
if (parts.length < 2) {
|
|
503
|
+
// Single-level route like "ping" -> namespace "", method "ping"
|
|
504
|
+
const ns = "";
|
|
505
|
+
if (!tree.has(ns)) tree.set(ns, new Map());
|
|
506
|
+
const rootMethods = tree.get(ns)!;
|
|
507
|
+
if (!rootMethods.has("")) rootMethods.set("", []);
|
|
508
|
+
rootMethods.get("")!.push({ method: parts[0]!, fullName: route.name });
|
|
509
|
+
} else if (parts.length === 2) {
|
|
510
|
+
// Two-level route like "health.ping" -> namespace "health", method "ping"
|
|
511
|
+
const [ns, method] = parts;
|
|
512
|
+
if (!tree.has(ns!)) tree.set(ns!, new Map());
|
|
513
|
+
const nsMethods = tree.get(ns!)!;
|
|
514
|
+
if (!nsMethods.has("")) nsMethods.set("", []);
|
|
515
|
+
nsMethods.get("")!.push({ method: method!, fullName: route.name });
|
|
516
|
+
} else {
|
|
517
|
+
// Multi-level route like "api.hello.test" -> namespace "api", sub "hello", method "test"
|
|
518
|
+
const [ns, sub, ...rest] = parts;
|
|
519
|
+
const method = rest.join(".");
|
|
520
|
+
if (!tree.has(ns!)) tree.set(ns!, new Map());
|
|
521
|
+
const nsMethods = tree.get(ns!)!;
|
|
522
|
+
if (!nsMethods.has(sub!)) nsMethods.set(sub!, []);
|
|
523
|
+
nsMethods.get(sub!)!.push({ method: method || sub!, fullName: route.name });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Generate method definitions
|
|
528
|
+
const namespaceBlocks: string[] = [];
|
|
529
|
+
|
|
530
|
+
for (const [namespace, subNamespaces] of tree) {
|
|
531
|
+
if (namespace === "") {
|
|
532
|
+
// Root-level methods
|
|
533
|
+
const rootMethods = subNamespaces.get("");
|
|
534
|
+
if (rootMethods && rootMethods.length > 0) {
|
|
535
|
+
for (const { method, fullName } of rootMethods) {
|
|
536
|
+
const methodName = method.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
537
|
+
namespaceBlocks.push(` ${methodName} = (input: any) => this.request("${fullName}", input);`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const subBlocks: string[] = [];
|
|
544
|
+
for (const [sub, methods] of subNamespaces) {
|
|
545
|
+
if (sub === "") {
|
|
546
|
+
// Direct methods on namespace
|
|
547
|
+
for (const { method, fullName } of methods) {
|
|
548
|
+
const methodName = method.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
549
|
+
subBlocks.push(` ${methodName}: (input: any) => this.request("${fullName}", input)`);
|
|
550
|
+
}
|
|
551
|
+
} else {
|
|
552
|
+
// Sub-namespace methods
|
|
553
|
+
const subMethods = methods.map(({ method, fullName }) => {
|
|
554
|
+
const methodName = method.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
555
|
+
return ` ${methodName}: (input: any) => this.request("${fullName}", input)`;
|
|
556
|
+
});
|
|
557
|
+
subBlocks.push(` ${sub}: {\n${subMethods.join(",\n")}\n }`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
namespaceBlocks.push(` ${namespace} = {\n${subBlocks.join(",\n")}\n };`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const content = `// Auto-generated by donkeylabs generate
|
|
565
|
+
// API Client
|
|
566
|
+
|
|
567
|
+
import { ApiClientBase, type ApiClientOptions } from "@donkeylabs/server/client";
|
|
568
|
+
|
|
569
|
+
export class ApiClient extends ApiClientBase<{}> {
|
|
570
|
+
constructor(baseUrl: string, options?: ApiClientOptions) {
|
|
571
|
+
super(baseUrl, options);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
${namespaceBlocks.join("\n\n") || " // No routes defined"}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function createApiClient(baseUrl: string, options?: ApiClientOptions) {
|
|
578
|
+
return new ApiClient(baseUrl, options);
|
|
579
|
+
}
|
|
580
|
+
`;
|
|
581
|
+
|
|
582
|
+
const outputDir = dirname(outputPath);
|
|
583
|
+
await mkdir(outputDir, { recursive: true });
|
|
584
|
+
await writeFile(outputPath, content);
|
|
585
|
+
}
|