@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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/package.json +51 -0
  4. package/src/commands/generate.ts +585 -0
  5. package/src/commands/init.ts +201 -0
  6. package/src/commands/interactive.ts +223 -0
  7. package/src/commands/plugin.ts +205 -0
  8. package/src/index.ts +108 -0
  9. package/templates/starter/.env.example +3 -0
  10. package/templates/starter/.gitignore.template +4 -0
  11. package/templates/starter/CLAUDE.md +144 -0
  12. package/templates/starter/donkeylabs.config.ts +6 -0
  13. package/templates/starter/package.json +21 -0
  14. package/templates/starter/src/client.test.ts +7 -0
  15. package/templates/starter/src/db.ts +9 -0
  16. package/templates/starter/src/index.ts +48 -0
  17. package/templates/starter/src/plugins/stats/index.ts +105 -0
  18. package/templates/starter/src/routes/health/index.ts +5 -0
  19. package/templates/starter/src/routes/health/ping/index.ts +13 -0
  20. package/templates/starter/src/routes/health/ping/models/model.ts +23 -0
  21. package/templates/starter/src/routes/health/ping/schema.ts +14 -0
  22. package/templates/starter/src/routes/health/ping/tests/integ.test.ts +20 -0
  23. package/templates/starter/src/routes/health/ping/tests/unit.test.ts +21 -0
  24. package/templates/starter/src/test-ctx.ts +24 -0
  25. package/templates/starter/tsconfig.json +27 -0
  26. package/templates/sveltekit-app/.env.example +3 -0
  27. package/templates/sveltekit-app/README.md +103 -0
  28. package/templates/sveltekit-app/donkeylabs.config.ts +10 -0
  29. package/templates/sveltekit-app/package.json +36 -0
  30. package/templates/sveltekit-app/src/app.css +40 -0
  31. package/templates/sveltekit-app/src/app.html +12 -0
  32. package/templates/sveltekit-app/src/hooks.server.ts +4 -0
  33. package/templates/sveltekit-app/src/lib/api.ts +134 -0
  34. package/templates/sveltekit-app/src/lib/components/ui/badge/badge.svelte +30 -0
  35. package/templates/sveltekit-app/src/lib/components/ui/badge/index.ts +3 -0
  36. package/templates/sveltekit-app/src/lib/components/ui/button/button.svelte +48 -0
  37. package/templates/sveltekit-app/src/lib/components/ui/button/index.ts +9 -0
  38. package/templates/sveltekit-app/src/lib/components/ui/card/card-content.svelte +18 -0
  39. package/templates/sveltekit-app/src/lib/components/ui/card/card-description.svelte +18 -0
  40. package/templates/sveltekit-app/src/lib/components/ui/card/card-footer.svelte +18 -0
  41. package/templates/sveltekit-app/src/lib/components/ui/card/card-header.svelte +18 -0
  42. package/templates/sveltekit-app/src/lib/components/ui/card/card-title.svelte +18 -0
  43. package/templates/sveltekit-app/src/lib/components/ui/card/card.svelte +21 -0
  44. package/templates/sveltekit-app/src/lib/components/ui/card/index.ts +21 -0
  45. package/templates/sveltekit-app/src/lib/components/ui/index.ts +4 -0
  46. package/templates/sveltekit-app/src/lib/components/ui/input/index.ts +2 -0
  47. package/templates/sveltekit-app/src/lib/components/ui/input/input.svelte +20 -0
  48. package/templates/sveltekit-app/src/lib/utils/index.ts +6 -0
  49. package/templates/sveltekit-app/src/routes/+layout.svelte +8 -0
  50. package/templates/sveltekit-app/src/routes/+page.server.ts +25 -0
  51. package/templates/sveltekit-app/src/routes/+page.svelte +401 -0
  52. package/templates/sveltekit-app/src/server/index.ts +263 -0
  53. package/templates/sveltekit-app/static/robots.txt +3 -0
  54. package/templates/sveltekit-app/svelte.config.js +18 -0
  55. package/templates/sveltekit-app/tsconfig.json +25 -0
  56. 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
+ }