@donkeylabs/cli 2.0.22 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/cli",
3
- "version": "2.0.22",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "CLI for @donkeylabs/server - project scaffolding and code generation",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,105 @@
1
+ import { writeFile, mkdir } from "node:fs/promises";
2
+ import { join, dirname } from "node:path";
3
+ import pc from "picocolors";
4
+ import { loadConfig, extractRoutesFromServer, type DonkeylabsConfig, type RouteInfo } from "./generate-utils";
5
+
6
+ export async function generateClientCommand(
7
+ _args: string[],
8
+ options: { output?: string; adapter?: string }
9
+ ): Promise<void> {
10
+ const config = await loadConfig();
11
+
12
+ // Resolve adapter: flag > config > default "typescript"
13
+ const adapter = options.adapter || config.adapter || "typescript";
14
+
15
+ // Resolve output: flag > config.client.output > "./client"
16
+ const output = options.output || config.client?.output || "./client";
17
+
18
+ // Extract routes from server
19
+ const entryPath = config.entry || "./src/index.ts";
20
+ console.log(pc.dim(`Extracting routes from ${entryPath}...`));
21
+ const serverOutput = await extractRoutesFromServer(entryPath);
22
+ const routes = serverOutput.routes;
23
+
24
+ if (routes.length === 0) {
25
+ console.warn(pc.yellow("No routes found - generating empty client"));
26
+ } else {
27
+ console.log(pc.green(`Found ${routes.length} routes`));
28
+ }
29
+
30
+ // Dispatch to adapter
31
+ if (adapter === "typescript") {
32
+ await generateTypescriptClient(routes, output);
33
+ } else {
34
+ await generateAdapterClient(adapter, config, routes, output);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Built-in TypeScript client generation using @donkeylabs/server/generator
40
+ */
41
+ async function generateTypescriptClient(routes: RouteInfo[], output: string): Promise<void> {
42
+ try {
43
+ const { generateClientCode } = await import("@donkeylabs/server/generator");
44
+
45
+ const code = generateClientCode({ routes });
46
+
47
+ // Write single .ts file
48
+ const outputPath = output.endsWith(".ts") ? output : join(output, "index.ts");
49
+ await mkdir(dirname(outputPath), { recursive: true });
50
+ await writeFile(outputPath, code);
51
+
52
+ console.log(pc.green(`Generated TypeScript client:`), pc.dim(outputPath));
53
+ } catch (e: any) {
54
+ if (e.code === "ERR_MODULE_NOT_FOUND" || e.message?.includes("Cannot find")) {
55
+ console.error(pc.red("@donkeylabs/server not found"));
56
+ console.error(pc.dim("Make sure @donkeylabs/server is installed"));
57
+ } else {
58
+ console.error(pc.red("Failed to generate TypeScript client"));
59
+ console.error(pc.dim(e.message));
60
+ }
61
+ process.exit(1);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Adapter-based client generation (sveltekit, swift, or custom package)
67
+ */
68
+ async function generateAdapterClient(
69
+ adapter: string,
70
+ config: DonkeylabsConfig,
71
+ routes: RouteInfo[],
72
+ output: string
73
+ ): Promise<void> {
74
+ // Resolve adapter to package path
75
+ let adapterPackage: string;
76
+ if (adapter === "sveltekit") {
77
+ adapterPackage = "@donkeylabs/adapter-sveltekit";
78
+ } else if (adapter === "swift") {
79
+ adapterPackage = "@donkeylabs/adapter-swift";
80
+ } else {
81
+ // Treat as full package name (e.g., "@myorg/custom-adapter")
82
+ adapterPackage = adapter;
83
+ }
84
+
85
+ const generatorPath = `${adapterPackage}/generator`;
86
+
87
+ try {
88
+ const adapterModule = await import(generatorPath);
89
+ if (!adapterModule.generateClient) {
90
+ console.error(pc.red(`Adapter ${adapterPackage} does not export generateClient`));
91
+ process.exit(1);
92
+ }
93
+ await adapterModule.generateClient(config, routes, output);
94
+ console.log(pc.green(`Generated client (${adapter}):`), pc.dim(output));
95
+ } catch (e: any) {
96
+ if (e.code === "ERR_MODULE_NOT_FOUND" || e.message?.includes("Cannot find")) {
97
+ console.error(pc.red(`Adapter not found: ${adapterPackage}`));
98
+ console.error(pc.dim(`Install it with: bun add ${adapterPackage}`));
99
+ } else {
100
+ console.error(pc.red(`Failed to generate client with adapter: ${adapterPackage}`));
101
+ console.error(pc.dim(e.message));
102
+ }
103
+ process.exit(1);
104
+ }
105
+ }
@@ -0,0 +1,140 @@
1
+ import { join } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
4
+ import pc from "picocolors";
5
+
6
+ export interface DonkeylabsConfig {
7
+ plugins: string[];
8
+ outDir?: string;
9
+ client?: {
10
+ output: string;
11
+ };
12
+ routes?: string;
13
+ entry?: string;
14
+ adapter?: string;
15
+ }
16
+
17
+ export async function loadConfig(): Promise<DonkeylabsConfig> {
18
+ const configPath = join(process.cwd(), "donkeylabs.config.ts");
19
+
20
+ if (!existsSync(configPath)) {
21
+ throw new Error("donkeylabs.config.ts not found. Run 'donkeylabs init' first.");
22
+ }
23
+
24
+ const config = await import(configPath);
25
+ return config.default;
26
+ }
27
+
28
+ export interface RouteInfo {
29
+ name: string;
30
+ prefix: string;
31
+ routeName: string;
32
+ handler: "typed" | "raw" | string;
33
+ inputSource?: string;
34
+ outputSource?: string;
35
+ /** SSE event schemas (for sse handler) */
36
+ eventsSource?: Record<string, string>;
37
+ }
38
+
39
+ export interface ProcessInfo {
40
+ name: string;
41
+ events?: Record<string, string>;
42
+ commands?: Record<string, string>;
43
+ }
44
+
45
+ export interface ServerOutput {
46
+ routes: RouteInfo[];
47
+ processes: ProcessInfo[];
48
+ }
49
+
50
+ /**
51
+ * Run the server entry file with DONKEYLABS_GENERATE=1 to get typed route metadata
52
+ */
53
+ export async function extractRoutesFromServer(entryPath: string): Promise<ServerOutput> {
54
+ const fullPath = join(process.cwd(), entryPath);
55
+
56
+ if (!existsSync(fullPath)) {
57
+ console.warn(pc.yellow(`Entry file not found: ${entryPath}`));
58
+ return { routes: [], processes: [] };
59
+ }
60
+
61
+ const TIMEOUT_MS = 10000; // 10 second timeout
62
+
63
+ return new Promise((resolve) => {
64
+ const child = spawn("bun", [fullPath], {
65
+ env: { ...process.env, DONKEYLABS_GENERATE: "1" },
66
+ stdio: ["inherit", "pipe", "pipe"],
67
+ cwd: process.cwd(),
68
+ });
69
+
70
+ let stdout = "";
71
+ let stderr = "";
72
+ let timedOut = false;
73
+
74
+ // Timeout handler
75
+ const timeout = setTimeout(() => {
76
+ timedOut = true;
77
+ child.kill("SIGTERM");
78
+ console.warn(pc.yellow(`Route extraction timed out after ${TIMEOUT_MS / 1000}s`));
79
+ console.warn(pc.dim("Make sure routes are registered with server.use() before any blocking operations"));
80
+ resolve({ routes: [], processes: [] });
81
+ }, TIMEOUT_MS);
82
+
83
+ child.stdout?.on("data", (data) => {
84
+ stdout += data.toString();
85
+ });
86
+
87
+ child.stderr?.on("data", (data) => {
88
+ stderr += data.toString();
89
+ });
90
+
91
+ child.on("close", (code) => {
92
+ clearTimeout(timeout);
93
+ if (timedOut) return; // Already resolved
94
+
95
+ if (code !== 0) {
96
+ console.warn(pc.yellow(`Failed to extract routes from server (exit code ${code})`));
97
+ if (stderr) console.warn(pc.dim(stderr));
98
+ resolve({ routes: [], processes: [] });
99
+ return;
100
+ }
101
+
102
+ try {
103
+ const result = JSON.parse(stdout.trim());
104
+ // Convert server output to RouteInfo format
105
+ const routes: RouteInfo[] = (result.routes || []).map((r: any) => {
106
+ const parts = r.name.split(".");
107
+ return {
108
+ name: r.name,
109
+ prefix: parts.slice(0, -1).join("."),
110
+ routeName: parts[parts.length - 1] || r.name,
111
+ handler: r.handler || "typed",
112
+ // Server outputs TypeScript strings directly now
113
+ inputSource: r.inputType,
114
+ outputSource: r.outputType,
115
+ // SSE event schemas
116
+ eventsSource: r.eventsType,
117
+ };
118
+ });
119
+
120
+ // Parse process definitions
121
+ const processes: ProcessInfo[] = (result.processes || []).map((p: any) => ({
122
+ name: p.name,
123
+ events: p.events,
124
+ commands: p.commands,
125
+ }));
126
+
127
+ resolve({ routes, processes });
128
+ } catch (e) {
129
+ console.warn(pc.yellow("Failed to parse route data from server"));
130
+ resolve({ routes: [], processes: [] });
131
+ }
132
+ });
133
+
134
+ child.on("error", (err) => {
135
+ clearTimeout(timeout);
136
+ console.warn(pc.yellow(`Failed to run entry file: ${err.message}`));
137
+ resolve({ routes: [], processes: [] });
138
+ });
139
+ });
140
+ }
@@ -1,35 +1,13 @@
1
1
  import { readdir, writeFile, readFile, mkdir, unlink } from "node:fs/promises";
2
2
  import { join, relative, dirname, basename } from "node:path";
3
3
  import { existsSync } from "node:fs";
4
- import { spawn } from "node:child_process";
5
4
  import pc from "picocolors";
6
5
  import { Kysely, Migrator, FileMigrationProvider } from "kysely";
7
6
  import { BunSqliteDialect } from "kysely-bun-sqlite";
8
7
  import { Database } from "bun:sqlite";
9
8
  import { generate, KyselyBunSqliteDialect } from "kysely-codegen";
10
9
  import * as ts from "typescript";
11
-
12
- interface DonkeylabsConfig {
13
- plugins: string[];
14
- outDir?: string;
15
- client?: {
16
- output: string;
17
- };
18
- routes?: string; // Route files pattern, default: "./src/routes/**/handler.ts"
19
- entry?: string; // Server entry file for extracting routes, default: "./src/index.ts"
20
- adapter?: string; // Adapter package for framework-specific generation, e.g., "@donkeylabs/adapter-sveltekit"
21
- }
22
-
23
- async function loadConfig(): Promise<DonkeylabsConfig> {
24
- const configPath = join(process.cwd(), "donkeylabs.config.ts");
25
-
26
- if (!existsSync(configPath)) {
27
- throw new Error("donkeylabs.config.ts not found. Run 'donkeylabs init' first.");
28
- }
29
-
30
- const config = await import(configPath);
31
- return config.default;
32
- }
10
+ import { loadConfig, extractRoutesFromServer, type DonkeylabsConfig, type RouteInfo, type ProcessInfo } from "./generate-utils";
33
11
 
34
12
  async function getPluginExportName(pluginPath: string): Promise<string | null> {
35
13
  try {
@@ -55,18 +33,95 @@ async function getPluginDefinedName(pluginPath: string): Promise<string | null>
55
33
  async function extractHandlerNames(pluginPath: string): Promise<string[]> {
56
34
  try {
57
35
  const content = await readFile(pluginPath, "utf-8");
58
- const handlersMatch = content.match(/handlers:\s*\{([^}]+)\}/);
59
- if (!handlersMatch?.[1]) return [];
36
+ const astNames = extractHandlerNamesFromAst(content, pluginPath);
37
+ if (astNames.length > 0) {
38
+ return astNames;
39
+ }
60
40
 
61
- const handlersBlock = handlersMatch[1];
62
- return [...handlersBlock.matchAll(/(\w+)\s*:/g)]
63
- .map((m) => m[1])
64
- .filter((name): name is string => !!name);
41
+ return extractHandlerNamesRegexFallback(content);
65
42
  } catch {
66
43
  return [];
67
44
  }
68
45
  }
69
46
 
47
+ function extractHandlerNamesFromAst(content: string, fileName: string): string[] {
48
+ const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
49
+ const names = new Set<string>();
50
+
51
+ const addFromObjectLiteral = (obj: ts.ObjectLiteralExpression) => {
52
+ for (const prop of obj.properties) {
53
+ if (!ts.isPropertyAssignment(prop) && !ts.isMethodDeclaration(prop)) continue;
54
+ const propName = getStaticPropertyName(prop.name);
55
+ if (propName && isSafeIdentifier(propName)) {
56
+ names.add(propName);
57
+ }
58
+ }
59
+ };
60
+
61
+ const collectFromHandlersInitializer = (initializer: ts.Expression) => {
62
+ if (ts.isObjectLiteralExpression(initializer)) {
63
+ addFromObjectLiteral(initializer);
64
+ return;
65
+ }
66
+
67
+ if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) {
68
+ const body = initializer.body;
69
+
70
+ if (ts.isObjectLiteralExpression(body)) {
71
+ addFromObjectLiteral(body);
72
+ return;
73
+ }
74
+
75
+ if (ts.isParenthesizedExpression(body) && ts.isObjectLiteralExpression(body.expression)) {
76
+ addFromObjectLiteral(body.expression);
77
+ return;
78
+ }
79
+
80
+ if (ts.isBlock(body)) {
81
+ for (const stmt of body.statements) {
82
+ if (!ts.isReturnStatement(stmt) || !stmt.expression) continue;
83
+
84
+ if (ts.isObjectLiteralExpression(stmt.expression)) {
85
+ addFromObjectLiteral(stmt.expression);
86
+ return;
87
+ }
88
+
89
+ if (
90
+ ts.isParenthesizedExpression(stmt.expression) &&
91
+ ts.isObjectLiteralExpression(stmt.expression.expression)
92
+ ) {
93
+ addFromObjectLiteral(stmt.expression.expression);
94
+ return;
95
+ }
96
+ }
97
+ }
98
+ }
99
+ };
100
+
101
+ const visit = (node: ts.Node) => {
102
+ if (ts.isPropertyAssignment(node)) {
103
+ const propName = getStaticPropertyName(node.name);
104
+ if (propName === "handlers") {
105
+ collectFromHandlersInitializer(node.initializer);
106
+ }
107
+ }
108
+ ts.forEachChild(node, visit);
109
+ };
110
+
111
+ visit(sourceFile);
112
+ return [...names];
113
+ }
114
+
115
+ function extractHandlerNamesRegexFallback(content: string): string[] {
116
+ const handlersMatch = content.match(/handlers:\s*\{([^}]+)\}/);
117
+ if (!handlersMatch?.[1]) return [];
118
+
119
+ const handlersBlock = handlersMatch[1];
120
+ return [...handlersBlock.matchAll(/(\w+)\s*:/g)]
121
+ .map((m) => m[1])
122
+ .filter((name): name is string => !!name);
123
+ }
124
+
70
125
  async function extractMiddlewareNames(pluginPath: string): Promise<string[]> {
71
126
  try {
72
127
  const content = await readFile(pluginPath, "utf-8");
@@ -358,82 +413,49 @@ interface EventDefinitionInfo {
358
413
  async function extractEventsFromFile(filePath: string): Promise<EventDefinitionInfo[]> {
359
414
  try {
360
415
  const content = await readFile(filePath, "utf-8");
416
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
361
417
  const events: EventDefinitionInfo[] = [];
362
418
 
363
- // Find defineEvents call
364
- const defineEventsMatch = content.match(/defineEvents\s*\(\s*\{/);
365
- if (!defineEventsMatch || defineEventsMatch.index === undefined) {
366
- return events;
367
- }
368
-
369
- // Extract the object block
370
- const blockStart = defineEventsMatch.index + defineEventsMatch[0].length - 1;
371
- const block = extractBalancedBlock(content, blockStart, "{", "}");
372
- if (!block) return events;
373
-
374
- // Parse event entries: "event.name": z.object({...})
375
- // Match quoted keys followed by Zod schemas
376
- const eventPattern = /["']([^"']+)["']\s*:\s*(z\.[^,}]+(?:\([^)]*\))?)/g;
377
-
378
- let match;
379
- const innerBlock = block.slice(1, -1); // Remove outer braces
419
+ const addFromObjectLiteral = (obj: ts.ObjectLiteralExpression) => {
420
+ for (const prop of obj.properties) {
421
+ if (!ts.isPropertyAssignment(prop)) continue;
422
+ const eventName = getEventNameFromPropertyName(prop.name);
423
+ if (!eventName) continue;
380
424
 
381
- // More robust extraction - find each event key and its schema
382
- const keyPattern = /["']([a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*)["']\s*:/gi;
383
- let keyMatch;
384
- const keyPositions: { name: string; pos: number }[] = [];
425
+ const schemaSource = prop.initializer.getText(sourceFile).trim();
426
+ if (!schemaSource) continue;
385
427
 
386
- while ((keyMatch = keyPattern.exec(innerBlock)) !== null) {
387
- keyPositions.push({ name: keyMatch[1]!, pos: keyMatch.index + keyMatch[0].length });
388
- }
389
-
390
- // For each key, extract the Zod schema that follows
391
- for (let i = 0; i < keyPositions.length; i++) {
392
- const { name, pos } = keyPositions[i]!;
393
- const nextPos = keyPositions[i + 1]?.pos ?? innerBlock.length;
394
-
395
- // Get the slice between this key and the next
396
- let schemaSlice = innerBlock.slice(pos, nextPos).trim();
397
-
398
- // Find where the Zod expression ends
399
- if (schemaSlice.startsWith("z.")) {
400
- // Extract balanced parentheses for the schema
401
- let depth = 0;
402
- let endIdx = 0;
403
- let foundParen = false;
404
-
405
- for (let j = 0; j < schemaSlice.length; j++) {
406
- if (schemaSlice[j] === "(") {
407
- depth++;
408
- foundParen = true;
409
- } else if (schemaSlice[j] === ")") {
410
- depth--;
411
- if (depth === 0 && foundParen) {
412
- endIdx = j + 1;
413
- // Check for chained methods
414
- const rest = schemaSlice.slice(endIdx);
415
- const chainMatch = rest.match(/^(\s*\.\w+\([^)]*\))+/);
416
- if (chainMatch) {
417
- endIdx += chainMatch[0].length;
418
- }
419
- break;
420
- }
421
- }
422
- }
428
+ events.push({ name: eventName, schemaSource });
429
+ }
430
+ };
423
431
 
424
- if (endIdx > 0) {
425
- const schema = schemaSlice.slice(0, endIdx).trim();
426
- events.push({ name, schemaSource: schema });
432
+ const visit = (node: ts.Node) => {
433
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "defineEvents") {
434
+ const firstArg = node.arguments[0];
435
+ if (firstArg && ts.isObjectLiteralExpression(firstArg)) {
436
+ addFromObjectLiteral(firstArg);
427
437
  }
428
438
  }
429
- }
439
+ ts.forEachChild(node, visit);
440
+ };
430
441
 
442
+ visit(sourceFile);
431
443
  return events;
432
444
  } catch {
433
445
  return [];
434
446
  }
435
447
  }
436
448
 
449
+ function getEventNameFromPropertyName(name: ts.PropertyName): string | null {
450
+ if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) {
451
+ return name.text;
452
+ }
453
+ if (ts.isIdentifier(name)) {
454
+ return name.text;
455
+ }
456
+ return null;
457
+ }
458
+
437
459
  /**
438
460
  * Find and extract server-level events from common locations.
439
461
  */
@@ -560,16 +582,7 @@ interface ExtractedRoute {
560
582
  handler: string;
561
583
  }
562
584
 
563
- interface RouteInfo {
564
- name: string;
565
- prefix: string;
566
- routeName: string;
567
- handler: "typed" | "raw" | string;
568
- inputSource?: string;
569
- outputSource?: string;
570
- /** SSE event schemas (for sse handler) */
571
- eventsSource?: Record<string, string>;
572
- }
585
+ // RouteInfo is imported from ./generate-utils
573
586
 
574
587
  /**
575
588
  * Extract a balanced block from source code starting at a given position
@@ -792,89 +805,7 @@ async function findRouteFiles(pattern: string): Promise<string[]> {
792
805
  return files;
793
806
  }
794
807
 
795
- /**
796
- * Run the server entry file with DONKEYLABS_GENERATE=1 to get typed route metadata
797
- */
798
- async function extractRoutesFromServer(entryPath: string): Promise<RouteInfo[]> {
799
- const fullPath = join(process.cwd(), entryPath);
800
-
801
- if (!existsSync(fullPath)) {
802
- console.warn(pc.yellow(`Entry file not found: ${entryPath}`));
803
- return [];
804
- }
805
-
806
- const TIMEOUT_MS = 10000; // 10 second timeout
807
-
808
- return new Promise((resolve) => {
809
- const child = spawn("bun", [fullPath], {
810
- env: { ...process.env, DONKEYLABS_GENERATE: "1" },
811
- stdio: ["inherit", "pipe", "pipe"],
812
- cwd: process.cwd(),
813
- });
814
-
815
- let stdout = "";
816
- let stderr = "";
817
- let timedOut = false;
818
-
819
- // Timeout handler
820
- const timeout = setTimeout(() => {
821
- timedOut = true;
822
- child.kill("SIGTERM");
823
- console.warn(pc.yellow(`Route extraction timed out after ${TIMEOUT_MS / 1000}s`));
824
- console.warn(pc.dim("Make sure routes are registered with server.use() before any blocking operations"));
825
- resolve([]);
826
- }, TIMEOUT_MS);
827
-
828
- child.stdout?.on("data", (data) => {
829
- stdout += data.toString();
830
- });
831
-
832
- child.stderr?.on("data", (data) => {
833
- stderr += data.toString();
834
- });
835
-
836
- child.on("close", (code) => {
837
- clearTimeout(timeout);
838
- if (timedOut) return; // Already resolved
839
-
840
- if (code !== 0) {
841
- console.warn(pc.yellow(`Failed to extract routes from server (exit code ${code})`));
842
- if (stderr) console.warn(pc.dim(stderr));
843
- resolve([]);
844
- return;
845
- }
846
-
847
- try {
848
- const result = JSON.parse(stdout.trim());
849
- // Convert server output to RouteInfo format
850
- const routes: RouteInfo[] = (result.routes || []).map((r: any) => {
851
- const parts = r.name.split(".");
852
- return {
853
- name: r.name,
854
- prefix: parts.slice(0, -1).join("."),
855
- routeName: parts[parts.length - 1] || r.name,
856
- handler: r.handler || "typed",
857
- // Server outputs TypeScript strings directly now
858
- inputSource: r.inputType,
859
- outputSource: r.outputType,
860
- // SSE event schemas
861
- eventsSource: r.eventsType,
862
- };
863
- });
864
- resolve(routes);
865
- } catch (e) {
866
- console.warn(pc.yellow("Failed to parse route data from server"));
867
- resolve([]);
868
- }
869
- });
870
-
871
- child.on("error", (err) => {
872
- clearTimeout(timeout);
873
- console.warn(pc.yellow(`Failed to run entry file: ${err.message}`));
874
- resolve([]);
875
- });
876
- });
877
- }
808
+ // extractRoutesFromServer is imported from ./generate-utils
878
809
 
879
810
  /**
880
811
  * Generate schema.ts from plugin migrations using kysely-codegen
@@ -987,9 +918,11 @@ export async function generateCommand(_args: string[]): Promise<void> {
987
918
  );
988
919
  }
989
920
 
990
- // Extract routes by running the server with DONKEYLABS_GENERATE=1
921
+ // Extract routes and processes by running the server with DONKEYLABS_GENERATE=1
991
922
  const entryPath = config.entry || "./src/index.ts";
992
- const serverRoutes = await extractRoutesFromServer(entryPath);
923
+ const serverOutput = await extractRoutesFromServer(entryPath);
924
+ const serverRoutes = serverOutput.routes;
925
+ const serverProcesses = serverOutput.processes;
993
926
 
994
927
  // Find custom service definitions
995
928
  const services = await findServiceDefinitions(entryPath);
@@ -1009,13 +942,22 @@ export async function generateCommand(_args: string[]): Promise<void> {
1009
942
  );
1010
943
  }
1011
944
 
945
+ // Log process definitions found
946
+ if (serverProcesses.length > 0) {
947
+ console.log(
948
+ pc.green("Found processes:"),
949
+ serverProcesses.map((p) => pc.dim(p.name)).join(", ")
950
+ );
951
+ }
952
+
1012
953
  // Generate all files
1013
954
  await generateRegistry(plugins, outPath);
1014
955
  await generateContext(plugins, services, outPath);
1015
956
  await generateRouteTypes(fileRoutes, outPath);
1016
957
  await generateEventTypes(serverEvents, outPath);
958
+ await generateProcessTypes(serverProcesses, outPath);
1017
959
 
1018
- const generated = ["registry", "context", "routes", "events"];
960
+ const generated = ["registry", "context", "routes", "events", "processes"];
1019
961
 
1020
962
  // Determine client output path
1021
963
  const clientOutput = config.client?.output || join(outPath, "client.ts");
@@ -1523,3 +1465,99 @@ ${eventRegistryEntries.join("\n")}
1523
1465
 
1524
1466
  await writeFile(join(outPath, "events.ts"), content);
1525
1467
  }
1468
+
1469
+ /**
1470
+ * Generate processes.ts with typed events, commands, and ProcessRegistry augmentation.
1471
+ */
1472
+ async function generateProcessTypes(processes: ProcessInfo[], outPath: string): Promise<void> {
1473
+ if (processes.length === 0) {
1474
+ const content = `// Auto-generated by donkeylabs generate
1475
+ // Processes - import as: import { type ProcessEventMap, type ProcessCommandMap } from ".@donkeylabs/server/processes";
1476
+
1477
+ /** Map of all process event names to their data types */
1478
+ export interface ProcessEventMap {}
1479
+
1480
+ /** Map of all process command names to their data types */
1481
+ export interface ProcessCommandMap {}
1482
+
1483
+ // Augment ProcessRegistry for typed send() (empty when no processes defined)
1484
+ declare module "@donkeylabs/server" {
1485
+ interface ProcessRegistry {}
1486
+ }
1487
+ `;
1488
+ await writeFile(join(outPath, "processes.ts"), content);
1489
+ return;
1490
+ }
1491
+
1492
+ const namespaceBlocks: string[] = [];
1493
+ const eventMapEntries: string[] = [];
1494
+ const commandMapEntries: string[] = [];
1495
+ const registryEntries: string[] = [];
1496
+
1497
+ for (const proc of processes) {
1498
+ const pascalName = toPascalCase(proc.name);
1499
+
1500
+ const eventDecls: string[] = [];
1501
+ const commandDecls: string[] = [];
1502
+
1503
+ // Generate event types
1504
+ if (proc.events) {
1505
+ for (const [eventName, tsType] of Object.entries(proc.events)) {
1506
+ const pascalEvent = toPascalCase(eventName);
1507
+ eventDecls.push(` export type ${pascalEvent} = ${tsType};`);
1508
+ eventMapEntries.push(` "${proc.name}.${eventName}": ${pascalName}.Events.${pascalEvent};`);
1509
+ }
1510
+ }
1511
+
1512
+ // Generate command types
1513
+ if (proc.commands) {
1514
+ for (const [cmdName, tsType] of Object.entries(proc.commands)) {
1515
+ const pascalCmd = toPascalCase(cmdName);
1516
+ commandDecls.push(` export type ${pascalCmd} = ${tsType};`);
1517
+ commandMapEntries.push(` "${proc.name}.${cmdName}": ${pascalName}.Commands.${pascalCmd};`);
1518
+ }
1519
+ }
1520
+
1521
+ // Build namespace
1522
+ const eventsNs = eventDecls.length > 0
1523
+ ? ` export namespace Events {\n${eventDecls.join("\n")}\n }`
1524
+ : ` export namespace Events {}`;
1525
+ const commandsNs = commandDecls.length > 0
1526
+ ? ` export namespace Commands {\n${commandDecls.join("\n")}\n }`
1527
+ : ` export namespace Commands {}`;
1528
+
1529
+ namespaceBlocks.push(`export namespace ${pascalName} {\n${eventsNs}\n${commandsNs}\n}`);
1530
+
1531
+ // Registry entry
1532
+ registryEntries.push(` "${proc.name}": {
1533
+ events: ${pascalName}.Events;
1534
+ commands: ${pascalName}.Commands;
1535
+ };`);
1536
+ }
1537
+
1538
+ const content = `// Auto-generated by donkeylabs generate
1539
+ // Processes - import as: import { type ProcessEventMap, type ProcessCommandMap } from ".@donkeylabs/server/processes";
1540
+
1541
+ // Process type namespaces
1542
+ ${namespaceBlocks.join("\n\n")}
1543
+
1544
+ /** Map of all process event names to their data types */
1545
+ export interface ProcessEventMap {
1546
+ ${eventMapEntries.join("\n")}
1547
+ }
1548
+
1549
+ /** Map of all process command names to their data types */
1550
+ export interface ProcessCommandMap {
1551
+ ${commandMapEntries.join("\n")}
1552
+ }
1553
+
1554
+ // Augment ProcessRegistry for typed send()
1555
+ declare module "@donkeylabs/server" {
1556
+ interface ProcessRegistry {
1557
+ ${registryEntries.join("\n")}
1558
+ }
1559
+ }
1560
+ `;
1561
+
1562
+ await writeFile(join(outPath, "processes.ts"), content);
1563
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ const { positionals, values } = parseArgs({
20
20
  local: { type: "boolean", short: "l" },
21
21
  list: { type: "boolean" },
22
22
  output: { type: "string", short: "o" },
23
+ adapter: { type: "string" },
23
24
  all: { type: "boolean", short: "a" },
24
25
  check: { type: "boolean", short: "c" },
25
26
  "skip-docs": { type: "boolean" },
@@ -41,6 +42,7 @@ ${pc.bold("Commands:")}
41
42
  ${pc.cyan("init")} Initialize a new project
42
43
  ${pc.cyan("add")} Add optional plugins (images, auth, etc.)
43
44
  ${pc.cyan("generate")} Generate types (registry, context, client)
45
+ ${pc.cyan("generate-client")} Generate API client only (TypeScript, Swift, SvelteKit)
44
46
  ${pc.cyan("plugin")} Plugin management
45
47
  ${pc.cyan("update")} Check and install package updates
46
48
  ${pc.cyan("docs")} Sync documentation from installed package
@@ -56,6 +58,7 @@ ${pc.bold("Options:")}
56
58
  -v, --version Show version number
57
59
  -t, --type <type> Project type for init (server, sveltekit)
58
60
  -l, --local Use local workspace packages (for monorepo dev)
61
+ --adapter <adapter> Client adapter (typescript, sveltekit, swift, or package name)
59
62
 
60
63
  ${pc.bold("Examples:")}
61
64
  donkeylabs # Interactive menu
@@ -63,6 +66,9 @@ ${pc.bold("Options:")}
63
66
  donkeylabs init --type server # Server-only project
64
67
  donkeylabs init --type sveltekit # SvelteKit + adapter project
65
68
  donkeylabs generate
69
+ donkeylabs generate-client -o ./clients/typescript
70
+ donkeylabs generate-client -o ./ios/ApiClient --adapter swift
71
+ donkeylabs generate-client --adapter sveltekit
66
72
  donkeylabs plugin create myPlugin
67
73
  donkeylabs update # Interactive package update
68
74
  donkeylabs update --check # Check for updates only
@@ -115,6 +121,15 @@ async function main() {
115
121
  await generateCommand(positionals.slice(1));
116
122
  break;
117
123
 
124
+ case "generate-client":
125
+ case "gen-client":
126
+ const { generateClientCommand } = await import("./commands/generate-client");
127
+ await generateClientCommand(positionals.slice(1), {
128
+ output: values.output,
129
+ adapter: values.adapter,
130
+ });
131
+ break;
132
+
118
133
  case "plugin":
119
134
  const { pluginCommand } = await import("./commands/plugin");
120
135
  await pluginCommand(positionals.slice(1));